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.
This commit is contained in:
Felix 2026-05-29 16:55:27 +03:00
parent 35bb155fa3
commit f793fefa82
3 changed files with 166 additions and 49 deletions

View File

@ -76,8 +76,10 @@ namespace LME.Uaflix.Controllers
if (play) if (play)
{ {
// Визначаємо URL для парсингу - або з параметра t, або з episode_url // Визначаємо URL для парсингу (параметр t тепер може бути назвою голосу, а не URL)
string urlToParse = !string.IsNullOrEmpty(t) ? t : Request.Query["episode_url"]; string urlToParse = Request.Query["episode_url"];
if (string.IsNullOrWhiteSpace(urlToParse))
urlToParse = t;
if (string.IsNullOrWhiteSpace(urlToParse)) if (string.IsNullOrWhiteSpace(urlToParse))
{ {
OnLog("=== RETURN: play missing url OnError ==="); OnLog("=== RETURN: play missing url OnError ===");
@ -87,8 +89,21 @@ namespace LME.Uaflix.Controllers
var playResult = await invoke.ParseEpisode(urlToParse); var playResult = await invoke.ParseEpisode(urlToParse);
if (playResult.streams != null && playResult.streams.Count > 0) 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 ==="); OnLog("=== RETURN: play no streams ===");
@ -102,9 +117,28 @@ namespace LME.Uaflix.Controllers
var playResult = await invoke.ParseEpisode(episodeUrl); var playResult = await invoke.ParseEpisode(episodeUrl);
if (playResult.streams != null && playResult.streams.Count > 0) 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' // Повертаємо JSON з інформацією про стрім для методу 'play'
string streamUrl = BuildStreamUrl(init, playResult.streams.First().link); string streamUrl = BuildStreamUrl(init, targetStream.link);
var subtitles = playResult.subtitles ?? playResult.streams.FirstOrDefault(s => s.subtitles != null)?.subtitles; var subtitles = playResult.subtitles ?? targetStream.subtitles;
OnLog($"=== RETURN: call method JSON for episode_url ==="); 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")); 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 епізоду для виклику // Для zetvideo-vod та ashdi-vod використовуємо URL епізоду для виклику
// Потрібно передати URL епізоду в інший параметр, щоб не плутати з play=true // Потрібно передати 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( episode_tpl.Append(
name: episodeTitle, name: episodeTitle,
title: title, title: title,

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace LME.Uaflix.Models 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 playerType { get; set; } // "ashdi-serial", "zetvideo-serial", "zetvideo-vod", "ashdi-vod"
public string iframeUrl { get; set; } // URL iframe для цього епізоду public string iframeUrl { get; set; } // URL iframe для цього епізоду
/// <summary>
/// Всі zetvideo iframe URL на сторінці епізоду (для створення кількох перекладів)
/// Перший елемент відповідає iframeUrl, наступні — додаткові плеєри (напр. з субтитрами)
/// </summary>
public List<string> zetvideoIframeUrls { get; set; }
} }
} }

View File

