diff --git a/AnimeON/AnimeONInvoke.cs b/AnimeON/AnimeONInvoke.cs index acabbc9..9be9108 100644 --- a/AnimeON/AnimeONInvoke.cs +++ b/AnimeON/AnimeONInvoke.cs @@ -7,6 +7,8 @@ using Shared.Models; using System.Text.Json; using System.Linq; using System.Text; +using System.Net; +using System.Text.RegularExpressions; using AnimeON.Models; using Shared.Engine; @@ -14,6 +16,9 @@ namespace AnimeON { public class AnimeONInvoke { + private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); + private static readonly Regex QualityFhdRegex = new Regex(@"(^|[^0-9])(1080p?)([^0-9]|$)|\bfhd\b", RegexOptions.IgnoreCase); + private OnlinesSettings _init; private IHybridCache _hybridCache; private Action _onLog; @@ -184,6 +189,13 @@ namespace AnimeON public async Task ParseAshdiPage(string url) { + var streams = await ParseAshdiPageStreams(url); + return streams?.FirstOrDefault().link; + } + + public async Task> ParseAshdiPageStreams(string url) + { + var streams = new List<(string title, string link)>(); try { var headers = new List() @@ -192,16 +204,48 @@ namespace AnimeON new HeadersModel("Referer", "https://ashdi.vip/") }; - string requestUrl = AshdiRequestUrl(url); + string requestUrl = AshdiRequestUrl(WithAshdiMultivoice(url)); _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {requestUrl}"); string html = await Http.Get(_init.cors(requestUrl), headers: headers, proxy: _proxyManager.Get()); if (string.IsNullOrEmpty(html)) - return null; + return streams; - var match = System.Text.RegularExpressions.Regex.Match(html, @"file\s*:\s*['""]([^'""]+)['""]"); + string rawArray = ExtractPlayerFileArray(html); + if (!string.IsNullOrWhiteSpace(rawArray)) + { + string json = WebUtility.HtmlDecode(rawArray) + .Replace("\\/", "/") + .Replace("\\'", "'") + .Replace("\\\"", "\""); + + using var jsonDoc = JsonDocument.Parse(json); + if (jsonDoc.RootElement.ValueKind == JsonValueKind.Array) + { + int index = 1; + foreach (var item in jsonDoc.RootElement.EnumerateArray()) + { + if (!item.TryGetProperty("file", out var fileProp)) + continue; + + string file = fileProp.GetString(); + if (string.IsNullOrWhiteSpace(file)) + continue; + + string rawTitle = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + streams.Add((BuildDisplayTitle(rawTitle, file, index), file)); + index++; + } + + if (streams.Count > 0) + return streams; + } + } + + var match = Regex.Match(html, @"file\s*:\s*['""]([^'""]+)['""]"); if (match.Success) { - return match.Groups[1].Value; + string file = match.Groups[1].Value; + streams.Add((BuildDisplayTitle("Основне джерело", file, 1), file)); } } catch (Exception ex) @@ -209,7 +253,7 @@ namespace AnimeON _onLog($"AnimeON ParseAshdiPage error: {ex.Message}"); } - return null; + return streams; } public async Task ResolveEpisodeStream(int episodeId) @@ -260,6 +304,168 @@ namespace AnimeON return url; } + private static string WithAshdiMultivoice(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.IndexOf("ashdi.vip/vod/", StringComparison.OrdinalIgnoreCase) < 0) + return url; + + if (url.IndexOf("multivoice", StringComparison.OrdinalIgnoreCase) >= 0) + return url; + + return url.Contains("?") ? $"{url}&multivoice" : $"{url}?multivoice"; + } + + private static string BuildDisplayTitle(string rawTitle, string link, int index) + { + string normalized = string.IsNullOrWhiteSpace(rawTitle) ? $"Варіант {index}" : StripMoviePrefix(WebUtility.HtmlDecode(rawTitle).Trim()); + string qualityTag = DetectQualityTag($"{normalized} {link}"); + if (string.IsNullOrWhiteSpace(qualityTag)) + return normalized; + + if (normalized.StartsWith("[4K]", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("[FHD]", StringComparison.OrdinalIgnoreCase)) + return normalized; + + return $"{qualityTag} {normalized}"; + } + + private static string DetectQualityTag(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (Quality4kRegex.IsMatch(value)) + return "[4K]"; + + if (QualityFhdRegex.IsMatch(value)) + return "[FHD]"; + + return null; + } + + private static string StripMoviePrefix(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return title; + + string normalized = Regex.Replace(title, @"\s+", " ").Trim(); + int sepIndex = normalized.LastIndexOf(" - ", StringComparison.Ordinal); + if (sepIndex <= 0 || sepIndex >= normalized.Length - 3) + return normalized; + + string prefix = normalized.Substring(0, sepIndex).Trim(); + string suffix = normalized.Substring(sepIndex + 3).Trim(); + if (string.IsNullOrWhiteSpace(suffix)) + return normalized; + + if (Regex.IsMatch(prefix, @"(19|20)\d{2}")) + return suffix; + + return normalized; + } + + private static string ExtractPlayerFileArray(string html) + { + if (string.IsNullOrWhiteSpace(html)) + return null; + + int searchIndex = 0; + while (searchIndex >= 0 && searchIndex < html.Length) + { + int fileIndex = html.IndexOf("file", searchIndex, StringComparison.OrdinalIgnoreCase); + if (fileIndex < 0) + return null; + + int colonIndex = html.IndexOf(':', fileIndex); + if (colonIndex < 0) + return null; + + int startIndex = colonIndex + 1; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + + if (startIndex < html.Length && (html[startIndex] == '\'' || html[startIndex] == '"')) + { + startIndex++; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + } + + if (startIndex >= html.Length || html[startIndex] != '[') + { + searchIndex = fileIndex + 4; + continue; + } + + return ExtractBracketArray(html, startIndex); + } + + return null; + } + + private static string ExtractBracketArray(string text, int startIndex) + { + if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '[') + return null; + + int depth = 0; + bool inString = false; + bool escaped = false; + char quoteChar = '\0'; + + for (int i = startIndex; i < text.Length; i++) + { + char ch = text[i]; + + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (ch == quoteChar) + { + inString = false; + quoteChar = '\0'; + } + + continue; + } + + if (ch == '"' || ch == '\'') + { + inString = true; + quoteChar = ch; + continue; + } + + if (ch == '[') + { + depth++; + continue; + } + + if (ch == ']') + { + depth--; + if (depth == 0) + return text.Substring(startIndex, i - startIndex + 1); + } + } + + return null; + } + public static TimeSpan cacheTime(int multiaccess, int home = 5, int mikrotik = 2, OnlinesSettings init = null, int rhub = -1) { if (init != null && init.rhub && rhub != -1) diff --git a/AnimeON/Controller.cs b/AnimeON/Controller.cs index 0b1ad97..3b10c47 100644 --- a/AnimeON/Controller.cs +++ b/AnimeON/Controller.cs @@ -223,6 +223,21 @@ namespace AnimeON.Controllers string translationName = $"[{player.Name}] {fundub.Fundub.Name}"; bool needsResolve = player.Name?.ToLower() == "moon" || player.Name?.ToLower() == "ashdi"; + if (streamLink.Contains("ashdi.vip/vod", StringComparison.OrdinalIgnoreCase)) + { + var ashdiStreams = await invoke.ParseAshdiPageStreams(streamLink); + if (ashdiStreams != null && ashdiStreams.Count > 0) + { + foreach (var ashdiStream in ashdiStreams) + { + string optionName = $"{translationName} {ashdiStream.title}"; + string callUrl = $"{host}/animeon/play?url={HttpUtility.UrlEncode(ashdiStream.link)}"; + tpl.Append(optionName, accsArgs(callUrl), "call"); + } + continue; + } + } + if (needsResolve || streamLink.Contains("moonanime.art/iframe/") || streamLink.Contains("ashdi.vip/vod")) { string callUrl = $"{host}/animeon/play?url={HttpUtility.UrlEncode(streamLink)}"; diff --git a/AnimeON/ModInit.cs b/AnimeON/ModInit.cs index 640f4f5..917fc93 100644 --- a/AnimeON/ModInit.cs +++ b/AnimeON/ModInit.cs @@ -25,7 +25,7 @@ namespace AnimeON { public class ModInit { - public static double Version => 3.5; + public static double Version => 3.6; public static OnlinesSettings AnimeON; public static bool ApnHostProvided; diff --git a/KlonFUN/KlonFUNInvoke.cs b/KlonFUN/KlonFUNInvoke.cs index 196c706..2505eea 100644 --- a/KlonFUN/KlonFUNInvoke.cs +++ b/KlonFUN/KlonFUNInvoke.cs @@ -21,6 +21,8 @@ namespace KlonFUN private static readonly Regex DirectFileRegex = new Regex(@"file\s*:\s*['""](?https?://[^'"">\s]+\.m3u8[^'"">\s]*)['""]", RegexOptions.Singleline | RegexOptions.IgnoreCase); private static readonly Regex YearRegex = new Regex(@"(19|20)\d{2}", RegexOptions.IgnoreCase); private static readonly Regex NumberRegex = new Regex(@"(\d+)", RegexOptions.IgnoreCase); + private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); + private static readonly Regex QualityFhdRegex = new Regex(@"(^|[^0-9])(1080p?)([^0-9]|$)|\bfhd\b", RegexOptions.IgnoreCase); private readonly OnlinesSettings _init; private readonly IHybridCache _hybridCache; @@ -181,7 +183,7 @@ namespace KlonFUN try { - string playerHtml = await GetPlayerHtml(playerUrl); + string playerHtml = await GetPlayerHtml(WithAshdiMultivoice(playerUrl)); if (string.IsNullOrWhiteSpace(playerHtml)) return null; @@ -197,9 +199,7 @@ namespace KlonFUN if (string.IsNullOrWhiteSpace(link)) continue; - string voiceTitle = CleanText(item.Value("title")); - if (string.IsNullOrWhiteSpace(voiceTitle)) - voiceTitle = $"Варіант {index}"; + string voiceTitle = FormatMovieTitle(item.Value("title"), link, index); streams.Add(new MovieStream { @@ -218,7 +218,7 @@ namespace KlonFUN { streams.Add(new MovieStream { - Title = "Основне джерело", + Title = FormatMovieTitle("Основне джерело", directMatch.Groups["url"].Value, 1), Link = directMatch.Groups["url"].Value }); } @@ -634,6 +634,71 @@ namespace KlonFUN return $"{baseName} #{count}"; } + private static string WithAshdiMultivoice(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.IndexOf("ashdi.vip/vod/", StringComparison.OrdinalIgnoreCase) < 0) + return url; + + if (url.IndexOf("multivoice", StringComparison.OrdinalIgnoreCase) >= 0) + return url; + + return url.Contains("?") ? $"{url}&multivoice" : $"{url}?multivoice"; + } + + private static string FormatMovieTitle(string rawTitle, string streamUrl, int index) + { + string title = StripMoviePrefix(CleanText(rawTitle)); + if (string.IsNullOrWhiteSpace(title)) + title = $"Варіант {index}"; + + string tag = DetectQualityTag($"{title} {streamUrl}"); + if (string.IsNullOrWhiteSpace(tag)) + return title; + + if (title.StartsWith("[4K]", StringComparison.OrdinalIgnoreCase) || title.StartsWith("[FHD]", StringComparison.OrdinalIgnoreCase)) + return title; + + return $"{tag} {title}"; + } + + private static string StripMoviePrefix(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return title; + + string normalized = Regex.Replace(title, @"\s+", " ").Trim(); + int sepIndex = normalized.LastIndexOf(" - ", StringComparison.Ordinal); + if (sepIndex <= 0 || sepIndex >= normalized.Length - 3) + return normalized; + + string prefix = normalized.Substring(0, sepIndex).Trim(); + string suffix = normalized.Substring(sepIndex + 3).Trim(); + if (string.IsNullOrWhiteSpace(suffix)) + return normalized; + + if (Regex.IsMatch(prefix, @"(19|20)\d{2}")) + return suffix; + + return normalized; + } + + private static string DetectQualityTag(string source) + { + if (string.IsNullOrWhiteSpace(source)) + return null; + + if (Quality4kRegex.IsMatch(source)) + return "[4K]"; + + if (QualityFhdRegex.IsMatch(source)) + return "[FHD]"; + + return null; + } + public static TimeSpan cacheTime(int multiaccess, int home = 5, int mikrotik = 2, OnlinesSettings init = null, int rhub = -1) { if (init != null && init.rhub && rhub != -1) diff --git a/KlonFUN/ModInit.cs b/KlonFUN/ModInit.cs index d174c42..5cd09f1 100644 --- a/KlonFUN/ModInit.cs +++ b/KlonFUN/ModInit.cs @@ -18,7 +18,7 @@ namespace KlonFUN { public class ModInit { - public static double Version => 1.0; + public static double Version => 1.1; public static OnlinesSettings KlonFUN; public static bool ApnHostProvided; diff --git a/Makhno/Controller.cs b/Makhno/Controller.cs index c320836..755087a 100644 --- a/Makhno/Controller.cs +++ b/Makhno/Controller.cs @@ -173,15 +173,37 @@ namespace Makhno return await invoke.GetPlayerData(playUrl); }); - if (playerData?.File == null) + var movieStreams = playerData?.Movies? + .Where(m => m != null && !string.IsNullOrEmpty(m.File)) + .ToList() ?? new List(); + + if (movieStreams.Count == 0 && !string.IsNullOrEmpty(playerData?.File)) + { + movieStreams.Add(new MovieVariant + { + File = playerData.File, + Title = "Основне джерело", + Quality = "auto" + }); + } + + if (movieStreams.Count == 0) { OnLog("Makhno HandleMovie: no file parsed"); return OnError(); } - string movieLink = $"{host}/makhno/play/movie?imdb_id={HttpUtility.UrlEncode(imdb_id)}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&play=true"; - var tpl = new MovieTpl(title ?? original_title, original_title, 1); - tpl.Append(title ?? original_title, accsArgs(movieLink), method: "play"); + var tpl = new MovieTpl(title ?? original_title, original_title, movieStreams.Count); + int index = 1; + foreach (var stream in movieStreams) + { + string label = !string.IsNullOrWhiteSpace(stream.Title) + ? stream.Title + : $"Варіант {index}"; + + tpl.Append(label, BuildStreamUrl(init, stream.File)); + index++; + } return rjson ? Content(tpl.ToJson(), "application/json; charset=utf-8") : Content(tpl.ToHtml(), "text/html; charset=utf-8"); } diff --git a/Makhno/MakhnoInvoke.cs b/Makhno/MakhnoInvoke.cs index b96a845..3dd3e89 100644 --- a/Makhno/MakhnoInvoke.cs +++ b/Makhno/MakhnoInvoke.cs @@ -19,6 +19,8 @@ namespace Makhno { private const string WormholeHost = "http://wormhole.lampame.v6.rocks/"; private const string AshdiHost = "https://ashdi.vip"; + private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); + private static readonly Regex QualityFhdRegex = new Regex(@"(^|[^0-9])(1080p?)([^0-9]|$)|\bfhd\b", RegexOptions.IgnoreCase); private readonly OnlinesSettings _init; private readonly IHybridCache _hybridCache; @@ -201,19 +203,20 @@ namespace Makhno try { - string requestUrl = playerUrl; + string sourceUrl = WithAshdiMultivoice(playerUrl); + string requestUrl = sourceUrl; var headers = new List() { new HeadersModel("User-Agent", Http.UserAgent) }; - if (playerUrl.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) + if (sourceUrl.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) { headers.Add(new HeadersModel("Referer", "https://ashdi.vip/")); } - if (ApnHelper.IsAshdiUrl(playerUrl) && ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost)) - requestUrl = ApnHelper.WrapUrl(_init, playerUrl); + if (ApnHelper.IsAshdiUrl(sourceUrl) && ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost)) + requestUrl = ApnHelper.WrapUrl(_init, sourceUrl); _onLog($"Makhno getting player data from: {requestUrl}"); @@ -243,12 +246,22 @@ namespace Makhno if (fileMatch.Success && !fileMatch.Groups[1].Value.StartsWith("[")) { + string file = fileMatch.Groups[1].Value; var posterMatch = Regex.Match(html, @"poster:[""']([^""']+)[""']", RegexOptions.IgnoreCase); return new PlayerData { - File = fileMatch.Groups[1].Value, + File = file, Poster = posterMatch.Success ? posterMatch.Groups[1].Value : null, - Voices = new List() + Voices = new List(), + Movies = new List() + { + new MovieVariant + { + File = file, + Quality = DetectQualityTag(file) ?? "auto", + Title = BuildMovieTitle("Основне джерело", file, 1) + } + } }; } @@ -260,12 +273,14 @@ namespace Makhno if (!string.IsNullOrEmpty(jsonData)) { var voices = ParseVoicesJson(jsonData); + var movies = ParseMovieVariantsJson(jsonData); _onLog($"Makhno ParsePlayerData: voices={voices?.Count ?? 0}"); return new PlayerData { - File = null, + File = movies.FirstOrDefault()?.File, Poster = null, - Voices = voices + Voices = voices, + Movies = movies }; } @@ -277,7 +292,16 @@ namespace Makhno { File = m3u8Match.Groups[1].Value, Poster = null, - Voices = new List() + Voices = new List(), + Movies = new List() + { + new MovieVariant + { + File = m3u8Match.Groups[1].Value, + Quality = DetectQualityTag(m3u8Match.Groups[1].Value) ?? "auto", + Title = BuildMovieTitle("Основне джерело", m3u8Match.Groups[1].Value, 1) + } + } }; } @@ -289,7 +313,16 @@ namespace Makhno { File = sourceMatch.Groups[1].Value, Poster = null, - Voices = new List() + Voices = new List(), + Movies = new List() + { + new MovieVariant + { + File = sourceMatch.Groups[1].Value, + Quality = DetectQualityTag(sourceMatch.Groups[1].Value) ?? "auto", + Title = BuildMovieTitle("Основне джерело", sourceMatch.Groups[1].Value, 1) + } + } }; } @@ -369,6 +402,41 @@ namespace Makhno } } + private List ParseMovieVariantsJson(string jsonData) + { + try + { + var voicesArray = JsonConvert.DeserializeObject>(jsonData); + var movies = new List(); + if (voicesArray == null || voicesArray.Count == 0) + return movies; + + int index = 1; + foreach (var item in voicesArray) + { + string file = item?["file"]?.ToString(); + if (string.IsNullOrWhiteSpace(file)) + continue; + + string rawTitle = item["title"]?.ToString(); + movies.Add(new MovieVariant + { + File = file, + Quality = DetectQualityTag($"{rawTitle} {file}") ?? "auto", + Title = BuildMovieTitle(rawTitle, file, index) + }); + index++; + } + + return movies; + } + catch (Exception ex) + { + _onLog($"Makhno ParseMovieVariantsJson error: {ex.Message}"); + return new List(); + } + } + private string ExtractPlayerJson(string html) { if (string.IsNullOrEmpty(html)) @@ -531,6 +599,69 @@ namespace Makhno return null; } + private static string WithAshdiMultivoice(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.IndexOf("ashdi.vip/vod/", StringComparison.OrdinalIgnoreCase) < 0) + return url; + + if (url.IndexOf("multivoice", StringComparison.OrdinalIgnoreCase) >= 0) + return url; + + return url.Contains("?") ? $"{url}&multivoice" : $"{url}?multivoice"; + } + + private static string BuildMovieTitle(string rawTitle, string file, int index) + { + string title = string.IsNullOrWhiteSpace(rawTitle) ? $"Варіант {index}" : StripMoviePrefix(WebUtility.HtmlDecode(rawTitle).Trim()); + string qualityTag = DetectQualityTag($"{title} {file}"); + + if (string.IsNullOrWhiteSpace(qualityTag)) + return title; + + if (title.StartsWith("[4K]", StringComparison.OrdinalIgnoreCase) || title.StartsWith("[FHD]", StringComparison.OrdinalIgnoreCase)) + return title; + + return $"{qualityTag} {title}"; + } + + private static string DetectQualityTag(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (Quality4kRegex.IsMatch(value)) + return "[4K]"; + + if (QualityFhdRegex.IsMatch(value)) + return "[FHD]"; + + return null; + } + + private static string StripMoviePrefix(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return title; + + string normalized = Regex.Replace(title, @"\s+", " ").Trim(); + int sepIndex = normalized.LastIndexOf(" - ", StringComparison.Ordinal); + if (sepIndex <= 0 || sepIndex >= normalized.Length - 3) + return normalized; + + string prefix = normalized.Substring(0, sepIndex).Trim(); + string suffix = normalized.Substring(sepIndex + 3).Trim(); + if (string.IsNullOrWhiteSpace(suffix)) + return normalized; + + if (Regex.IsMatch(prefix, @"(19|20)\d{2}")) + return suffix; + + return normalized; + } + public string ExtractAshdiPath(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Makhno/ModInit.cs b/Makhno/ModInit.cs index 29dc899..e229339 100644 --- a/Makhno/ModInit.cs +++ b/Makhno/ModInit.cs @@ -23,7 +23,7 @@ namespace Makhno { public class ModInit { - public static double Version => 1.9; + public static double Version => 2.0; public static OnlinesSettings Makhno; public static bool ApnHostProvided; diff --git a/Makhno/Models/MakhnoModels.cs b/Makhno/Models/MakhnoModels.cs index 39fbfb8..9bbdb5a 100644 --- a/Makhno/Models/MakhnoModels.cs +++ b/Makhno/Models/MakhnoModels.cs @@ -36,6 +36,7 @@ namespace Makhno.Models public string Poster { get; set; } public List Voices { get; set; } public List Seasons { get; set; } + public List Movies { get; set; } } public class Voice @@ -58,4 +59,11 @@ namespace Makhno.Models public string Poster { get; set; } public string Subtitle { get; set; } } + + public class MovieVariant + { + public string Title { get; set; } + public string File { get; set; } + public string Quality { get; set; } + } } diff --git a/Mikai/Controller.cs b/Mikai/Controller.cs index 87489db..4d3a635 100644 --- a/Mikai/Controller.cs +++ b/Mikai/Controller.cs @@ -169,6 +169,21 @@ namespace Mikai.Controllers if (NeedsResolve(voice.ProviderName, episode.Url)) { + if (episode.Url.Contains("ashdi.vip/vod", StringComparison.OrdinalIgnoreCase)) + { + var ashdiStreams = await invoke.ParseAshdiPageStreams(episode.Url); + if (ashdiStreams != null && ashdiStreams.Count > 0) + { + foreach (var ashdiStream in ashdiStreams) + { + string optionName = $"{voice.DisplayName} {ashdiStream.title}"; + string ashdiCallUrl = $"{host}/mikai/play?url={HttpUtility.UrlEncode(ashdiStream.link)}&title={HttpUtility.UrlEncode(displayTitle)}"; + movieTpl.Append(optionName, accsArgs(ashdiCallUrl), "call"); + } + continue; + } + } + string callUrl = $"{host}/mikai/play?url={HttpUtility.UrlEncode(episode.Url)}&title={HttpUtility.UrlEncode(displayTitle)}"; movieTpl.Append(voice.DisplayName, accsArgs(callUrl), "call"); } diff --git a/Mikai/MikaiInvoke.cs b/Mikai/MikaiInvoke.cs index 2f8039e..e8e4c60 100644 --- a/Mikai/MikaiInvoke.cs +++ b/Mikai/MikaiInvoke.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using System.Web; +using System.Net; +using System.Text.RegularExpressions; using Mikai.Models; using Shared; using Shared.Engine; @@ -14,6 +16,9 @@ namespace Mikai { public class MikaiInvoke { + private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); + private static readonly Regex QualityFhdRegex = new Regex(@"(^|[^0-9])(1080p?)([^0-9]|$)|\bfhd\b", RegexOptions.IgnoreCase); + private readonly OnlinesSettings _init; private readonly IHybridCache _hybridCache; private readonly Action _onLog; @@ -168,6 +173,13 @@ namespace Mikai public async Task ParseAshdiPage(string url) { + var streams = await ParseAshdiPageStreams(url); + return streams?.FirstOrDefault().link; + } + + public async Task> ParseAshdiPageStreams(string url) + { + var streams = new List<(string title, string link)>(); try { var headers = new List() @@ -176,22 +188,56 @@ namespace Mikai new HeadersModel("Referer", "https://ashdi.vip/") }; - string requestUrl = AshdiRequestUrl(url); + string requestUrl = AshdiRequestUrl(WithAshdiMultivoice(url)); _onLog($"Mikai: using proxy {_proxyManager.CurrentProxyIp} for {requestUrl}"); string html = await Http.Get(_init.cors(requestUrl), headers: headers, proxy: _proxyManager.Get()); if (string.IsNullOrEmpty(html)) - return null; + return streams; - var match = System.Text.RegularExpressions.Regex.Match(html, @"file\s*:\s*['""]([^'""]+)['""]"); + string rawArray = ExtractPlayerFileArray(html); + if (!string.IsNullOrWhiteSpace(rawArray)) + { + string json = WebUtility.HtmlDecode(rawArray) + .Replace("\\/", "/") + .Replace("\\'", "'") + .Replace("\\\"", "\""); + + using var jsonDoc = JsonDocument.Parse(json); + if (jsonDoc.RootElement.ValueKind == JsonValueKind.Array) + { + int index = 1; + foreach (var item in jsonDoc.RootElement.EnumerateArray()) + { + if (!item.TryGetProperty("file", out var fileProp)) + continue; + + string file = fileProp.GetString(); + if (string.IsNullOrWhiteSpace(file)) + continue; + + string rawTitle = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + streams.Add((BuildDisplayTitle(rawTitle, file, index), file)); + index++; + } + + if (streams.Count > 0) + return streams; + } + } + + var match = Regex.Match(html, @"file\s*:\s*['""]([^'""]+)['""]"); if (match.Success) - return match.Groups[1].Value; + { + string file = match.Groups[1].Value; + streams.Add((BuildDisplayTitle("Основне джерело", file, 1), file)); + } } catch (Exception ex) { _onLog($"Mikai ParseAshdiPage error: {ex.Message}"); } - return null; + return streams; } private List DefaultHeaders() @@ -204,6 +250,168 @@ namespace Mikai }; } + private static string WithAshdiMultivoice(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.IndexOf("ashdi.vip/vod/", StringComparison.OrdinalIgnoreCase) < 0) + return url; + + if (url.IndexOf("multivoice", StringComparison.OrdinalIgnoreCase) >= 0) + return url; + + return url.Contains("?") ? $"{url}&multivoice" : $"{url}?multivoice"; + } + + private static string BuildDisplayTitle(string rawTitle, string link, int index) + { + string normalized = string.IsNullOrWhiteSpace(rawTitle) ? $"Варіант {index}" : StripMoviePrefix(WebUtility.HtmlDecode(rawTitle).Trim()); + string qualityTag = DetectQualityTag($"{normalized} {link}"); + if (string.IsNullOrWhiteSpace(qualityTag)) + return normalized; + + if (normalized.StartsWith("[4K]", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("[FHD]", StringComparison.OrdinalIgnoreCase)) + return normalized; + + return $"{qualityTag} {normalized}"; + } + + private static string DetectQualityTag(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (Quality4kRegex.IsMatch(value)) + return "[4K]"; + + if (QualityFhdRegex.IsMatch(value)) + return "[FHD]"; + + return null; + } + + private static string StripMoviePrefix(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return title; + + string normalized = Regex.Replace(title, @"\s+", " ").Trim(); + int sepIndex = normalized.LastIndexOf(" - ", StringComparison.Ordinal); + if (sepIndex <= 0 || sepIndex >= normalized.Length - 3) + return normalized; + + string prefix = normalized.Substring(0, sepIndex).Trim(); + string suffix = normalized.Substring(sepIndex + 3).Trim(); + if (string.IsNullOrWhiteSpace(suffix)) + return normalized; + + if (Regex.IsMatch(prefix, @"(19|20)\d{2}")) + return suffix; + + return normalized; + } + + private static string ExtractPlayerFileArray(string html) + { + if (string.IsNullOrWhiteSpace(html)) + return null; + + int searchIndex = 0; + while (searchIndex >= 0 && searchIndex < html.Length) + { + int fileIndex = html.IndexOf("file", searchIndex, StringComparison.OrdinalIgnoreCase); + if (fileIndex < 0) + return null; + + int colonIndex = html.IndexOf(':', fileIndex); + if (colonIndex < 0) + return null; + + int startIndex = colonIndex + 1; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + + if (startIndex < html.Length && (html[startIndex] == '\'' || html[startIndex] == '"')) + { + startIndex++; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + } + + if (startIndex >= html.Length || html[startIndex] != '[') + { + searchIndex = fileIndex + 4; + continue; + } + + return ExtractBracketArray(html, startIndex); + } + + return null; + } + + private static string ExtractBracketArray(string text, int startIndex) + { + if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '[') + return null; + + int depth = 0; + bool inString = false; + bool escaped = false; + char quoteChar = '\0'; + + for (int i = startIndex; i < text.Length; i++) + { + char ch = text[i]; + + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (ch == quoteChar) + { + inString = false; + quoteChar = '\0'; + } + + continue; + } + + if (ch == '"' || ch == '\'') + { + inString = true; + quoteChar = ch; + continue; + } + + if (ch == '[') + { + depth++; + continue; + } + + if (ch == ']') + { + depth--; + if (depth == 0) + return text.Substring(startIndex, i - startIndex + 1); + } + } + + return null; + } + public static TimeSpan cacheTime(int multiaccess, int home = 5, int mikrotik = 2, OnlinesSettings init = null, int rhub = -1) { if (init != null && init.rhub && rhub != -1) diff --git a/Mikai/ModInit.cs b/Mikai/ModInit.cs index 5853535..1708188 100644 --- a/Mikai/ModInit.cs +++ b/Mikai/ModInit.cs @@ -24,7 +24,7 @@ namespace Mikai { public class ModInit { - public static double Version => 3.6; + public static double Version => 3.7; public static OnlinesSettings Mikai; public static bool ApnHostProvided; diff --git a/UaTUT/Controller.cs b/UaTUT/Controller.cs index ecb64f3..303cd14 100644 --- a/UaTUT/Controller.cs +++ b/UaTUT/Controller.cs @@ -281,12 +281,31 @@ namespace UaTUT continue; var playerData = await invoke.GetPlayerData(playerUrl); - if (playerData?.File == null) + var movieStreams = playerData?.Movies? + .Where(m => m != null && !string.IsNullOrEmpty(m.File)) + .ToList() ?? new List(); + + if (movieStreams.Count == 0 && !string.IsNullOrEmpty(playerData?.File)) + { + movieStreams.Add(new MovieVariant + { + File = playerData.File, + Title = "Основне джерело", + Quality = "auto" + }); + } + + if (movieStreams.Count == 0) continue; - string movieName = $"{movie.Title} ({movie.Year})"; - string movieLink = $"{host}/uatut/play/movie?imdb_id={movie.Id}&title={HttpUtility.UrlEncode(movie.Title)}&year={movie.Year}"; - movie_tpl.Append(movieName, movieLink, "call"); + foreach (var variant in movieStreams) + { + string label = !string.IsNullOrWhiteSpace(variant.Title) + ? variant.Title + : "Варіант"; + + movie_tpl.Append(label, BuildStreamUrl(init, variant.File)); + } } if (movie_tpl.data == null || movie_tpl.data.Count == 0) @@ -301,7 +320,7 @@ namespace UaTUT [HttpGet] [Route("play/movie")] - async public Task PlayMovie(long imdb_id, string title, int year, bool play = false, bool rjson = false) + async public Task PlayMovie(long imdb_id, string title, int year, string stream = null, bool play = false, bool rjson = false) { await UpdateService.ConnectAsync(host); @@ -344,12 +363,16 @@ namespace UaTUT return OnError(); var playerData = await invoke.GetPlayerData(playerUrl); - if (playerData?.File == null) + string selectedFile = HttpUtility.UrlDecode(stream); + if (string.IsNullOrWhiteSpace(selectedFile)) + selectedFile = playerData?.Movies?.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m.File))?.File ?? playerData?.File; + + if (string.IsNullOrWhiteSpace(selectedFile)) return OnError(); - OnLog($"UaTUT PlayMovie: Found direct file: {playerData.File}"); + OnLog($"UaTUT PlayMovie: обрано потік {selectedFile}"); - string streamUrl = BuildStreamUrl(init, playerData.File); + string streamUrl = BuildStreamUrl(init, selectedFile); // Якщо play=true, робимо Redirect, інакше повертаємо JSON if (play) diff --git a/UaTUT/ModInit.cs b/UaTUT/ModInit.cs index 2ed2987..ef957c3 100644 --- a/UaTUT/ModInit.cs +++ b/UaTUT/ModInit.cs @@ -24,7 +24,7 @@ namespace UaTUT { public class ModInit { - public static double Version => 3.6; + public static double Version => 3.7; public static OnlinesSettings UaTUT; public static bool ApnHostProvided; diff --git a/UaTUT/Models/UaTUTModels.cs b/UaTUT/Models/UaTUTModels.cs index c9ea23d..714f274 100644 --- a/UaTUT/Models/UaTUTModels.cs +++ b/UaTUT/Models/UaTUTModels.cs @@ -36,6 +36,7 @@ namespace UaTUT.Models public string Poster { get; set; } public List Voices { get; set; } public List Seasons { get; set; } // Залишаємо для зворотної сумісності + public List Movies { get; set; } } public class Voice @@ -58,4 +59,11 @@ namespace UaTUT.Models public string Poster { get; set; } public string Subtitle { get; set; } } + + public class MovieVariant + { + public string Title { get; set; } + public string File { get; set; } + public string Quality { get; set; } + } } diff --git a/UaTUT/UaTUTInvoke.cs b/UaTUT/UaTUTInvoke.cs index 9ead66e..1c3c561 100644 --- a/UaTUT/UaTUTInvoke.cs +++ b/UaTUT/UaTUTInvoke.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; @@ -16,6 +17,9 @@ namespace UaTUT { public class UaTUTInvoke { + private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); + private static readonly Regex QualityFhdRegex = new Regex(@"(^|[^0-9])(1080p?)([^0-9]|$)|\bfhd\b", RegexOptions.IgnoreCase); + private OnlinesSettings _init; private IHybridCache _hybridCache; private Action _onLog; @@ -129,13 +133,18 @@ namespace UaTUT { try { - string requestUrl = playerUrl; - if (ApnHelper.IsAshdiUrl(playerUrl) && ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost)) - requestUrl = ApnHelper.WrapUrl(_init, playerUrl); + string sourceUrl = WithAshdiMultivoice(playerUrl); + string requestUrl = sourceUrl; + if (ApnHelper.IsAshdiUrl(sourceUrl) && ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost)) + requestUrl = ApnHelper.WrapUrl(_init, sourceUrl); _onLog($"UaTUT getting player data from: {requestUrl}"); - var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") }; + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"), + new HeadersModel("Referer", sourceUrl.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase) ? "https://ashdi.vip/" : _init.apihost) + }; var response = await Http.Get(_init.cors(requestUrl), headers: headers, proxy: _proxyManager.Get()); if (string.IsNullOrEmpty(response)) @@ -161,6 +170,15 @@ namespace UaTUT if (fileMatch.Success && !fileMatch.Groups[1].Value.StartsWith("[")) { playerData.File = fileMatch.Groups[1].Value; + playerData.Movies = new List() + { + new MovieVariant + { + File = playerData.File, + Quality = DetectQualityTag(playerData.File) ?? "auto", + Title = BuildMovieTitle("Основне джерело", playerData.File, 1) + } + }; _onLog($"UaTUT found direct file: {playerData.File}"); // Шукаємо poster @@ -172,13 +190,19 @@ namespace UaTUT } // Для серіалів шукаємо JSON структуру з сезонами та озвучками - var jsonMatch = Regex.Match(playerHtml, @"file:'(\[.*?\])'", RegexOptions.Singleline); - if (jsonMatch.Success) + string jsonData = ExtractPlayerFileArray(playerHtml); + if (!string.IsNullOrWhiteSpace(jsonData)) { - string jsonData = jsonMatch.Groups[1].Value; + string normalizedJson = WebUtility.HtmlDecode(jsonData) + .Replace("\\/", "/") + .Replace("\\'", "'") + .Replace("\\\"", "\""); + _onLog($"UaTUT found JSON data for series"); - playerData.Voices = ParseVoicesJson(jsonData); + playerData.Movies = ParseMovieVariantsJson(normalizedJson); + playerData.File = playerData.Movies?.FirstOrDefault()?.File; + playerData.Voices = ParseVoicesJson(normalizedJson); return playerData; } @@ -253,5 +277,202 @@ namespace UaTUT return new List(); } } + + private List ParseMovieVariantsJson(string jsonData) + { + try + { + var data = JsonConvert.DeserializeObject>(jsonData); + var movies = new List(); + if (data == null || data.Count == 0) + return movies; + + int index = 1; + foreach (var item in data) + { + string file = item?.file?.ToString(); + if (string.IsNullOrWhiteSpace(file)) + continue; + + string rawTitle = item?.title?.ToString(); + movies.Add(new MovieVariant + { + File = file, + Quality = DetectQualityTag($"{rawTitle} {file}") ?? "auto", + Title = BuildMovieTitle(rawTitle, file, index) + }); + index++; + } + + return movies; + } + catch (Exception ex) + { + _onLog($"UaTUT ParseMovieVariantsJson error: {ex.Message}"); + return new List(); + } + } + + private static string WithAshdiMultivoice(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.IndexOf("ashdi.vip/vod/", StringComparison.OrdinalIgnoreCase) < 0) + return url; + + if (url.IndexOf("multivoice", StringComparison.OrdinalIgnoreCase) >= 0) + return url; + + return url.Contains("?") ? $"{url}&multivoice" : $"{url}?multivoice"; + } + + private static string BuildMovieTitle(string rawTitle, string file, int index) + { + string title = string.IsNullOrWhiteSpace(rawTitle) ? $"Варіант {index}" : StripMoviePrefix(WebUtility.HtmlDecode(rawTitle).Trim()); + string qualityTag = DetectQualityTag($"{title} {file}"); + if (string.IsNullOrWhiteSpace(qualityTag)) + return title; + + if (title.StartsWith("[4K]", StringComparison.OrdinalIgnoreCase) || title.StartsWith("[FHD]", StringComparison.OrdinalIgnoreCase)) + return title; + + return $"{qualityTag} {title}"; + } + + private static string DetectQualityTag(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (Quality4kRegex.IsMatch(value)) + return "[4K]"; + + if (QualityFhdRegex.IsMatch(value)) + return "[FHD]"; + + return null; + } + + private static string StripMoviePrefix(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return title; + + string normalized = Regex.Replace(title, @"\s+", " ").Trim(); + int sepIndex = normalized.LastIndexOf(" - ", StringComparison.Ordinal); + if (sepIndex <= 0 || sepIndex >= normalized.Length - 3) + return normalized; + + string prefix = normalized.Substring(0, sepIndex).Trim(); + string suffix = normalized.Substring(sepIndex + 3).Trim(); + if (string.IsNullOrWhiteSpace(suffix)) + return normalized; + + if (Regex.IsMatch(prefix, @"(19|20)\d{2}")) + return suffix; + + return normalized; + } + + private static string ExtractPlayerFileArray(string html) + { + if (string.IsNullOrWhiteSpace(html)) + return null; + + int searchIndex = 0; + while (searchIndex >= 0 && searchIndex < html.Length) + { + int fileIndex = html.IndexOf("file", searchIndex, StringComparison.OrdinalIgnoreCase); + if (fileIndex < 0) + return null; + + int colonIndex = html.IndexOf(':', fileIndex); + if (colonIndex < 0) + return null; + + int startIndex = colonIndex + 1; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + + if (startIndex < html.Length && (html[startIndex] == '\'' || html[startIndex] == '"')) + { + startIndex++; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + } + + if (startIndex >= html.Length || html[startIndex] != '[') + { + searchIndex = fileIndex + 4; + continue; + } + + return ExtractBracketArray(html, startIndex); + } + + return null; + } + + private static string ExtractBracketArray(string text, int startIndex) + { + if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '[') + return null; + + int depth = 0; + bool inString = false; + bool escaped = false; + char quoteChar = '\0'; + + for (int i = startIndex; i < text.Length; i++) + { + char ch = text[i]; + + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (ch == quoteChar) + { + inString = false; + quoteChar = '\0'; + } + + continue; + } + + if (ch == '"' || ch == '\'') + { + inString = true; + quoteChar = ch; + continue; + } + + if (ch == '[') + { + depth++; + continue; + } + + if (ch == ']') + { + depth--; + if (depth == 0) + return text.Substring(startIndex, i - startIndex + 1); + } + } + + return null; + } } } diff --git a/Uaflix/Controller.cs b/Uaflix/Controller.cs index 4b4b952..3660df0 100644 --- a/Uaflix/Controller.cs +++ b/Uaflix/Controller.cs @@ -392,9 +392,34 @@ namespace Uaflix.Controllers } else // Фільм { - string link = $"{host}/uaflix?t={HttpUtility.UrlEncode(filmUrl)}&play=true"; - var tpl = new MovieTpl(title, original_title, 1); - tpl.Append(title, accsArgs(link), method: "play"); + var playResult = await invoke.ParseEpisode(filmUrl); + if (playResult?.streams == null || playResult.streams.Count == 0) + { + OnLog("=== RETURN: movie no streams ==="); + return OnError("uaflix", proxyManager); + } + + var tpl = new MovieTpl(title, original_title, playResult.streams.Count); + int index = 1; + foreach (var stream in playResult.streams) + { + if (stream == null || string.IsNullOrEmpty(stream.link)) + continue; + + string label = !string.IsNullOrWhiteSpace(stream.title) + ? stream.title + : $"Варіант {index}"; + + tpl.Append(label, BuildStreamUrl(init, stream.link)); + index++; + } + + if (tpl.data == null || tpl.data.Count == 0) + { + OnLog("=== RETURN: movie template empty ==="); + return OnError("uaflix", proxyManager); + } + OnLog("=== RETURN: movie template ==="); return rjson ? Content(tpl.ToJson(), "application/json; charset=utf-8") : Content(tpl.ToHtml(), "text/html; charset=utf-8"); } diff --git a/Uaflix/ModInit.cs b/Uaflix/ModInit.cs index d7aff5e..b934bc3 100644 --- a/Uaflix/ModInit.cs +++ b/Uaflix/ModInit.cs @@ -25,7 +25,7 @@ namespace Uaflix { public class ModInit { - public static double Version => 3.7; + public static double Version => 3.8; public static OnlinesSettings UaFlix; public static bool ApnHostProvided; diff --git a/Uaflix/Models/PlayResult.cs b/Uaflix/Models/PlayResult.cs index 2275af7..04914b2 100644 --- a/Uaflix/Models/PlayResult.cs +++ b/Uaflix/Models/PlayResult.cs @@ -6,7 +6,14 @@ namespace Uaflix.Models public class PlayResult { public string ashdi_url { get; set; } - public List<(string link, string quality)> streams { get; set; } + public List streams { get; set; } public SubtitleTpl? subtitles { get; set; } } -} \ No newline at end of file + + public class PlayStream + { + public string link { get; set; } + public string quality { get; set; } + public string title { get; set; } + } +} diff --git a/Uaflix/UaflixInvoke.cs b/Uaflix/UaflixInvoke.cs index 1f3e7a6..c162c9e 100644 --- a/Uaflix/UaflixInvoke.cs +++ b/Uaflix/UaflixInvoke.cs @@ -20,6 +20,9 @@ namespace Uaflix { public class UaflixInvoke { + private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); + private static readonly Regex QualityFhdRegex = new Regex(@"(^|[^0-9])(1080p?)([^0-9]|$)|\bfhd\b", RegexOptions.IgnoreCase); + private OnlinesSettings _init; private IHybridCache _hybridCache; private Action _onLog; @@ -426,40 +429,77 @@ namespace Uaflix /// /// Парсинг одного епізоду з ashdi-vod (новий метод для обробки окремих епізодів з ashdi.vip/vod/) /// - private async Task<(string file, string voiceName)> ParseAshdiVodEpisode(string iframeUrl) + private async Task> ParseAshdiVodEpisode(string iframeUrl) { var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://uafix.net/") }; - + + var result = new List(); try { - string html = await Http.Get(_init.cors(iframeUrl), headers: headers, proxy: _proxyManager.Get()); - - // Шукаємо Playerjs конфігурацію з file параметром + string requestUrl = WithAshdiMultivoice(iframeUrl); + string html = await Http.Get(_init.cors(AshdiRequestUrl(requestUrl)), headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return result; + + string rawArray = ExtractPlayerFileArray(html); + if (!string.IsNullOrWhiteSpace(rawArray)) + { + string json = WebUtility.HtmlDecode(rawArray) + .Replace("\\/", "/") + .Replace("\\'", "'") + .Replace("\\\"", "\""); + + var items = JsonConvert.DeserializeObject>(json); + if (items != null && items.Count > 0) + { + int index = 1; + foreach (var item in items) + { + string fileUrl = item?["file"]?.ToString(); + if (string.IsNullOrWhiteSpace(fileUrl)) + continue; + + string rawTitle = item["title"]?.ToString(); + result.Add(new PlayStream + { + link = fileUrl, + quality = DetectQualityTag($"{rawTitle} {fileUrl}") ?? "auto", + title = BuildDisplayTitle(rawTitle, fileUrl, index) + }); + index++; + } + + if (result.Count > 0) + return result; + } + } + + // Fallback для старого формату, де є лише один file var match = Regex.Match(html, @"file:\s*'?([^'""\s,}]+\.m3u8)'?"); if (!match.Success) - { - // Якщо не знайдено, шукаємо в іншому форматі match = Regex.Match(html, @"file['""]?\s*:\s*['""]([^'""}]+\.m3u8)['""]"); - } - + if (!match.Success) - return (null, null); - - string fileUrl = match.Groups[1].Value; - - // Визначити озвучку з URL - string voiceName = ExtractVoiceFromUrl(fileUrl); - - return (fileUrl, voiceName); + return result; + + string fallbackFile = match.Groups[1].Value; + result.Add(new PlayStream + { + link = fallbackFile, + quality = DetectQualityTag(fallbackFile) ?? "auto", + title = BuildDisplayTitle(ExtractVoiceFromUrl(fallbackFile), fallbackFile, 1) + }); + + return result; } catch (Exception ex) { _onLog($"ParseAshdiVodEpisode error: {ex.Message}"); - return (null, null); + return result; } } @@ -1283,7 +1323,7 @@ namespace Uaflix public async Task ParseEpisode(string url) { - var result = new Uaflix.Models.PlayResult() { streams = new List<(string, string)>() }; + var result = new Uaflix.Models.PlayResult() { streams = new List() }; try { string html = await Http.Get(_init.cors(url), headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }, proxy: _proxyManager.Get()); @@ -1296,7 +1336,12 @@ namespace Uaflix string videoUrl = videoNode.GetAttributeValue("src", ""); if (!string.IsNullOrEmpty(videoUrl)) { - result.streams.Add((videoUrl, "1080p")); + result.streams.Add(new PlayStream + { + link = videoUrl, + quality = "1080p", + title = BuildDisplayTitle("Основне джерело", videoUrl, 1) + }); return result; } } @@ -1325,11 +1370,7 @@ namespace Uaflix if (iframeUrl.Contains("/vod/")) { // Це окремий епізод на ashdi.vip/vod/, обробляємо як ashdi-vod - var (file, voiceName) = await ParseAshdiVodEpisode(iframeUrl); - if (!string.IsNullOrEmpty(file)) - { - result.streams.Add((file, "1080p")); - } + result.streams = await ParseAshdiVodEpisode(iframeUrl); } else { @@ -1385,9 +1426,9 @@ namespace Uaflix } } - async Task> ParseAllZetvideoSources(string iframeUrl) + async Task> ParseAllZetvideoSources(string iframeUrl) { - var result = new List<(string link, string quality)>(); + var result = new List(); var html = await Http.Get(_init.cors(iframeUrl), headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://zetvideo.net/") }, proxy: _proxyManager.Get()); if (string.IsNullOrEmpty(html)) return result; @@ -1400,7 +1441,13 @@ namespace Uaflix var match = Regex.Match(script.InnerText, @"file:\s*""([^""]+\.m3u8)"); if (match.Success) { - result.Add((match.Groups[1].Value, "1080p")); + string link = match.Groups[1].Value; + result.Add(new PlayStream + { + link = link, + quality = "1080p", + title = BuildDisplayTitle("Основне джерело", link, 1) + }); return result; } } @@ -1410,15 +1457,22 @@ namespace Uaflix { foreach (var node in sourceNodes) { - result.Add((node.GetAttributeValue("src", null), node.GetAttributeValue("label", null) ?? node.GetAttributeValue("res", null) ?? "1080p")); + string link = node.GetAttributeValue("src", null); + string quality = node.GetAttributeValue("label", null) ?? node.GetAttributeValue("res", null) ?? "1080p"; + result.Add(new PlayStream + { + link = link, + quality = quality, + title = BuildDisplayTitle(quality, link, result.Count + 1) + }); } } return result; } - async Task> ParseAllAshdiSources(string iframeUrl) + async Task> ParseAllAshdiSources(string iframeUrl) { - var result = new List<(string link, string quality)>(); + var result = new List(); var html = await Http.Get(_init.cors(AshdiRequestUrl(iframeUrl)), headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://ashdi.vip/") }, proxy: _proxyManager.Get()); if (string.IsNullOrEmpty(html)) return result; @@ -1430,7 +1484,14 @@ namespace Uaflix { foreach (var node in sourceNodes) { - result.Add((node.GetAttributeValue("src", null), node.GetAttributeValue("label", null) ?? node.GetAttributeValue("res", null) ?? "1080p")); + string link = node.GetAttributeValue("src", null); + string quality = node.GetAttributeValue("label", null) ?? node.GetAttributeValue("res", null) ?? "1080p"; + result.Add(new PlayStream + { + link = link, + quality = quality, + title = BuildDisplayTitle(quality, link, result.Count + 1) + }); } } return result; @@ -1456,6 +1517,168 @@ namespace Uaflix return null; } + private static string WithAshdiMultivoice(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.IndexOf("ashdi.vip/vod/", StringComparison.OrdinalIgnoreCase) < 0) + return url; + + if (url.IndexOf("multivoice", StringComparison.OrdinalIgnoreCase) >= 0) + return url; + + return url.Contains("?") ? $"{url}&multivoice" : $"{url}?multivoice"; + } + + private static string BuildDisplayTitle(string rawTitle, string link, int index) + { + string normalized = string.IsNullOrWhiteSpace(rawTitle) ? $"Варіант {index}" : StripMoviePrefix(WebUtility.HtmlDecode(rawTitle).Trim()); + string qualityTag = DetectQualityTag($"{normalized} {link}"); + if (string.IsNullOrWhiteSpace(qualityTag)) + return normalized; + + if (normalized.StartsWith("[4K]", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("[FHD]", StringComparison.OrdinalIgnoreCase)) + return normalized; + + return $"{qualityTag} {normalized}"; + } + + private static string DetectQualityTag(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (Quality4kRegex.IsMatch(value)) + return "[4K]"; + + if (QualityFhdRegex.IsMatch(value)) + return "[FHD]"; + + return null; + } + + private static string StripMoviePrefix(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return title; + + string normalized = Regex.Replace(title, @"\s+", " ").Trim(); + int sepIndex = normalized.LastIndexOf(" - ", StringComparison.Ordinal); + if (sepIndex <= 0 || sepIndex >= normalized.Length - 3) + return normalized; + + string prefix = normalized.Substring(0, sepIndex).Trim(); + string suffix = normalized.Substring(sepIndex + 3).Trim(); + if (string.IsNullOrWhiteSpace(suffix)) + return normalized; + + if (Regex.IsMatch(prefix, @"(19|20)\d{2}")) + return suffix; + + return normalized; + } + + private static string ExtractPlayerFileArray(string html) + { + if (string.IsNullOrWhiteSpace(html)) + return null; + + int searchIndex = 0; + while (searchIndex >= 0 && searchIndex < html.Length) + { + int fileIndex = html.IndexOf("file", searchIndex, StringComparison.OrdinalIgnoreCase); + if (fileIndex < 0) + return null; + + int colonIndex = html.IndexOf(':', fileIndex); + if (colonIndex < 0) + return null; + + int startIndex = colonIndex + 1; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + + if (startIndex < html.Length && (html[startIndex] == '\'' || html[startIndex] == '"')) + { + startIndex++; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + } + + if (startIndex >= html.Length || html[startIndex] != '[') + { + searchIndex = fileIndex + 4; + continue; + } + + return ExtractBracketArray(html, startIndex); + } + + return null; + } + + private static string ExtractBracketArray(string text, int startIndex) + { + if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '[') + return null; + + int depth = 0; + bool inString = false; + bool escaped = false; + char quoteChar = '\0'; + + for (int i = startIndex; i < text.Length; i++) + { + char ch = text[i]; + + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (ch == quoteChar) + { + inString = false; + quoteChar = '\0'; + } + + continue; + } + + if (ch == '"' || ch == '\'') + { + inString = true; + quoteChar = ch; + continue; + } + + if (ch == '[') + { + depth++; + continue; + } + + if (ch == ']') + { + depth--; + if (depth == 0) + return text.Substring(startIndex, i - startIndex + 1); + } + } + + return null; + } + sealed class EpisodePlayerInfo { public string IframeUrl { get; set; }