Compare commits

...

7 Commits

Author SHA1 Message Date
Felix
6a398317a4 feat(uaflix): detect missing player and retry movie streams
Add page-status detection for episode parsing so the controller can
distinguish between a missing page, an existing page without a player,
and a page with available streams.

When a movie page exists but no player is present, retry once after a
short delay before failing. If the player still is unavailable, return a
non-cached error so temporary site state does not get persisted.
2026-05-29 18:08:27 +03:00
Felix
444e4c876e delete and move 2 LMG 2026-05-29 17:09:51 +03:00
Felix
9a6fe0ab7c chore(uaflix): bump cache keys for episode and season structures 2026-05-29 16:59:26 +03:00
Felix
f33ffdd930 fix(uaflix): use public logger in stream selection fallback 2026-05-29 16:56:25 +03:00
Felix
f793fefa82 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.
2026-05-29 16:55:27 +03:00
Felix
35bb155fa3 fix(uaflix): simplify subtitle presence check in episode logging 2026-05-29 16:30:50 +03:00
Felix
dd40ed69f2 refactor(uaflix): handle multiple iframe sources on episode pages
Extract iframe URLs from video-box containers, fallback to all page
iframes, and include og:video:iframe metadata so pages with multiple
players are parsed consistently. Update the episode resolver to probe
all zetvideo embeds and aggregate playable streams instead of relying
on a single iframe. Bump the module version to 5.3.
2026-05-29 16:25:17 +03:00
11 changed files with 461 additions and 725 deletions

View File

