Compare commits

..

17 Commits

Author SHA1 Message Date
Felix
3c475352fe
Merge pull request #27 from lampame/streamdata
Streamdata
2026-05-15 22:30:39 +03:00
Felix
b38c8bd4f4 refactor(streamdata): simplify source labeling and remove hostname parsing
Replace hostname-based source labels with stable numbered labels to keep
episode and source selection consistent across serial and movie flows.

Drop unused URL hostname extraction logic from `StreamDataInvoke` and
propagate the selected source parameter through play handling to align
with the new voice-tab source selection structure.
2026-05-15 22:22:19 +03:00
Felix
d2e7f9aa6b fix(streamdata): correctly track explicit APN host configuration
Record whether an APN host was explicitly provided during module init by
setting `ApnHostProvided` only when APN is enabled and host is non-empty.

This preserves accurate APN state for downstream logic that depends on
distinguishing defaulted host values from user-provided configuration.
2026-05-15 22:15:46 +03:00
Felix
3d47e802f1 feat(streamdata): add new StreamData online provider module
Introduce a full LME.StreamData module with initialization, manifest, and
online registration to expose StreamData as a new content source.

Implement TMDB-based movie, series, and episode retrieval flows with
controller endpoints, API invoke client, proxy-aware requests, caching, and
subtitle/stream mapping for playback templates.
2026-05-15 22:11:17 +03:00
Felix
7fbe28421d
Merge pull request #26 from lampame/uakino
Uakino
2026-05-15 20:26:54 +03:00
Felix
79f75ef168 refactor(uakino): resolve Ashdi VOD links before stream URL assembly
Switch movie and serial stream preparation to resolve Ashdi VOD URLs first
and then pass the resolved value into stream URL construction.

Also make serial handling asynchronous and inject the invoke dependency so
episode file links can be normalized consistently with movie playback flow.
2026-05-15 20:16:58 +03:00
Felix
d145313c5b fix(uakino): always request ashdi vod with multivoice parameter
Ensure Ashdi VOD fetches include the `multivoice` query flag so the response
contains the full stream array instead of a single variant. This restores
complete track availability for downstream parsing and labeling logic.
2026-05-15 20:08:08 +03:00
Felix
b6a884d6e3 feat(uakino): return all Ashdi VOD streams with per-track labels
Add a dedicated resolver that parses the Ashdi multivoice JSON array and
returns every available stream entry instead of a single resolved URL.

Update controller fallback handling to build MovieTpl output from the full
stream list, preserving optional item titles as display labels and defaulting
to "Фільм" when absent. This exposes separate playable variants in results
and avoids collapsing multivoice entries into one stream link.
2026-05-15 20:03:34 +03:00
Felix
460f527a6f refactor(uakino): stop appending multivoice query in Ashdi VOD resolver
Remove automatic `?multivoice` query injection when resolving Ashdi VOD
URLs. Each VOD now resolves through its own stream endpoint without
forcing voice aggregation into a single mixed array.

This keeps stream selection isolated per item and avoids unintended
cross-voice merging during direct link resolution.
2026-05-15 19:58:20 +03:00
Felix
6800c5d393 fix(uakino): parse Ashdi file arrays to resolve direct stream URL
Handle Ashdi responses where `file` contains a JSON array encoded in the
page script instead of a plain URL. Extract the balanced array payload,
parse it safely, and return the first stream `file` entry when present.

This prevents unresolved VOD links when complex playlist structures are
embedded inline and keeps existing simple URL handling unchanged.
2026-05-15 19:53:00 +03:00
Felix
a2071449f9 fix(uakino): group movie stream entries when voice tabs are absent
Handle playlist items differently when `playlists-lists` voice tabs are
missing so film pages no longer collapse multiple stream versions into a
single fallback voice group.

When tabs are absent, treat each `li` as a stream variant and resolve its
target voice by `data-voice` (or item text) with on-demand group creation.
Keep existing tab-based matching logic unchanged for serial/episode layouts.
2026-05-15 19:40:06 +03:00
Felix
9e677b4113 refactor(uakino): remove PlayerJS decode fallback from Ashdi resolver
Drop the secondary PlayerJsDecoder extraction path and keep complex VOD
payloads untouched by returning the original value. Align the module
manifest by removing the now-unused shared PlayerJsDecoder dependency.
2026-05-15 19:30:42 +03:00
Felix
90e7ec4602 build(uakino): include PlayerJs decoder shared dependency
Add `PlayerJsDecoder.cs` to the UAKino module manifest so the module ships
with the shared PlayerJS decoding utility required at runtime.
2026-05-15 19:28:13 +03:00
Felix
15ff1ee10c refactor(uakino): centralize Ashdi VOD resolution and dedupe movie streams
Move Ashdi URL handling into a dedicated resolver in `UAKinoInvoke` and
reuse it from controller paths instead of appending query params inline.

Resolve Ashdi pages to direct player file URLs, with fallback extraction
strategies and safe error handling, then build stream links from resolved
targets.

Avoid duplicate movie entries by skipping already processed resolved URLs,
and propagate the selected season number when rendering episode metadata.
2026-05-15 19:26:55 +03:00
Felix
80f0869401 fix(uakino): add HTML page fallback when playlist API returns no data
Handle cases where UAKino playlist requests return empty results by
resolving stream URLs directly from the content page HTML.

