From ff320a97f97934625d4cf400ca7da1fbe57963dd Mon Sep 17 00:00:00 2001 From: baliasnyifeliks Date: Sun, 1 Feb 2026 17:38:37 +0200 Subject: [PATCH 1/3] feat(animeon, mikai): add multi-season support for anime controllers Implement comprehensive multi-season handling for AnimeON and Mikai controllers to properly display and navigate anime series with multiple seasons. For AnimeON: - Fix season numbering to use actual season numbers instead of array indices - Group seasons by SeasonNumber property with fallback to index-based numbering - Update season selection links to use correct season identifiers For Mikai: - Add relation fetching to discover sequels, prequels, and related anime - Implement season detail collection and ordering by release date - Refactor voice building to support multiple seasons per voice actor - Update season selection UI to display all available seasons - Add MikaiRelation and MikaiRelationAnime models for API data handling --- AnimeON/Controller.cs | 50 +++++++--- Mikai/Controller.cs | 185 +++++++++++++++++++++++++++--------- Mikai/Models/MikaiModels.cs | 27 ++++++ 3 files changed, 206 insertions(+), 56 deletions(-) diff --git a/AnimeON/Controller.cs b/AnimeON/Controller.cs index 286a54f..0c1b7df 100644 --- a/AnimeON/Controller.cs +++ b/AnimeON/Controller.cs @@ -52,24 +52,52 @@ 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); @@ -98,7 +126,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; diff --git a/Mikai/Controller.cs b/Mikai/Controller.cs index 3fcd339..c664336 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") @@ -178,65 +190,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; } + } } From 35769320892b01a3edc7cca506c52a04cbf863ed Mon Sep 17 00:00:00 2001 From: baliasnyifeliks Date: Sun, 1 Feb 2026 18:05:28 +0200 Subject: [PATCH 2/3] feat(animeon): enhance search for serials and improve voice display Add serial parameter to Search method to enable enhanced search logic for series content. When serial flag is set, perform additional search using English title to find more results. Improve voice selection by properly handling display names with fallbacks to key or default label. --- AnimeON/AnimeONInvoke.cs | 25 +++++++++++++++++++++---- AnimeON/Controller.cs | 20 ++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) 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 0c1b7df..153915b 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); @@ -102,17 +102,29 @@ namespace AnimeON.Controllers 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); } // Перевірка вибраної озвучки From 764bee04058b9165f492bf69d7233e113303a2b4 Mon Sep 17 00:00:00 2001 From: baliasnyifeliks Date: Sun, 1 Feb 2026 18:17:04 +0200 Subject: [PATCH 3/3] refactor(animeon, mikai): consolidate template output --- AnimeON/Controller.cs | 5 +++-- Mikai/Controller.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/AnimeON/Controller.cs b/AnimeON/Controller.cs index 153915b..6715fa1 100644 --- a/AnimeON/Controller.cs +++ b/AnimeON/Controller.cs @@ -168,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 c664336..51cc11c 100644 --- a/Mikai/Controller.cs +++ b/Mikai/Controller.cs @@ -121,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);