mirror of
https://github.com/lampame/lampac-ukraine.git
synced 2026-06-17 12:08:54 +00:00
Compare commits
7 Commits
d2c7ee7c72
...
6a398317a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a398317a4 | ||
|
|
444e4c876e | ||
|
|
9a6fe0ab7c | ||
|
|
f33ffdd930 | ||
|
|
f793fefa82 | ||
|
|
35bb155fa3 | ||
|
|
dd40ed69f2 |
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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,41 +2016,76 @@ namespace LME.Uaflix
|
||||
}
|
||||
}
|
||||
|
||||
string iframeUrl = ExtractIframeUrl(doc);
|
||||
if (!string.IsNullOrEmpty(iframeUrl))
|
||||
// Отримуємо всі 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)
|
||||
{
|
||||
if (iframeUrl.Contains("ashdi.vip/serial/"))
|
||||
int streamIndex = 0;
|
||||
foreach (var zetIframe in zetvideoIframes)
|
||||
{
|
||||
result.ashdi_url = iframeUrl;
|
||||
return result;
|
||||
}
|
||||
var streams = await ParseAllZetvideoSources(zetIframe);
|
||||
if (streams == null || streams.Count == 0)
|
||||
continue;
|
||||
|
||||
// Ігноруємо YouTube трейлери
|
||||
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/"))
|
||||
foreach (var stream in streams)
|
||||
{
|
||||
// Це окремий епізод на ashdi.vip/vod/, обробляємо як ashdi-vod
|
||||
result.streams = await ParseAshdiVodEpisode(iframeUrl);
|
||||
// Перший потік → "Uaflix" (переклад), наступні → "Оригінал"
|
||||
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.streams = await ParseAllAshdiSources(iframeUrl);
|
||||
var idMatch = Regex.Match(iframeUrl, @"_(\d+)|vod/(\d+)");
|
||||
if (idMatch.Success)
|
||||
result.ashdi_url = iframeUrl;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ігноруємо 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;
|
||||
result.subtitles = await GetAshdiSubtitles(ashdiId);
|
||||
// Це окремий епізод на ashdi.vip/vod/, обробляємо як ashdi-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user