Add a fallback parser that extracts video sources from `link[itemprop=video]`
or `iframe#pre`, and use it to return playable movie or single-episode
responses instead of failing immediately.

Ensure Ashdi links consistently include `multivoice` for movie playback,
including episode file URLs, to improve stream compatibility.
2026-05-15 19:06:29 +03:00
Felix
a54bc0e435 refactor(uakino): restructure search results into grouped season entries
Replace flat search result handling with a grouped model where each show
contains a list of season entries, enabling deterministic serial flow for
single-show and multi-season matches.

Update controller logic to branch serial/movie processing against grouped
results, add explicit season selection handling, and reuse selected season
URLs on follow-up requests.

Adjust search parsing to collect raw items, filter non-content entries, and
group by normalized show identity before caching, removing early year-based
filtering from cached returns.
2026-05-15 19:05:01 +03:00
Felix
317cb6292c feat(uakino): add UAKino online source module
Add new online source module for UAKino website providing movie and series search and playback functionality. Includes controller, model definitions, online API integration, search implementation with caching, and module initialization. Implements similar result handling for multiple search results and serial/movie playback differentiation.
2026-05-15 18:46:00 +03:00
14 changed files with 1966 additions and 0 deletions

View File

@ -0,0 +1,306 @@
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);
}
}
}

91
LME.StreamData/ModInit.cs Normal file
View File

@ -0,0 +1,91 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,15 @@
<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

@ -0,0 +1,155 @@
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

@ -0,0 +1,12 @@
{
"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"
]
}

316
LME.UAKino/Controller.cs Normal file
View File

