From f793fefa82bc6cb7cdcb0f52042f3c1d03050fbe Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 29 May 2026 16:55:27 +0300 Subject: [PATCH] feat(uaflix): support multiple episode streams and translations Add stream selection by voice title when multiple player sources are available, and preserve all zetvideo iframe URLs on episode pages so multiple translations can be generated. Update episode probing to return structured player info and propagate the selected stream metadata through play and episode JSON responses. --- LME.Uaflix/Controller.cs | 48 ++++++-- LME.Uaflix/Models/EpisodeLinkInfo.cs | 7 ++ LME.Uaflix/UaflixInvoke.cs | 160 ++++++++++++++++++++------- 3 files changed, 166 insertions(+), 49 deletions(-) diff --git a/LME.Uaflix/Controller.cs b/LME.Uaflix/Controller.cs index 065bb5d..c9588a2 100644 --- a/LME.Uaflix/Controller.cs +++ b/LME.Uaflix/Controller.cs @@ -76,8 +76,10 @@ namespace LME.Uaflix.Controllers if (play) { - // Визначаємо URL для парсингу - або з параметра t, або з episode_url - string urlToParse = !string.IsNullOrEmpty(t) ? t : Request.Query["episode_url"]; + // Визначаємо URL для парсингу (параметр t тепер може бути назвою голосу, а не URL) + string urlToParse = Request.Query["episode_url"]; + if (string.IsNullOrWhiteSpace(urlToParse)) + urlToParse = t; if (string.IsNullOrWhiteSpace(urlToParse)) { OnLog("=== RETURN: play missing url OnError ==="); @@ -87,8 +89,21 @@ namespace LME.Uaflix.Controllers var playResult = await invoke.ParseEpisode(urlToParse); if (playResult.streams != null && playResult.streams.Count > 0) { - OnLog("=== RETURN: play redirect ==="); - return UpdateService.Validate(Redirect(BuildStreamUrl(init, playResult.streams.First().link))); + // Якщо кілька потоків, вибираємо за голосом + PlayStream targetStream; + if (playResult.streams.Count > 1 && !string.IsNullOrEmpty(t)) + { + targetStream = playResult.streams.FirstOrDefault(s => + string.Equals(s.title, t, StringComparison.OrdinalIgnoreCase)) + ?? playResult.streams.First(); + } + else + { + targetStream = playResult.streams.First(); + } + + OnLog($"=== RETURN: play redirect (stream: {targetStream.title}) ==="); + return UpdateService.Validate(Redirect(BuildStreamUrl(init, targetStream.link))); } OnLog("=== RETURN: play no streams ==="); @@ -102,9 +117,28 @@ namespace LME.Uaflix.Controllers var playResult = await invoke.ParseEpisode(episodeUrl); if (playResult.streams != null && playResult.streams.Count > 0) { + // Якщо є кілька потоків (напр. Uaflix + Оригінал), вибираємо за голосом (t) + PlayStream targetStream; + if (playResult.streams.Count > 1 && !string.IsNullOrEmpty(t)) + { + targetStream = playResult.streams.FirstOrDefault(s => + string.Equals(s.title, t, StringComparison.OrdinalIgnoreCase)); + if (targetStream == null) + { + _onLog($"call method: голос '{t}' не знайдено серед потоків, використовую перший"); + targetStream = playResult.streams.First(); + } + else + _onLog($"call method: вибрано потік для голосу '{t}'"); + } + else + { + targetStream = playResult.streams.First(); + } + // Повертаємо JSON з інформацією про стрім для методу 'play' - string streamUrl = BuildStreamUrl(init, playResult.streams.First().link); - var subtitles = playResult.subtitles ?? playResult.streams.FirstOrDefault(s => s.subtitles != null)?.subtitles; + string streamUrl = BuildStreamUrl(init, targetStream.link); + var subtitles = playResult.subtitles ?? targetStream.subtitles; OnLog($"=== RETURN: call method JSON for episode_url ==="); return UpdateService.Validate(Content(VideoTpl.ToJson("play", streamUrl, title ?? original_title, subtitles: subtitles), "application/json; charset=utf-8")); @@ -277,7 +311,7 @@ namespace LME.Uaflix.Controllers { // Для zetvideo-vod та ashdi-vod використовуємо URL епізоду для виклику // Потрібно передати URL епізоду в інший параметр, щоб не плутати з play=true - string callUrl = $"{host}/lite/lme_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}"; + string callUrl = $"{host}/lite/lme_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}&t={HttpUtility.UrlEncode(t ?? "Uaflix")}"; episode_tpl.Append( name: episodeTitle, title: title, diff --git a/LME.Uaflix/Models/EpisodeLinkInfo.cs b/LME.Uaflix/Models/EpisodeLinkInfo.cs index d21c791..eb4eedb 100644 --- a/LME.Uaflix/Models/EpisodeLinkInfo.cs +++ b/LME.Uaflix/Models/EpisodeLinkInfo.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace LME.Uaflix.Models { @@ -12,5 +13,11 @@ namespace LME.Uaflix.Models // Нові поля для підтримки змішаних плеєрів public string playerType { get; set; } // "ashdi-serial", "zetvideo-serial", "zetvideo-vod", "ashdi-vod" public string iframeUrl { get; set; } // URL iframe для цього епізоду + + /// + /// Всі zetvideo iframe URL на сторінці епізоду (для створення кількох перекладів) + /// Перший елемент відповідає iframeUrl, наступні — додаткові плеєри (напр. з субтитрами) + /// + public List zetvideoIframeUrls { get; set; } } } \ No newline at end of file diff --git a/LME.Uaflix/UaflixInvoke.cs b/LME.Uaflix/UaflixInvoke.cs index fc7b43f..676cd22 100644 --- a/LME.Uaflix/UaflixInvoke.cs +++ b/LME.Uaflix/UaflixInvoke.cs @@ -286,14 +286,14 @@ namespace LME.Uaflix return result; } - private async Task<(string iframeUrl, string playerType)> ProbeEpisodePlayer(string pageUrl) + private async Task ProbeEpisodePlayer(string pageUrl) { if (string.IsNullOrWhiteSpace(pageUrl)) - return (null, null); + return null; string memKey = $"lme_uaflix:episode-player:{pageUrl}"; if (_hybridCache.TryGetValue(memKey, out EpisodePlayerInfo cached)) - return (cached?.IframeUrl, cached?.PlayerType); + return cached; try { @@ -305,7 +305,7 @@ namespace LME.Uaflix string html = await GetHtml(pageUrl, headers); if (string.IsNullOrWhiteSpace(html)) - return (null, null); + return null; var doc = new HtmlDocument(); doc.LoadHtml(html); @@ -313,25 +313,40 @@ namespace LME.Uaflix string iframeUrl = ExtractIframeUrl(doc); string playerType = DeterminePlayerType(iframeUrl); - _hybridCache.Set(memKey, new EpisodePlayerInfo + // Витягуємо всі zetvideo iframe для підтримки кількох перекладів + List zetvideoIframeUrls = null; + if (playerType == "zetvideo-vod") + { + var allIframes = ExtractAllIframeUrls(doc); + var zetIframes = allIframes + .Where(u => u != null && u.Contains("zetvideo.net")) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (zetIframes.Count > 1) + zetvideoIframeUrls = zetIframes; + } + + var info = new EpisodePlayerInfo { IframeUrl = iframeUrl, - PlayerType = playerType - }, cacheTime(20)); + PlayerType = playerType, + ZetvideoIframeUrls = zetvideoIframeUrls + }; - return (iframeUrl, playerType); + _hybridCache.Set(memKey, info, cacheTime(20)); + return info; } catch (Exception ex) { _onLog($"ProbeEpisodePlayer error ({pageUrl}): {ex.Message}"); - return (null, null); + return null; } } - private async Task<(string iframeUrl, string playerType)> ProbeSeasonPlayer(List seasonEpisodes) + private async Task ProbeSeasonPlayer(List seasonEpisodes) { if (seasonEpisodes == null || seasonEpisodes.Count == 0) - return (null, null); + return null; foreach (var episode in seasonEpisodes.OrderBy(e => e.episode)) { @@ -339,21 +354,26 @@ namespace LME.Uaflix continue; var probed = await ProbeEpisodePlayer(episode.url); - string playerType = probed.playerType; - - episode.iframeUrl = probed.iframeUrl; - episode.playerType = playerType; - - if (string.IsNullOrWhiteSpace(playerType)) + if (probed == null) continue; - if (playerType == "trailer") + episode.iframeUrl = probed.IframeUrl; + episode.playerType = probed.PlayerType; + + // Зберігаємо всі zetvideo iframe для створення кількох перекладів + if (probed.ZetvideoIframeUrls != null && probed.ZetvideoIframeUrls.Count > 0) + episode.zetvideoIframeUrls = probed.ZetvideoIframeUrls; + + if (string.IsNullOrWhiteSpace(probed.PlayerType)) + continue; + + if (probed.PlayerType == "trailer") continue; return probed; } - return (null, null); + return null; } private static string NormalizeSerialPlayerKey(string playerType, string iframeUrl) @@ -732,22 +752,22 @@ namespace LME.Uaflix _onLog($"AggregateSerialStructure: Processing season {season}"); var seasonProbe = await ProbeSeasonPlayer(seasonGroup.Value); - if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) + if (seasonProbe == null || string.IsNullOrWhiteSpace(seasonProbe.PlayerType)) { _onLog($"AggregateSerialStructure: Season {season} has no supported player"); continue; } - if (seasonProbe.playerType == "ashdi-serial" || seasonProbe.playerType == "zetvideo-serial") + if (seasonProbe.PlayerType == "ashdi-serial" || seasonProbe.PlayerType == "zetvideo-serial") { - string serialKey = NormalizeSerialPlayerKey(seasonProbe.playerType, seasonProbe.iframeUrl); + string serialKey = NormalizeSerialPlayerKey(seasonProbe.PlayerType, seasonProbe.IframeUrl); if (!serialPlayersProcessed.Add(serialKey)) { _onLog($"AggregateSerialStructure: Serial player already parsed for season {season}: {serialKey}"); continue; } - var voices = await ParseMultiEpisodePlayer(seasonProbe.iframeUrl, seasonProbe.playerType); + var voices = await ParseMultiEpisodePlayer(seasonProbe.IframeUrl, seasonProbe.PlayerType); if (voices == null || voices.Count == 0) { _onLog($"AggregateSerialStructure: No voices in serial player for season {season}"); @@ -755,18 +775,18 @@ namespace LME.Uaflix } MergeVoices(structure, voices); - _onLog($"AggregateSerialStructure: Parsed serial player {seasonProbe.playerType}, voices={voices.Count}"); + _onLog($"AggregateSerialStructure: Parsed serial player {seasonProbe.PlayerType}, voices={voices.Count}"); continue; } - if (seasonProbe.playerType == "ashdi-vod" || seasonProbe.playerType == "zetvideo-vod") + if (seasonProbe.PlayerType == "ashdi-vod" || seasonProbe.PlayerType == "zetvideo-vod") { - AddVodSeasonEpisodes(structure, seasonProbe.playerType, season, seasonGroup.Value); + AddVodSeasonEpisodes(structure, seasonProbe.PlayerType, season, seasonGroup.Value); _onLog($"AggregateSerialStructure: Added vod season {season}, episodes={seasonGroup.Value.Count}"); continue; } - _onLog($"AggregateSerialStructure: Unsupported player {seasonProbe.playerType} for season {season}"); + _onLog($"AggregateSerialStructure: Unsupported player {seasonProbe.PlayerType} for season {season}"); } } else @@ -774,15 +794,15 @@ namespace LME.Uaflix _onLog($"AggregateSerialStructure: No episodes from pagination for {serialUrl}, fallback to page iframe"); var serialProbe = await ProbeEpisodePlayer(serialUrl); - if (string.IsNullOrWhiteSpace(serialProbe.playerType)) + if (serialProbe == null || string.IsNullOrWhiteSpace(serialProbe.PlayerType)) { _onLog($"AggregateSerialStructure: Fallback probe failed for {serialUrl}"); return null; } - if (serialProbe.playerType == "ashdi-serial" || serialProbe.playerType == "zetvideo-serial") + if (serialProbe.PlayerType == "ashdi-serial" || serialProbe.PlayerType == "zetvideo-serial") { - var voices = await ParseMultiEpisodePlayer(serialProbe.iframeUrl, serialProbe.playerType); + var voices = await ParseMultiEpisodePlayer(serialProbe.IframeUrl, serialProbe.PlayerType); if (voices == null || voices.Count == 0) { _onLog($"AggregateSerialStructure: Fallback serial player has no voices for {serialUrl}"); @@ -792,8 +812,13 @@ namespace LME.Uaflix MergeVoices(structure, voices); _onLog($"AggregateSerialStructure: Fallback serial player parsed, voices={voices.Count}"); } - else if (serialProbe.playerType == "ashdi-vod" || serialProbe.playerType == "zetvideo-vod") + else if (serialProbe.PlayerType == "ashdi-vod" || serialProbe.PlayerType == "zetvideo-vod") { + // Копіюємо zetvideoIframeUrls якщо є + List zetvideoUrls = null; + if (serialProbe.ZetvideoIframeUrls != null && serialProbe.ZetvideoIframeUrls.Count > 0) + zetvideoUrls = serialProbe.ZetvideoIframeUrls; + var syntheticEpisodes = new List { new EpisodeLinkInfo @@ -802,17 +827,18 @@ namespace LME.Uaflix title = "Епізод 1", season = 1, episode = 1, - iframeUrl = serialProbe.iframeUrl, - playerType = serialProbe.playerType + iframeUrl = serialProbe.IframeUrl, + playerType = serialProbe.PlayerType, + zetvideoIframeUrls = zetvideoUrls } }; structure.AllEpisodes = syntheticEpisodes; - AddVodSeasonEpisodes(structure, serialProbe.playerType, 1, syntheticEpisodes); + AddVodSeasonEpisodes(structure, serialProbe.PlayerType, 1, syntheticEpisodes); } else { - _onLog($"AggregateSerialStructure: Fallback player is not supported for serial: {serialProbe.playerType}"); + _onLog($"AggregateSerialStructure: Fallback player is not supported for serial: {serialProbe.PlayerType}"); return null; } } @@ -1096,20 +1122,20 @@ namespace LME.Uaflix }; var seasonProbe = await ProbeSeasonPlayer(seasonEpisodes); - if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) + if (seasonProbe == null || string.IsNullOrWhiteSpace(seasonProbe.PlayerType)) { // fallback: інколи плеєр є лише на головній сторінці seasonProbe = await ProbeEpisodePlayer(serialUrl); - if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) + if (seasonProbe == null || string.IsNullOrWhiteSpace(seasonProbe.PlayerType)) { _onLog($"GetSeasonStructure: unsupported player for season={season}"); return null; } } - if (seasonProbe.playerType == "ashdi-serial" || seasonProbe.playerType == "zetvideo-serial") + if (seasonProbe.PlayerType == "ashdi-serial" || seasonProbe.PlayerType == "zetvideo-serial") { - var voices = await ParseMultiEpisodePlayerCached(seasonProbe.iframeUrl, seasonProbe.playerType); + 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) @@ -1138,13 +1164,53 @@ namespace LME.Uaflix }; } } - else if (seasonProbe.playerType == "ashdi-vod" || seasonProbe.playerType == "zetvideo-vod") + else if (seasonProbe.PlayerType == "ashdi-vod" || seasonProbe.PlayerType == "zetvideo-vod") { - AddVodSeasonEpisodes(structure, seasonProbe.playerType, season, seasonEpisodes); + // Створюємо базовий голос (перший плеєр) + AddVodSeasonEpisodes(structure, seasonProbe.PlayerType, season, seasonEpisodes); + + // Якщо є додаткові zetvideo плеєри — створюємо окремий голос для кожного + if (seasonEpisodes != null && seasonEpisodes.Count > 0) + { + var firstEp = seasonEpisodes.FirstOrDefault(e => e.zetvideoIframeUrls != null && e.zetvideoIframeUrls.Count > 1); + if (firstEp != null) + { + // Додаткові плеєри починаються з індексу 1 + for (int extraIdx = 1; extraIdx < firstEp.zetvideoIframeUrls.Count; extraIdx++) + { + string extraVoiceName = GetZetvideoVoiceName(extraIdx); + _onLog($"GetSeasonStructure: створюю додатковий голос '{extraVoiceName}' для zetvideo плеєра #{extraIdx + 1}"); + + var extraEpisodes = seasonEpisodes + .OrderBy(ep => ep.episode) + .Select(ep => new EpisodeInfo + { + Number = ep.episode, + Title = ep.title, + File = ep.url, + Id = ep.url, + Poster = null, + Subtitle = null + }) + .ToList(); + + structure.Voices[extraVoiceName] = new VoiceInfo + { + Name = extraVoiceName, + PlayerType = seasonProbe.PlayerType, + DisplayName = extraVoiceName, + Seasons = new Dictionary> + { + [season] = extraEpisodes + } + }; + } + } + } } else { - _onLog($"GetSeasonStructure: player '{seasonProbe.playerType}' is not supported"); + _onLog($"GetSeasonStructure: player '{seasonProbe.PlayerType}' is not supported"); return null; } @@ -2056,6 +2122,15 @@ namespace LME.Uaflix } } + /// + /// Повертає назву голосу для додаткового zetvideo плеєра за індексом + /// Індекс 0 = "Uaflix" (основний), індекс 1 = "Оригінал", індекс 2+ = "Оригінал #N" + /// + private static string GetZetvideoVoiceName(int playerIndex) + { + return playerIndex <= 1 ? "Оригінал" : $"Оригінал #{playerIndex}"; + } + async Task> ParseAllZetvideoSources(string iframeUrl) { var result = new List(); @@ -2313,6 +2388,7 @@ namespace LME.Uaflix { public string IframeUrl { get; set; } public string PlayerType { get; set; } + public List ZetvideoIframeUrls { get; set; } } sealed class SearchMeta