Compare commits

..

10 Commits

Author SHA1 Message Date
Felix
adfa97e810 fix(jacktor): use season-specific extracted year in search URLs
Previously, the same year parameter was used for all seasons in generated
search links. The change calculates a distinct year per season by finding
the minimum ExtractedYear from torrents that contain that season and have
a valid year (>1900), falling back to the provided year when no matches
exist. This improves the accuracy of search results for multi-season
content where seasons may have different release years.
2026-05-05 22:01:02 +03:00
Felix
5829c65426 refactor(jacktor): simplify year validation logic in search filtering
Extract year extraction before conditional check and combine conditions
into a single if statement. This improves readability without changing
the filtering behavior.
2026-05-05 21:55:23 +03:00
Felix
5e551b2746 refactor(imports): migrate PlayerJsDecoder from global to explicit usings
Remove global using directive for LME.Common.Playerjs from GlobalUsings.cs
and add explicit using statements in module Invoke files that reference
PlayerJsDecoder. This improves namespace clarity and reduces unnecessary
global imports across the codebase.
2026-05-05 21:53:25 +03:00
Felix
cfdf0f2d76 refactor(playerjs): rename PlayerJsDecoder namespace to LME.Common.Playerjs
Move the PlayerJsDecoder class from Shared.Engine to LME.Common.Playerjs
namespace to align with the common library structure. Update global usings
in GlobalUsings.cs and add references to PlayerJsDecoder.cs in module
manifest files to reflect the new namespace location.
2026-05-05 21:50:50 +03:00
Felix
020f331729 feat(shared): implement player payload extraction and decoding infrastructure
Add PlayerJsDecoder class with comprehensive methods for extracting player
payloads from HTML content, handling atob-encoded strings, and parsing JSON
configurations. The implementation includes regex patterns for various
encoding schemes, loose JSON parsing with trailing comma tolerance, and
helper function resolution. Additionally, configure global usings for
Shared.Services, Shared.Services.Hybrid, and Shared.Models.Base to improve
type accessibility across the shared library.
2026-05-05 21:44:31 +03:00
Felix
fd01af1e2c refactor(shared): consolidate player payload decoding into Shared.Engine namespace
Move PlayerPayload class into PlayerJsDecoder.cs and rename namespace to
Shared.Engine. Remove linked source file references from anime projects
(AnimeON, Mikai, NMoonAnime) to prevent duplicate compilation and ensure
single source of truth through Shared.dll.
2026-05-05 21:40:22 +03:00
Felix
b253a21cdf refactor(shared): migrate player payload decoding to common namespace
Move PlayerJsDecoder and PlayerPayload from LME.Shared to LME.Common.Playerjs
namespace. Replace ProjectReference with direct Compile includes for source files
in AnimeON, Mikai, and NMoonAnime projects. Update all using directives to
reference the new namespace across Invoke files.

Note: Controller.cs contains a typo (Firts instead of First) that requires
correction in a subsequent commit.
2026-05-05 21:31:48 +03:00
Felix
60867dabae Update 2026-05-05 20:52:40 +03:00
Felix
0f89b08ee1 fix(jacktor): skip year extraction and tolerance check for serial content
Move year extraction and comparison inside conditional to only apply for
non-serial content (serial != 1). Previously, year tolerance was enforced
for all search results, causing serial content to be incorrectly filtered
when year mismatches occurred. Serial content often spans multiple years,
so year matching is less reliable for identification. This change ensures
serials bypass the year check while movies/single episodes still benefit
from year-based filtering. Bump module version to 2.2.
2026-05-05 20:52:31 +03:00
Felix
d36f29b7be refactor(shared): extract player payload decoding logic into shared library
Move player payload extraction and decoding logic from individual modules
(AnimeON, Mikai, NMoonAnime) into a new LME.Shared library. This reduces
code duplication and centralizes the parsing logic for better maintainability.
The new PlayerJsDecoder class handles various player script formats including
atob-encoded payloads, JSON.parse helpers, and different file payload structures.
All consuming modules now reference the shared project and use the common decoder.

Also fix typo in NMoonAnime Controller (Firts -> First).
2026-05-05 20:40:13 +03:00
13 changed files with 620 additions and 456 deletions

View File

