diff --git a/Uaflix/Controller.cs b/Uaflix/Controller.cs index 94e45a6..10c2cb7 100644 --- a/Uaflix/Controller.cs +++ b/Uaflix/Controller.cs @@ -163,97 +163,50 @@ namespace Uaflix.Controllers if (serial == 1) { - // Агрегуємо всі озвучки з усіх плеєрів - var structure = await invoke.AggregateSerialStructure(filmUrl); - if (structure == null || !structure.Voices.Any()) - { - OnLog("No voices found in aggregated structure"); - OnLog("=== RETURN: no voices OnError ==="); - return OnError("uaflix", refresh_proxy: true); - } - - OnLog($"Structure aggregated successfully: {structure.Voices.Count} voices, URL: {filmUrl}"); - foreach (var voice in structure.Voices) - { - OnLog($"Voice: {voice.Key}, Type: {voice.Value.PlayerType}, Seasons: {voice.Value.Seasons.Count}"); - foreach (var season in voice.Value.Seasons) - { - OnLog($" Season {season.Key}: {season.Value.Count} episodes"); - } - } - - // s == -1: Вибір сезону + // s == -1: швидкий вибір сезону без повної агрегації серіалу if (s == -1) { - List allSeasons; - VoiceInfo tVoice = null; - bool restrictByVoice = !string.IsNullOrEmpty(t) && structure.Voices.TryGetValue(t, out tVoice) && IsAshdiVoice(tVoice); - if (restrictByVoice) - { - allSeasons = GetSeasonSet(tVoice).OrderBy(sn => sn).ToList(); - OnLog($"Ashdi voice selected (t='{t}'), seasons count={allSeasons.Count}"); - } - else - { - allSeasons = structure.Voices - .SelectMany(v => GetSeasonSet(v.Value)) - .Distinct() - .OrderBy(sn => sn) - .ToList(); - } + var seasonIndex = await invoke.GetSeasonIndex(filmUrl); + var seasons = seasonIndex?.Seasons?.Keys + .Distinct() + .OrderBy(sn => sn) + .ToList(); - OnLog($"Found {allSeasons.Count} seasons in structure: {string.Join(", ", allSeasons)}"); - - // Перевіряємо чи сезони містять валідні епізоди з файлами - var seasonsWithValidEpisodes = allSeasons.Where(season => - structure.Voices.Values.Any(v => - v.Seasons.ContainsKey(season) && - v.Seasons[season].Any(ep => !string.IsNullOrEmpty(ep.File)) - ) - ).ToList(); - - OnLog($"Seasons with valid episodes: {seasonsWithValidEpisodes.Count}"); - foreach (var season in allSeasons) + if (seasons == null || seasons.Count == 0) { - var episodesInSeason = structure.Voices.Values - .Where(v => v.Seasons.ContainsKey(season)) - .SelectMany(v => v.Seasons[season]) - .Where(ep => !string.IsNullOrEmpty(ep.File)) - .ToList(); - OnLog($"Season {season}: {episodesInSeason.Count} valid episodes"); - } - - if (!seasonsWithValidEpisodes.Any()) - { - OnLog("No seasons with valid episodes found in structure"); - OnLog("=== RETURN: no valid seasons OnError ==="); + OnLog("No seasons found in season index"); + OnLog("=== RETURN: no seasons OnError ==="); return OnError("uaflix", refresh_proxy: true); } - var season_tpl = new SeasonTpl(seasonsWithValidEpisodes.Count); - foreach (var season in seasonsWithValidEpisodes) + var season_tpl = new SeasonTpl(seasons.Count); + foreach (int season in seasons) { string link = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season}&href={HttpUtility.UrlEncode(filmUrl)}"; - if (restrictByVoice) + if (!string.IsNullOrWhiteSpace(t)) link += $"&t={HttpUtility.UrlEncode(t)}"; + season_tpl.Append($"{season}", link, season.ToString()); - OnLog($"Added season {season} to template"); } - OnLog($"Returning season template with {seasonsWithValidEpisodes.Count} seasons"); - - var htmlContent = rjson ? season_tpl.ToJson() : season_tpl.ToHtml(); - OnLog($"Season template response length: {htmlContent.Length}"); - OnLog($"Season template HTML (first 300): {htmlContent.Substring(0, Math.Min(300, htmlContent.Length))}"); - OnLog($"=== RETURN: season template ({seasonsWithValidEpisodes.Count} seasons) ==="); - - return Content(htmlContent, rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8"); + OnLog($"=== RETURN: season template ({seasons.Count} seasons) ==="); + return Content( + rjson ? season_tpl.ToJson() : season_tpl.ToHtml(), + rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8" + ); } - // s >= 0: Показуємо озвучки + епізоди - else if (s >= 0) + + // s >= 0: завантажуємо тільки потрібний сезон + if (s >= 0) { + var structure = await invoke.GetSeasonStructure(filmUrl, s); + if (structure == null || structure.Voices == null || structure.Voices.Count == 0) + { + OnLog($"No voices found for season {s}"); + OnLog("=== RETURN: no voices for season OnError ==="); + return OnError("uaflix", refresh_proxy: true); + } var voicesForSeason = structure.Voices - .Where(v => v.Value.Seasons.ContainsKey(s)) .Select(v => new { DisplayName = v.Key, Info = v.Value }) .ToList(); @@ -276,60 +229,48 @@ namespace Uaflix.Controllers OnLog($"Voice '{t}' not found, fallback to first voice: {t}"); } + VoiceInfo selectedVoice = null; + if (!structure.Voices.TryGetValue(t, out selectedVoice) || !selectedVoice.Seasons.ContainsKey(s) || selectedVoice.Seasons[s] == null || selectedVoice.Seasons[s].Count == 0) + { + var fallbackVoice = voicesForSeason.FirstOrDefault(v => v.Info.Seasons.ContainsKey(s) && v.Info.Seasons[s] != null && v.Info.Seasons[s].Count > 0); + if (fallbackVoice == null) + { + OnLog($"Season {s} not found for selected voice and fallback voice missing"); + OnLog("=== RETURN: season not found for voice OnError ==="); + return OnError("uaflix", refresh_proxy: true); + } + + t = fallbackVoice.DisplayName; + selectedVoice = fallbackVoice.Info; + OnLog($"Selected voice had no episodes, fallback to: {t}"); + } + // Створюємо VoiceTpl з усіма озвучками var voice_tpl = new VoiceTpl(); - var selectedVoiceInfo = structure.Voices[t]; - var selectedSeasonSet = GetSeasonSet(selectedVoiceInfo); - bool selectedIsAshdi = IsAshdiVoice(selectedVoiceInfo); - foreach (var voice in voicesForSeason) { - bool targetIsAshdi = IsAshdiVoice(voice.Info); - var targetSeasonSet = GetSeasonSet(voice.Info); - bool sameSeasonSet = targetSeasonSet.SetEquals(selectedSeasonSet); - bool needSeasonReset = (selectedIsAshdi || targetIsAshdi) && !sameSeasonSet; - string voiceLink = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&href={HttpUtility.UrlEncode(filmUrl)}"; - if (needSeasonReset) - voiceLink += $"&s=-1&t={HttpUtility.UrlEncode(voice.DisplayName)}"; - else - voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}"; + voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}"; bool isActive = voice.DisplayName == t; voice_tpl.Append(voice.DisplayName, isActive, voiceLink); } OnLog($"Created VoiceTpl with {voicesForSeason.Count} voices, active: {t}"); - - // Відображення епізодів для вибраної озвучки - if (!structure.Voices.ContainsKey(t)) - { - OnLog($"Voice '{t}' not found in structure"); - OnLog("=== RETURN: voice not found OnError ==="); - return OnError("uaflix", refresh_proxy: true); - } - if (!structure.Voices[t].Seasons.ContainsKey(s)) - { - OnLog($"Season {s} not found for voice '{t}'"); - if (IsAshdiVoice(structure.Voices[t])) - { - string redirectUrl = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s=-1&t={HttpUtility.UrlEncode(t)}&href={HttpUtility.UrlEncode(filmUrl)}"; - OnLog($"Ashdi voice missing season, redirect to season selector: {redirectUrl}"); - return Redirect(redirectUrl); - } - - OnLog("=== RETURN: season not found for voice OnError ==="); - return OnError("uaflix", refresh_proxy: true); - } - - var episodes = structure.Voices[t].Seasons[s]; + var episodes = selectedVoice.Seasons[s]; var episode_tpl = new EpisodeTpl(); + int appendedEpisodes = 0; foreach (var ep in episodes) { + if (ep == null || string.IsNullOrWhiteSpace(ep.File)) + continue; + + string episodeTitle = !string.IsNullOrWhiteSpace(ep.Title) ? ep.Title : $"Епізод {ep.Number}"; + // Для zetvideo-vod повертаємо URL епізоду з методом call // Для ashdi/zetvideo-serial повертаємо готове посилання з play - var voice = structure.Voices[t]; + var voice = selectedVoice; if (voice.PlayerType == "zetvideo-vod" || voice.PlayerType == "ashdi-vod") { @@ -337,7 +278,7 @@ namespace Uaflix.Controllers // Потрібно передати URL епізоду в інший параметр, щоб не плутати з play=true string callUrl = $"{host}/lite/uaflix?episode_url={HttpUtility.UrlEncode(ep.File)}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&s={s}&e={ep.Number}"; episode_tpl.Append( - name: ep.Title, + name: episodeTitle, title: title, s: s.ToString(), e: ep.Number.ToString(), @@ -351,16 +292,25 @@ namespace Uaflix.Controllers // Для багатосерійних плеєрів (ashdi-serial, zetvideo-serial) - пряме відтворення string playUrl = BuildStreamUrl(init, ep.File); episode_tpl.Append( - name: ep.Title, + name: episodeTitle, title: title, s: s.ToString(), e: ep.Number.ToString(), link: playUrl ); } + + appendedEpisodes++; } - OnLog($"Created EpisodeTpl with {episodes.Count} episodes"); + if (appendedEpisodes == 0) + { + OnLog($"No valid episodes after filtering for season {s}, voice {t}"); + OnLog("=== RETURN: no valid episodes OnError ==="); + return OnError("uaflix", refresh_proxy: true); + } + + OnLog($"Created EpisodeTpl with {appendedEpisodes} episodes"); // Повертаємо VoiceTpl + EpisodeTpl разом episode_tpl.Append(voice_tpl); @@ -470,25 +420,6 @@ namespace Uaflix.Controllers return cleaned; } - private static bool IsAshdiVoice(VoiceInfo voice) - { - if (voice == null || string.IsNullOrEmpty(voice.PlayerType)) - return false; - - return voice.PlayerType == "ashdi-serial" || voice.PlayerType == "ashdi-vod"; - } - - private static HashSet GetSeasonSet(VoiceInfo voice) - { - if (voice?.Seasons == null || voice.Seasons.Count == 0) - return new HashSet(); - - return voice.Seasons - .Where(kv => kv.Value != null && kv.Value.Any(ep => !string.IsNullOrEmpty(ep.File))) - .Select(kv => kv.Key) - .ToHashSet(); - } - private static bool IsCheckOnlineSearchEnabled() { try diff --git a/Uaflix/ModInit.cs b/Uaflix/ModInit.cs index fdf0d29..3dea888 100644 --- a/Uaflix/ModInit.cs +++ b/Uaflix/ModInit.cs @@ -19,7 +19,7 @@ namespace Uaflix { public class ModInit : IModuleLoaded { - public static double Version => 5.0; + public static double Version => 5.1; public static UaflixSettings UaFlix; diff --git a/Uaflix/Models/PaginationInfo.cs b/Uaflix/Models/PaginationInfo.cs index 495afe9..9b9bc3c 100644 --- a/Uaflix/Models/PaginationInfo.cs +++ b/Uaflix/Models/PaginationInfo.cs @@ -7,6 +7,9 @@ namespace Uaflix.Models { // Словник сезонів, де ключ - номер сезону, значення - кількість сторінок public Dictionary Seasons { get; set; } = new Dictionary(); + + // URL сторінки сезону: ключ - номер сезону, значення - абсолютний URL сторінки + public Dictionary SeasonUrls { get; set; } = new Dictionary(); // Загальна кількість сторінок (якщо потрібно) public int TotalPages { get; set; } @@ -16,4 +19,4 @@ namespace Uaflix.Models public List Episodes { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/Uaflix/UaflixInvoke.cs b/Uaflix/UaflixInvoke.cs index 2558c15..2c0f8f7 100644 --- a/Uaflix/UaflixInvoke.cs +++ b/Uaflix/UaflixInvoke.cs @@ -821,6 +821,440 @@ namespace Uaflix #endregion + #region Сезонний (лінивий) парсинг серіалу + + public async Task GetSeasonIndex(string serialUrl) + { + string memKey = $"UaFlix:season-index:{serialUrl}"; + if (_hybridCache.TryGetValue(memKey, out PaginationInfo cached)) + return cached; + + try + { + if (string.IsNullOrWhiteSpace(serialUrl) || !Uri.IsWellFormedUriString(serialUrl, UriKind.Absolute)) + return null; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + string html = await GetHtml(serialUrl, headers); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var result = new PaginationInfo + { + SerialUrl = serialUrl + }; + + var seasonNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'sez-wr')]//a"); + if (seasonNodes == null) + seasonNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'fss-box')]//a"); + + if (seasonNodes == null || seasonNodes.Count == 0) + { + // Якщо явного списку сезонів немає, вважаємо що є один сезон. + result.Seasons[1] = 1; + result.SeasonUrls[1] = serialUrl; + _hybridCache.Set(memKey, result, cacheTime(40)); + return result; + } + + foreach (var node in seasonNodes) + { + string href = node.GetAttributeValue("href", null); + string seasonUrl = ToAbsoluteUrl(href); + if (string.IsNullOrWhiteSpace(seasonUrl)) + continue; + + string tabText = WebUtility.HtmlDecode(node.InnerText ?? string.Empty); + if (!IsSeasonTabLink(seasonUrl, tabText)) + continue; + + int season = ExtractSeasonNumber(seasonUrl, tabText); + if (season <= 0) + continue; + + if (!result.SeasonUrls.TryGetValue(season, out string existing)) + { + result.SeasonUrls[season] = seasonUrl; + result.Seasons[season] = 1; + continue; + } + + if (IsPreferableSeasonUrl(existing, seasonUrl, season)) + result.SeasonUrls[season] = seasonUrl; + } + + if (result.SeasonUrls.Count == 0) + { + result.Seasons[1] = 1; + result.SeasonUrls[1] = serialUrl; + } + + _hybridCache.Set(memKey, result, cacheTime(40)); + return result; + } + catch (Exception ex) + { + _onLog($"GetSeasonIndex error: {ex.Message}"); + return null; + } + } + + public async Task> GetSeasonEpisodes(string serialUrl, int season) + { + if (season < 0) + return new List(); + + string memKey = $"UaFlix:season-episodes:{serialUrl}:{season}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + try + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + var index = await GetSeasonIndex(serialUrl); + string seasonUrl = index?.SeasonUrls != null && index.SeasonUrls.TryGetValue(season, out string mapped) + ? mapped + : serialUrl; + + if (string.IsNullOrWhiteSpace(seasonUrl)) + seasonUrl = serialUrl; + + string html = await GetHtml(seasonUrl, headers); + if (string.IsNullOrWhiteSpace(html) && !string.Equals(seasonUrl, serialUrl, StringComparison.OrdinalIgnoreCase)) + html = await GetHtml(serialUrl, headers); + + if (string.IsNullOrWhiteSpace(html)) + return new List(); + + var result = ParseSeasonEpisodesFromHtml(html, season); + if (result.Count == 0 && !string.Equals(seasonUrl, serialUrl, StringComparison.OrdinalIgnoreCase)) + { + string serialHtml = await GetHtml(serialUrl, headers); + if (!string.IsNullOrWhiteSpace(serialHtml)) + result = ParseSeasonEpisodesFromHtml(serialHtml, season); + } + + if (result.Count == 0 && season == 1 && string.Equals(seasonUrl, serialUrl, StringComparison.OrdinalIgnoreCase)) + { + // Fallback для сторінок без окремих епізодів. + result.Add(new EpisodeLinkInfo + { + url = serialUrl, + title = "Епізод 1", + season = 1, + episode = 1 + }); + } + + _hybridCache.Set(memKey, result, cacheTime(20)); + return result; + } + catch (Exception ex) + { + _onLog($"GetSeasonEpisodes error: {ex.Message}"); + return new List(); + } + } + + List ParseSeasonEpisodesFromHtml(string html, int season) + { + if (string.IsNullOrWhiteSpace(html)) + return new List(); + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var episodeNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'frels')]//a[contains(@class, 'vi-img')]"); + if (episodeNodes == null || episodeNodes.Count == 0) + return new List(); + + var episodes = new List(); + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + int fallbackEpisode = 1; + + foreach (var episodeNode in episodeNodes) + { + string episodeUrl = ToAbsoluteUrl(episodeNode.GetAttributeValue("href", null)); + if (string.IsNullOrWhiteSpace(episodeUrl) || !used.Add(episodeUrl)) + continue; + + var match = Regex.Match(episodeUrl, @"season-(\d+).*?episode-(\d+)", RegexOptions.IgnoreCase); + int parsedSeason = season; + int parsedEpisode = fallbackEpisode; + + if (match.Success) + { + if (int.TryParse(match.Groups[1].Value, out int seasonFromUrl)) + parsedSeason = seasonFromUrl; + if (int.TryParse(match.Groups[2].Value, out int episodeFromUrl)) + parsedEpisode = episodeFromUrl; + } + + episodes.Add(new EpisodeLinkInfo + { + url = episodeUrl, + title = episodeNode.SelectSingleNode(".//div[@class='vi-rate']")?.InnerText.Trim() ?? $"Епізод {parsedEpisode}", + season = parsedSeason, + episode = parsedEpisode + }); + + fallbackEpisode = Math.Max(fallbackEpisode, parsedEpisode + 1); + } + + return episodes + .Where(e => e != null && !string.IsNullOrWhiteSpace(e.url)) + .Where(e => e.season == season) + .OrderBy(e => e.episode) + .ToList(); + } + + public async Task GetSeasonStructure(string serialUrl, int season) + { + if (season < 0) + return null; + + string memKey = $"UaFlix:season-structure:{serialUrl}:{season}"; + if (_hybridCache.TryGetValue(memKey, out SerialAggregatedStructure cached)) + { + _onLog($"GetSeasonStructure: Using cached structure for season={season}, url={serialUrl}"); + return cached; + } + + try + { + var seasonEpisodes = await GetSeasonEpisodes(serialUrl, season); + if (seasonEpisodes == null || seasonEpisodes.Count == 0) + { + _onLog($"GetSeasonStructure: No episodes for season={season}, url={serialUrl}"); + return null; + } + + var structure = new SerialAggregatedStructure + { + SerialUrl = serialUrl, + AllEpisodes = seasonEpisodes + }; + + var seasonProbe = await ProbeSeasonPlayer(seasonEpisodes); + if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) + { + // fallback: інколи плеєр є лише на головній сторінці + seasonProbe = await ProbeEpisodePlayer(serialUrl); + if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) + { + _onLog($"GetSeasonStructure: unsupported player for season={season}"); + return null; + } + } + + if (seasonProbe.playerType == "ashdi-serial" || seasonProbe.playerType == "zetvideo-serial") + { + var voices = await ParseMultiEpisodePlayerCached(seasonProbe.iframeUrl, seasonProbe.playerType); + foreach (var voice in voices) + { + if (voice?.Seasons == null || !voice.Seasons.TryGetValue(season, out List seasonVoiceEpisodes) || seasonVoiceEpisodes == null || seasonVoiceEpisodes.Count == 0) + continue; + + structure.Voices[voice.DisplayName] = new VoiceInfo + { + Name = voice.Name, + PlayerType = voice.PlayerType, + DisplayName = voice.DisplayName, + Seasons = new Dictionary> + { + [season] = seasonVoiceEpisodes + .Where(ep => ep != null && !string.IsNullOrWhiteSpace(ep.File)) + .Select(ep => new EpisodeInfo + { + Number = ep.Number, + Title = ep.Title, + File = ep.File, + Id = ep.Id, + Poster = ep.Poster, + Subtitle = ep.Subtitle + }) + .ToList() + } + }; + } + } + else if (seasonProbe.playerType == "ashdi-vod" || seasonProbe.playerType == "zetvideo-vod") + { + AddVodSeasonEpisodes(structure, seasonProbe.playerType, season, seasonEpisodes); + } + else + { + _onLog($"GetSeasonStructure: player '{seasonProbe.playerType}' is not supported"); + return null; + } + + if (!structure.Voices.Any()) + { + _onLog($"GetSeasonStructure: voices are empty for season={season}, url={serialUrl}"); + return null; + } + + NormalizeUaflixVoiceNames(structure); + _hybridCache.Set(memKey, structure, cacheTime(30)); + return structure; + } + catch (Exception ex) + { + _onLog($"GetSeasonStructure error: {ex.Message}"); + return null; + } + } + + async Task> ParseMultiEpisodePlayerCached(string iframeUrl, string playerType) + { + string serialKey = NormalizeSerialPlayerKey(playerType, iframeUrl); + string memKey = $"UaFlix:player-voices:{playerType}:{serialKey}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return CloneVoices(cached); + + var parsed = await ParseMultiEpisodePlayer(iframeUrl, playerType); + if (parsed == null || parsed.Count == 0) + return new List(); + + _hybridCache.Set(memKey, parsed, cacheTime(40)); + return CloneVoices(parsed); + } + + static List CloneVoices(List voices) + { + if (voices == null || voices.Count == 0) + return new List(); + + var result = new List(voices.Count); + foreach (var voice in voices) + { + if (voice == null) + continue; + + var clone = new VoiceInfo + { + Name = voice.Name, + PlayerType = voice.PlayerType, + DisplayName = voice.DisplayName, + Seasons = new Dictionary>() + }; + + if (voice.Seasons != null) + { + foreach (var season in voice.Seasons) + { + clone.Seasons[season.Key] = season.Value? + .Where(ep => ep != null) + .Select(ep => new EpisodeInfo + { + Number = ep.Number, + Title = ep.Title, + File = ep.File, + Id = ep.Id, + Poster = ep.Poster, + Subtitle = ep.Subtitle + }) + .ToList() ?? new List(); + } + } + + result.Add(clone); + } + + return result; + } + + string ToAbsoluteUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + string clean = WebUtility.HtmlDecode(url.Trim()); + if (clean.StartsWith("//")) + clean = "https:" + clean; + + if (clean.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || clean.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + return clean; + + if (string.IsNullOrWhiteSpace(_init?.host)) + return clean; + + return $"{_init.host.TrimEnd('/')}/{clean.TrimStart('/')}"; + } + + static bool IsSeasonTabLink(string url, string text) + { + string u = (url ?? string.Empty).ToLowerInvariant(); + string t = (text ?? string.Empty).ToLowerInvariant(); + + if (u.Contains("/date/") || t.Contains("графік") || t.Contains("дата виходу")) + return false; + + if (Regex.IsMatch(u, @"(?:sezon|season)[-_/ ]?\d+", RegexOptions.IgnoreCase)) + return true; + + if (Regex.IsMatch(t, @"(?:сезон|season)\s*\d+", RegexOptions.IgnoreCase)) + return true; + + return false; + } + + static bool IsPreferableSeasonUrl(string oldUrl, string newUrl, int season) + { + if (string.IsNullOrWhiteSpace(newUrl)) + return false; + + if (string.IsNullOrWhiteSpace(oldUrl)) + return true; + + string marker = $"/sezon-{season}/"; + bool oldHasMarker = oldUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0; + bool newHasMarker = newUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0; + + if (!oldHasMarker && newHasMarker) + return true; + + return false; + } + + static int ExtractSeasonNumber(string url, string text) + { + foreach (string source in new[] { url, text }) + { + if (string.IsNullOrWhiteSpace(source)) + continue; + + var seasonBySlug = Regex.Match(source, @"(?:sezon|season)[-_/ ]?(\d+)", RegexOptions.IgnoreCase); + if (seasonBySlug.Success && int.TryParse(seasonBySlug.Groups[1].Value, out int seasonSlug) && seasonSlug > 0) + return seasonSlug; + + var seasonByWordUa = Regex.Match(source, @"сезон\s*(\d+)", RegexOptions.IgnoreCase); + if (seasonByWordUa.Success && int.TryParse(seasonByWordUa.Groups[1].Value, out int seasonWordUa) && seasonWordUa > 0) + return seasonWordUa; + + var seasonByWordEn = Regex.Match(source, @"season\s*(\d+)", RegexOptions.IgnoreCase); + if (seasonByWordEn.Success && int.TryParse(seasonByWordEn.Groups[1].Value, out int seasonWordEn) && seasonWordEn > 0) + return seasonWordEn; + } + + return 0; + } + + #endregion + public async Task> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year, int serial, string original_language, string source, string search_query) { bool allowAnime = IsAnimeRequest(title, original_title, original_language, source);