@ -286,14 +286,14 @@ namespace LME.Uaflix
return result; return result;
} }
private async Task<(string iframeUrl, string playerType)> ProbeEpisodePlayer(string pageUrl) private async Task<EpisodePlayerInfo> ProbeEpisodePlayer(string pageUrl)
{ {
if (string.IsNullOrWhiteSpace(pageUrl)) if (string.IsNullOrWhiteSpace(pageUrl))
return (null, null); return null;
string memKey = $"lme_uaflix:episode-player:{pageUrl}"; string memKey = $"lme_uaflix:episode-player:{pageUrl}";
if (_hybridCache.TryGetValue(memKey, out EpisodePlayerInfo cached)) if (_hybridCache.TryGetValue(memKey, out EpisodePlayerInfo cached))
return (cached?.IframeUrl, cached?.PlayerType); return cached;
try try
{ {
@ -305,7 +305,7 @@ namespace LME.Uaflix
string html = await GetHtml(pageUrl, headers); string html = await GetHtml(pageUrl, headers);
if (string.IsNullOrWhiteSpace(html)) if (string.IsNullOrWhiteSpace(html))
return (null, null); return null;
var doc = new HtmlDocument(); var doc = new HtmlDocument();
doc.LoadHtml(html); doc.LoadHtml(html);
@ -313,25 +313,40 @@ namespace LME.Uaflix
string iframeUrl = ExtractIframeUrl(doc); string iframeUrl = ExtractIframeUrl(doc);
string playerType = DeterminePlayerType(iframeUrl); string playerType = DeterminePlayerType(iframeUrl);
_hybridCache.Set(memKey, new EpisodePlayerInfo // Витягуємо всі zetvideo iframe для підтримки кількох перекладів
List<string> 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, IframeUrl = iframeUrl,
PlayerType = playerType PlayerType = playerType,
}, cacheTime(20)); ZetvideoIframeUrls = zetvideoIframeUrls
};
return (iframeUrl, playerType); _hybridCache.Set(memKey, info, cacheTime(20));
return info;
} }
catch (Exception ex) catch (Exception ex)
{ {
_onLog($"ProbeEpisodePlayer error ({pageUrl}): {ex.Message}"); _onLog($"ProbeEpisodePlayer error ({pageUrl}): {ex.Message}");
return (null, null); return null;
} }
} }
private async Task<(string iframeUrl, string playerType)> ProbeSeasonPlayer(List<EpisodeLinkInfo> seasonEpisodes) private async Task<EpisodePlayerInfo> ProbeSeasonPlayer(List<EpisodeLinkInfo> seasonEpisodes)
{ {
if (seasonEpisodes == null || seasonEpisodes.Count == 0) if (seasonEpisodes == null || seasonEpisodes.Count == 0)
return (null, null); return null;
foreach (var episode in seasonEpisodes.OrderBy(e => e.episode)) foreach (var episode in seasonEpisodes.OrderBy(e => e.episode))
{ {
@ -339,21 +354,26 @@ namespace LME.Uaflix
continue; continue;
var probed = await ProbeEpisodePlayer(episode.url); var probed = await ProbeEpisodePlayer(episode.url);
string playerType = probed.playerType; if (probed == null)
episode.iframeUrl = probed.iframeUrl;
episode.playerType = playerType;
if (string.IsNullOrWhiteSpace(playerType))
continue; 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; continue;
return probed; return probed;
} }
return (null, null); return null;
} }
private static string NormalizeSerialPlayerKey(string playerType, string iframeUrl) private static string NormalizeSerialPlayerKey(string playerType, string iframeUrl)
@ -732,22 +752,22 @@ namespace LME.Uaflix
_onLog($"AggregateSerialStructure: Processing season {season}"); _onLog($"AggregateSerialStructure: Processing season {season}");
var seasonProbe = await ProbeSeasonPlayer(seasonGroup.Value); 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"); _onLog($"AggregateSerialStructure: Season {season} has no supported player");
continue; 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)) if (!serialPlayersProcessed.Add(serialKey))
{ {
_onLog($"AggregateSerialStructure: Serial player already parsed for season {season}: {serialKey}"); _onLog($"AggregateSerialStructure: Serial player already parsed for season {season}: {serialKey}");
continue; continue;
} }
var voices = await ParseMultiEpisodePlayer(seasonProbe.iframeUrl, seasonProbe.playerType); var voices = await ParseMultiEpisodePlayer(seasonProbe.IframeUrl, seasonProbe.PlayerType);
if (voices == null || voices.Count == 0) if (voices == null || voices.Count == 0)
{ {
_onLog($"AggregateSerialStructure: No voices in serial player for season {season}"); _onLog($"AggregateSerialStructure: No voices in serial player for season {season}");
@ -755,18 +775,18 @@ namespace LME.Uaflix
} }
MergeVoices(structure, voices); MergeVoices(structure, voices);
_onLog($"AggregateSerialStructure: Parsed serial player {seasonProbe.playerType}, voices={voices.Count}"); _onLog($"AggregateSerialStructure: Parsed serial player {seasonProbe.PlayerType}, voices={voices.Count}");
continue; 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}"); _onLog($"AggregateSerialStructure: Added vod season {season}, episodes={seasonGroup.Value.Count}");
continue; continue;
} }
_onLog($"AggregateSerialStructure: Unsupported player {seasonProbe.playerType} for season {season}"); _onLog($"AggregateSerialStructure: Unsupported player {seasonProbe.PlayerType} for season {season}");
} }
} }
else else
@ -774,15 +794,15 @@ namespace LME.Uaflix
_onLog($"AggregateSerialStructure: No episodes from pagination for {serialUrl}, fallback to page iframe"); _onLog($"AggregateSerialStructure: No episodes from pagination for {serialUrl}, fallback to page iframe");
var serialProbe = await ProbeEpisodePlayer(serialUrl); var serialProbe = await ProbeEpisodePlayer(serialUrl);
if (string.IsNullOrWhiteSpace(serialProbe.playerType)) if (serialProbe == null || string.IsNullOrWhiteSpace(serialProbe.PlayerType))
{ {
_onLog($"AggregateSerialStructure: Fallback probe failed for {serialUrl}"); _onLog($"AggregateSerialStructure: Fallback probe failed for {serialUrl}");
return null; 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) if (voices == null || voices.Count == 0)
{ {
_onLog($"AggregateSerialStructure: Fallback serial player has no voices for {serialUrl}"); _onLog($"AggregateSerialStructure: Fallback serial player has no voices for {serialUrl}");
@ -792,8 +812,13 @@ namespace LME.Uaflix
MergeVoices(structure, voices); MergeVoices(structure, voices);
_onLog($"AggregateSerialStructure: Fallback serial player parsed, voices={voices.Count}"); _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<string> zetvideoUrls = null;
if (serialProbe.ZetvideoIframeUrls != null && serialProbe.ZetvideoIframeUrls.Count > 0)
zetvideoUrls = serialProbe.ZetvideoIframeUrls;
var syntheticEpisodes = new List<EpisodeLinkInfo> var syntheticEpisodes = new List<EpisodeLinkInfo>
{ {
new EpisodeLinkInfo new EpisodeLinkInfo
@ -802,17 +827,18 @@ namespace LME.Uaflix
title = "Епізод 1", title = "Епізод 1",
season = 1, season = 1,
episode = 1, episode = 1,
iframeUrl = serialProbe.iframeUrl, iframeUrl = serialProbe.IframeUrl,
playerType = serialProbe.playerType playerType = serialProbe.PlayerType,
zetvideoIframeUrls = zetvideoUrls
} }
}; };
structure.AllEpisodes = syntheticEpisodes; structure.AllEpisodes = syntheticEpisodes;
AddVodSeasonEpisodes(structure, serialProbe.playerType, 1, syntheticEpisodes); AddVodSeasonEpisodes(structure, serialProbe.PlayerType, 1, syntheticEpisodes);
} }
else 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; return null;
} }
} }
@ -1096,20 +1122,20 @@ namespace LME.Uaflix
}; };
var seasonProbe = await ProbeSeasonPlayer(seasonEpisodes); var seasonProbe = await ProbeSeasonPlayer(seasonEpisodes);
if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) if (seasonProbe == null || string.IsNullOrWhiteSpace(seasonProbe.PlayerType))
{ {
// fallback: інколи плеєр є лише на головній сторінці // fallback: інколи плеєр є лише на головній сторінці
seasonProbe = await ProbeEpisodePlayer(serialUrl); seasonProbe = await ProbeEpisodePlayer(serialUrl);
if (string.IsNullOrWhiteSpace(seasonProbe.playerType)) if (seasonProbe == null || string.IsNullOrWhiteSpace(seasonProbe.PlayerType))
{ {
_onLog($"GetSeasonStructure: unsupported player for season={season}"); _onLog($"GetSeasonStructure: unsupported player for season={season}");
return null; 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) foreach (var voice in voices)
{ {
if (voice?.Seasons == null || !voice.Seasons.TryGetValue(season, out List<EpisodeInfo> seasonVoiceEpisodes) || seasonVoiceEpisodes == null || seasonVoiceEpisodes.Count == 0) if (voice?.Seasons == null || !voice.Seasons.TryGetValue(season, out List<EpisodeInfo> 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<int, List<EpisodeInfo>>
{
[season] = extraEpisodes
}
};
}
}
}
} }
else else
{ {
_onLog($"GetSeasonStructure: player '{seasonProbe.playerType}' is not supported"); _onLog($"GetSeasonStructure: player '{seasonProbe.PlayerType}' is not supported");
return null; return null;
} }
@ -2056,6 +2122,15 @@ namespace LME.Uaflix
} }
} }
/// <summary>
/// Повертає назву голосу для додаткового zetvideo плеєра за індексом
/// Індекс 0 = "Uaflix" (основний), індекс 1 = "Оригінал", індекс 2+ = "Оригінал #N"
/// </summary>
private static string GetZetvideoVoiceName(int playerIndex)
{
return playerIndex <= 1 ? "Оригінал" : $"Оригінал #{playerIndex}";
}
async Task<List<PlayStream>> ParseAllZetvideoSources(string iframeUrl) async Task<List<PlayStream>> ParseAllZetvideoSources(string iframeUrl)
{ {
var result = new List<PlayStream>(); var result = new List<PlayStream>();
@ -2313,6 +2388,7 @@ namespace LME.Uaflix
{ {
public string IframeUrl { get; set; } public string IframeUrl { get; set; }
public string PlayerType { get; set; } public string PlayerType { get; set; }
public List<string> ZetvideoIframeUrls { get; set; }
} }
sealed class SearchMeta sealed class SearchMeta