From 317cb6292c4f9a122b48556ce3f6c212184d846c Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 15 May 2026 18:46:00 +0300 Subject: [PATCH 01/12] 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. --- LME.UAKino/Controller.cs | 222 ++++++++++++++++ LME.UAKino/ModInit.cs | 99 ++++++++ LME.UAKino/Models/UAKinoModels.cs | 28 ++ LME.UAKino/OnlineApi.cs | 35 +++ LME.UAKino/UAKino.csproj | 15 ++ LME.UAKino/UAKinoInvoke.cs | 407 ++++++++++++++++++++++++++++++ LME.UAKino/manifest.json | 12 + 7 files changed, 818 insertions(+) create mode 100644 LME.UAKino/Controller.cs create mode 100644 LME.UAKino/ModInit.cs create mode 100644 LME.UAKino/Models/UAKinoModels.cs create mode 100644 LME.UAKino/OnlineApi.cs create mode 100644 LME.UAKino/UAKino.csproj create mode 100644 LME.UAKino/UAKinoInvoke.cs create mode 100644 LME.UAKino/manifest.json diff --git a/LME.UAKino/Controller.cs b/LME.UAKino/Controller.cs new file mode 100644 index 0000000..0374701 --- /dev/null +++ b/LME.UAKino/Controller.cs @@ -0,0 +1,222 @@ +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 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 (searchResults.Count > 1) + { + var similar_tpl = new SimilarTpl(searchResults.Count); + foreach (var res in searchResults) + { + string link = $"{host}/lite/lme_uakino?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(res.Url)}"; + similar_tpl.Append(res.Title, res.Year?.ToString() ?? "", 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"); + } + + itemUrl = searchResults[0].Url; + newsId = searchResults[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) + return OnError("lme_uakino", refresh_proxy: true); + + if (serial == 1) + { + return HandleSerial(init, voices, title, original_title, year, imdb_id, kinopoisk_id, itemUrl, t, rjson); + } + else + { + return HandleMovie(init, voices, title, original_title, rjson); + } + } + + private ActionResult HandleSerial(OnlinesSettings init, List voices, string title, string original_title, int year, string imdb_id, long kinopoisk_id, string itemUrl, string t, bool rjson) + { + 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)}&year={year}&serial=1&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); + + 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 streamUrl = BuildStreamUrl(init, ep.FileUrl); + episode_tpl.Append(epName, title ?? original_title, "1", 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"); + } + + private ActionResult HandleMovie(OnlinesSettings init, List voices, string title, string original_title, bool rjson) + { + 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 streamUrl = BuildStreamUrl(init, ep.FileUrl); + 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); + } + } +} diff --git a/LME.UAKino/ModInit.cs b/LME.UAKino/ModInit.cs new file mode 100644 index 0000000..2bd0d2d --- /dev/null +++ b/LME.UAKino/ModInit.cs @@ -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; + } + + /// + /// модуль загружен + /// + 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(); + 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); + } +} diff --git a/LME.UAKino/Models/UAKinoModels.cs b/LME.UAKino/Models/UAKinoModels.cs new file mode 100644 index 0000000..bb5943a --- /dev/null +++ b/LME.UAKino/Models/UAKinoModels.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace LME.UAKino.Models +{ + public class SearchResult + { + 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; } + } + + public class VoiceGroup + { + public string Name { get; set; } + public string DataId { get; set; } + public List Episodes { get; set; } = new(); + } + + public class EpisodeItem + { + public string Title { get; set; } + public string FileUrl { get; set; } + public int? EpisodeNumber { get; set; } + } +} diff --git a/LME.UAKino/OnlineApi.cs b/LME.UAKino/OnlineApi.cs new file mode 100644 index 0000000..857a997 --- /dev/null +++ b/LME.UAKino/OnlineApi.cs @@ -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 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 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(); + + var init = ModInit.UAKino; + if (init.enable && !init.rip) + { + if (UpdateService.IsDisconnected()) + init.overridehost = null; + + online.Add(new ModuleOnlineItem(init, "lme_uakino")); + } + + return online; + } + } +} diff --git a/LME.UAKino/UAKino.csproj b/LME.UAKino/UAKino.csproj new file mode 100644 index 0000000..c280999 --- /dev/null +++ b/LME.UAKino/UAKino.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/LME.UAKino/UAKinoInvoke.cs b/LME.UAKino/UAKinoInvoke.cs new file mode 100644 index 0000000..cf3c2de --- /dev/null +++ b/LME.UAKino/UAKinoInvoke.cs @@ -0,0 +1,407 @@ +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 _onLog; + private readonly ProxyManager _proxyManager; + private readonly HttpHydra _httpHydra; + + public UAKinoInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager, HttpHydra httpHydra = null) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + _httpHydra = httpHydra; + } + + public async Task> 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 cached)) + return FilterByYear(cached, year); + + 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() + { + 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 results = ParseSearchResults(htmlDoc); + if (results.Count > 0) + _hybridCache.Set(memKey, results, cacheTime(20)); + + return FilterByYear(results, year); + } + catch (Exception ex) + { + _onLog?.Invoke($"UAKino search error: {ex.Message}"); + return null; + } + } + + /// + /// Отримати плейлист (озвучки + епізоди) за news_id + /// + public async Task> GetPlaylist(string newsId) + { + if (string.IsNullOrEmpty(newsId)) + return null; + + string memKey = $"UAKino:playlist:{newsId}"; + if (_hybridCache.TryGetValue(memKey, out List 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() + { + 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; + } + } + + /// + /// Витягнути news_id з URL контенту + /// + 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; + } + + private List ParseSearchResults(HtmlDocument doc) + { + var results = new List(); + var nodes = doc.DocumentNode.SelectNodes("//a[@class='search-result-link']"); + if (nodes == null) + return results; + + 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; + } + + string newsId = ExtractNewsId(href); + + results.Add(new SearchResult + { + Title = title, + OriginalTitle = origTitle, + Url = NormalizeUrl(href), + Poster = NormalizeUrl(poster), + Year = year, + NewsId = newsId + }); + } + catch + { + continue; + } + } + + return results; + } + + private List ParsePlaylistHtml(string html) + { + var voices = new List(); + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var playerDiv = doc.DocumentNode.SelectSingleNode("//div[@class='playlists-player']"); + if (playerDiv == null) + { + // спроба знайти епізоди без обгортки playlists-player + return ParseEpisodesFlat(doc.DocumentNode); + } + + // Парсимо голоси (озвучки) з вкладки playlists-lists + var voiceItems = playerDiv.SelectNodes(".//div[@class='playlists-lists']//ul/li"); + if (voiceItems != null) + { + foreach (var li in voiceItems) + { + string dataId = li.GetAttributeValue("data-id", ""); + string text = CleanText(li.InnerText); + // Прибираємо "(X-Y)" з кінця назви озвучки + 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() + }); + } + } + } + + // Парсимо епізоди з 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); + + // Визначаємо до якого voice групи належить + VoiceGroup targetVoice = null; + + // Спершу за data-id + if (!string.IsNullOrEmpty(dataId)) + targetVoice = voices.FirstOrDefault(v => v.DataId == dataId); + + // Якщо не знайшли, то за data-voice (назвою) + 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; + } + + /// + /// Парсинг коли немає структури playlists-player (наприклад для фільмів) + /// + private List ParseEpisodesFlat(HtmlNode scope) + { + var voices = new List(); + var items = scope.SelectNodes("//li[@data-file]"); + if (items == null) + return voices; + + var defaultVoice = new VoiceGroup + { + Name = "Озвучення", + DataId = "0_0", + Episodes = new List() + }; + + foreach (var li in items) + { + string fileUrl = li.GetAttributeValue("data-file", ""); + string voiceAttr = li.GetAttributeValue("data-voice", ""); + 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 static List FilterByYear(List results, int year) + { + if (results == null || results.Count <= 1 || year <= 0) + return results; + + var yearMatch = results.Where(r => r.Year == year).ToList(); + if (yearMatch.Count > 0) + return yearMatch; + + return results; + } + + 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 HttpGet(string url, List 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); + } + } +} diff --git a/LME.UAKino/manifest.json b/LME.UAKino/manifest.json new file mode 100644 index 0000000..9ed0240 --- /dev/null +++ b/LME.UAKino/manifest.json @@ -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" + ] +} From a54bc0e435abb18dccacd35ce1a855c2fa57ae31 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 15 May 2026 19:05:01 +0300 Subject: [PATCH 02/12] 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. --- LME.UAKino/Controller.cs | 82 ++++++++++++--- LME.UAKino/Models/UAKinoModels.cs | 11 +- LME.UAKino/UAKinoInvoke.cs | 169 +++++++++++++++++++++++++----- 3 files changed, 216 insertions(+), 46 deletions(-) diff --git a/LME.UAKino/Controller.cs b/LME.UAKino/Controller.cs index 0374701..afa10e5 100644 --- a/LME.UAKino/Controller.cs +++ b/LME.UAKino/Controller.cs @@ -51,30 +51,46 @@ namespace LME.UAKino.Controllers 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 (searchResults.Count > 1) + if (serial == 1) { - var similar_tpl = new SimilarTpl(searchResults.Count); - foreach (var res in searchResults) + // Серіал + if (searchResults.Count == 1) { - string link = $"{host}/lite/lme_uakino?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(res.Url)}"; - similar_tpl.Append(res.Title, res.Year?.ToString() ?? "", res.OriginalTitle ?? "", link, res.Poster); + 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); } - - return rjson - ? Content(similar_tpl.ToJson(), "application/json; charset=utf-8") - : Content(similar_tpl.ToHtml(), "text/html; charset=utf-8"); } - - itemUrl = searchResults[0].Url; - newsId = searchResults[0].NewsId; + 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); } @@ -87,7 +103,7 @@ namespace LME.UAKino.Controllers if (serial == 1) { - return HandleSerial(init, voices, title, original_title, year, imdb_id, kinopoisk_id, itemUrl, t, rjson); + return HandleSerial(init, voices, title, original_title, imdb_id, kinopoisk_id, itemUrl, s, t, rjson); } else { @@ -95,7 +111,40 @@ namespace LME.UAKino.Controllers } } - private ActionResult HandleSerial(OnlinesSettings init, List voices, string title, string original_title, int year, string imdb_id, long kinopoisk_id, string itemUrl, string t, bool rjson) + /// Вибір сезону для багатосезонного серіалу + 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"); + } + + /// Вибір між різними шоу/фільмами + private ActionResult ShowSimilarTpl(List 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"); + } + + /// Серіал: озвучки + епізоди + private ActionResult HandleSerial(OnlinesSettings init, List voices, string title, string original_title, string imdb_id, long kinopoisk_id, string itemUrl, int s, string t, bool rjson) { var voice_tpl = new VoiceTpl(); var episode_tpl = new EpisodeTpl(); @@ -105,7 +154,7 @@ namespace LME.UAKino.Controllers 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)}&year={year}&serial=1&t={voice.DataId}&href={HttpUtility.UrlEncode(itemUrl)}"; + 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); } @@ -128,6 +177,7 @@ namespace LME.UAKino.Controllers : Content(episode_tpl.ToHtml(), "text/html; charset=utf-8"); } + /// Фільм: список стрімів private ActionResult HandleMovie(OnlinesSettings init, List voices, string title, string original_title, bool rjson) { var movie_tpl = new MovieTpl(title, original_title); diff --git a/LME.UAKino/Models/UAKinoModels.cs b/LME.UAKino/Models/UAKinoModels.cs index bb5943a..4efb767 100644 --- a/LME.UAKino/Models/UAKinoModels.cs +++ b/LME.UAKino/Models/UAKinoModels.cs @@ -6,10 +6,19 @@ namespace LME.UAKino.Models { public string Title { get; set; } public string OriginalTitle { get; set; } - public string Url { get; set; } public string Poster { get; set; } + /// Сезони серіалу. Для фільмів — один елемент без SeasonNumber + public List Seasons { get; set; } = new(); + /// Рік фільму (тільки для не-сезонних результатів) 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 diff --git a/LME.UAKino/UAKinoInvoke.cs b/LME.UAKino/UAKinoInvoke.cs index cf3c2de..ef4d760 100644 --- a/LME.UAKino/UAKinoInvoke.cs +++ b/LME.UAKino/UAKinoInvoke.cs @@ -39,7 +39,7 @@ namespace LME.UAKino string memKey = $"UAKino:search:{query}"; if (_hybridCache.TryGetValue(memKey, out List cached)) - return FilterByYear(cached, year); + return cached; try { @@ -73,11 +73,13 @@ namespace LME.UAKino var htmlDoc = new HtmlDocument(); htmlDoc.LoadHtml(html); - var results = ParseSearchResults(htmlDoc); + var rawItems = ParseRawItems(htmlDoc); + var results = GroupByShow(rawItems); + if (results.Count > 0) _hybridCache.Set(memKey, results, cacheTime(20)); - return FilterByYear(results, year); + return results; } catch (Exception ex) { @@ -152,12 +154,25 @@ namespace LME.UAKino return null; } - private List ParseSearchResults(HtmlDocument doc) + // ===================== Парсинг результатів пошуку ===================== + + /// Сирий елемент з HTML пошуку, до групування + private class RawSearchItem { - var results = new List(); + 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 ParseRawItems(HtmlDocument doc) + { + var items = new List(); var nodes = doc.DocumentNode.SelectNodes("//a[@class='search-result-link']"); if (nodes == null) - return results; + return items; foreach (var node in nodes) { @@ -186,9 +201,13 @@ namespace LME.UAKino year = parsedYear; } + // Фільтр: пропускаємо новини/трейлери — без року та без оригінальної назви + if (!IsRealContent(title, origTitle, year)) + continue; + string newsId = ExtractNewsId(href); - results.Add(new SearchResult + items.Add(new RawSearchItem { Title = title, OriginalTitle = origTitle, @@ -204,9 +223,121 @@ namespace LME.UAKino } } + return items; + } + + /// Фільтр: реальний контент (не новина/трейлер) + 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; + } + + /// Групування сирих елементів по назві шоу. Кожна група = один SearchResult зі списком сезонів + private List GroupByShow(List rawItems) + { + if (rawItems.Count == 0) + return new List(); + + var groups = new Dictionary>(); + + 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(); + + groups[key].Add(item); + } + + var results = new List(); + + 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; } + /// Витягти чисту назву шоу (без "N сезон" суфіксу) + private static string CleanShowTitle(string title) + { + if (string.IsNullOrEmpty(title)) + return title; + + return Regex.Replace(title, @"\s*\d+\s*сезон\s*$", "", RegexOptions.IgnoreCase).Trim(); + } + + /// Витягти номер сезону з назви + 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 ParsePlaylistHtml(string html) { var voices = new List(); @@ -217,7 +348,6 @@ namespace LME.UAKino var playerDiv = doc.DocumentNode.SelectSingleNode("//div[@class='playlists-player']"); if (playerDiv == null) { - // спроба знайти епізоди без обгортки playlists-player return ParseEpisodesFlat(doc.DocumentNode); } @@ -229,7 +359,6 @@ namespace LME.UAKino { string dataId = li.GetAttributeValue("data-id", ""); string text = CleanText(li.InnerText); - // Прибираємо "(X-Y)" з кінця назви озвучки string voiceName = Regex.Replace(text, @"\s*\(\d+[\d,\s-]*\)\s*$", "").Trim(); if (string.IsNullOrEmpty(voiceName)) voiceName = text; @@ -257,19 +386,15 @@ namespace LME.UAKino string voiceAttr = li.GetAttributeValue("data-voice", ""); string text = CleanText(li.InnerText); - // Визначаємо до якого voice групи належить VoiceGroup targetVoice = null; - // Спершу за data-id if (!string.IsNullOrEmpty(dataId)) targetVoice = voices.FirstOrDefault(v => v.DataId == dataId); - // Якщо не знайшли, то за data-voice (назвою) if (targetVoice == null && !string.IsNullOrEmpty(voiceAttr)) targetVoice = voices.FirstOrDefault(v => v.Name.Equals(voiceAttr, StringComparison.OrdinalIgnoreCase)); - // Якщо досі не знайшли, беремо перший голос targetVoice ??= voices.FirstOrDefault(); int? epNum = ExtractEpisodeNumber(text); @@ -289,9 +414,6 @@ namespace LME.UAKino return voices; } - /// - /// Парсинг коли немає структури playlists-player (наприклад для фільмів) - /// private List ParseEpisodesFlat(HtmlNode scope) { var voices = new List(); @@ -309,7 +431,6 @@ namespace LME.UAKino foreach (var li in items) { string fileUrl = li.GetAttributeValue("data-file", ""); - string voiceAttr = li.GetAttributeValue("data-voice", ""); string text = CleanText(li.InnerText); int? epNum = ExtractEpisodeNumber(text); @@ -327,6 +448,8 @@ namespace LME.UAKino return voices; } + // ===================== Допоміжні методи ===================== + private static string BuildSearchQuery(string title, string original_title, string imdb_id) { if (!string.IsNullOrEmpty(imdb_id) && imdb_id.StartsWith("tt")) @@ -341,18 +464,6 @@ namespace LME.UAKino return null; } - private static List FilterByYear(List results, int year) - { - if (results == null || results.Count <= 1 || year <= 0) - return results; - - var yearMatch = results.Where(r => r.Year == year).ToList(); - if (yearMatch.Count > 0) - return yearMatch; - - return results; - } - private string NormalizeUrl(string url) { if (string.IsNullOrEmpty(url)) From 80f086940172d06925cbc7a0fc39bb6541188a33 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 15 May 2026 19:06:29 +0300 Subject: [PATCH 03/12] 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. --- LME.UAKino/Controller.cs | 36 ++++++++++++++++++++++++- LME.UAKino/UAKinoInvoke.cs | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/LME.UAKino/Controller.cs b/LME.UAKino/Controller.cs index afa10e5..41b9ed7 100644 --- a/LME.UAKino/Controller.cs +++ b/LME.UAKino/Controller.cs @@ -99,7 +99,37 @@ namespace LME.UAKino.Controllers 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 streamUrl = BuildStreamUrl(init, fallbackUrl); + 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 + { + if (ApnHelper.IsAshdiUrl(fallbackUrl) && !fallbackUrl.Contains("multivoice")) + fallbackUrl += (fallbackUrl.Contains("?") ? "&" : "?") + "multivoice"; + string streamUrl = BuildStreamUrl(init, fallbackUrl); + var movie_tpl = new MovieTpl(title, original_title); + movie_tpl.Append("Фільм", 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) { @@ -190,7 +220,11 @@ namespace LME.UAKino.Controllers if (voices.Count == 1 && voice.Episodes.Count > 1) label = ep.Title; - string streamUrl = BuildStreamUrl(init, ep.FileUrl); + string fileUrl = ep.FileUrl; + if (ApnHelper.IsAshdiUrl(fileUrl) && !fileUrl.Contains("multivoice")) + fileUrl += (fileUrl.Contains("?") ? "&" : "?") + "multivoice"; + + string streamUrl = BuildStreamUrl(init, fileUrl); movie_tpl.Append(label, streamUrl); } } diff --git a/LME.UAKino/UAKinoInvoke.cs b/LME.UAKino/UAKinoInvoke.cs index ef4d760..e8cb8da 100644 --- a/LME.UAKino/UAKinoInvoke.cs +++ b/LME.UAKino/UAKinoInvoke.cs @@ -139,6 +139,60 @@ namespace LME.UAKino } } + /// + /// Fallback: отримати стрім з HTML сторінки фільму коли playlist API недоступний + /// Парсить <link itemprop="video" value="..."> або <iframe id="pre" src="..."> + /// + public async Task GetPageFallbackUrl(string pageUrl) + { + if (string.IsNullOrEmpty(pageUrl)) + return null; + + try + { + _onLog?.Invoke($"UAKino page fallback: {pageUrl}"); + + var headers = new List() + { + 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); + + // Спершу пробуємо + var linkTag = doc.DocumentNode.SelectSingleNode("//link[@itemprop='video']"); + if (linkTag != null) + { + string value = linkTag.GetAttributeValue("value", ""); + if (!string.IsNullOrEmpty(value)) + return NormalizeUrl(value); + } + + // Fallback до