@ -6,11 +6,13 @@ using Shared.Models.Online.Settings;
using Shared.Models;
using Shared.Models.Templates;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Linq;
using System.Text;
using System.Net;
using System.Text.RegularExpressions;
using LME.AnimeON.Models;
using LME.Common.Playerjs;
using Shared.Engine;
namespace LME.AnimeON
@ -183,11 +185,12 @@ namespace LME.AnimeON
if (string.IsNullOrEmpty(html))
return null;
var match = System.Text.RegularExpressions.Regex.Match(html, @"file:\s*""([^""]+\.m3u8)""");
if (match.Success)
{
return match.Groups[1].Value;
}
var payload = PlayerJsDecoder.ExtractPlayerPayload(html);
if (payload?.FilePayload == null)
return null;
var streamUrls = ExtractStreamUrls(payload.FilePayload);
return streamUrls?.FirstOrDefault();
}
catch (Exception ex)
{
@ -197,6 +200,61 @@ namespace LME.AnimeON
return null;
}
private List<string> ExtractStreamUrls(object filePayload)
{
var urls = new List<string>();
if (filePayload == null)
return urls;
// Обробка string значення
if (filePayload is string strPayload)
{
urls.Add(strPayload);
return urls;
}
// Обробка JsonValue
if (filePayload is JsonValue jsonValue && jsonValue.TryGetValue<string>(out string strValue))
{
urls.Add(strValue);
return urls;
}
// Обробка JsonObject — витягти 'file' поле
if (filePayload is JsonObject objPayload)
{
if (objPayload.TryGetPropertyValue("file", out JsonNode fileNode))
{
string fileStr = fileNode?.ToString();
if (!string.IsNullOrEmpty(fileStr))
urls.Add(fileStr);
}
return urls;
}
// Обробка JsonArray
if (filePayload is JsonArray arrayPayload)
{
foreach (var item in arrayPayload)
{
if (item is JsonObject itemObj && itemObj.TryGetPropertyValue("file", out JsonNode fileProp))
{
string fileStr = fileProp?.ToString();
if (!string.IsNullOrEmpty(fileStr))
urls.Add(fileStr);
}
else if (item is JsonValue itemValue && itemValue.TryGetValue<string>(out string itemStr))
{
if (!string.IsNullOrEmpty(itemStr))
urls.Add(itemStr);
}
}
return urls;
}
return urls;
}
public async Task<string> ParseAshdiPage(string url, bool disableAshdiMultivoiceForVod = false)
{
var streams = await ParseAshdiPageStreams(url, disableAshdiMultivoiceForVod);

View File

@ -26,7 +26,7 @@ namespace LME.AnimeON
{
public class ModInit : IModuleLoaded
{
public static double Version => 4.1;
public static double Version => 4.2;
public static OnlinesSettings AnimeON;
public static bool ApnHostProvided;

View File

@ -7,6 +7,7 @@
"../LME.Shared/GlobalUsings.cs",
"../LME.Shared/Online/OnlineRegistry.cs",
"../LME.Shared/Update/ModuleUpdateService.cs",
"../LME.Shared/Apn/ApnHelper.cs"
"../LME.Shared/Apn/ApnHelper.cs",
"../LME.Shared/Playerjs/PlayerJsDecoder.cs"
]
}

View File

@ -95,9 +95,15 @@ namespace LME.JackTor.Controllers
var seasonTpl = new SeasonTpl(quality: quality);
foreach (int season in seasons)
{
int seasonYear = torrents
.Where(i => i.Seasons != null && i.Seasons.Contains(season) && i.ExtractedYear > 1900)
.Select(i => i.ExtractedYear)
.DefaultIfEmpty(year)
.Min();
seasonTpl.Append(
$"{season} сезон",
$"{host}/lite/lme_jacktor?rjson={rjson}&title={enTitle}&original_title={enOriginal}&year={year}&original_language={original_language}&serial=1&s={season}",
$"{host}/lite/lme_jacktor?rjson={rjson}&title={enTitle}&original_title={enOriginal}&year={seasonYear}&original_language={original_language}&serial=1&s={season}",
season);
}

View File

@ -335,7 +335,7 @@ namespace LME.JackTor
continue;
int extractedYear = ExtractYear(searchable);
if (year > 1900 && extractedYear > 1900 && Math.Abs(extractedYear - year) > yearTolerance)
if (serial != 1 && year > 1900 && extractedYear > 1900 && Math.Abs(extractedYear - year) > yearTolerance)
continue;
int[] seasons = ParseSeasons(searchable);

View File

@ -18,7 +18,7 @@ namespace LME.JackTor
{
public class ModInit : IModuleLoaded
{
public static double Version => 2.1;
public static double Version => 2.2;
public static JackTorSettings JackTor;

View File

@ -2,11 +2,13 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Web;
using System.Net;
using System.Text.RegularExpressions;
using LME.Mikai.Models;
using LME.Common.Playerjs;
using Shared;
using Shared.Engine;
using Shared.Models;
@ -158,16 +160,73 @@ namespace LME.Mikai
if (string.IsNullOrEmpty(html))
return null;
var match = System.Text.RegularExpressions.Regex.Match(html, @"file:\s*""([^""]+\.m3u8)""");
if (match.Success)
return match.Groups[1].Value;
var payload = PlayerJsDecoder.ExtractPlayerPayload(html);
if (payload?.FilePayload == null)
return null;
var streamUrls = ExtractStreamUrls(payload.FilePayload);
return streamUrls?.FirstOrDefault();
}
catch (Exception ex)
{
_onLog($"Mikai ParseMoonAnimePage error: {ex.Message}");
return null;
}
}
private List<string> ExtractStreamUrls(object filePayload)
{
var urls = new List<string>();
if (filePayload == null)
return urls;
// Обробка string значення
if (filePayload is string strPayload)
{
urls.Add(strPayload);
return urls;
}
return null;
// Обробка JsonValue
if (filePayload is JsonValue jsonValue && jsonValue.TryGetValue<string>(out string strValue))
{
urls.Add(strValue);
return urls;
}
// Обробка JsonObject — витягти 'file' поле
if (filePayload is JsonObject objPayload)
{
if (objPayload.TryGetPropertyValue("file", out JsonNode fileNode))
{
string fileStr = fileNode?.ToString();
if (!string.IsNullOrEmpty(fileStr))
urls.Add(fileStr);
}
return urls;
}
// Обробка JsonArray
if (filePayload is JsonArray arrayPayload)
{
foreach (var item in arrayPayload)
{
if (item is JsonObject itemObj && itemObj.TryGetPropertyValue("file", out JsonNode fileProp))
{
string fileStr = fileProp?.ToString();
if (!string.IsNullOrEmpty(fileStr))
urls.Add(fileStr);
}
else if (item is JsonValue itemValue && itemValue.TryGetValue<string>(out string itemStr))
{
if (!string.IsNullOrEmpty(itemStr))
urls.Add(itemStr);
}
}
return urls;
}
return urls;
}
string AshdiRequestUrl(string url)

View File

@ -25,7 +25,7 @@ namespace LME.Mikai
{
public class ModInit : IModuleLoaded
{
public static double Version => 4.1;
public static double Version => 4.2;
public static OnlinesSettings Mikai;
public static bool ApnHostProvided;

View File

@ -7,6 +7,7 @@
"../LME.Shared/GlobalUsings.cs",
"../LME.Shared/Online/OnlineRegistry.cs",
"../LME.Shared/Update/ModuleUpdateService.cs",
"../LME.Shared/Apn/ApnHelper.cs"
"../LME.Shared/Apn/ApnHelper.cs",
"../LME.Shared/Playerjs/PlayerJsDecoder.cs"
]
}

View File

@ -1,4 +1,5 @@
using LME.NMoonAnime.Models;
using LME.Common.Playerjs;
using Shared;
using Shared.Engine;
using Shared.Models;
@ -29,12 +30,6 @@ namespace LME.NMoonAnime
};
private static readonly Regex _reSeason = new Regex(@"(?:season|сезон)\s*(\d+)|(\d+)\s*(?:season|сезон)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex _reEpisode = new Regex(@"(?:episode|серія|серия|епізод|ep)\s*(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex _reTrailingComma = new Regex(@",\s*([}\]])", RegexOptions.Compiled);
private static readonly Regex _reAtobLiteral = new Regex(@"atob\(\s*(['""])(?<payload>[A-Za-z0-9+/=]+)\1\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex _reJsonParseHelper = new Regex(@"JSON\.parse\(\s*(?<fn>[A-Za-z_$][\w$]*)\(\s*(?<quote>['""])(?<payload>.*?)(\k<quote>)\s*\)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex _reHelperCall = new Regex(@"^\s*(?<fn>[A-Za-z_$][\w$]*)\(\s*(?<quote>['""])(?<payload>.*?)(\k<quote>)\s*\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly UTF8Encoding _utf8Strict = new UTF8Encoding(false, true);
private static readonly Encoding _latin1 = Encoding.GetEncoding("ISO-8859-1");
public NMoonAnimeInvoke(OnlinesSettings init, IHybridCache hybridCache, Action<string> onLog, ProxyManager proxyManager, HttpHydra httpHydra = null)
{
@ -241,7 +236,7 @@ namespace LME.NMoonAnime
IsSeries = false
};
var payload = ExtractPlayerPayload(html);
var payload = PlayerJsDecoder.ExtractPlayerPayload(html);
if (payload == null)
return content;
@ -495,437 +490,14 @@ namespace LME.NMoonAnime
return entries;
}
private PlayerPayload ExtractPlayerPayload(string htmlText)
{
string cleanHtml = WebUtility.HtmlDecode(htmlText ?? string.Empty);
if (string.IsNullOrWhiteSpace(cleanHtml))
return null;
var candidates = new List<string> { cleanHtml };
string decodedScript = DecodeOuterPlayerScript(cleanHtml);
if (!string.IsNullOrWhiteSpace(decodedScript))
candidates.Insert(0, decodedScript);
foreach (string sourceText in candidates)
{
string objectText = ExtractObjectByBraces(sourceText, "new Playerjs");
if (string.IsNullOrWhiteSpace(objectText))
objectText = ExtractObjectByBraces(sourceText, "Playerjs({");
string searchText = string.IsNullOrWhiteSpace(objectText) ? sourceText : objectText;
string fileValue = ExtractJsValue(searchText, "file");
if (fileValue == null && !string.IsNullOrWhiteSpace(objectText))
fileValue = ExtractJsValue(sourceText, "file");
if (fileValue == null)
continue;
string titleValue = ExtractJsValue(searchText, "title");
object parsedFile = ParsePlayerFileValue(fileValue, sourceText);
return new PlayerPayload
{
Title = Nullish(titleValue),
FilePayload = parsedFile
};
}
return null;
}
private object ParsePlayerFileValue(string rawValue, string contextText)
{
string text = rawValue?.Trim();
if (string.IsNullOrWhiteSpace(text))
return rawValue;
if (text.StartsWith("[") || text.StartsWith("{"))
{
JsonNode loaded = LoadJsonLoose(text);
if (loaded != null)
return loaded;
}
var parseMatch = _reJsonParseHelper.Match(text);
if (parseMatch.Success)
{
string decoded = DecodeHelperPayload(parseMatch.Groups["fn"].Value, parseMatch.Groups["payload"].Value, contextText);
if (!string.IsNullOrWhiteSpace(decoded))
{
JsonNode loaded = LoadJsonLoose(decoded);
if (loaded != null)
return loaded;
}
}
var helperMatch = _reHelperCall.Match(text);
if (helperMatch.Success)
{
string decoded = DecodeHelperPayload(helperMatch.Groups["fn"].Value, helperMatch.Groups["payload"].Value, contextText);
if (!string.IsNullOrWhiteSpace(decoded))
{
JsonNode loaded = LoadJsonLoose(decoded);
if (loaded != null)
return loaded;
return decoded;
}
}
return rawValue;
}
private string DecodeHelperPayload(string helperName, string payload, string contextText)
{
if (string.IsNullOrWhiteSpace(helperName))
return null;
if (helperName.Equals("atob", StringComparison.OrdinalIgnoreCase))
{
byte[] rawBytes = SafeBase64Decode(payload);
return rawBytes == null ? null : DecodeBytes(rawBytes);
}
string helperKey = ExtractHelperKey(contextText, helperName);
if (string.IsNullOrWhiteSpace(helperKey))
return null;
byte[] keyBytes = Encoding.UTF8.GetBytes(helperKey);
if (keyBytes.Length == 0)
return null;
byte[] payloadBytes = SafeBase64Decode(payload);
if (payloadBytes == null)
return null;
var decoded = new byte[payloadBytes.Length];
for (int index = 0; index < payloadBytes.Length; index++)
decoded[index] = (byte)(payloadBytes[index] ^ keyBytes[index % keyBytes.Length]);
return DecodeBytes(decoded);
}
private static string ExtractHelperKey(string contextText, string helperName)
{
if (string.IsNullOrWhiteSpace(contextText) || string.IsNullOrWhiteSpace(helperName))
return null;
string pattern = $@"function\s+{Regex.Escape(helperName)}\s*\([^)]*\)\s*\{{[\s\S]*?var\s+k\s*=\s*(['""])(?<key>.*?)\1";
var match = Regex.Match(contextText, pattern, RegexOptions.IgnoreCase);
if (!match.Success)
return null;
return Nullish(match.Groups["key"].Value);
}
private static string DecodeOuterPlayerScript(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var match = _reAtobLiteral.Match(text);
if (!match.Success)
return null;
byte[] rawData = SafeBase64Decode(match.Groups["payload"].Value);
if (rawData == null || rawData.Length <= 32)
return null;
byte[] key = rawData.Take(32).ToArray();
byte[] encryptedData = rawData.Skip(32).ToArray();
var decoded = new byte[encryptedData.Length];
for (int index = 0; index < encryptedData.Length; index++)
decoded[index] = (byte)(encryptedData[index] ^ key[index % key.Length]);
return DecodeBytes(decoded);
}
private static byte[] SafeBase64Decode(string value)
{
string text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
int remainder = text.Length % 4;
if (remainder != 0)
text += new string('=', 4 - remainder);
try
{
return Convert.FromBase64String(text);
}
catch
{
return null;
}
}
private static string DecodeBytes(byte[] data)
{
if (data == null || data.Length == 0)
return null;
try
{
return _utf8Strict.GetString(data);
}
catch
{
return _latin1.GetString(data);
}
}
private static string ExtractObjectByBraces(string text, string anchor)
{
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(anchor))
return null;
int anchorIndex = text.IndexOf(anchor, StringComparison.OrdinalIgnoreCase);
if (anchorIndex < 0)
return null;
int braceIndex = text.IndexOf('{', anchorIndex);
if (braceIndex < 0)
return null;
int depth = 0;
bool escaped = false;
char? inString = null;
for (int index = braceIndex; index < text.Length; index++)
{
char current = text[index];
if (inString.HasValue)
{
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == inString.Value)
inString = null;
continue;
}
if (current == '"' || current == '\'')
{
inString = current;
continue;
}
if (current == '{')
{
depth++;
continue;
}
if (current == '}')
{
depth--;
if (depth == 0)
return text.Substring(braceIndex + 1, index - braceIndex - 1);
}
}
return null;
}
private static string ExtractJsValue(string text, string key)
{
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(key))
return null;
var match = Regex.Match(text, $@"\b{Regex.Escape(key)}\b\s*:\s*", RegexOptions.IgnoreCase);
if (!match.Success)
return null;
int index = match.Index + match.Length;
while (index < text.Length && char.IsWhiteSpace(text[index]))
index++;
if (index >= text.Length)
return null;
char token = text[index];
if (token == '"' || token == '\'')
{
var (value, _) = ReadJsString(text, index);
return value;
}
if (token == '[')
{
int endIndex = FindMatchingBracket(text, index, '[', ']');
return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null;
}
if (token == '{')
{
int endIndex = FindMatchingBracket(text, index, '{', '}');
return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null;
}
int stopIndex = index;
while (stopIndex < text.Length && text[stopIndex] != ',' && text[stopIndex] != '}' && text[stopIndex] != '\n' && text[stopIndex] != '\r')
stopIndex++;
return text.Substring(index, stopIndex - index).Trim();
}
private static (string value, int nextIndex) ReadJsString(string text, int startIndex)
{
if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length)
return (null, -1);
char quote = text[startIndex];
if (quote != '"' && quote != '\'')
return (null, -1);
var buffer = new StringBuilder();
bool escaped = false;
for (int index = startIndex + 1; index < text.Length; index++)
{
char current = text[index];
if (escaped)
{
buffer.Append(current);
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == quote)
return (buffer.ToString(), index + 1);
buffer.Append(current);
}
return (null, -1);
}
private static int FindMatchingBracket(string text, int startIndex, char openChar, char closeChar)
{
if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length || text[startIndex] != openChar)
return -1;
int depth = 0;
bool escaped = false;
char? inString = null;
for (int index = startIndex; index < text.Length; index++)
{
char current = text[index];
if (inString.HasValue)
{
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == inString.Value)
inString = null;
continue;
}
if (current == '"' || current == '\'')
{
inString = current;
continue;
}
if (current == openChar)
{
depth++;
continue;
}
if (current == closeChar)
{
depth--;
if (depth == 0)
return index;
}
}
return -1;
}
private static JsonNode LoadJsonLoose(string value)
{
string text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
string normalized = WebUtility.HtmlDecode(text).Replace("\\/", "/");
string unescapedQuotes = normalized.Replace("\\'", "'").Replace("\\\"", "\"");
var candidates = new[]
{
normalized,
unescapedQuotes,
RemoveTrailingCommas(normalized),
RemoveTrailingCommas(unescapedQuotes)
};
foreach (string candidate in candidates.Distinct(StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
try
{
return JsonNode.Parse(candidate);
}
catch
{
}
}
return null;
}
private static string RemoveTrailingCommas(string value)
{
return string.IsNullOrWhiteSpace(value) ? value : _reTrailingComma.Replace(value, "$1");
return PlayerJsDecoder.LoadJsonLoose(value);
}
private static string Nullish(string value)
{
string text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
if (text.Equals("null", StringComparison.OrdinalIgnoreCase) ||
text.Equals("none", StringComparison.OrdinalIgnoreCase) ||
text.Equals("undefined", StringComparison.OrdinalIgnoreCase))
return null;
return text;
return PlayerJsDecoder.Nullish(value);
}
private static bool TryGetArray(JsonObject obj, string key, out JsonArray array)
@ -1120,13 +692,6 @@ namespace LME.NMoonAnime
return TimeSpan.FromMinutes(ctime);
}
private sealed class PlayerPayload
{
public string Title { get; set; }
public object FilePayload { get; set; }
}
private sealed class NMoonAnimeMovieEntry
{
public string Title { get; set; }

View File

@ -7,6 +7,7 @@
"../LME.Shared/GlobalUsings.cs",
"../LME.Shared/Online/OnlineRegistry.cs",
"../LME.Shared/Update/ModuleUpdateService.cs",
"../LME.Shared/Apn/ApnHelper.cs"
"../LME.Shared/Apn/ApnHelper.cs",
"../LME.Shared/Playerjs/PlayerJsDecoder.cs"
]
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Shared">
<HintPath>..\..\Shared.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,461 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace LME.Common.Playerjs
{
public static class PlayerJsDecoder
{
private static readonly Regex _reAtobLiteral = new Regex(@"atob\(\s*(['""])(?<payload>[A-Za-z0-9+/=]+)\1\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex _reJsonParseHelper = new Regex(@"JSON\.parse\(\s*(?<fn>[A-Za-z_$][\w$]*)\(\s*(?<quote>['""])(?<payload>.*?)(\k<quote>)\s*\)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex _reHelperCall = new Regex(@"^\s*(?<fn>[A-Za-z_$][\w$]*)\(\s*(?<quote>['""])(?<payload>.*?)(\k<quote>)\s*\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex _reTrailingComma = new Regex(@",\s*([}\]])", RegexOptions.Compiled);
private static readonly UTF8Encoding _utf8Strict = new UTF8Encoding(false, true);
private static readonly Encoding _latin1 = Encoding.GetEncoding("ISO-8859-1");
public static PlayerPayload ExtractPlayerPayload(string htmlText)
{
string cleanHtml = WebUtility.HtmlDecode(htmlText ?? string.Empty);
if (string.IsNullOrWhiteSpace(cleanHtml))
return null;
var candidates = new List<string> { cleanHtml };
string decodedScript = DecodeOuterPlayerScript(cleanHtml);
if (!string.IsNullOrWhiteSpace(decodedScript))
candidates.Insert(0, decodedScript);
foreach (string sourceText in candidates)
{
string objectText = ExtractObjectByBraces(sourceText, "new Playerjs");
if (string.IsNullOrWhiteSpace(objectText))
objectText = ExtractObjectByBraces(sourceText, "Playerjs({");
string searchText = string.IsNullOrWhiteSpace(objectText) ? sourceText : objectText;
string fileValue = ExtractJsValue(searchText, "file");
if (fileValue == null && !string.IsNullOrWhiteSpace(objectText))
fileValue = ExtractJsValue(sourceText, "file");
if (fileValue == null)
continue;
string titleValue = ExtractJsValue(searchText, "title");
object parsedFile = ParsePlayerFileValue(fileValue, sourceText);
return new PlayerPayload
{
Title = Nullish(titleValue),
FilePayload = parsedFile
};
}
return null;
}
private static object ParsePlayerFileValue(string rawValue, string contextText)
{
string text = rawValue?.Trim();
if (string.IsNullOrWhiteSpace(text))
return rawValue;
if (text.StartsWith("[") || text.StartsWith("{"))
{
JsonNode loaded = LoadJsonLoose(text);
if (loaded != null)
return loaded;
}
var parseMatch = _reJsonParseHelper.Match(text);
if (parseMatch.Success)
{
string decoded = DecodeHelperPayload(parseMatch.Groups["fn"].Value, parseMatch.Groups["payload"].Value, contextText);
if (!string.IsNullOrWhiteSpace(decoded))
{
JsonNode loaded = LoadJsonLoose(decoded);
if (loaded != null)
return loaded;
}
}
var helperMatch = _reHelperCall.Match(text);
if (helperMatch.Success)
{
string decoded = DecodeHelperPayload(helperMatch.Groups["fn"].Value, helperMatch.Groups["payload"].Value, contextText);
if (!string.IsNullOrWhiteSpace(decoded))
{
JsonNode loaded = LoadJsonLoose(decoded);
if (loaded != null)
return loaded;
return decoded;
}
}
return rawValue;
}
private static string DecodeHelperPayload(string helperName, string payload, string contextText)
{
if (string.IsNullOrWhiteSpace(helperName))
return null;
if (helperName.Equals("atob", StringComparison.OrdinalIgnoreCase))
{
byte[] rawBytes = SafeBase64Decode(payload);
return rawBytes == null ? null : DecodeBytes(rawBytes);
}
string helperKey = ExtractHelperKey(contextText, helperName);
if (string.IsNullOrWhiteSpace(helperKey))
return null;
byte[] keyBytes = Encoding.UTF8.GetBytes(helperKey);
if (keyBytes.Length == 0)
return null;
byte[] payloadBytes = SafeBase64Decode(payload);
if (payloadBytes == null)
return null;
var decoded = new byte[payloadBytes.Length];
for (int index = 0; index < payloadBytes.Length; index++)
decoded[index] = (byte)(payloadBytes[index] ^ keyBytes[index % keyBytes.Length]);
return DecodeBytes(decoded);
}
private static string ExtractHelperKey(string contextText, string helperName)
{
if (string.IsNullOrWhiteSpace(contextText) || string.IsNullOrWhiteSpace(helperName))
return null;
string pattern = $@"function\s+{Regex.Escape(helperName)}\s*\([^)]*\)\s*\{{[\s\S]*?var\s+k\s*=\s*(['""])(?<key>.*?)\1";
var match = Regex.Match(contextText, pattern, RegexOptions.IgnoreCase);
if (!match.Success)
return null;
return Nullish(match.Groups["key"].Value);
}
private static string DecodeOuterPlayerScript(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var match = _reAtobLiteral.Match(text);
if (!match.Success)
return null;
byte[] rawData = SafeBase64Decode(match.Groups["payload"].Value);
if (rawData == null || rawData.Length <= 32)
return null;
byte[] key = rawData.Take(32).ToArray();
byte[] encryptedData = rawData.Skip(32).ToArray();
var decoded = new byte[encryptedData.Length];
for (int index = 0; index < encryptedData.Length; index++)
decoded[index] = (byte)(encryptedData[index] ^ key[index % key.Length]);
return DecodeBytes(decoded);
}
private static byte[] SafeBase64Decode(string value)
{
string text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
int remainder = text.Length % 4;
if (remainder != 0)
text += new string('=', 4 - remainder);
try
{
return Convert.FromBase64String(text);
}
catch
{
return null;
}
}
private static string DecodeBytes(byte[] data)
{
if (data == null || data.Length == 0)
return null;
try
{
return _utf8Strict.GetString(data);
}
catch
{
return _latin1.GetString(data);
}
}
private static string ExtractObjectByBraces(string text, string anchor)
{
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(anchor))
return null;
int anchorIndex = text.IndexOf(anchor, StringComparison.OrdinalIgnoreCase);
if (anchorIndex < 0)
return null;
int braceIndex = text.IndexOf('{', anchorIndex);
if (braceIndex < 0)
return null;
int depth = 0;
bool escaped = false;
char? inString = null;
for (int index = braceIndex; index < text.Length; index++)
{
char current = text[index];
if (inString.HasValue)
{
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == inString.Value)
inString = null;
continue;
}
if (current == '"' || current == '\'')
{
inString = current;
continue;
}
if (current == '{')
{
depth++;
continue;
}
if (current == '}')
{
depth--;
if (depth == 0)
return text.Substring(braceIndex + 1, index - braceIndex - 1);
}
}
return null;
}
private static string ExtractJsValue(string text, string key)
{
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(key))
return null;
var match = Regex.Match(text, $@"\b{Regex.Escape(key)}\b\s*:\s*", RegexOptions.IgnoreCase);
if (!match.Success)
return null;
int index = match.Index + match.Length;
while (index < text.Length && char.IsWhiteSpace(text[index]))
index++;
if (index >= text.Length)
return null;
char token = text[index];
if (token == '"' || token == '\'')
{
var (value, _) = ReadJsString(text, index);
return value;
}
if (token == '[')
{
int endIndex = FindMatchingBracket(text, index, '[', ']');
return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null;
}
if (token == '{')
{
int endIndex = FindMatchingBracket(text, index, '{', '}');
return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null;
}
int stopIndex = index;
while (stopIndex < text.Length && text[stopIndex] != ',' && text[stopIndex] != '}' && text[stopIndex] != '\n' && text[stopIndex] != '\r')
stopIndex++;
return text.Substring(index, stopIndex - index).Trim();
}
private static (string value, int nextIndex) ReadJsString(string text, int startIndex)
{
if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length)
return (null, -1);
char quote = text[startIndex];
if (quote != '"' && quote != '\'')
return (null, -1);
var buffer = new StringBuilder();
bool escaped = false;
for (int index = startIndex + 1; index < text.Length; index++)
{
char current = text[index];
if (escaped)
{
buffer.Append(current);
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == quote)
return (buffer.ToString(), index + 1);
buffer.Append(current);
}
return (null, -1);
}
private static int FindMatchingBracket(string text, int startIndex, char openChar, char closeChar)
{
if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length || text[startIndex] != openChar)
return -1;
int depth = 0;
bool escaped = false;
char? inString = null;
for (int index = startIndex; index < text.Length; index++)
{
char current = text[index];
if (inString.HasValue)
{
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == inString.Value)
inString = null;
continue;
}
if (current == '"' || current == '\'')
{
inString = current;
continue;
}
if (current == openChar)
{
depth++;
continue;
}
if (current == closeChar)
{
depth--;
if (depth == 0)
return index;
}
}
return -1;
}
public static JsonNode LoadJsonLoose(string value)
{
string text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
string normalized = WebUtility.HtmlDecode(text).Replace("\\/", "/");
string unescapedQuotes = normalized.Replace("\\'", "'").Replace("\\\"", "\"");
var candidates = new[]
{
normalized,
unescapedQuotes,
RemoveTrailingCommas(normalized),
RemoveTrailingCommas(unescapedQuotes)
};
foreach (string candidate in candidates.Distinct(StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
try
{
return JsonNode.Parse(candidate);
}
catch
{
}
}
return null;
}
private static string RemoveTrailingCommas(string value)
{
return string.IsNullOrWhiteSpace(value) ? value : _reTrailingComma.Replace(value, "$1");
}
public static string Nullish(string value)
{
string text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
if (text.Equals("null", StringComparison.OrdinalIgnoreCase) ||
text.Equals("none", StringComparison.OrdinalIgnoreCase) ||
text.Equals("undefined", StringComparison.OrdinalIgnoreCase))
return null;
return text;
}
}
public sealed class PlayerPayload
{
public string Title { get; set; }
public object FilePayload { get; set; }
}
}