@ -0,0 +1,316 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using LME.UAKino.Models;
using Microsoft.AspNetCore.Mvc;
using Shared;
using Shared.Engine;
using Shared.Models;
using Shared.Models.Online.Settings;
using Shared.Models.Templates;
namespace LME.UAKino.Controllers
{
public class Controller : BaseOnlineController
{
ProxyManager proxyManager;
public Controller() : base(ModInit.Settings)
{
proxyManager = new ProxyManager(ModInit.UAKino);
}
[HttpGet]
[Route("lite/lme_uakino")]
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, bool rjson = false, string href = null, bool checksearch = false)
{
await UpdateService.ConnectAsync(host);
var init = loadKit(ModInit.UAKino);
if (!init.enable)
return Forbid();
var invoke = new UAKinoInvoke(init, hybridCache, OnLog, proxyManager, httpHydra);
if (checksearch)
{
if (!IsCheckOnlineSearchEnabled())
return OnError("lme_uakino", refresh_proxy: true);
var searchResults = await invoke.Search(title, original_title, year, imdb_id);
if (searchResults != null && searchResults.Count > 0)
return Content("data-json=", "text/plain; charset=utf-8");
return OnError("lme_uakino", refresh_proxy: true);
}
string newsId = null;
string itemUrl = href;
if (string.IsNullOrEmpty(itemUrl))
{
// === ПЕРШИЙ ЗАПИТ: пошук ===
var searchResults = await invoke.Search(title, original_title, year, imdb_id);
if (searchResults == null || searchResults.Count == 0)
return OnError("lme_uakino", refresh_proxy: true);
if (serial == 1)
{
// Серіал
if (searchResults.Count == 1)
{
var sr = searchResults[0];
if (sr.Seasons.Count > 1 && s == -1)
{
// Кілька сезонів — показуємо SeasonTpl для вибору
return HandleSeasonSelection(sr, id, imdb_id, kinopoisk_id, title, original_title, year, rjson);
}
// Один сезон — використовуємо його
itemUrl = sr.Seasons[0].Url;
newsId = sr.Seasons[0].NewsId;
}
else
{
// Кілька різних шоу — обирає
return ShowSimilarTpl(searchResults, id, imdb_id, kinopoisk_id, title, original_title, year, serial, rjson);
}
}
else
{
// Фільм
if (searchResults.Count > 1)
{
return ShowSimilarTpl(searchResults, id, imdb_id, kinopoisk_id, title, original_title, year, serial, rjson);
}
itemUrl = searchResults[0].Seasons[0].Url;
newsId = searchResults[0].Seasons[0].NewsId;
}
}
else
{
// Повторний запит (з селектора сезонів або озвучок)
newsId = UAKinoInvoke.ExtractNewsId(itemUrl);
}
if (string.IsNullOrEmpty(newsId))
return OnError("lme_uakino", refresh_proxy: true);
var voices = await invoke.GetPlaylist(newsId);
if (voices == null || voices.Count == 0)
{
// Fallback: playlist API повернув ERR_NOT_DATA — пробуємо зі сторінки
string fallbackUrl = await invoke.GetPageFallbackUrl(itemUrl);
if (!string.IsNullOrEmpty(fallbackUrl))
{
if (serial == 1)
{
var voice_tpl = new VoiceTpl();
var episode_tpl = new EpisodeTpl();
string resolvedUrl = await invoke.ResolveAshdiVod(fallbackUrl);
string streamUrl = BuildStreamUrl(init, resolvedUrl);
voice_tpl.Append("Озвучення", true, null);
episode_tpl.Append("Епізод 1", title ?? original_title, s >= 0 ? s.ToString() : "1", "01", streamUrl);
episode_tpl.Append(voice_tpl);
return rjson
? Content(episode_tpl.ToJson(), "application/json; charset=utf-8")
: Content(episode_tpl.ToHtml(), "text/html; charset=utf-8");
}
else
{
var resolvedStreams = await invoke.ResolveAshdiVodAll(fallbackUrl);
var movie_tpl = new MovieTpl(title, original_title, resolvedStreams.Count);
foreach (var (file, label) in resolvedStreams)
{
string displayLabel = !string.IsNullOrEmpty(label) ? label : "Фільм";
string streamUrl = BuildStreamUrl(init, file);
movie_tpl.Append(displayLabel, streamUrl);
}
return rjson
? Content(movie_tpl.ToJson(), "application/json; charset=utf-8")
: Content(movie_tpl.ToHtml(), "text/html; charset=utf-8");
}
}
return OnError("lme_uakino", refresh_proxy: true);
}
if (serial == 1)
{
return await HandleSerial(init, voices, title, original_title, imdb_id, kinopoisk_id, itemUrl, s, t, rjson, invoke);
}
else
{
return await HandleMovie(init, voices, title, original_title, rjson, invoke);
}
}
/// <summary>Вибір сезону для багатосезонного серіалу</summary>
private ActionResult HandleSeasonSelection(SearchResult sr, long id, string imdb_id, long kinopoisk_id, string title, string original_title, int year, bool rjson)
{
var season_tpl = new SeasonTpl(sr.Seasons.Count);
foreach (var season in sr.Seasons)
{
string link = $"{host}/lite/lme_uakino?id={id}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season.SeasonNumber}&href={HttpUtility.UrlEncode(season.Url)}";
season_tpl.Append($"Сезон {season.SeasonNumber}", link, season.SeasonNumber.ToString());
}
return rjson
? Content(season_tpl.ToJson(), "application/json; charset=utf-8")
: Content(season_tpl.ToHtml(), "text/html; charset=utf-8");
}
/// <summary>Вибір між різними шоу/фільмами</summary>
private ActionResult ShowSimilarTpl(List<SearchResult> searchResults, long id, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int serial, bool rjson)
{
var similar_tpl = new SimilarTpl(searchResults.Count);
foreach (var res in searchResults)
{
string seasonUrl = res.Seasons.Count > 0 ? res.Seasons[0].Url : "";
string yearStr = res.Seasons.Count > 0 ? (res.Seasons[0].Year?.ToString() ?? "") : (res.Year?.ToString() ?? "");
string link = $"{host}/lite/lme_uakino?id={id}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&href={HttpUtility.UrlEncode(seasonUrl)}";
similar_tpl.Append(res.Title, yearStr, res.OriginalTitle ?? "", link, res.Poster);
}
return rjson
? Content(similar_tpl.ToJson(), "application/json; charset=utf-8")
: Content(similar_tpl.ToHtml(), "text/html; charset=utf-8");
}
/// <summary>Серіал: озвучки + епізоди</summary>
private async Task<ActionResult> HandleSerial(OnlinesSettings init, List<VoiceGroup> voices, string title, string original_title, string imdb_id, long kinopoisk_id, string itemUrl, int s, string t, bool rjson, UAKinoInvoke invoke)
{
var voice_tpl = new VoiceTpl();
var episode_tpl = new EpisodeTpl();
if (string.IsNullOrEmpty(t))
t = voices.First().DataId;
foreach (var voice in voices)
{
string voiceLink = $"{host}/lite/lme_uakino?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&serial=1&s={s}&t={voice.DataId}&href={HttpUtility.UrlEncode(itemUrl)}";
voice_tpl.Append(voice.Name, voice.DataId == t, voiceLink);
}
var selected = voices.FirstOrDefault(v => v.DataId == t);
if (selected == null || selected.Episodes.Count == 0)
return OnError("lme_uakino", refresh_proxy: true);
string seasonStr = s >= 0 ? s.ToString() : "1";
foreach (var ep in selected.Episodes.OrderBy(e => e.EpisodeNumber ?? int.MaxValue))
{
int epNum = ep.EpisodeNumber ?? 1;
string epName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {epNum}" : ep.Title;
string fileUrl = await invoke.ResolveAshdiVod(ep.FileUrl);
string streamUrl = BuildStreamUrl(init, fileUrl);
episode_tpl.Append(epName, title ?? original_title, seasonStr, epNum.ToString("D2"), streamUrl);
}
episode_tpl.Append(voice_tpl);
return rjson
? Content(episode_tpl.ToJson(), "application/json; charset=utf-8")
: Content(episode_tpl.ToHtml(), "text/html; charset=utf-8");
}
/// <summary>Фільм: список стрімів</summary>
private async Task<ActionResult> HandleMovie(OnlinesSettings init, List<VoiceGroup> voices, string title, string original_title, bool rjson, UAKinoInvoke invoke)
{
var processed = new HashSet<string>();
var movie_tpl = new MovieTpl(title, original_title);
foreach (var voice in voices)
{
foreach (var ep in voice.Episodes)
{
string label = voice.Name;
if (voices.Count == 1 && voice.Episodes.Count > 1)
label = ep.Title;
string fileUrl = ep.FileUrl;
// Резолвимо Ashdi VOD — отримуємо реальний .m3u8 стрім
string resolvedUrl = await invoke.ResolveAshdiVod(fileUrl);
// Дедуплікація: якщо той самий стрім — пропускаємо
if (!processed.Add(resolvedUrl))
continue;
string streamUrl = BuildStreamUrl(init, resolvedUrl);
movie_tpl.Append(label, streamUrl);
}
}
return rjson
? Content(movie_tpl.ToJson(), "application/json; charset=utf-8")
: Content(movie_tpl.ToHtml(), "text/html; charset=utf-8");
}
string BuildStreamUrl(OnlinesSettings init, string streamLink)
{
string link = StripLampacArgs(streamLink?.Trim());
if (string.IsNullOrEmpty(link))
return link;
if (ApnHelper.IsEnabled(init))
{
if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link))
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);
}
}
}

