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;
using LME.Uaflix.Models;
using LME.Uaflix;
namespace LME.Uaflix.Controllers
{
@ -76,8 +77,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,12 +90,25 @@ 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 ===");
return OnError("lme_uaflix", refresh_proxy: true);
return OnError("lme_uaflix", gbcache: false, refresh_proxy: true);
}
// Якщо є episode_url але немає play=true, це виклик для отримання інформації про стрім (для method: 'call')
@ -102,16 +118,35 @@ 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"));
}
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;
@ -277,7 +312,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,
@ -336,9 +371,37 @@ namespace LME.Uaflix.Controllers
}
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 (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 ===");
return OnError("lme_uaflix", refresh_proxy: true);
}

View File

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

View File

@ -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 для цього епізоду
/// <summary>
/// Всі zetvideo iframe URL на сторінці епізоду (для створення кількох перекладів)
/// Перший елемент відповідає iframeUrl, наступні — додаткові плеєри (напр. з субтитрами)
/// </summary>
public List<string> zetvideoIframeUrls { get; set; }
}
}

View File

@ -18,6 +18,13 @@ using System.Text;
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
{
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);
}
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))
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))
return (cached?.IframeUrl, cached?.PlayerType);
return cached;
try
{
@ -260,7 +313,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);
@ -268,25 +321,40 @@ namespace LME.Uaflix
string iframeUrl = ExtractIframeUrl(doc);
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,
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<EpisodeLinkInfo> seasonEpisodes)
private async Task<EpisodePlayerInfo> ProbeSeasonPlayer(List<EpisodeLinkInfo> seasonEpisodes)
{
if (seasonEpisodes == null || seasonEpisodes.Count == 0)
return (null, null);
return null;
foreach (var episode in seasonEpisodes.OrderBy(e => e.episode))
{
@ -294,21 +362,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)
@ -687,22 +760,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}");
@ -710,18 +783,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
@ -729,15 +802,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}");
@ -747,8 +820,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<string> zetvideoUrls = null;
if (serialProbe.ZetvideoIframeUrls != null && serialProbe.ZetvideoIframeUrls.Count > 0)
zetvideoUrls = serialProbe.ZetvideoIframeUrls;
var syntheticEpisodes = new List<EpisodeLinkInfo>
{
new EpisodeLinkInfo
@ -757,17 +835,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;
}
}
@ -1028,7 +1107,8 @@ namespace LME.Uaflix
if (season < 0)
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))
{
_onLog($"GetSeasonStructure: Using cached structure for season={season}, url={serialUrl}");
@ -1051,20 +1131,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<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
{
_onLog($"GetSeasonStructure: player '{seasonProbe.playerType}' is not supported");
_onLog($"GetSeasonStructure: player '{seasonProbe.PlayerType}' is not supported");
return null;
}
@ -1896,6 +2016,42 @@ namespace LME.Uaflix
}
}
// Отримуємо всі iframe зі сторінки (підтримка кількох плеєрів, напр. zetvideo з субтитрами)
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)
{
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)
{
// Перший потік → "Uaflix" (переклад), наступні → "Оригінал"
string label = streamIndex == 0 ? "Uaflix" : "Оригінал";
stream.title = label;
_onLog($"ParseEpisode: zetvideo потік #{streamIndex + 1}: {label} -> {zetIframe}" +
(stream.subtitles?.data?.Count > 0
? " (має субтитри)" : ""));
}
result.streams.AddRange(streams);
streamIndex++;
}
}
else
{
// Старий код: використовуємо перший iframe для ashdi/інших
string iframeUrl = ExtractIframeUrl(doc);
if (!string.IsNullOrEmpty(iframeUrl))
{
@ -1912,9 +2068,7 @@ namespace LME.Uaflix
return result;
}
if (iframeUrl.Contains("zetvideo.net"))
result.streams = await ParseAllZetvideoSources(iframeUrl);
else if (iframeUrl.Contains("ashdi.vip"))
if (iframeUrl.Contains("ashdi.vip"))
{
// Перевіряємо, чи це ashdi-vod (окремий епізод) або ashdi-serial (багатосерійний плеєр)
if (iframeUrl.Contains("/vod/"))
@ -1936,6 +2090,7 @@ namespace LME.Uaflix
}
}
}
}
catch (Exception ex)
{
_onLog($"ParseEpisode error: {ex.Message}");
@ -1944,6 +2099,133 @@ namespace LME.Uaflix
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)
{
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)
{
var result = new List<PlayStream>();
@ -1988,15 +2279,27 @@ namespace LME.Uaflix
var script = doc.DocumentNode.SelectSingleNode("//script[contains(text(), 'file:')]");
if (script != null)
{
var match = Regex.Match(script.InnerText, @"file:\s*""([^""]+\.m3u8)");
if (match.Success)
var fileMatch = Regex.Match(script.InnerText, @"file:\s*""([^""]+\.m3u8)");
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
{
link = link,
quality = "1080p",
title = BuildDisplayTitle("Основне джерело", link, 1)
title = BuildDisplayTitle("Основне джерело", link, 1),
subtitles = subtitles
});
return result;
}
@ -2221,6 +2524,7 @@ namespace LME.Uaflix
{
public string IframeUrl { get; set; }
public string PlayerType { get; set; }
public List<string> ZetvideoIframeUrls { get; set; }
}
sealed class SearchMeta