diff --git a/AnimeON/AnimeONInvoke.cs b/AnimeON/AnimeONInvoke.cs index 514b1e3..7f68469 100644 --- a/AnimeON/AnimeONInvoke.cs +++ b/AnimeON/AnimeONInvoke.cs @@ -35,7 +35,7 @@ namespace AnimeON return ApnHelper.WrapUrl(_init, url); } - public async Task> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year) + public async Task> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year, int serial) { string memKey = $"AnimeON:search:{kinopoisk_id}:{imdb_id}"; if (_hybridCache.TryGetValue(memKey, out List res)) @@ -44,7 +44,7 @@ namespace AnimeON try { var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }; - + async Task> FindAnime(string query) { if (string.IsNullOrEmpty(query)) @@ -64,7 +64,24 @@ namespace AnimeON var searchResults = await FindAnime(title) ?? await FindAnime(original_title); if (searchResults == null) return null; - + + if (serial == 1 && searchResults.Count > 0) + { + string fallbackTitleEn = searchResults.FirstOrDefault()?.TitleEn; + if (!string.IsNullOrWhiteSpace(fallbackTitleEn)) + { + var extraResults = await FindAnime(fallbackTitleEn); + if (extraResults != null && extraResults.Count > 0) + { + searchResults = searchResults + .Concat(extraResults) + .GroupBy(a => a.Id) + .Select(g => g.First()) + .ToList(); + } + } + } + if (!string.IsNullOrEmpty(imdb_id)) { var seasons = searchResults.Where(a => a.ImdbId == imdb_id).ToList(); @@ -74,7 +91,7 @@ namespace AnimeON return seasons; } } - + // Fallback to first result if no imdb match var firstResult = searchResults.FirstOrDefault(); if (firstResult != null) diff --git a/AnimeON/Controller.cs b/AnimeON/Controller.cs index 286a54f..6715fa1 100644 --- a/AnimeON/Controller.cs +++ b/AnimeON/Controller.cs @@ -39,7 +39,7 @@ namespace AnimeON.Controllers var invoke = new AnimeONInvoke(init, hybridCache, OnLog, proxyManager); OnLog($"AnimeON Index: title={title}, original_title={original_title}, serial={serial}, s={s}, t={t}, year={year}, imdb_id={imdb_id}, kp={kinopoisk_id}"); - var seasons = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year); + var seasons = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, serial); OnLog($"AnimeON: search results = {seasons?.Count ?? 0}"); if (seasons == null || seasons.Count == 0) return OnError("animeon", proxyManager); @@ -52,39 +52,79 @@ namespace AnimeON.Controllers { if (s == -1) // Крок 1: Вибір аніме (як сезони) { - var season_tpl = new SeasonTpl(seasons.Count); - for (int i = 0; i < seasons.Count; i++) + var seasonItems = seasons + .Select((anime, index) => new + { + Anime = anime, + Index = index, + SeasonNumber = anime.Season > 0 ? anime.Season : index + 1 + }) + .GroupBy(x => x.SeasonNumber) + .Select(g => g.First()) + .OrderBy(x => x.SeasonNumber) + .ToList(); + + var season_tpl = new SeasonTpl(seasonItems.Count); + foreach (var item in seasonItems) { - var anime = seasons[i]; - string seasonName = anime.Season.ToString(); - string link = $"{host}/animeon?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={i}"; - season_tpl.Append(seasonName, link, anime.Season.ToString()); + string seasonName = item.SeasonNumber.ToString(); + string link = $"{host}/animeon?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={item.SeasonNumber}"; + season_tpl.Append(seasonName, link, seasonName); } - OnLog($"AnimeON: return seasons count={seasons.Count}"); + OnLog($"AnimeON: return seasons count={seasonItems.Count}"); return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); } else // Крок 2/3: Вибір озвучки та епізодів { - if (s >= seasons.Count) + var seasonItems = seasons + .Select((anime, index) => new + { + Anime = anime, + Index = index, + SeasonNumber = anime.Season > 0 ? anime.Season : index + 1 + }) + .GroupBy(x => x.SeasonNumber) + .Select(g => g.First()) + .OrderBy(x => x.SeasonNumber) + .ToList(); + + var selected = seasonItems.FirstOrDefault(x => x.SeasonNumber == s); + if (selected == null && s >= 0 && s < seasons.Count) + selected = new { Anime = seasons[s], Index = s, SeasonNumber = seasons[s].Season > 0 ? seasons[s].Season : s + 1 }; + + if (selected == null) return OnError("animeon", proxyManager); - var selectedAnime = seasons[s]; - var structure = await invoke.AggregateSerialStructure(selectedAnime.Id, selectedAnime.Season); + var selectedAnime = selected.Anime; + int selectedSeasonNumber = selected.SeasonNumber; + var structure = await invoke.AggregateSerialStructure(selectedAnime.Id, selectedSeasonNumber); if (structure == null || !structure.Voices.Any()) return OnError("animeon", proxyManager); OnLog($"AnimeON: voices found = {structure.Voices.Count}"); + var voiceItems = structure.Voices + .Select(v => + { + string display = v.Value?.DisplayName; + if (string.IsNullOrWhiteSpace(display)) + display = v.Key; + if (string.IsNullOrWhiteSpace(display)) + display = "Озвучка"; + return new { Key = v.Key, Display = display }; + }) + .ToList(); + // Автовибір першої озвучки якщо t не задано if (string.IsNullOrEmpty(t)) - t = structure.Voices.Keys.First(); + t = voiceItems.First().Key; // Формуємо список озвучок var voice_tpl = new VoiceTpl(); - foreach (var voice in structure.Voices) + foreach (var voice in voiceItems) { string voiceLink = $"{host}/animeon?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&t={HttpUtility.UrlEncode(voice.Key)}"; bool isActive = voice.Key == t; - voice_tpl.Append(voice.Key, isActive, voiceLink); + voice_tpl.Append(voice.Display, isActive, voiceLink); } // Перевірка вибраної озвучки @@ -98,7 +138,7 @@ namespace AnimeON.Controllers foreach (var ep in selectedVoiceInfo.Episodes.OrderBy(e => e.Number)) { string episodeName = !string.IsNullOrEmpty(ep.Title) ? ep.Title : $"Епізод {ep.Number}"; - string seasonStr = selectedAnime.Season.ToString(); + string seasonStr = selectedSeasonNumber.ToString(); string episodeStr = ep.Number.ToString(); string streamLink = !string.IsNullOrEmpty(ep.Hls) ? ep.Hls : ep.VideoUrl; @@ -128,10 +168,11 @@ namespace AnimeON.Controllers // Повертаємо озвучки + епізоди разом OnLog($"AnimeON: return episodes count={selectedVoiceInfo.Episodes.Count} for voice='{t}' season={selectedAnime.Season}"); + episode_tpl.Append(voice_tpl); if (rjson) - return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8"); + return Content(episode_tpl.ToJson(), "application/json; charset=utf-8"); - return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8"); + return Content(episode_tpl.ToHtml(), "text/html; charset=utf-8"); } } else // Фільм diff --git a/Mikai/Controller.cs b/Mikai/Controller.cs index 3fcd339..51cc11c 100644 --- a/Mikai/Controller.cs +++ b/Mikai/Controller.cs @@ -48,7 +48,8 @@ namespace Mikai.Controllers return OnError("mikai", _proxyManager); bool isSerial = serial == 1 || (serial == -1 && !string.Equals(details.Format, "movie", StringComparison.OrdinalIgnoreCase)); - var voices = BuildVoices(details); + var seasonDetails = await CollectSeasonDetails(details, invoke); + var voices = BuildVoices(seasonDetails); if (voices.Count == 0) return OnError("mikai", _proxyManager); @@ -56,12 +57,23 @@ namespace Mikai.Controllers if (isSerial) { - const int seasonNumber = 1; + var seasonNumbers = voices.Values + .SelectMany(v => v.Seasons.Keys) + .Distinct() + .OrderBy(n => n) + .ToList(); + + if (seasonNumbers.Count == 0) + return OnError("mikai", _proxyManager); + if (s == -1) { - var seasonTpl = new SeasonTpl(1); - string link = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={seasonNumber}"; - seasonTpl.Append($"{seasonNumber}", link, seasonNumber.ToString()); + var seasonTpl = new SeasonTpl(seasonNumbers.Count); + foreach (var seasonNumber in seasonNumbers) + { + string link = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={seasonNumber}"; + seasonTpl.Append($"{seasonNumber}", link, seasonNumber.ToString()); + } return rjson ? Content(seasonTpl.ToJson(), "application/json; charset=utf-8") @@ -109,10 +121,11 @@ namespace Mikai.Controllers } } + episodeTpl.Append(voiceTpl); if (rjson) - return Content(episodeTpl.ToJson(voiceTpl), "application/json; charset=utf-8"); + return Content(episodeTpl.ToJson(), "application/json; charset=utf-8"); - return Content(voiceTpl.ToHtml() + episodeTpl.ToHtml(), "text/html; charset=utf-8"); + return Content(episodeTpl.ToHtml(), "text/html; charset=utf-8"); } var movieTpl = new MovieTpl(displayTitle, original_title); @@ -178,65 +191,148 @@ namespace Mikai.Controllers return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); } - private Dictionary BuildVoices(MikaiAnime details) + private async Task> CollectSeasonDetails(MikaiAnime details, MikaiInvoke invoke) { - var voices = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (details?.Players == null) - return voices; + var seasonDetails = new List(); + if (details == null) + return seasonDetails; - int totalProviders = details.Players.Sum(p => p?.Providers?.Count ?? 0); + seasonDetails.Add(details); - foreach (var player in details.Players) + if (details.Relations == null || details.Relations.Count == 0) + return seasonDetails; + + var relationIds = details.Relations + .Where(r => ShouldIncludeRelation(r?.RelationType)) + .Select(r => r?.Anime?.Id ?? 0) + .Where(id => id > 0 && id != details.Id) + .Distinct() + .ToList(); + + foreach (var relationId in relationIds) { - if (player?.Providers == null || player.Providers.Count == 0) + var relationDetails = await invoke.GetDetails(relationId); + if (relationDetails?.Players == null || relationDetails.Players.Count == 0) continue; - string teamName = player.Team?.Name; - if (string.IsNullOrWhiteSpace(teamName)) - teamName = "Озвучка"; + seasonDetails.Add(relationDetails); + } - string baseName = player.IsSubs ? $"{teamName} (Субтитри)" : teamName; + return OrderSeasonDetails(seasonDetails); + } - foreach (var provider in player.Providers) + private static bool ShouldIncludeRelation(string relationType) + { + if (string.IsNullOrWhiteSpace(relationType)) + return false; + + return relationType.Equals("other", StringComparison.OrdinalIgnoreCase) || + relationType.Equals("parent", StringComparison.OrdinalIgnoreCase) || + relationType.Equals("sequel", StringComparison.OrdinalIgnoreCase) || + relationType.Equals("prequel", StringComparison.OrdinalIgnoreCase); + } + + private static List OrderSeasonDetails(List seasonDetails) + { + return seasonDetails + .Where(d => d != null) + .GroupBy(d => d.Id) + .Select(g => g.First()) + .OrderBy(d => d.Year > 0 ? d.Year : int.MaxValue) + .ThenBy(d => { - if (provider?.Episodes == null || provider.Episodes.Count == 0) + if (DateTime.TryParse(d.StartDate, out var parsed)) + return parsed; + + return DateTime.MaxValue; + }) + .ThenBy(d => d.Id) + .ToList(); + } + + private Dictionary BuildVoices(List seasonDetails) + { + var voices = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (seasonDetails == null || seasonDetails.Count == 0) + return voices; + + var voiceKeyMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + int seasonNumber = 1; + + foreach (var details in seasonDetails) + { + if (details?.Players == null || details.Players.Count == 0) + { + seasonNumber++; + continue; + } + + int totalProviders = details.Players.Sum(p => p?.Providers?.Count ?? 0); + + foreach (var player in details.Players) + { + if (player?.Providers == null || player.Providers.Count == 0) continue; - string displayName = baseName; - if (totalProviders > 1 && !string.IsNullOrWhiteSpace(provider.Name)) - displayName = $"[{provider.Name}] {displayName}"; + string teamName = player.Team?.Name; + if (string.IsNullOrWhiteSpace(teamName)) + teamName = "Озвучка"; - displayName = EnsureUniqueName(voices, displayName); + string baseName = player.IsSubs ? $"{teamName} (Субтитри)" : teamName; - var voice = new MikaiVoiceInfo + int providerIndex = 0; + foreach (var provider in player.Providers) { - DisplayName = displayName, - ProviderName = provider.Name, - IsSubs = player.IsSubs - }; - - var episodes = new List(); - int fallbackIndex = 1; - foreach (var ep in provider.Episodes.OrderBy(e => e.Number)) - { - if (string.IsNullOrWhiteSpace(ep.PlayLink)) + providerIndex++; + if (provider?.Episodes == null || provider.Episodes.Count == 0) continue; - int number = ep.Number > 0 ? ep.Number : fallbackIndex++; - episodes.Add(new MikaiEpisodeInfo + string displayName = baseName; + if (totalProviders > 1 && !string.IsNullOrWhiteSpace(provider.Name)) + displayName = $"[{provider.Name}] {displayName}"; + + string providerKey = string.IsNullOrWhiteSpace(provider.Name) ? $"provider-{providerIndex}" : provider.Name; + string voiceKey = $"{providerKey}|{teamName}|{player.IsSubs}"; + + if (!voiceKeyMap.TryGetValue(voiceKey, out var voiceName)) { - Number = number, - Title = $"Епізод {number}", - Url = ep.PlayLink - }); + displayName = EnsureUniqueName(voices, displayName); + voiceKeyMap[voiceKey] = displayName; + voices[displayName] = new MikaiVoiceInfo + { + DisplayName = displayName, + ProviderName = provider.Name, + IsSubs = player.IsSubs + }; + voiceName = displayName; + } + + var voice = voices[voiceName]; + + var episodes = new List(); + int fallbackIndex = 1; + foreach (var ep in provider.Episodes.OrderBy(e => e.Number)) + { + if (string.IsNullOrWhiteSpace(ep.PlayLink)) + continue; + + int number = ep.Number > 0 ? ep.Number : fallbackIndex++; + episodes.Add(new MikaiEpisodeInfo + { + Number = number, + Title = $"Епізод {number}", + Url = ep.PlayLink + }); + } + + if (episodes.Count == 0) + continue; + + voice.Seasons[seasonNumber] = episodes; } - - if (episodes.Count == 0) - continue; - - voice.Seasons[1] = episodes; - voices[displayName] = voice; } + + seasonNumber++; } return voices; diff --git a/Mikai/Models/MikaiModels.cs b/Mikai/Models/MikaiModels.cs index b1e6c6f..5d17c35 100644 --- a/Mikai/Models/MikaiModels.cs +++ b/Mikai/Models/MikaiModels.cs @@ -61,6 +61,9 @@ namespace Mikai.Models [JsonPropertyName("players")] public List Players { get; set; } + + [JsonPropertyName("relations")] + public List Relations { get; set; } } public class MikaiMedia @@ -206,4 +209,28 @@ namespace Mikai.Models [JsonPropertyName("playLink")] public string PlayLink { get; set; } } + + public class MikaiRelation + { + [JsonPropertyName("relationType")] + public string RelationType { get; set; } + + [JsonPropertyName("anime")] + public MikaiRelationAnime Anime { get; set; } + } + + public class MikaiRelationAnime + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("media")] + public MikaiMedia Media { get; set; } + + [JsonPropertyName("details")] + public MikaiDetails Details { get; set; } + } }