99
LME.UAKino/ModInit.cs Normal file
View File

@ -0,0 +1,99 @@
using Newtonsoft.Json.Linq;
using Shared;
using Shared.Engine;
using Shared.Models.Online.Settings;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
using Shared.Models;
using Shared.Models.Events;
using System;
using System.Net.Http;
using System.Net.Mime;
using System.Net.Security;
using System.Security.Authentication;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LME.UAKino
{
public class ModInit : IModuleLoaded
{
public static double Version => 1.0;
public static OnlinesSettings UAKino;
public static bool ApnHostProvided;
public static OnlinesSettings Settings
{
get => UAKino;
set => UAKino = value;
}
/// <summary>
/// модуль загружен
/// </summary>
public void Loaded(InitspaceModel initspace)
{
UAKino = new OnlinesSettings("LME.UAKino", "https://uakino.top", streamproxy: false, useproxy: false)
{
displayname = "UAKino",
displayindex = 0,
proxy = new Shared.Models.Base.ProxySettings()
{
useAuth = true,
username = "",
password = "",
list = new string[] { "socks5://ip:port" }
}
};
var defaults = JObject.FromObject(UAKino);
defaults["enabled"] = true;
var conf = ModuleInvoke.Init("LME.UAKino", defaults);
bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost);
conf.Remove("apn");
conf.Remove("apn_host");
UAKino = conf.ToObject<OnlinesSettings>();
if (hasApn)
ApnHelper.ApplyInitConf(apnEnabled, apnHost, UAKino, useDefaultHostWhenEmpty: true);
ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost);
if (hasApn && apnEnabled)
{
UAKino.streamproxy = false;
}
else if (UAKino.streamproxy)
{
UAKino.apnstream = false;
UAKino.apn = null;
}
// Виводити "уточнити пошук"
OnlineRegistry.RegisterWithSearch("lme_uakino");
}
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

@ -0,0 +1,37 @@
using System.Collections.Generic;
namespace LME.UAKino.Models
{
public class SearchResult
{
public string Title { get; set; }
public string OriginalTitle { get; set; }
public string Poster { get; set; }
/// <summary>Сезони серіалу. Для фільмів — один елемент без SeasonNumber</summary>
public List<SeasonEntry> Seasons { get; set; } = new();
/// <summary>Рік фільму (тільки для не-сезонних результатів)</summary>
public int? Year { get; set; }
}
public class SeasonEntry
{
public int SeasonNumber { get; set; }
public string NewsId { get; set; }
public string Url { get; set; }
public int? Year { get; set; }
}
public class VoiceGroup
{
public string Name { get; set; }
public string DataId { get; set; }
public List<EpisodeItem> Episodes { get; set; } = new();
}
public class EpisodeItem
{
public string Title { get; set; }
public string FileUrl { get; set; }
public int? EpisodeNumber { get; set; }
}
}

35
LME.UAKino/OnlineApi.cs Normal file
View File

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Shared.Models;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace LME.UAKino
{
public class OnlineApi : IModuleOnline
{
public List<ModuleOnlineItem> Invoke(HttpContext httpContext, RequestModel requestInfo, string host, OnlineEventsModel args)
{
long.TryParse(args.id, out long tmdbid);
return Events(host, tmdbid, args.imdb_id, args.kinopoisk_id, args.title, args.original_title, args.original_language, args.year, args.source, args.serial, args.account_email);
}
private static List<ModuleOnlineItem> Events(string host, 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)
{
var online = new List<ModuleOnlineItem>();
var init = ModInit.UAKino;
if (init.enable && !init.rip)
{
if (UpdateService.IsDisconnected())
init.overridehost = null;
online.Add(new ModuleOnlineItem(init, "lme_uakino"));
}
return online;
}
}
}

15
LME.UAKino/UAKino.csproj Normal file
View File

@ -0,0 +1,15 @@
<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>

814
LME.UAKino/UAKinoInvoke.cs Normal file
View File

