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