@ -1,306 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using LME.StreamData.Models;
using Microsoft.AspNetCore.Mvc;
using Shared;
using Shared.Engine;
using Shared.Models;
using Shared.Models.Online.Settings;
using Shared.Models.Templates;
namespace LME.StreamData.Controllers
{
public class Controller : BaseOnlineController
{
ProxyManager proxyManager;
public Controller() : base(ModInit.Settings)
{
proxyManager = new ProxyManager(ModInit.StreamDataSettings);
}
/// <summary>
/// Головний ендпоінт модуля StreamData
/// Працює виключно через TMDB ID (параметр id)
/// </summary>
[HttpGet]
[Route("lite/lme_streamdata")]
async public Task<ActionResult> Index(long id, string imdb_id, long kinopoisk_id, string title, string original_title, string original_language, int year, string source, int serial, string account_email, string t, int s = -1, int e = -1, bool play = false, bool rjson = false, string href = null, bool checksearch = false)
{
await UpdateService.ConnectAsync(host);
var init = loadKit(ModInit.StreamDataSettings);
if (!init.enable)
return Forbid();
var invoke = new StreamDataInvoke(init, hybridCache, OnLog, proxyManager, httpHydra);
// checksearch — перевірка доступності
if (checksearch)
{
if (!IsCheckOnlineSearchEnabled())
return OnError("lme_streamdata", refresh_proxy: true);
if (id > 0)
return Content("data-json=", "text/plain; charset=utf-8");
return OnError("lme_streamdata", refresh_proxy: true);
}
// play — повернути стрім для конкретного епізоду (call метод)
if (play)
{
return await HandlePlay(invoke, init, id, s, e, title, original_title, t);
}
// Фільм
if (serial != 1)
{
return await HandleMovie(invoke, init, id, title, original_title, rjson);
}
// Серіал
return await HandleSerial(invoke, init, id, title, original_title, s, e, t, rjson);
}
/// <summary>
/// Обробка фільму: отримуємо всі stream_urls та показуємо їх
/// </summary>
private async Task<ActionResult> HandleMovie(StreamDataInvoke invoke, OnlinesSettings init, long tmdbId, string title, string originalTitle, bool rjson)
{
var response = await invoke.GetMovie(tmdbId);
if (response?.data?.stream_urls == null || response.data.stream_urls.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
var streamUrls = response.data.stream_urls;
var subs = CollectSubtitles(response);
var displayTitle = !string.IsNullOrEmpty(title) ? title : (!string.IsNullOrEmpty(originalTitle) ? originalTitle : response.data.title);
var tpl = new MovieTpl(displayTitle, originalTitle);
int index = 1;
foreach (var streamUrl in streamUrls)
{
if (string.IsNullOrWhiteSpace(streamUrl))
continue;
string label = $"Джерело #{index}";
string processedUrl = BuildStreamUrl(init, streamUrl);
tpl.Append(label, processedUrl, subtitles: subs);
index++;
}
if (tpl.data == null || tpl.data.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
return Content(
rjson ? tpl.ToJson() : tpl.ToHtml(),
rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8"
);
}
/// <summary>
/// Обробка серіалу: eps → сезони → епізоди з voice-вкладками (джерела)
/// </summary>
private async Task<ActionResult> HandleSerial(StreamDataInvoke invoke, OnlinesSettings init, long tmdbId, string title, string originalTitle, int s, int e, string t, bool rjson)
{
var response = await invoke.GetTvSeries(tmdbId);
if (response?.data?.eps == null || response.data.eps.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
var eps = response.data.eps;
var seasons = eps.Keys
.Select(k => int.TryParse(k, out int sn) ? sn : 0)
.Where(sn => sn > 0)
.OrderBy(sn => sn)
.ToList();
if (seasons.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
// Кількість джерел (CDN) з першого запиту
var sources = response.data?.stream_urls?.Where(u => !string.IsNullOrWhiteSpace(u)).ToList() ?? new List<string>();
int sourceCount = Math.Max(1, sources.Count);
var displayTitle = !string.IsNullOrEmpty(title) ? title : (!string.IsNullOrEmpty(originalTitle) ? originalTitle : response.data.title);
// Список сезонів
if (s <= 0)
{
var seasonTpl = new SeasonTpl(seasons.Count);
foreach (var season in seasons)
{
string seasonLink = $"{host}/lite/lme_streamdata?id={tmdbId}&serial=1&s={season}&title={HttpUtility.UrlEncode(displayTitle)}&original_title={HttpUtility.UrlEncode(originalTitle)}";
seasonTpl.Append($"Сезон {season}", seasonLink, season.ToString());
}
return Content(
rjson ? seasonTpl.ToJson() : seasonTpl.ToHtml(),
rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8"
);
}
// Список епізодів з voice-вкладками для вибору джерела
string seasonKey = s.ToString();
if (!eps.ContainsKey(seasonKey) || eps[seasonKey] == null || eps[seasonKey].Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
var episodeNumbers = eps[seasonKey]
.Select(ep => int.TryParse(ep, out int en) ? en : 0)
.Where(en => en > 0)
.OrderBy(en => en)
.ToList();
if (episodeNumbers.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
// Voice-вкладки: кожне джерело як окрема "озвучка"
string selectedSource = string.IsNullOrEmpty(t) ? "1" : t;
int selectedIndex = int.TryParse(selectedSource, out int si) && si >= 1 && si <= sourceCount ? si : 1;
var voiceTpl = new VoiceTpl();
for (int i = 1; i <= sourceCount; i++)
{
string voiceLink = $"{host}/lite/lme_streamdata?id={tmdbId}&serial=1&s={s}&t={i}&title={HttpUtility.UrlEncode(displayTitle)}&original_title={HttpUtility.UrlEncode(originalTitle)}";
voiceTpl.Append($"Джерело #{i}", i == selectedIndex, voiceLink);
}
// Епізоди з посиланнями на вибране джерело
var episodeTpl = new EpisodeTpl(episodeNumbers.Count);
foreach (var epNum in episodeNumbers)
{
string episodeName = $"Епізод {epNum}";
string callUrl = $"{host}/lite/lme_streamdata?id={tmdbId}&serial=1&s={s}&e={epNum}&play=true&t={selectedSource}&title={HttpUtility.UrlEncode(displayTitle)}&original_title={HttpUtility.UrlEncode(originalTitle)}";
episodeTpl.Append(episodeName, displayTitle, s.ToString(), epNum.ToString("D2"), accsArgs(callUrl), "call");
}
episodeTpl.Append(voiceTpl);
return Content(
rjson ? episodeTpl.ToJson() : episodeTpl.ToHtml(),
rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8"
);
}
/// <summary>
/// Обробка play-запиту: API з season/episode → JSON з вибраним джерелом
/// </summary>
private async Task<ActionResult> HandlePlay(StreamDataInvoke invoke, OnlinesSettings init, long tmdbId, int season, int episode, string title, string originalTitle, string t)
{
if (tmdbId <= 0 || season <= 0 || episode <= 0)
return OnError("lme_streamdata", refresh_proxy: true);
var response = await invoke.GetEpisode(tmdbId, season, episode);
if (response?.data?.stream_urls == null || response.data.stream_urls.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
var streamUrls = response.data.stream_urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (streamUrls.Count == 0)
return OnError("lme_streamdata", refresh_proxy: true);
// Вибираємо джерело за індексом з voice-вкладки t (1-based)
int sourceIndex = int.TryParse(t, out int si) && si >= 1 && si <= streamUrls.Count ? si - 1 : 0;
string streamUrl = BuildStreamUrl(init, streamUrls[sourceIndex]);
var subs = CollectSubtitles(response);
string displayTitle = !string.IsNullOrEmpty(title) ? title : (!string.IsNullOrEmpty(originalTitle) ? originalTitle : response.data.title);
return UpdateService.Validate(Content(
VideoTpl.ToJson("play", streamUrl, displayTitle, subtitles: subs),
"application/json; charset=utf-8"
));
}
/// <summary>
/// Зібрати субтитри з відповіді API
/// </summary>
private SubtitleTpl CollectSubtitles(StreamDataResponse response)
{
if (response?.default_subs == null || response.default_subs.Count == 0)
return null;
var tpl = new SubtitleTpl(response.default_subs.Count);
foreach (var sub in response.default_subs)
{
if (!string.IsNullOrWhiteSpace(sub?.url) && !string.IsNullOrWhiteSpace(sub?.lang))
{
tpl.Append(sub.lang, sub.url);
}
}
return tpl;
}
string BuildStreamUrl(OnlinesSettings init, string streamLink)
{
string link = StripLampacArgs(streamLink?.Trim());
if (string.IsNullOrEmpty(link))
return link;
if (ApnHelper.IsEnabled(init))
{
if (ModInit.ApnHostProvided)
return ApnHelper.WrapUrl(init, link);
var noApn = (OnlinesSettings)init.Clone();
noApn.apnstream = false;
noApn.apn = null;
return HostStreamProxy(noApn, link);
}
return HostStreamProxy(init, link);
}
private static string StripLampacArgs(string url)
{
if (string.IsNullOrEmpty(url))
return url;
string cleaned = System.Text.RegularExpressions.Regex.Replace(
url,
@"([?&])(account_email|uid|nws_id)=[^&]*",
"$1",
System.Text.RegularExpressions.RegexOptions.IgnoreCase
);
cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&');
return cleaned;
}
private static bool IsCheckOnlineSearchEnabled()
{
try
{
var onlineType = Type.GetType("Online.ModInit");
if (onlineType == null)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
onlineType = asm.GetType("Online.ModInit");
if (onlineType != null)
break;
}
}
var confField = onlineType?.GetField("conf", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
var conf = confField?.GetValue(null);
var checkProp = conf?.GetType().GetProperty("checkOnlineSearch", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
if (checkProp?.GetValue(conf) is bool enabled)
return enabled;
}
catch
{
}
return true;
}
private static void OnLog(string message)
{
System.Console.WriteLine(message);
}
}
}

View File

@ -1,91 +0,0 @@
using Newtonsoft.Json.Linq;
using Shared;
using Shared.Engine;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Shared.Models;
using Shared.Models.Events;
using Shared.Models.Online.Settings;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LME.StreamData
{
public class ModInit : IModuleLoaded
{
public static double Version => 1.5;
public static OnlinesSettings StreamDataSettings;
public static bool ApnHostProvided;
public static OnlinesSettings Settings
{
get => StreamDataSettings;
set => StreamDataSettings = value;
}
/// <summary>
/// Модуль завантажено
/// </summary>
public void Loaded(InitspaceModel initspace)
{
StreamDataSettings = new OnlinesSettings("LME.StreamData", "https://streamdata.vaplayer.ru", streamproxy: false, useproxy: false)
{
displayname = "StreamData",
displayindex = 0,
proxy = new Shared.Models.Base.ProxySettings()
{
useAuth = true,
username = "",
password = "",
list = new string[] { "socks5://ip:port" }
}
};
var defaults = JObject.FromObject(StreamDataSettings);
defaults["enabled"] = true;
var conf = ModuleInvoke.Init("LME.StreamData", defaults);
bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost);
conf.Remove("apn");
conf.Remove("apn_host");
StreamDataSettings = conf.ToObject<OnlinesSettings>();
if (hasApn)
ApnHelper.ApplyInitConf(apnEnabled, apnHost, StreamDataSettings, useDefaultHostWhenEmpty: true);
ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost);
if (hasApn && apnEnabled)
{
StreamDataSettings.streamproxy = false;
}
else if (StreamDataSettings.streamproxy)
{
StreamDataSettings.apnstream = false;
StreamDataSettings.apn = null;
}
// Реєструємо плагін без пошуку — працюємо тільки через TMDB ID
OnlineRegistry.RegisterWithSearch("lme_streamdata");
}
public void Dispose()
{
}
}
public static class UpdateService
{
private static readonly ModuleUpdateService _service = new(
() => ModInit.Settings?.plugin,
() => ModInit.Version);
public static Task ConnectAsync(string host, CancellationToken cancellationToken = default)
=> _service.ConnectAsync(host, cancellationToken);
public static bool IsDisconnected()
=> _service.IsDisconnected();
public static ActionResult Validate(ActionResult result)
=> _service.Validate(result);
}
}

View File

@ -1,31 +0,0 @@
using System.Collections.Generic;
namespace LME.StreamData.Models
{
public class SubtitleItem
{
public string lang { get; set; }
public string code { get; set; }
public string url { get; set; }
}
public class StreamDataResponse
{
public string status_code { get; set; }
public StreamDataInfo data { get; set; }
public List<SubtitleItem> default_subs { get; set; }
}
public class StreamDataInfo
{
public string title { get; set; }
public string imdb_id { get; set; }
public int season { get; set; }
public string episode { get; set; }
public Dictionary<string, List<string>> eps { get; set; }
public string file_name { get; set; }
public string backdrop { get; set; }
public List<string> stream_urls { get; set; }
public string thumbnails_url { get; set; }
}
}

View File

@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Http;
using Shared.Models;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using System.Collections.Generic;
namespace LME.StreamData
{
public class OnlineApi : IModuleOnline
{
public List<ModuleOnlineItem> Invoke(HttpContext httpContext, RequestModel requestInfo, string host, OnlineEventsModel args)
{
var online = new List<ModuleOnlineItem>();
var init = ModInit.StreamDataSettings;
if (init.enable && !init.rip)
{
if (UpdateService.IsDisconnected())
init.overridehost = null;
// StreamData працює з TMDB ID — показуємо для всього контенту
online.Add(new ModuleOnlineItem(init, "lme_streamdata"));
}
return online;
}
}
}

View File

@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Shared">
<HintPath>..\..\Shared.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -1,155 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LME.StreamData.Models;
using Newtonsoft.Json;
using Shared;
using Shared.Engine;
using Shared.Models;
using Shared.Models.Online.Settings;
namespace LME.StreamData
{
public class StreamDataInvoke
{
private readonly OnlinesSettings _init;
private readonly IHybridCache _hybridCache;
private readonly Action<string> _onLog;
private readonly ProxyManager _proxyManager;
private readonly HttpHydra _httpHydra;
private const string API_BASE = "https://streamdata.vaplayer.ru/api.php";
public StreamDataInvoke(OnlinesSettings init, IHybridCache hybridCache, Action<string> onLog, ProxyManager proxyManager, HttpHydra httpHydra = null)
{
_init = init;
_hybridCache = hybridCache;
_onLog = onLog;
_proxyManager = proxyManager;
_httpHydra = httpHydra;
}
/// <summary>
/// Отримати дані для фільму за TMDB ID
/// </summary>
public async Task<StreamDataResponse> GetMovie(long tmdbId)
{
string memKey = $"StreamData:movie:{tmdbId}";
if (_hybridCache.TryGetValue(memKey, out StreamDataResponse cached))
return cached;
try
{
string url = $"{API_BASE}?tmdb={tmdbId}&type=movie";
_onLog?.Invoke($"StreamData movie: {url}");
string json = await ApiGet(url);
if (string.IsNullOrEmpty(json))
return null;
var response = JsonConvert.DeserializeObject<StreamDataResponse>(json);
if (response?.status_code != "200" || response?.data?.stream_urls == null || response.data.stream_urls.Count == 0)
return null;
_hybridCache.Set(memKey, response, cacheTime(30, init: _init));
return response;
}
catch (Exception ex)
{
_onLog?.Invoke($"StreamData movie error: {ex.Message}");
return null;
}
}
/// <summary>
/// Отримати дані для серіалу (без season/episode - отримуємо eps структуру + S01E01)
/// </summary>
public async Task<StreamDataResponse> GetTvSeries(long tmdbId)
{
string memKey = $"StreamData:tv:{tmdbId}";
if (_hybridCache.TryGetValue(memKey, out StreamDataResponse cached))
return cached;
try
{
string url = $"{API_BASE}?tmdb={tmdbId}&type=tv";
_onLog?.Invoke($"StreamData tv: {url}");
string json = await ApiGet(url);
if (string.IsNullOrEmpty(json))
return null;
var response = JsonConvert.DeserializeObject<StreamDataResponse>(json);
if (response?.status_code != "200" || response?.data?.eps == null || response.data.eps.Count == 0)
return null;
_hybridCache.Set(memKey, response, cacheTime(30, init: _init));
return response;
}
catch (Exception ex)
{
_onLog?.Invoke($"StreamData tv error: {ex.Message}");
return null;
}
}
/// <summary>
/// Отримати стріми для конкретного епізоду
/// </summary>
public async Task<StreamDataResponse> GetEpisode(long tmdbId, int season, int episode)
{
string memKey = $"StreamData:ep:{tmdbId}:s{season}e{episode}";
if (_hybridCache.TryGetValue(memKey, out StreamDataResponse cached))
return cached;
try
{
string url = $"{API_BASE}?tmdb={tmdbId}&type=tv&season={season}&episode={episode}";
_onLog?.Invoke($"StreamData episode: {url}");
string json = await ApiGet(url);
if (string.IsNullOrEmpty(json))
return null;
var response = JsonConvert.DeserializeObject<StreamDataResponse>(json);
if (response?.status_code != "200" || response?.data?.stream_urls == null || response.data.stream_urls.Count == 0)
return null;
_hybridCache.Set(memKey, response, cacheTime(30, init: _init));
return response;
}
catch (Exception ex)
{
_onLog?.Invoke($"StreamData episode error: {ex.Message}");
return null;
}
}
private Task<string> ApiGet(string url)
{
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0"),
new HeadersModel("Referer", "https://brightpathsignals.com/"),
new HeadersModel("X-Requested-With", "XMLHttpRequest")
};
if (_httpHydra != null)
return _httpHydra.Get(url, newheaders: headers);
return Http.Get(_init.cors(url), headers: headers, proxy: _proxyManager.Get());
}
public static TimeSpan cacheTime(int multiaccess, int home = 5, int mikrotik = 2, OnlinesSettings init = null, int rhub = -1)
{
if (init != null && init.rhub && rhub != -1)
return TimeSpan.FromMinutes(rhub);
int ctime = init != null && init.cache_time > 0 ? init.cache_time : multiaccess;
if (ctime > multiaccess)
ctime = multiaccess;
return TimeSpan.FromMinutes(ctime);
}
}
}

View File

@ -1,12 +0,0 @@
{
"enable": true,
"version": 3,
"initspace": "LME.StreamData.ModInit",
"online": "LME.StreamData.OnlineApi",
"syntaxPaths": [
"../LME.Shared/GlobalUsings.cs",
"../LME.Shared/Online/OnlineRegistry.cs",
"../LME.Shared/Update/ModuleUpdateService.cs",
"../LME.Shared/Apn/ApnHelper.cs"
]
}

View File

@ -14,6 +14,7 @@ using System.Text;
using Shared.Models.Online.Settings; using Shared.Models.Online.Settings;
using Shared.Models; using Shared.Models;
using LME.Uaflix.Models; using LME.Uaflix.Models;
using LME.Uaflix;
namespace LME.Uaflix.Controllers namespace LME.Uaflix.Controllers
{ {
@ -76,8 +77,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,12 +90,25 @@ 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 ===");
return OnError("lme_uaflix", refresh_proxy: true); return OnError("lme_uaflix", gbcache: false, refresh_proxy: true);
} }
// Якщо є episode_url але немає play=true, це виклик для отримання інформації про стрім (для method: 'call') // Якщо є episode_url але немає play=true, це виклик для отримання інформації про стрім (для method: 'call')
@ -102,16 +118,35 @@ 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"));
} }
OnLog("=== RETURN: call method no streams ==="); OnLog("=== RETURN: call method no streams ===");
return OnError("lme_uaflix", refresh_proxy: true); return OnError("lme_uaflix", gbcache: false, refresh_proxy: true);
} }
string filmUrl = href; string filmUrl = href;
@ -277,7 +312,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,
@ -336,9 +371,37 @@ namespace LME.Uaflix.Controllers
} }
else // Фільм else // Фільм
{ {
var playResult = await invoke.ParseEpisode(filmUrl); var (playResult, pageStatus) = await invoke.ParseEpisodeWithStatus(filmUrl);
// Retry: якщо сторінка існує, але плеєр ще не додано — чекаємо 3 сек і пробуємо знову
if (pageStatus == PageStatus.PageExistsNoPlayer
&& (playResult?.streams == null || playResult.streams.Count == 0))
{
OnLog("Movie page exists but no player found, retrying in 3 seconds...");
await Task.Delay(3000);
var retryResult = await invoke.ParseEpisode(filmUrl);
if (retryResult?.streams != null && retryResult.streams.Count > 0)
{
playResult = retryResult;
pageStatus = PageStatus.HasStreams;
OnLog("Retry successful: streams found after delay");
}
else
{
OnLog("Retry: still no streams after delay");
}
}
if (playResult?.streams == null || playResult.streams.Count == 0) if (playResult?.streams == null || playResult.streams.Count == 0)
{ {
if (pageStatus == PageStatus.PageExistsNoPlayer)
{
OnLog("=== RETURN: movie page exists but player temporarily unavailable ===");
// gbcache: false — не кешувати помилку, щоб наступні запити не блокувалися
return OnError("Плеєр тимчасово недоступний, спробуйте пізніше", gbcache: false, refresh_proxy: false);
}
OnLog("=== RETURN: movie no streams ==="); OnLog("=== RETURN: movie no streams ===");
return OnError("lme_uaflix", refresh_proxy: true); return OnError("lme_uaflix", refresh_proxy: true);
} }

View File

@ -19,7 +19,7 @@ namespace LME.Uaflix
{ {
public class ModInit : IModuleLoaded public class ModInit : IModuleLoaded
{ {
public static double Version => 5.2; public static double Version => 5.3;
public static UaflixSettings UaFlix; public static UaflixSettings UaFlix;

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

@ -18,6 +18,13 @@ using System.Text;
namespace LME.Uaflix namespace LME.Uaflix
{ {
public enum PageStatus
{
PageNotFound,
PageExistsNoPlayer, // Page loaded (HTTP 200) but no playable streams found
HasStreams // At least one stream found
}
public class UaflixInvoke public class UaflixInvoke
{ {
private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase); private static readonly Regex Quality4kRegex = new Regex(@"(^|[^0-9])(2160p?)([^0-9]|$)|\b4k\b|\buhd\b", RegexOptions.IgnoreCase);
@ -241,14 +248,60 @@ namespace LME.Uaflix
return ExtractIframeFromMeta(doc); return ExtractIframeFromMeta(doc);
} }
private async Task<(string iframeUrl, string playerType)> ProbeEpisodePlayer(string pageUrl) /// <summary>
/// Отримати всі iframe URL зі сторінки (з video-box, загальних iframe та og:meta)
/// Використовується для сторінок з кількома плеєрами (напр. zetvideo з субтитрами)
/// </summary>
private List<string> ExtractAllIframeUrls(HtmlDocument doc)
{
var result = new List<string>();
if (doc?.DocumentNode == null)
return result;
// Спочатку збираємо всі iframe з video-box контейнерів
var videoBoxNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'video-box')]//iframe");
if (videoBoxNodes != null)
{
foreach (var node in videoBoxNodes)
{
string url = NormalizeIframeUrl(node.GetAttributeValue("src", null));
if (!string.IsNullOrEmpty(url) && !result.Any(u => string.Equals(u, url, StringComparison.OrdinalIgnoreCase)))
result.Add(url);
}
}
// Якщо нічого не знайшли в video-box, шукаємо будь-які iframe
if (result.Count == 0)
{
var allIframeNodes = doc.DocumentNode.SelectNodes("//iframe");
if (allIframeNodes != null)
{
foreach (var node in allIframeNodes)
{
string url = NormalizeIframeUrl(node.GetAttributeValue("src", null));
if (!string.IsNullOrEmpty(url) && !result.Any(u => string.Equals(u, url, StringComparison.OrdinalIgnoreCase)))
result.Add(url);
}
}
}
// Також додаємо URL з og:video:iframe meta
string metaIframe = ExtractIframeFromMeta(doc);
if (!string.IsNullOrEmpty(metaIframe) && !result.Any(u => string.Equals(u, metaIframe, StringComparison.OrdinalIgnoreCase)))
result.Add(metaIframe);
return result;
}
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}"; // v2 — зміна кеш-ключа для інвалідації старих даних без ZetvideoIframeUrls
string memKey = $"lme_uaflix:episode-player-v2:{pageUrl}";
if (_hybridCache.TryGetValue(memKey, out EpisodePlayerInfo cached)) if (_hybridCache.TryGetValue(memKey, out EpisodePlayerInfo cached))
return (cached?.IframeUrl, cached?.PlayerType); return cached;
try try
{ {
@ -260,7 +313,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);
@ -268,25 +321,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))
{ {
@ -294,21 +362,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)
@ -687,22 +760,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}");
@ -710,18 +783,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
@ -729,15 +802,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}");
@ -747,8 +820,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
@ -757,17 +835,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;
} }
} }
@ -1028,7 +1107,8 @@ namespace LME.Uaflix
if (season < 0) if (season < 0)
return null; return null;
string memKey = $"lme_uaflix:season-structure:{serialUrl}:{season}"; // v2 — зміна кеш-ключа для інвалідації старих структур без multi-voice
string memKey = $"lme_uaflix:season-structure-v2:{serialUrl}:{season}";
if (_hybridCache.TryGetValue(memKey, out SerialAggregatedStructure cached)) if (_hybridCache.TryGetValue(memKey, out SerialAggregatedStructure cached))
{ {
_onLog($"GetSeasonStructure: Using cached structure for season={season}, url={serialUrl}"); _onLog($"GetSeasonStructure: Using cached structure for season={season}, url={serialUrl}");
@ -1051,20 +1131,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)
@ -1093,13 +1173,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;
} }
@ -1896,41 +2016,76 @@ namespace LME.Uaflix
} }
} }
string iframeUrl = ExtractIframeUrl(doc); // Отримуємо всі iframe зі сторінки (підтримка кількох плеєрів, напр. zetvideo з субтитрами)
if (!string.IsNullOrEmpty(iframeUrl)) var allIframes = ExtractAllIframeUrls(doc);
// Фільтруємо zetvideo iframe — повертаємо всі як окремі потоки
var zetvideoIframes = allIframes
.Where(u => u != null && u.Contains("zetvideo.net"))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (zetvideoIframes.Count > 0)
{ {
if (iframeUrl.Contains("ashdi.vip/serial/")) int streamIndex = 0;
foreach (var zetIframe in zetvideoIframes)
{ {
result.ashdi_url = iframeUrl; var streams = await ParseAllZetvideoSources(zetIframe);
return result; if (streams == null || streams.Count == 0)
} continue;
// Ігноруємо YouTube трейлери foreach (var stream in streams)
if (iframeUrl.Contains("youtube.com/embed/"))
{
_onLog($"ParseEpisode: Ignoring YouTube trailer iframe: {iframeUrl}");
return result;
}
if (iframeUrl.Contains("zetvideo.net"))
result.streams = await ParseAllZetvideoSources(iframeUrl);
else if (iframeUrl.Contains("ashdi.vip"))
{
// Перевіряємо, чи це ashdi-vod (окремий епізод) або ashdi-serial (багатосерійний плеєр)
if (iframeUrl.Contains("/vod/"))
{ {
// Це окремий епізод на ashdi.vip/vod/, обробляємо як ashdi-vod // Перший потік → "Uaflix" (переклад), наступні → "Оригінал"
result.streams = await ParseAshdiVodEpisode(iframeUrl); string label = streamIndex == 0 ? "Uaflix" : "Оригінал";
stream.title = label;
_onLog($"ParseEpisode: zetvideo потік #{streamIndex + 1}: {label} -> {zetIframe}" +
(stream.subtitles?.data?.Count > 0
? " (має субтитри)" : ""));
} }
else
result.streams.AddRange(streams);
streamIndex++;
}
}
else
{
// Старий код: використовуємо перший iframe для ashdi/інших
string iframeUrl = ExtractIframeUrl(doc);
if (!string.IsNullOrEmpty(iframeUrl))
{
if (iframeUrl.Contains("ashdi.vip/serial/"))
{ {
// Це багатосерійний плеєр, обробляємо як і раніше result.ashdi_url = iframeUrl;
result.streams = await ParseAllAshdiSources(iframeUrl); return result;
var idMatch = Regex.Match(iframeUrl, @"_(\d+)|vod/(\d+)"); }
if (idMatch.Success)
// Ігноруємо YouTube трейлери
if (iframeUrl.Contains("youtube.com/embed/"))
{
_onLog($"ParseEpisode: Ignoring YouTube trailer iframe: {iframeUrl}");
return result;
}
if (iframeUrl.Contains("ashdi.vip"))
{
// Перевіряємо, чи це ashdi-vod (окремий епізод) або ashdi-serial (багатосерійний плеєр)
if (iframeUrl.Contains("/vod/"))
{ {
string ashdiId = idMatch.Groups[1].Success ? idMatch.Groups[1].Value : idMatch.Groups[2].Value; // Це окремий епізод на ashdi.vip/vod/, обробляємо як ashdi-vod
result.subtitles = await GetAshdiSubtitles(ashdiId); result.streams = await ParseAshdiVodEpisode(iframeUrl);
}
else
{
// Це багатосерійний плеєр, обробляємо як і раніше
result.streams = await ParseAllAshdiSources(iframeUrl);
var idMatch = Regex.Match(iframeUrl, @"_(\d+)|vod/(\d+)");
if (idMatch.Success)
{
string ashdiId = idMatch.Groups[1].Success ? idMatch.Groups[1].Value : idMatch.Groups[2].Value;
result.subtitles = await GetAshdiSubtitles(ashdiId);
}
} }
} }
} }
@ -1944,6 +2099,133 @@ namespace LME.Uaflix
return result; return result;
} }
/// <summary>
/// Parse episode with page status detection — distinguishes between
/// "page not found", "page exists but no player", and "has streams"
/// </summary>
public async Task<(PlayResult result, PageStatus status)> ParseEpisodeWithStatus(string url)
{
var result = new PlayResult() { streams = new List<PlayStream>() };
try
{
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0"),
new HeadersModel("Referer", _init.host)
};
string html = await GetHtml(url, headers);
if (string.IsNullOrWhiteSpace(html))
{
_onLog($"ParseEpisodeWithStatus: Page not found or empty for {url}");
return (result, PageStatus.PageNotFound);
}
var doc = new HtmlDocument();
doc.LoadHtml(html);
var videoNode = doc.DocumentNode.SelectSingleNode("//video");
if (videoNode != null)
{
string videoUrl = videoNode.GetAttributeValue("src", "");
if (!string.IsNullOrEmpty(videoUrl))
{
result.streams.Add(new PlayStream
{
link = videoUrl,
quality = "1080p",
title = BuildDisplayTitle("Основне джерело", videoUrl, 1)
});
return (result, PageStatus.HasStreams);
}
}
var allIframes = ExtractAllIframeUrls(doc);
var zetvideoIframes = allIframes
.Where(u => u != null && u.Contains("zetvideo.net"))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (zetvideoIframes.Count > 0)
{
int streamIndex = 0;
foreach (var zetIframe in zetvideoIframes)
{
var streams = await ParseAllZetvideoSources(zetIframe);
if (streams == null || streams.Count == 0)
continue;
foreach (var stream in streams)
{
string label = streamIndex == 0 ? "Uaflix" : "Оригінал";
stream.title = label;
_onLog($"ParseEpisodeWithStatus: zetvideo stream #{streamIndex + 1}: {label} -> {zetIframe}" +
(stream.subtitles?.data?.Count > 0
? " (has subtitles)" : ""));
}
result.streams.AddRange(streams);
streamIndex++;
}
if (result.streams.Count > 0)
return (result, PageStatus.HasStreams);
}
else
{
string iframeUrl = ExtractIframeUrl(doc);
if (!string.IsNullOrEmpty(iframeUrl))
{
if (iframeUrl.Contains("ashdi.vip/serial/"))
{
result.ashdi_url = iframeUrl;
return (result, PageStatus.HasStreams);
}
if (iframeUrl.Contains("youtube.com/embed/"))
{
_onLog($"ParseEpisodeWithStatus: Only YouTube trailer found on page: {iframeUrl}");
return (result, PageStatus.PageExistsNoPlayer);
}
if (iframeUrl.Contains("ashdi.vip"))
{
if (iframeUrl.Contains("/vod/"))
{
result.streams = await ParseAshdiVodEpisode(iframeUrl);
}
else
{
result.streams = await ParseAllAshdiSources(iframeUrl);
var idMatch = Regex.Match(iframeUrl, @"_(\d+)|vod/(\d+)");
if (idMatch.Success)
{
string ashdiId = idMatch.Groups[1].Success ? idMatch.Groups[1].Value : idMatch.Groups[2].Value;
result.subtitles = await GetAshdiSubtitles(ashdiId);
}
}
if (result.streams.Count > 0)
return (result, PageStatus.HasStreams);
}
}
}
_onLog($"ParseEpisodeWithStatus: Page exists but no playable streams for {url}");
return (result, PageStatus.PageExistsNoPlayer);
}
catch (Exception ex)
{
_onLog($"ParseEpisodeWithStatus error: {ex.Message}");
}
_onLog($"ParseEpisodeWithStatus result: streams.count={result.streams.Count}, ashdi_url={result.ashdi_url}");
return (result, result.streams.Count > 0 ? PageStatus.HasStreams : PageStatus.PageNotFound);
}
private void NormalizeUaflixVoiceNames(SerialAggregatedStructure structure) private void NormalizeUaflixVoiceNames(SerialAggregatedStructure structure)
{ {
const string baseName = "Uaflix"; const string baseName = "Uaflix";
@ -1976,6 +2258,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>();
@ -1988,15 +2279,27 @@ namespace LME.Uaflix
var script = doc.DocumentNode.SelectSingleNode("//script[contains(text(), 'file:')]"); var script = doc.DocumentNode.SelectSingleNode("//script[contains(text(), 'file:')]");
if (script != null) if (script != null)
{ {
var match = Regex.Match(script.InnerText, @"file:\s*""([^""]+\.m3u8)"); var fileMatch = Regex.Match(script.InnerText, @"file:\s*""([^""]+\.m3u8)");
if (match.Success) if (fileMatch.Success)
{ {
string link = match.Groups[1].Value; string link = fileMatch.Groups[1].Value;
// Парсимо subtitle з того ж Playerjs конфігу
SubtitleTpl? subtitles = null;
var subtitleMatch = Regex.Match(script.InnerText, @"subtitle:\s*""([^""]*)""");
if (subtitleMatch.Success)
{
string subtitleStr = subtitleMatch.Groups[1].Value;
if (!string.IsNullOrWhiteSpace(subtitleStr))
subtitles = ApnHelper.ParseSubtitles(subtitleStr);
}
result.Add(new PlayStream result.Add(new PlayStream
{ {
link = link, link = link,
quality = "1080p", quality = "1080p",
title = BuildDisplayTitle("Основне джерело", link, 1) title = BuildDisplayTitle("Основне джерело", link, 1),
subtitles = subtitles
}); });
return result; return result;
} }
@ -2221,6 +2524,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