@ -0,0 +1,814 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using LME.UAKino.Models;
using HtmlAgilityPack;
using Shared;
using Shared.Engine;
using Shared.Models;
using Shared.Models.Online.Settings;
namespace LME.UAKino
{
public class UAKinoInvoke
{
private readonly OnlinesSettings _init;
private readonly IHybridCache _hybridCache;
private readonly Action<string> _onLog;
private readonly ProxyManager _proxyManager;
private readonly HttpHydra _httpHydra;
public UAKinoInvoke(OnlinesSettings init, IHybridCache hybridCache, Action<string> onLog, ProxyManager proxyManager, HttpHydra httpHydra = null)
{
_init = init;
_hybridCache = hybridCache;
_onLog = onLog;
_proxyManager = proxyManager;
_httpHydra = httpHydra;
}
public async Task<List<SearchResult>> Search(string title, string original_title, int year, string imdb_id)
{
string query = BuildSearchQuery(title, original_title, imdb_id);
if (string.IsNullOrEmpty(query))
return null;
string memKey = $"UAKino:search:{query}";
if (_hybridCache.TryGetValue(memKey, out List<SearchResult> cached))
return cached;
try
{
_onLog?.Invoke($"UAKino search: {query}");
string url = $"{_init.host}/engine/lazydev/dle_search/ajax.php";
string body = $"story={HttpUtility.UrlEncode(query)}&thisUrl=/ua/";
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15"),
new HeadersModel("Referer", $"{_init.host}/ua/"),
new HeadersModel("X-Requested-With", "XMLHttpRequest"),
new HeadersModel("Origin", _init.host),
new HeadersModel("Accept", "*/*"),
new HeadersModel("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
};
string json = await Http.Post(_init.cors(url), body, headers: headers, proxy: _proxyManager.Get());
if (string.IsNullOrEmpty(json))
return null;
using var jsonDoc = JsonDocument.Parse(json);
if (!jsonDoc.RootElement.TryGetProperty("content", out JsonElement contentElem))
return null;
string html = contentElem.GetString();
if (string.IsNullOrEmpty(html))
return null;
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(html);
var rawItems = ParseRawItems(htmlDoc);
var results = GroupByShow(rawItems);
if (results.Count > 0)
_hybridCache.Set(memKey, results, cacheTime(20));
return results;
}
catch (Exception ex)
{
_onLog?.Invoke($"UAKino search error: {ex.Message}");
return null;
}
}
/// <summary>
/// Отримати плейлист (озвучки + епізоди) за news_id
/// </summary>
public async Task<List<VoiceGroup>> GetPlaylist(string newsId)
{
if (string.IsNullOrEmpty(newsId))
return null;
string memKey = $"UAKino:playlist:{newsId}";
if (_hybridCache.TryGetValue(memKey, out List<VoiceGroup> cached))
return cached;
try
{
_onLog?.Invoke($"UAKino playlist: {newsId}");
string url = $"{_init.host}/engine/ajax/playlists.php?news_id={newsId}&xfield=playlist";
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15"),
new HeadersModel("Referer", $"{_init.host}/{newsId}-"),
new HeadersModel("X-Requested-With", "XMLHttpRequest"),
new HeadersModel("Accept", "application/json, text/javascript, */*; q=0.01")
};
string json = await HttpGet(url, headers);
if (string.IsNullOrEmpty(json))
return null;
using var jsonDoc = JsonDocument.Parse(json);
if (!jsonDoc.RootElement.TryGetProperty("response", out JsonElement responseElem))
return null;
string html = responseElem.GetString();
if (string.IsNullOrEmpty(html))
return null;
var voices = ParsePlaylistHtml(html);
if (voices.Count > 0)
_hybridCache.Set(memKey, voices, cacheTime(30));
return voices;
}
catch (Exception ex)
{
_onLog?.Invoke($"UAKino playlist error: {ex.Message}");
return null;
}
}
/// <summary>
/// Fallback: отримати стрім з HTML сторінки фільму коли playlist API недоступний
/// Парсить &lt;link itemprop="video" value="..."&gt; або &lt;iframe id="pre" src="..."&gt;
/// </summary>
public async Task<string> GetPageFallbackUrl(string pageUrl)
{
if (string.IsNullOrEmpty(pageUrl))
return null;
try
{
_onLog?.Invoke($"UAKino page fallback: {pageUrl}");
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15"),
new HeadersModel("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
new HeadersModel("Referer", _init.host)
};
string html = await HttpGet(pageUrl, headers);
if (string.IsNullOrEmpty(html))
return null;
var doc = new HtmlDocument();
doc.LoadHtml(html);
// Спершу пробуємо <link itemprop="video" value="...">
var linkTag = doc.DocumentNode.SelectSingleNode("//link[@itemprop='video']");
if (linkTag != null)
{
string value = linkTag.GetAttributeValue("value", "");
if (!string.IsNullOrEmpty(value))
return NormalizeUrl(value);
}
// Fallback до <iframe id="pre" src="...">
var iframeTag = doc.DocumentNode.SelectSingleNode("//iframe[@id='pre']");
if (iframeTag != null)
{
string src = iframeTag.GetAttributeValue("src", "");
if (!string.IsNullOrEmpty(src))
return NormalizeUrl(src);
}
return null;
}
catch (Exception ex)
{
_onLog?.Invoke($"UAKino page fallback error: {ex.Message}");
return null;
}
}
/// <summary>
/// Резолв Ashdi VOD сторінки: отримати реальний .m3u8 стрім з Playerjs file:'...'
/// </summary>
public async Task<string> ResolveAshdiVod(string vodUrl)
{
if (string.IsNullOrEmpty(vodUrl) || !ApnHelper.IsAshdiUrl(vodUrl))
return vodUrl;
try
{
string fetchUrl = vodUrl;
// Не додаємо ?multivoice — кожен VOD має свій унікальний стрім
// ?multivoice змішує всі голоси в один масив
_onLog?.Invoke($"UAKino resolve Ashdi: {fetchUrl}");
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", Http.UserAgent),
new HeadersModel("Referer", "https://ashdi.vip/")
};
if (ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost))
fetchUrl = ApnHelper.WrapUrl(_init, fetchUrl);
string html = await HttpGet(fetchUrl, headers);
if (string.IsNullOrEmpty(html))
return vodUrl;
// Спершу простий pattern file:'url'
var fileMatch = Regex.Match(html, @"file:\s*'([^']+)'", RegexOptions.IgnoreCase);
if (!fileMatch.Success)
fileMatch = Regex.Match(html, @"file:\s*""([^""]+)""", RegexOptions.IgnoreCase);
if (fileMatch.Success)
{
string resolvedUrl = fileMatch.Groups[1].Value;
if (!string.IsNullOrEmpty(resolvedUrl) && !resolvedUrl.StartsWith("["))
{
_onLog?.Invoke($"UAKino resolved Ashdi: {resolvedUrl}");
return resolvedUrl;
}
}
// Складний масив — знаходимо file:'[' і витягуємо збалансований JSON
int arrayStart = FindAshdiJsonArray(html);
if (arrayStart >= 0)
{
string jsonArray = ExtractBalancedBrackets(html, arrayStart);
if (!string.IsNullOrEmpty(jsonArray))
{
try
{
using var arr = JsonDocument.Parse(jsonArray);
if (arr.RootElement.ValueKind == JsonValueKind.Array && arr.RootElement.GetArrayLength() > 0)
{
string firstFile = arr.RootElement[0].GetProperty("file").GetString();
if (!string.IsNullOrEmpty(firstFile))
{
_onLog?.Invoke($"UAKino resolved Ashdi (array): {firstFile}");
return firstFile;
}
}
}
catch { }
}
}
return vodUrl;
}
catch (Exception ex)
{
_onLog?.Invoke($"UAKino resolve Ashdi error: {ex.Message}");
return vodUrl;
}
}
/// <summary>
/// Резолв Ashdi VOD з ?multivoice: повертає ВСІ стріми з JSON масиву
/// </summary>
public async Task<List<(string file, string title)>> ResolveAshdiVodAll(string vodUrl)
{
var result = new List<(string file, string title)>();
if (string.IsNullOrEmpty(vodUrl) || !ApnHelper.IsAshdiUrl(vodUrl))
{
if (!string.IsNullOrEmpty(vodUrl))
result.Add((vodUrl, null));
return result;
}
try
{
_onLog?.Invoke($"UAKino resolve Ashdi all: {vodUrl}");
// Для ?multivoice — Ашді повертає всі стріми в одному масиві
string fetchUrl = vodUrl;
if (!fetchUrl.Contains("multivoice"))
fetchUrl += (fetchUrl.Contains("?") ? "&" : "?") + "multivoice";
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", Http.UserAgent),
new HeadersModel("Referer", "https://ashdi.vip/")
};
if (ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost))
fetchUrl = ApnHelper.WrapUrl(_init, fetchUrl);
string html = await HttpGet(fetchUrl, headers);
if (string.IsNullOrEmpty(html))
{
result.Add((vodUrl, null));
return result;
}
int arrayStart = FindAshdiJsonArray(html);
if (arrayStart >= 0)
{
string jsonArray = ExtractBalancedBrackets(html, arrayStart);
if (!string.IsNullOrEmpty(jsonArray))
{
try
{
using var arr = JsonDocument.Parse(jsonArray);
if (arr.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.RootElement.EnumerateArray())
{
string file = item.GetProperty("file").GetString();
string title = item.TryGetProperty("title", out var t) ? t.GetString() : null;
if (!string.IsNullOrEmpty(file))
result.Add((file, title?.Trim()));
}
}
}
catch { }
}
}
if (result.Count == 0)
result.Add((vodUrl, null));
return result;
}
catch (Exception ex)
{
_onLog?.Invoke($"UAKino resolve Ashdi all error: {ex.Message}");
result.Add((vodUrl, null));
return result;
}
}
/// <summary>
/// Знайти позицію JSON масиву `[{...}]` після `file:'`
/// </summary>
private static int FindAshdiJsonArray(string html)
{
int idx = html.IndexOf("file:'[", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
idx = html.IndexOf("file:\"[", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return -1;
int bracket = html.IndexOf('[', idx);
return bracket;
}
/// <summary>
/// Витягнути збалансований вміст між [ ] з урахуванням вкладеності та рядків
/// </summary>
private static string ExtractBalancedBrackets(string text, int startIndex)
{
if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '[')
return null;
int depth = 0;
bool inString = false;
char quote = '\0';
for (int i = startIndex; i < text.Length; i++)
{
char ch = text[i];
if (inString)
{
if (ch == '\\')
{
i++; // пропускаємо екранований символ
continue;
}
if (ch == quote)
inString = false;
continue;
}
if (ch == '"' || ch == '\'')
{
inString = true;
quote = ch;
continue;
}
if (ch == '[')
{
depth++;
continue;
}
if (ch == ']')
{
depth--;
if (depth == 0)
return text.Substring(startIndex, i - startIndex + 1);
}
}
return null;
}
/// <summary>
/// Витягнути news_id з URL контенту
/// </summary>
public static string ExtractNewsId(string url)
{
if (string.IsNullOrEmpty(url))
return null;
var match = Regex.Match(url, @"[?/](\d+)-[^/]*\.html");
if (match.Success)
return match.Groups[1].Value;
return null;
}
// ===================== Парсинг результатів пошуку =====================
/// <summary>Сирий елемент з HTML пошуку, до групування</summary>
private class RawSearchItem
{
public string Title { get; set; }
public string OriginalTitle { get; set; }
public string Url { get; set; }
public string Poster { get; set; }
public int? Year { get; set; }
public string NewsId { get; set; }
}
private List<RawSearchItem> ParseRawItems(HtmlDocument doc)
{
var items = new List<RawSearchItem>();
var nodes = doc.DocumentNode.SelectNodes("//a[@class='search-result-link']");
if (nodes == null)
return items;
foreach (var node in nodes)
{
try
{
string href = node.GetAttributeValue("href", "");
if (string.IsNullOrEmpty(href))
continue;
var imgNode = node.SelectSingleNode(".//img[@class='search-poster']");
string poster = imgNode?.GetAttributeValue("src", "") ?? "";
var titleNode = node.SelectSingleNode(".//span[@class='searchheading']");
string title = CleanText(titleNode?.InnerText);
var origTitleNode = node.SelectSingleNode(".//span[@class='search-orig-title']");
string origTitle = CleanText(origTitleNode?.InnerText);
var infoNode = node.SelectSingleNode(".//div[@class='search-extend-info']");
int? year = null;
if (infoNode != null)
{
var yearSpan = infoNode.SelectSingleNode("./span[1]");
string yearText = CleanText(yearSpan?.InnerText);
if (!string.IsNullOrEmpty(yearText) && int.TryParse(yearText.Trim(), out int parsedYear))
year = parsedYear;
}
// Фільтр: пропускаємо новини/трейлери — без року та без оригінальної назви
if (!IsRealContent(title, origTitle, year))
continue;
string newsId = ExtractNewsId(href);
items.Add(new RawSearchItem
{
Title = title,
OriginalTitle = origTitle,
Url = NormalizeUrl(href),
Poster = NormalizeUrl(poster),
Year = year,
NewsId = newsId
});
}
catch
{
continue;
}
}
return items;
}
/// <summary>Фільтр: реальний контент (не новина/трейлер)</summary>
private static bool IsRealContent(string title, string origTitle, int? year)
{
// Є рік — контент
if (year.HasValue)
return true;
// Є оригінальна назва — контент
if (!string.IsNullOrEmpty(origTitle))
return true;
// Дуже довга назва без року — скоріше новина
if (!string.IsNullOrEmpty(title) && title.Length > 50)
return false;
return false;
}
/// <summary>Групування сирих елементів по назві шоу. Кожна група = один SearchResult зі списком сезонів</summary>
private List<SearchResult> GroupByShow(List<RawSearchItem> rawItems)
{
if (rawItems.Count == 0)
return new List<SearchResult>();
var groups = new Dictionary<string, List<RawSearchItem>>();
foreach (var item in rawItems)
{
string cleanTitle = CleanShowTitle(item.Title);
string key = $"{cleanTitle.ToLowerInvariant()}|{(item.OriginalTitle ?? "").ToLowerInvariant()}";
if (!groups.ContainsKey(key))
groups[key] = new List<RawSearchItem>();
groups[key].Add(item);
}
var results = new List<SearchResult>();
foreach (var kvp in groups)
{
var items = kvp.Value;
var first = items[0];
string showTitle = CleanShowTitle(first.Title);
var sr = new SearchResult
{
Title = showTitle,
OriginalTitle = first.OriginalTitle,
Poster = first.Poster
};
foreach (var item in items)
{
int? seasonNum = ExtractSeasonNumber(item.Title);
if (seasonNum.HasValue)
{
sr.Seasons.Add(new SeasonEntry
{
SeasonNumber = seasonNum.Value,
NewsId = item.NewsId,
Url = item.Url,
Year = item.Year
});
}
else
{
// Фільм або контент без сезону
sr.Seasons.Add(new SeasonEntry
{
SeasonNumber = 1,
NewsId = item.NewsId,
Url = item.Url,
Year = item.Year
});
sr.Year = item.Year;
}
}
// Сортуємо сезони за номером
sr.Seasons = sr.Seasons.OrderBy(s => s.SeasonNumber).ToList();
results.Add(sr);
}
return results;
}
/// <summary>Витягти чисту назву шоу (без "N сезон" суфіксу)</summary>
private static string CleanShowTitle(string title)
{
if (string.IsNullOrEmpty(title))
return title;
return Regex.Replace(title, @"\s*\d+\s*сезон\s*$", "", RegexOptions.IgnoreCase).Trim();
}
/// <summary>Витягти номер сезону з назви</summary>
private static int? ExtractSeasonNumber(string title)
{
if (string.IsNullOrEmpty(title))
return null;
var match = Regex.Match(title, @"(\d+)\s*сезон", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out int num))
return num;
return null;
}
// ===================== Парсинг плейлиста =====================
private List<VoiceGroup> ParsePlaylistHtml(string html)
{
var voices = new List<VoiceGroup>();
var doc = new HtmlDocument();
doc.LoadHtml(html);
var playerDiv = doc.DocumentNode.SelectSingleNode("//div[@class='playlists-player']");
if (playerDiv == null)
{
return ParseEpisodesFlat(doc.DocumentNode);
}
// Парсимо голоси (озвучки) з вкладки playlists-lists
var voiceItems = playerDiv.SelectNodes(".//div[@class='playlists-lists']//ul/li");
bool hasVoiceTabs = voiceItems != null && voiceItems.Count > 0;
if (hasVoiceTabs)
{
foreach (var li in voiceItems)
{
string dataId = li.GetAttributeValue("data-id", "");
string text = CleanText(li.InnerText);
string voiceName = Regex.Replace(text, @"\s*\(\d+[\d,\s-]*\)\s*$", "").Trim();
if (string.IsNullOrEmpty(voiceName))
voiceName = text;
if (!string.IsNullOrEmpty(dataId))
{
voices.Add(new VoiceGroup
{
Name = voiceName,
DataId = dataId,
Episodes = new List<EpisodeItem>()
});
}
}
}
// Парсимо епізоди з playlists-videos
var episodeItems = playerDiv.SelectNodes(".//div[@class='playlists-videos']//ul/li[@data-file]");
if (episodeItems != null)
{
foreach (var li in episodeItems)
{
string fileUrl = li.GetAttributeValue("data-file", "");
string dataId = li.GetAttributeValue("data-id", "");
string voiceAttr = li.GetAttributeValue("data-voice", "");
string text = CleanText(li.InnerText);
VoiceGroup targetVoice = null;
if (!hasVoiceTabs)
{
// Фільм: вкладок голосів нема — кожен li це окремий стрім (версія)
// Групуємо за data-voice або створюємо нову групу
string groupName = !string.IsNullOrEmpty(voiceAttr) ? voiceAttr : text;
targetVoice = voices.FirstOrDefault(v =>
v.Name.Equals(groupName, StringComparison.OrdinalIgnoreCase));
if (targetVoice == null)
{
targetVoice = new VoiceGroup
{
Name = groupName,
DataId = dataId,
Episodes = new List<EpisodeItem>()
};
voices.Add(targetVoice);
}
}
else
{
if (!string.IsNullOrEmpty(dataId))
targetVoice = voices.FirstOrDefault(v => v.DataId == dataId);
if (targetVoice == null && !string.IsNullOrEmpty(voiceAttr))
targetVoice = voices.FirstOrDefault(v =>
v.Name.Equals(voiceAttr, StringComparison.OrdinalIgnoreCase));
targetVoice ??= voices.FirstOrDefault();
}
int? epNum = ExtractEpisodeNumber(text);
var episode = new EpisodeItem
{
Title = string.IsNullOrEmpty(text) ? $"Епізод {epNum ?? 1}" : text,
FileUrl = NormalizeUrl(fileUrl),
EpisodeNumber = epNum
};
if (targetVoice != null)
targetVoice.Episodes.Add(episode);
}
}
return voices;
}
private List<VoiceGroup> ParseEpisodesFlat(HtmlNode scope)
{
var voices = new List<VoiceGroup>();
var items = scope.SelectNodes("//li[@data-file]");
if (items == null)
return voices;
var defaultVoice = new VoiceGroup
{
Name = "Озвучення",
DataId = "0_0",
Episodes = new List<EpisodeItem>()
};
foreach (var li in items)
{
string fileUrl = li.GetAttributeValue("data-file", "");
string text = CleanText(li.InnerText);
int? epNum = ExtractEpisodeNumber(text);
defaultVoice.Episodes.Add(new EpisodeItem
{
Title = string.IsNullOrEmpty(text) ? "Фільм" : text,
FileUrl = NormalizeUrl(fileUrl),
EpisodeNumber = epNum
});
}
if (defaultVoice.Episodes.Count > 0)
voices.Add(defaultVoice);
return voices;
}
// ===================== Допоміжні методи =====================
private static string BuildSearchQuery(string title, string original_title, string imdb_id)
{
if (!string.IsNullOrEmpty(imdb_id) && imdb_id.StartsWith("tt"))
return imdb_id;
if (!string.IsNullOrEmpty(title))
return title;
if (!string.IsNullOrEmpty(original_title))
return original_title;
return null;
}
private string NormalizeUrl(string url)
{
if (string.IsNullOrEmpty(url))
return string.Empty;
if (url.StartsWith("//"))
return $"https:{url}";
if (url.StartsWith("/"))
return $"{_init.host}{url}";
return url;
}
private static int? ExtractEpisodeNumber(string title)
{
if (string.IsNullOrEmpty(title))
return null;
var match = Regex.Match(title, @"(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out int value))
return value;
return null;
}
private static string CleanText(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return HtmlEntity.DeEntitize(value).Trim();
}
private Task<string> HttpGet(string url, List<HeadersModel> headers)
{
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, OnlinesSettings init = null)
{
int ctime = init != null && init.cache_time > 0 ? init.cache_time : multiaccess;
if (ctime > multiaccess)
ctime = multiaccess;
return TimeSpan.FromMinutes(ctime);
}
}
}

12
LME.UAKino/manifest.json Normal file
View File

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