diff --git a/LME.UAKino/Controller.cs b/LME.UAKino/Controller.cs new file mode 100644 index 0000000..d4509a6 --- /dev/null +++ b/LME.UAKino/Controller.cs @@ -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 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); + } + } + + /// Вибір сезону для багатосезонного серіалу + 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 async Task HandleSerial(OnlinesSettings init, List 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"); + } + + /// Фільм: список стрімів + private async Task HandleMovie(OnlinesSettings init, List voices, string title, string original_title, bool rjson, UAKinoInvoke invoke) + { + var processed = new HashSet(); + 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); + } + } +} 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..4efb767 --- /dev/null +++ b/LME.UAKino/Models/UAKinoModels.cs @@ -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; } + /// Сезони серіалу. Для фільмів — один елемент без 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 + { + 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..0a2776d --- /dev/null +++ b/LME.UAKino/UAKinoInvoke.cs @@ -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 _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 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() + { + 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; + } + } + + /// + /// Отримати плейлист (озвучки + епізоди) за 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; + } + } + + /// + /// 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 до