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
This commit is contained in:
baliasnyifeliks 2026-02-01 17:38:37 +02:00
parent 0928fb668c
commit ff320a97f9
3 changed files with 206 additions and 56 deletions

View File

@ -52,24 +52,52 @@ namespace AnimeON.Controllers
{ {
if (s == -1) // Крок 1: Вибір аніме (як сезони) if (s == -1) // Крок 1: Вибір аніме (як сезони)
{ {
var season_tpl = new SeasonTpl(seasons.Count); var seasonItems = seasons
for (int i = 0; i < seasons.Count; i++) .Select((anime, index) => new
{ {
var anime = seasons[i]; Anime = anime,
string seasonName = anime.Season.ToString(); Index = index,
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}"; SeasonNumber = anime.Season > 0 ? anime.Season : index + 1
season_tpl.Append(seasonName, link, anime.Season.ToString()); })
.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)
{
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"); return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8");
} }
else // Крок 2/3: Вибір озвучки та епізодів 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); return OnError("animeon", proxyManager);
var selectedAnime = seasons[s]; var selectedAnime = selected.Anime;
var structure = await invoke.AggregateSerialStructure(selectedAnime.Id, selectedAnime.Season); int selectedSeasonNumber = selected.SeasonNumber;
var structure = await invoke.AggregateSerialStructure(selectedAnime.Id, selectedSeasonNumber);
if (structure == null || !structure.Voices.Any()) if (structure == null || !structure.Voices.Any())
return OnError("animeon", proxyManager); return OnError("animeon", proxyManager);
@ -98,7 +126,7 @@ namespace AnimeON.Controllers
foreach (var ep in selectedVoiceInfo.Episodes.OrderBy(e => e.Number)) foreach (var ep in selectedVoiceInfo.Episodes.OrderBy(e => e.Number))
{ {
string episodeName = !string.IsNullOrEmpty(ep.Title) ? ep.Title : $"Епізод {ep.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 episodeStr = ep.Number.ToString();
string streamLink = !string.IsNullOrEmpty(ep.Hls) ? ep.Hls : ep.VideoUrl; string streamLink = !string.IsNullOrEmpty(ep.Hls) ? ep.Hls : ep.VideoUrl;

View File

@ -48,7 +48,8 @@ namespace Mikai.Controllers
return OnError("mikai", _proxyManager); return OnError("mikai", _proxyManager);
bool isSerial = serial == 1 || (serial == -1 && !string.Equals(details.Format, "movie", StringComparison.OrdinalIgnoreCase)); 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) if (voices.Count == 0)
return OnError("mikai", _proxyManager); return OnError("mikai", _proxyManager);
@ -56,12 +57,23 @@ namespace Mikai.Controllers
if (isSerial) 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) if (s == -1)
{ {
var seasonTpl = new SeasonTpl(1); 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}"; 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()); seasonTpl.Append($"{seasonNumber}", link, seasonNumber.ToString());
}
return rjson return rjson
? Content(seasonTpl.ToJson(), "application/json; charset=utf-8") ? Content(seasonTpl.ToJson(), "application/json; charset=utf-8")
@ -178,12 +190,82 @@ namespace Mikai.Controllers
return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8"));
} }
private Dictionary<string, MikaiVoiceInfo> BuildVoices(MikaiAnime details) private async Task<List<MikaiAnime>> CollectSeasonDetails(MikaiAnime details, MikaiInvoke invoke)
{
var seasonDetails = new List<MikaiAnime>();
if (details == null)
return seasonDetails;
seasonDetails.Add(details);
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)
{
var relationDetails = await invoke.GetDetails(relationId);
if (relationDetails?.Players == null || relationDetails.Players.Count == 0)
continue;
seasonDetails.Add(relationDetails);
}
return OrderSeasonDetails(seasonDetails);
}
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<MikaiAnime> OrderSeasonDetails(List<MikaiAnime> 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 (DateTime.TryParse(d.StartDate, out var parsed))
return parsed;
return DateTime.MaxValue;
})
.ThenBy(d => d.Id)
.ToList();
}
private Dictionary<string, MikaiVoiceInfo> BuildVoices(List<MikaiAnime> seasonDetails)
{ {
var voices = new Dictionary<string, MikaiVoiceInfo>(StringComparer.OrdinalIgnoreCase); var voices = new Dictionary<string, MikaiVoiceInfo>(StringComparer.OrdinalIgnoreCase);
if (details?.Players == null) if (seasonDetails == null || seasonDetails.Count == 0)
return voices; return voices;
var voiceKeyMap = new Dictionary<string, string>(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); int totalProviders = details.Players.Sum(p => p?.Providers?.Count ?? 0);
foreach (var player in details.Players) foreach (var player in details.Players)
@ -197,8 +279,10 @@ namespace Mikai.Controllers
string baseName = player.IsSubs ? $"{teamName} (Субтитри)" : teamName; string baseName = player.IsSubs ? $"{teamName} (Субтитри)" : teamName;
int providerIndex = 0;
foreach (var provider in player.Providers) foreach (var provider in player.Providers)
{ {
providerIndex++;
if (provider?.Episodes == null || provider.Episodes.Count == 0) if (provider?.Episodes == null || provider.Episodes.Count == 0)
continue; continue;
@ -206,14 +290,23 @@ namespace Mikai.Controllers
if (totalProviders > 1 && !string.IsNullOrWhiteSpace(provider.Name)) if (totalProviders > 1 && !string.IsNullOrWhiteSpace(provider.Name))
displayName = $"[{provider.Name}] {displayName}"; displayName = $"[{provider.Name}] {displayName}";
displayName = EnsureUniqueName(voices, displayName); string providerKey = string.IsNullOrWhiteSpace(provider.Name) ? $"provider-{providerIndex}" : provider.Name;
string voiceKey = $"{providerKey}|{teamName}|{player.IsSubs}";
var voice = new MikaiVoiceInfo if (!voiceKeyMap.TryGetValue(voiceKey, out var voiceName))
{
displayName = EnsureUniqueName(voices, displayName);
voiceKeyMap[voiceKey] = displayName;
voices[displayName] = new MikaiVoiceInfo
{ {
DisplayName = displayName, DisplayName = displayName,
ProviderName = provider.Name, ProviderName = provider.Name,
IsSubs = player.IsSubs IsSubs = player.IsSubs
}; };
voiceName = displayName;
}
var voice = voices[voiceName];
var episodes = new List<MikaiEpisodeInfo>(); var episodes = new List<MikaiEpisodeInfo>();
int fallbackIndex = 1; int fallbackIndex = 1;
@ -234,11 +327,13 @@ namespace Mikai.Controllers
if (episodes.Count == 0) if (episodes.Count == 0)
continue; continue;
voice.Seasons[1] = episodes; voice.Seasons[seasonNumber] = episodes;
voices[displayName] = voice;
} }
} }
seasonNumber++;
}
return voices; return voices;
} }

View File

@ -61,6 +61,9 @@ namespace Mikai.Models
[JsonPropertyName("players")] [JsonPropertyName("players")]
public List<MikaiPlayer> Players { get; set; } public List<MikaiPlayer> Players { get; set; }
[JsonPropertyName("relations")]
public List<MikaiRelation> Relations { get; set; }
} }
public class MikaiMedia public class MikaiMedia
@ -206,4 +209,28 @@ namespace Mikai.Models
[JsonPropertyName("playLink")] [JsonPropertyName("playLink")]
public string PlayLink { get; set; } 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; }
}
} }