From 2249a36c967457b74bfc7d384c61aafb5f8072f1 Mon Sep 17 00:00:00 2001 From: baliasnyifeliks Date: Sat, 31 Jan 2026 21:30:55 +0200 Subject: [PATCH] Fix --- .gitignore | 9 + AnimeON/AnimeON.csproj | 15 + AnimeON/AnimeONInvoke.cs | 323 ++++++ AnimeON/ApnHelper.cs | 86 ++ AnimeON/Controller.cs | 355 ++++++ AnimeON/ModInit.cs | 64 ++ AnimeON/Models/AnimeONAggregatedStructure.cs | 58 + AnimeON/Models/EmbedModel.cs | 23 + AnimeON/Models/Models.cs | 169 +++ AnimeON/Models/Serial.cs | 29 + AnimeON/Models/Voice.cs | 26 + AnimeON/OnlineApi.cs | 50 + AnimeON/UpdateService.cs | 139 +++ AnimeON/manifest.json | 6 + Bamboo/ApnHelper.cs | 86 ++ Bamboo/Bamboo.csproj | 15 + Bamboo/BambooInvoke.cs | 326 ++++++ Bamboo/Controller.cs | 138 +++ Bamboo/ModInit.cs | 63 ++ Bamboo/Models/BambooModels.cs | 30 + Bamboo/OnlineApi.cs | 40 + Bamboo/UpdateService.cs | 139 +++ Bamboo/manifest.json | 6 + CikavaIdeya/ApnHelper.cs | 86 ++ CikavaIdeya/CikavaIdeya.csproj | 15 + CikavaIdeya/CikavaIdeyaInvoke.cs | 371 +++++++ CikavaIdeya/Controller.cs | 406 +++++++ CikavaIdeya/ModInit.cs | 70 ++ CikavaIdeya/Models/EpisodeModel.cs | 16 + CikavaIdeya/Models/Models.cs | 19 + CikavaIdeya/Models/PlayerModel.cs | 17 + CikavaIdeya/Models/SeasonModel.cs | 14 + CikavaIdeya/OnlineApi.cs | 49 + CikavaIdeya/UpdateService.cs | 139 +++ CikavaIdeya/manifest.json | 6 + LICENSE | 201 ++++ Mikai/ApnHelper.cs | 86 ++ Mikai/Controller.cs | 291 +++++ Mikai/Mikai.csproj | 15 + Mikai/MikaiInvoke.cs | 216 ++++ Mikai/ModInit.cs | 65 ++ Mikai/Models/MikaiModels.cs | 209 ++++ Mikai/Models/MikaiStructure.cs | 19 + Mikai/OnlineApi.cs | 50 + Mikai/UpdateService.cs | 139 +++ Mikai/manifest.json | 6 + README.md | 85 ++ StarLight/ApnHelper.cs | 86 ++ StarLight/Controller.cs | 239 ++++ StarLight/ModInit.cs | 63 ++ StarLight/Models/StarLightModels.cs | 49 + StarLight/OnlineApi.cs | 44 + StarLight/StarLight.csproj | 15 + StarLight/StarLightInvoke.cs | 426 +++++++ StarLight/UpdateService.cs | 139 +++ StarLight/manifest.json | 6 + UAKino/ApnHelper.cs | 86 ++ UAKino/Controller.cs | 162 +++ UAKino/ModInit.cs | 62 ++ UAKino/Models/UAKinoModels.cs | 31 + UAKino/OnlineApi.cs | 40 + UAKino/UAKino.csproj | 15 + UAKino/UAKinoInvoke.cs | 498 +++++++++ UAKino/UpdateService.cs | 139 +++ UAKino/manifest.json | 6 + UaTUT/ApnHelper.cs | 86 ++ UaTUT/Controller.cs | 450 ++++++++ UaTUT/ModInit.cs | 65 ++ UaTUT/Models/UaTUTModels.cs | 61 ++ UaTUT/OnlineApi.cs | 41 + UaTUT/UaTUT.csproj | 15 + UaTUT/UaTUTInvoke.cs | 257 +++++ UaTUT/UpdateService.cs | 139 +++ UaTUT/manifest.json | 6 + Uaflix/ApnHelper.cs | 86 ++ Uaflix/Controller.cs | 353 ++++++ Uaflix/ModInit.cs | 69 ++ Uaflix/Models/EpisodeLinkInfo.cs | 16 + Uaflix/Models/FilmInfo.cs | 51 + Uaflix/Models/PaginationInfo.cs | 19 + Uaflix/Models/PlayResult.cs | 12 + Uaflix/Models/SearchResult.cs | 13 + Uaflix/Models/SerialAggregatedStructure.cs | 31 + Uaflix/Models/VoiceInfo.cs | 71 ++ Uaflix/OnlineApi.cs | 40 + Uaflix/Uaflix.csproj | 15 + Uaflix/UaflixInvoke.cs | 1036 ++++++++++++++++++ Uaflix/UpdateService.cs | 139 +++ Uaflix/manifest.json | 6 + Unimay/Controllers/Controller.cs | 144 +++ Unimay/ModInit.cs | 53 + Unimay/Models/Episode.cs | 22 + Unimay/Models/ReleaseResponse.cs | 23 + Unimay/Models/SearchResponse.cs | 41 + Unimay/OnlineApi.cs | 50 + Unimay/Unimay.csproj | 17 + Unimay/UnimayInvoke.cs | 175 +++ Unimay/UpdateService.cs | 139 +++ Unimay/manifest.json | 6 + 99 files changed, 10638 insertions(+) create mode 100644 .gitignore create mode 100644 AnimeON/AnimeON.csproj create mode 100644 AnimeON/AnimeONInvoke.cs create mode 100644 AnimeON/ApnHelper.cs create mode 100644 AnimeON/Controller.cs create mode 100644 AnimeON/ModInit.cs create mode 100644 AnimeON/Models/AnimeONAggregatedStructure.cs create mode 100644 AnimeON/Models/EmbedModel.cs create mode 100644 AnimeON/Models/Models.cs create mode 100644 AnimeON/Models/Serial.cs create mode 100644 AnimeON/Models/Voice.cs create mode 100644 AnimeON/OnlineApi.cs create mode 100644 AnimeON/UpdateService.cs create mode 100644 AnimeON/manifest.json create mode 100644 Bamboo/ApnHelper.cs create mode 100644 Bamboo/Bamboo.csproj create mode 100644 Bamboo/BambooInvoke.cs create mode 100644 Bamboo/Controller.cs create mode 100644 Bamboo/ModInit.cs create mode 100644 Bamboo/Models/BambooModels.cs create mode 100644 Bamboo/OnlineApi.cs create mode 100644 Bamboo/UpdateService.cs create mode 100644 Bamboo/manifest.json create mode 100644 CikavaIdeya/ApnHelper.cs create mode 100644 CikavaIdeya/CikavaIdeya.csproj create mode 100644 CikavaIdeya/CikavaIdeyaInvoke.cs create mode 100644 CikavaIdeya/Controller.cs create mode 100644 CikavaIdeya/ModInit.cs create mode 100644 CikavaIdeya/Models/EpisodeModel.cs create mode 100644 CikavaIdeya/Models/Models.cs create mode 100644 CikavaIdeya/Models/PlayerModel.cs create mode 100644 CikavaIdeya/Models/SeasonModel.cs create mode 100644 CikavaIdeya/OnlineApi.cs create mode 100644 CikavaIdeya/UpdateService.cs create mode 100644 CikavaIdeya/manifest.json create mode 100644 LICENSE create mode 100644 Mikai/ApnHelper.cs create mode 100644 Mikai/Controller.cs create mode 100644 Mikai/Mikai.csproj create mode 100644 Mikai/MikaiInvoke.cs create mode 100644 Mikai/ModInit.cs create mode 100644 Mikai/Models/MikaiModels.cs create mode 100644 Mikai/Models/MikaiStructure.cs create mode 100644 Mikai/OnlineApi.cs create mode 100644 Mikai/UpdateService.cs create mode 100644 Mikai/manifest.json create mode 100644 README.md create mode 100644 StarLight/ApnHelper.cs create mode 100644 StarLight/Controller.cs create mode 100644 StarLight/ModInit.cs create mode 100644 StarLight/Models/StarLightModels.cs create mode 100644 StarLight/OnlineApi.cs create mode 100644 StarLight/StarLight.csproj create mode 100644 StarLight/StarLightInvoke.cs create mode 100644 StarLight/UpdateService.cs create mode 100644 StarLight/manifest.json create mode 100644 UAKino/ApnHelper.cs create mode 100644 UAKino/Controller.cs create mode 100644 UAKino/ModInit.cs create mode 100644 UAKino/Models/UAKinoModels.cs create mode 100644 UAKino/OnlineApi.cs create mode 100644 UAKino/UAKino.csproj create mode 100644 UAKino/UAKinoInvoke.cs create mode 100644 UAKino/UpdateService.cs create mode 100644 UAKino/manifest.json create mode 100644 UaTUT/ApnHelper.cs create mode 100644 UaTUT/Controller.cs create mode 100644 UaTUT/ModInit.cs create mode 100644 UaTUT/Models/UaTUTModels.cs create mode 100644 UaTUT/OnlineApi.cs create mode 100644 UaTUT/UaTUT.csproj create mode 100644 UaTUT/UaTUTInvoke.cs create mode 100644 UaTUT/UpdateService.cs create mode 100644 UaTUT/manifest.json create mode 100644 Uaflix/ApnHelper.cs create mode 100644 Uaflix/Controller.cs create mode 100644 Uaflix/ModInit.cs create mode 100644 Uaflix/Models/EpisodeLinkInfo.cs create mode 100644 Uaflix/Models/FilmInfo.cs create mode 100644 Uaflix/Models/PaginationInfo.cs create mode 100644 Uaflix/Models/PlayResult.cs create mode 100644 Uaflix/Models/SearchResult.cs create mode 100644 Uaflix/Models/SerialAggregatedStructure.cs create mode 100644 Uaflix/Models/VoiceInfo.cs create mode 100644 Uaflix/OnlineApi.cs create mode 100644 Uaflix/Uaflix.csproj create mode 100644 Uaflix/UaflixInvoke.cs create mode 100644 Uaflix/UpdateService.cs create mode 100644 Uaflix/manifest.json create mode 100644 Unimay/Controllers/Controller.cs create mode 100644 Unimay/ModInit.cs create mode 100644 Unimay/Models/Episode.cs create mode 100644 Unimay/Models/ReleaseResponse.cs create mode 100644 Unimay/Models/SearchResponse.cs create mode 100644 Unimay/OnlineApi.cs create mode 100644 Unimay/Unimay.csproj create mode 100644 Unimay/UnimayInvoke.cs create mode 100644 Unimay/UpdateService.cs create mode 100644 Unimay/manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa69327 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.idea/ +/AIDocumentation/ +/Lampac/ +/BanderaBackend/ +/Kinovezha/ +/.clinerules/moduls.md +/.clinerules/uaflix-optimization.md +/.clinerules/ +/.qodo/ \ No newline at end of file diff --git a/AnimeON/AnimeON.csproj b/AnimeON/AnimeON.csproj new file mode 100644 index 0000000..c26a806 --- /dev/null +++ b/AnimeON/AnimeON.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + \ No newline at end of file diff --git a/AnimeON/AnimeONInvoke.cs b/AnimeON/AnimeONInvoke.cs new file mode 100644 index 0000000..514b1e3 --- /dev/null +++ b/AnimeON/AnimeONInvoke.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shared; +using Shared.Models.Online.Settings; +using Shared.Models; +using System.Text.Json; +using System.Linq; +using System.Text; +using AnimeON.Models; +using Shared.Engine; + +namespace AnimeON +{ + public class AnimeONInvoke + { + private OnlinesSettings _init; + private IHybridCache _hybridCache; + private Action _onLog; + private ProxyManager _proxyManager; + + public AnimeONInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + string AshdiRequestUrl(string url) + { + if (!ApnHelper.IsAshdiUrl(url)) + return url; + + return ApnHelper.WrapUrl(_init, url); + } + + public async Task> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year) + { + string memKey = $"AnimeON:search:{kinopoisk_id}:{imdb_id}"; + if (_hybridCache.TryGetValue(memKey, out List res)) + return res; + + try + { + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }; + + async Task> FindAnime(string query) + { + if (string.IsNullOrEmpty(query)) + return null; + + string searchUrl = $"{_init.host}/api/anime/search?text={System.Web.HttpUtility.UrlEncode(query)}"; + + _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {searchUrl}"); + string searchJson = await Http.Get(searchUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(searchJson)) + return null; + + var searchResponse = JsonSerializer.Deserialize(searchJson); + return searchResponse?.Result; + } + + var searchResults = await FindAnime(title) ?? await FindAnime(original_title); + if (searchResults == null) + return null; + + if (!string.IsNullOrEmpty(imdb_id)) + { + var seasons = searchResults.Where(a => a.ImdbId == imdb_id).ToList(); + if (seasons.Count > 0) + { + _hybridCache.Set(memKey, seasons, cacheTime(5)); + return seasons; + } + } + + // Fallback to first result if no imdb match + var firstResult = searchResults.FirstOrDefault(); + if (firstResult != null) + { + var list = new List { firstResult }; + _hybridCache.Set(memKey, list, cacheTime(5)); + return list; + } + + return null; + } + catch (Exception ex) + { + _onLog($"AnimeON error: {ex.Message}"); + } + + return null; + } + + public async Task> GetFundubs(int animeId) + { + string fundubsUrl = $"{_init.host}/api/player/{animeId}/translations"; + + _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {fundubsUrl}"); + string fundubsJson = await Http.Get(fundubsUrl, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(fundubsJson)) + return null; + + var fundubsResponse = JsonSerializer.Deserialize(fundubsJson); + if (fundubsResponse?.Translations == null || fundubsResponse.Translations.Count == 0) + return null; + + var fundubs = new List(); + foreach (var translation in fundubsResponse.Translations) + { + var fundubModel = new FundubModel + { + Fundub = translation.Translation, + Player = translation.Player + }; + fundubs.Add(fundubModel); + } + return fundubs; + } + + public async Task GetEpisodes(int animeId, int playerId, int fundubId) + { + string episodesUrl = $"{_init.host}/api/player/{animeId}/episodes?take=100&skip=-1&playerId={playerId}&translationId={fundubId}"; + + _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {episodesUrl}"); + string episodesJson = await Http.Get(episodesUrl, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(episodesJson)) + return null; + + return JsonSerializer.Deserialize(episodesJson); + } + + public async Task ParseMoonAnimePage(string url) + { + try + { + string requestUrl = $"{url}?player=animeon.club"; + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://animeon.club/") + }; + + _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {requestUrl}"); + string html = await Http.Get(requestUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var match = System.Text.RegularExpressions.Regex.Match(html, @"file:\s*""([^""]+\.m3u8)"""); + if (match.Success) + { + return match.Groups[1].Value; + } + } + catch (Exception ex) + { + _onLog($"AnimeON ParseMoonAnimePage error: {ex.Message}"); + } + + return null; + } + + public async Task ParseAshdiPage(string url) + { + try + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://ashdi.vip/") + }; + + string requestUrl = AshdiRequestUrl(url); + _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {requestUrl}"); + string html = await Http.Get(requestUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var match = System.Text.RegularExpressions.Regex.Match(html, @"file\s*:\s*['""]([^'""]+)['""]"); + if (match.Success) + { + return match.Groups[1].Value; + } + } + catch (Exception ex) + { + _onLog($"AnimeON ParseAshdiPage error: {ex.Message}"); + } + + return null; + } + + public async Task ResolveEpisodeStream(int episodeId) + { + try + { + string url = $"{_init.host}/api/player/{episodeId}/episode"; + + _onLog($"AnimeON: using proxy {_proxyManager.CurrentProxyIp} for {url}"); + string json = await Http.Get(url, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(json)) + return null; + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.TryGetProperty("fileUrl", out var fileProp)) + { + string fileUrl = fileProp.GetString(); + if (!string.IsNullOrEmpty(fileUrl)) + return fileUrl; + } + + if (root.TryGetProperty("videoUrl", out var videoProp)) + { + string videoUrl = videoProp.GetString(); + return await ResolveVideoUrl(videoUrl); + } + } + catch (Exception ex) + { + _onLog($"AnimeON ResolveEpisodeStream error: {ex.Message}"); + } + + return null; + } + + public async Task ResolveVideoUrl(string url) + { + if (string.IsNullOrEmpty(url)) + return null; + + if (url.Contains("moonanime.art")) + return await ParseMoonAnimePage(url); + + if (url.Contains("ashdi.vip/vod")) + return await ParseAshdiPage(url); + + return url; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + public async Task AggregateSerialStructure(int animeId, int season) + { + string memKey = $"AnimeON:aggregated:{animeId}:{season}"; + if (_hybridCache.TryGetValue(memKey, out AnimeON.Models.AnimeONAggregatedStructure cached)) + return cached; + + try + { + var structure = new AnimeON.Models.AnimeONAggregatedStructure + { + AnimeId = animeId, + Season = season, + Voices = new Dictionary() + }; + + var fundubs = await GetFundubs(animeId); + if (fundubs == null || fundubs.Count == 0) + return null; + + foreach (var fundub in fundubs) + { + if (fundub?.Fundub == null || fundub.Player == null) + continue; + + foreach (var player in fundub.Player) + { + string display = $"[{player.Name}] {fundub.Fundub.Name}"; + + var episodesData = await GetEpisodes(animeId, player.Id, fundub.Fundub.Id); + if (episodesData?.Episodes == null || episodesData.Episodes.Count == 0) + continue; + + var voiceInfo = new AnimeON.Models.AnimeONVoiceInfo + { + Name = fundub.Fundub.Name, + PlayerType = player.Name?.ToLower(), + DisplayName = display, + PlayerId = player.Id, + FundubId = fundub.Fundub.Id, + Episodes = episodesData.Episodes + .OrderBy(ep => ep.EpisodeNum) + .Select(ep => new AnimeON.Models.AnimeONEpisodeInfo + { + Number = ep.EpisodeNum, + Title = ep.Name, + Hls = ep.Hls, + VideoUrl = ep.VideoUrl, + EpisodeId = ep.Id + }) + .ToList() + }; + + structure.Voices[display] = voiceInfo; + } + } + + if (!structure.Voices.Any()) + return null; + + _hybridCache.Set(memKey, structure, cacheTime(20, init: _init)); + return structure; + } + catch (Exception ex) + { + _onLog?.Invoke($"AnimeON AggregateSerialStructure error: {ex.Message}"); + return null; + } + } + } +} diff --git a/AnimeON/ApnHelper.cs b/AnimeON/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/AnimeON/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/AnimeON/Controller.cs b/AnimeON/Controller.cs new file mode 100644 index 0000000..286a54f --- /dev/null +++ b/AnimeON/Controller.cs @@ -0,0 +1,355 @@ +using System.Text.Json; +using Shared.Engine; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Web; +using System.Linq; +using Shared; +using Shared.Models.Templates; +using AnimeON.Models; +using System.Text.RegularExpressions; +using System.Text; +using Shared.Models.Online.Settings; +using Shared.Models; +using HtmlAgilityPack; + +namespace AnimeON.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.AnimeON); + } + + [HttpGet] + [Route("animeon")] + 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) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.AnimeON); + if (!init.enable) + return Forbid(); + + var invoke = new AnimeONInvoke(init, hybridCache, OnLog, proxyManager); + OnLog($"AnimeON Index: title={title}, original_title={original_title}, serial={serial}, s={s}, t={t}, year={year}, imdb_id={imdb_id}, kp={kinopoisk_id}"); + + var seasons = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year); + OnLog($"AnimeON: search results = {seasons?.Count ?? 0}"); + if (seasons == null || seasons.Count == 0) + return OnError("animeon", proxyManager); + + // [Refactoring] Використовується агрегована структура (AggregateSerialStructure) — попередній збір allOptions не потрібний + + // [Refactoring] Перевірка allOptions видалена — використовується перевірка структури озвучок нижче + + if (serial == 1) + { + if (s == -1) // Крок 1: Вибір аніме (як сезони) + { + var season_tpl = new SeasonTpl(seasons.Count); + for (int i = 0; i < seasons.Count; i++) + { + var anime = seasons[i]; + string seasonName = anime.Season.ToString(); + string link = $"{host}/animeon?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={i}"; + season_tpl.Append(seasonName, link, anime.Season.ToString()); + } + OnLog($"AnimeON: return seasons count={seasons.Count}"); + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else // Крок 2/3: Вибір озвучки та епізодів + { + if (s >= seasons.Count) + return OnError("animeon", proxyManager); + + var selectedAnime = seasons[s]; + var structure = await invoke.AggregateSerialStructure(selectedAnime.Id, selectedAnime.Season); + if (structure == null || !structure.Voices.Any()) + return OnError("animeon", proxyManager); + + OnLog($"AnimeON: voices found = {structure.Voices.Count}"); + // Автовибір першої озвучки якщо t не задано + if (string.IsNullOrEmpty(t)) + t = structure.Voices.Keys.First(); + + // Формуємо список озвучок + var voice_tpl = new VoiceTpl(); + foreach (var voice in structure.Voices) + { + string voiceLink = $"{host}/animeon?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&t={HttpUtility.UrlEncode(voice.Key)}"; + bool isActive = voice.Key == t; + voice_tpl.Append(voice.Key, isActive, voiceLink); + } + + // Перевірка вибраної озвучки + if (!structure.Voices.ContainsKey(t)) + return OnError("animeon", proxyManager); + + var episode_tpl = new EpisodeTpl(); + var selectedVoiceInfo = structure.Voices[t]; + + // Формуємо епізоди для вибраної озвучки + foreach (var ep in selectedVoiceInfo.Episodes.OrderBy(e => e.Number)) + { + string episodeName = !string.IsNullOrEmpty(ep.Title) ? ep.Title : $"Епізод {ep.Number}"; + string seasonStr = selectedAnime.Season.ToString(); + string episodeStr = ep.Number.ToString(); + + string streamLink = !string.IsNullOrEmpty(ep.Hls) ? ep.Hls : ep.VideoUrl; + bool needsResolve = selectedVoiceInfo.PlayerType == "moon" || selectedVoiceInfo.PlayerType == "ashdi"; + + if (string.IsNullOrEmpty(streamLink) && ep.EpisodeId > 0) + { + string callUrl = $"{host}/animeon/play?episode_id={ep.EpisodeId}"; + episode_tpl.Append(episodeName, title ?? original_title, seasonStr, episodeStr, accsArgs(callUrl), "call"); + continue; + } + + if (string.IsNullOrEmpty(streamLink)) + continue; + + if (needsResolve || streamLink.Contains("moonanime.art") || streamLink.Contains("ashdi.vip/vod")) + { + string callUrl = $"{host}/animeon/play?url={HttpUtility.UrlEncode(streamLink)}"; + episode_tpl.Append(episodeName, title ?? original_title, seasonStr, episodeStr, accsArgs(callUrl), "call"); + } + else + { + string playUrl = HostStreamProxy(init, accsArgs(streamLink)); + episode_tpl.Append(episodeName, title ?? original_title, seasonStr, episodeStr, playUrl); + } + } + + // Повертаємо озвучки + епізоди разом + OnLog($"AnimeON: return episodes count={selectedVoiceInfo.Episodes.Count} for voice='{t}' season={selectedAnime.Season}"); + if (rjson) + return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8"); + + return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + else // Фільм + { + var firstAnime = seasons.FirstOrDefault(); + if (firstAnime == null) + return OnError("animeon", proxyManager); + + var fundubs = await invoke.GetFundubs(firstAnime.Id); + OnLog($"AnimeON: movie fundubs count = {fundubs?.Count ?? 0}"); + if (fundubs == null || fundubs.Count == 0) + return OnError("animeon", proxyManager); + + var tpl = new MovieTpl(title, original_title); + + foreach (var fundub in fundubs) + { + if (fundub?.Fundub == null || fundub.Player == null || fundub.Player.Count == 0) + continue; + + foreach (var player in fundub.Player) + { + var episodesData = await invoke.GetEpisodes(firstAnime.Id, player.Id, fundub.Fundub.Id); + if (episodesData == null || episodesData.Episodes == null || episodesData.Episodes.Count == 0) + continue; + + var firstEp = episodesData.Episodes.FirstOrDefault(); + if (firstEp == null) + continue; + + string streamLink = !string.IsNullOrEmpty(firstEp.Hls) ? firstEp.Hls : firstEp.VideoUrl; + if (string.IsNullOrEmpty(streamLink)) + continue; + + string translationName = $"[{player.Name}] {fundub.Fundub.Name}"; + + bool needsResolve = player.Name?.ToLower() == "moon" || player.Name?.ToLower() == "ashdi"; + if (needsResolve || streamLink.Contains("moonanime.art/iframe/") || streamLink.Contains("ashdi.vip/vod")) + { + string callUrl = $"{host}/animeon/play?url={HttpUtility.UrlEncode(streamLink)}"; + tpl.Append(translationName, accsArgs(callUrl), "call"); + } + else + { + tpl.Append(translationName, HostStreamProxy(init, accsArgs(streamLink))); + } + } + } + + // Якщо не зібрали жодної опції — повертаємо помилку + if (tpl.data == null || tpl.data.Count == 0) + return OnError("animeon", proxyManager); + + OnLog("AnimeON: return movie options"); + return rjson ? Content(tpl.ToJson(), "application/json; charset=utf-8") : Content(tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + async Task> GetFundubs(OnlinesSettings init, int animeId) + { + string fundubsUrl = $"{init.host}/api/player/{animeId}/translations"; + + string fundubsJson = await Http.Get(fundubsUrl, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", init.host) }); + if (string.IsNullOrEmpty(fundubsJson)) + return null; + + var fundubsResponse = JsonSerializer.Deserialize(fundubsJson); + if (fundubsResponse?.Translations == null || fundubsResponse.Translations.Count == 0) + return null; + + var fundubs = new List(); + foreach (var translation in fundubsResponse.Translations) + { + var fundubModel = new FundubModel + { + Fundub = translation.Translation, + Player = translation.Player + }; + fundubs.Add(fundubModel); + } + return fundubs; + } + + async Task GetEpisodes(OnlinesSettings init, int animeId, int playerId, int fundubId) + { + string episodesUrl = $"{init.host}/api/player/{animeId}/episodes?take=100&skip=-1&playerId={playerId}&translationId={fundubId}"; + + string episodesJson = await Http.Get(episodesUrl, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", init.host) }); + if (string.IsNullOrEmpty(episodesJson)) + return null; + + return JsonSerializer.Deserialize(episodesJson); + } + + async ValueTask> search(OnlinesSettings init, string imdb_id, long kinopoisk_id, string title, string original_title, int year) + { + string memKey = $"AnimeON:search:{kinopoisk_id}:{imdb_id}"; + if (hybridCache.TryGetValue(memKey, out List res)) + return res; + + try + { + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", init.host) }; + + async Task> FindAnime(string query) + { + if (string.IsNullOrEmpty(query)) + return null; + + string searchUrl = $"{init.host}/api/anime/search?text={HttpUtility.UrlEncode(query)}"; + + string searchJson = await Http.Get(searchUrl, headers: headers); + if (string.IsNullOrEmpty(searchJson)) + return null; + + var searchResponse = JsonSerializer.Deserialize(searchJson); + return searchResponse?.Result; + } + + var searchResults = await FindAnime(title) ?? await FindAnime(original_title); + if (searchResults == null) + return null; + + if (!string.IsNullOrEmpty(imdb_id)) + { + var seasons = searchResults.Where(a => a.ImdbId == imdb_id).ToList(); + if (seasons.Count > 0) + { + hybridCache.Set(memKey, seasons, cacheTime(5)); + return seasons; + } + } + + // Fallback to first result if no imdb match + var firstResult = searchResults.FirstOrDefault(); + if (firstResult != null) + { + var list = new List { firstResult }; + hybridCache.Set(memKey, list, cacheTime(5)); + return list; + } + + return null; + } + catch (Exception ex) + { + OnLog($"AnimeON error: {ex.Message}"); + } + + return null; + } + + [HttpGet("animeon/play")] + public async Task Play(string url, int episode_id = 0, string title = null) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.AnimeON); + if (!init.enable) + return Forbid(); + + var invoke = new AnimeONInvoke(init, hybridCache, OnLog, proxyManager); + OnLog($"AnimeON Play: url={url}, episode_id={episode_id}"); + + string streamLink = null; + if (episode_id > 0) + { + streamLink = await invoke.ResolveEpisodeStream(episode_id); + } + else if (!string.IsNullOrEmpty(url)) + { + streamLink = await invoke.ResolveVideoUrl(url); + } + else + { + OnLog("AnimeON Play: empty url"); + return OnError("animeon", proxyManager); + } + + if (string.IsNullOrEmpty(streamLink)) + { + OnLog("AnimeON Play: cannot extract stream"); + return OnError("animeon", proxyManager); + } + + List streamHeaders = null; + bool forceProxy = false; + if (streamLink.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) + { + streamHeaders = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://ashdi.vip/") + }; + forceProxy = true; + } + + string streamUrl = BuildStreamUrl(init, streamLink, streamHeaders, forceProxy); + string jsonResult = $"{{\"method\":\"play\",\"url\":\"{streamUrl}\",\"title\":\"{title ?? string.Empty}\"}}"; + OnLog("AnimeON Play: return call JSON"); + return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink, List headers, bool forceProxy) + { + string link = accsArgs(streamLink); + 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, headers: headers, force_streamproxy: forceProxy); + } + + return HostStreamProxy(init, link, headers: headers, force_streamproxy: forceProxy); + } + } +} diff --git a/AnimeON/ModInit.cs b/AnimeON/ModInit.cs new file mode 100644 index 0000000..90c8d22 --- /dev/null +++ b/AnimeON/ModInit.cs @@ -0,0 +1,64 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Module; +using Shared.Models.Online.Settings; + +namespace AnimeON +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings AnimeON; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => AnimeON; + set => AnimeON = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + AnimeON = new OnlinesSettings("AnimeON", "https://animeon.club", streamproxy: false, useproxy: false) + { + displayname = "🇯🇵 AnimeON", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + var conf = ModuleInvoke.Conf("AnimeON", AnimeON); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + AnimeON = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, AnimeON); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + AnimeON.streamproxy = false; + } + else if (AnimeON.streamproxy) + { + AnimeON.apnstream = false; + AnimeON.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("animeon"); + } + } +} diff --git a/AnimeON/Models/AnimeONAggregatedStructure.cs b/AnimeON/Models/AnimeONAggregatedStructure.cs new file mode 100644 index 0000000..d3f3f32 --- /dev/null +++ b/AnimeON/Models/AnimeONAggregatedStructure.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +namespace AnimeON.Models +{ + /// Aggregated structure for AnimeON serial content to match Lampac standard navigation. + public class AnimeONAggregatedStructure + { + /// Anime identifier from AnimeON API. + public int AnimeId { get; set; } + + /// Season number. + public int Season { get; set; } + + /// Voices mapped by display key e.g. "[Moon] AniUA". + public Dictionary Voices { get; set; } = new Dictionary(); + } + + /// Voice information for a specific player/studio combination within a season. + public class AnimeONVoiceInfo + { + /// Studio/voice name (e.g., AniUA). + public string Name { get; set; } + + /// Player type ("moon" or "ashdi"). + public string PlayerType { get; set; } + + /// Display name (e.g., "[Moon] AniUA"). + public string DisplayName { get; set; } + + /// Player identifier from API. + public int PlayerId { get; set; } + + /// Fundub identifier from API. + public int FundubId { get; set; } + + /// Flat list of episodes for the selected season. + public List Episodes { get; set; } = new List(); + } + + /// Episode information within a voice. + public class AnimeONEpisodeInfo + { + /// Episode number. + public int Number { get; set; } + + /// Episode title. + public string Title { get; set; } + + /// Primary HLS link if available. + public string Hls { get; set; } + + /// Fallback video URL (iframe or direct). + public string VideoUrl { get; set; } + + /// Episode identifier from API. + public int EpisodeId { get; set; } + } +} \ No newline at end of file diff --git a/AnimeON/Models/EmbedModel.cs b/AnimeON/Models/EmbedModel.cs new file mode 100644 index 0000000..afd9d3c --- /dev/null +++ b/AnimeON/Models/EmbedModel.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AnimeON.Models +{ + public class EmbedModel + { + [JsonPropertyName("translation")] + public string Translation { get; set; } + + [JsonPropertyName("links")] + public List<(string link, string quality)> Links { get; set; } + + [JsonPropertyName("subtitles")] + public Shared.Models.Templates.SubtitleTpl? Subtitles { get; set; } + + [JsonPropertyName("season")] + public int Season { get; set; } + + [JsonPropertyName("episode")] + public int Episode { get; set; } + } +} \ No newline at end of file diff --git a/AnimeON/Models/Models.cs b/AnimeON/Models/Models.cs new file mode 100644 index 0000000..a498121 --- /dev/null +++ b/AnimeON/Models/Models.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AnimeON.Models +{ + public class SearchResponseModel + { + [JsonPropertyName("result")] + public List Result { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + } + + public class SearchModel + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("titleUa")] + public string TitleUa { get; set; } + + [JsonPropertyName("titleEn")] + public string TitleEn { get; set; } + + [JsonPropertyName("releaseDate")] + public string Year { get; set; } + + [JsonPropertyName("imdbId")] + public string ImdbId { get; set; } + + [JsonPropertyName("season")] + public int Season { get; set; } + } + + public class FundubsResponseModel + { + [JsonPropertyName("translations")] + public List Translations { get; set; } + } + + public class TranslationModel + { + [JsonPropertyName("translation")] + public Fundub Translation { get; set; } + + [JsonPropertyName("player")] + public List Player { get; set; } + } + + public class Fundub + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("isSub")] + public bool IsSub { get; set; } + + [JsonPropertyName("synonyms")] + public List Synonyms { get; set; } + + [JsonPropertyName("studios")] + public List Studios { get; set; } + } + + public class Studio + { + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("team")] + public object Team { get; set; } + + [JsonPropertyName("telegram")] + public string Telegram { get; set; } + + [JsonPropertyName("youtube")] + public string Youtube { get; set; } + + [JsonPropertyName("patreon")] + public string Patreon { get; set; } + + [JsonPropertyName("buymeacoffee")] + public string BuyMeACoffee { get; set; } + + [JsonPropertyName("avatar")] + public Avatar Avatar { get; set; } + } + + public class Avatar + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("original")] + public string Original { get; set; } + + [JsonPropertyName("preview")] + public string Preview { get; set; } + } + + public class Player + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("episodesCount")] + public int EpisodesCount { get; set; } + } + + public class FundubModel + { + public Fundub Fundub { get; set; } + public List Player { get; set; } + } + + public class EpisodeModel + { + [JsonPropertyName("episodes")] + public List Episodes { get; set; } + + [JsonPropertyName("anotherPlayer")] + public System.Text.Json.JsonElement AnotherPlayer { get; set; } + } + + public class Episode + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("episode")] + public int EpisodeNum { get; set; } + + [JsonPropertyName("fileUrl")] + public string Hls { get; set; } + + [JsonPropertyName("videoUrl")] + public string VideoUrl { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Movie + { + public string translation { get; set; } + public List<(string link, string quality)> links { get; set; } + public Shared.Models.Templates.SubtitleTpl? subtitles { get; set; } + public int season { get; set; } + public int episode { get; set; } + } + + public class Result + { + public List movie { get; set; } + } +} \ No newline at end of file diff --git a/AnimeON/Models/Serial.cs b/AnimeON/Models/Serial.cs new file mode 100644 index 0000000..ed2392a --- /dev/null +++ b/AnimeON/Models/Serial.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AnimeON.Models +{ + public class Serial + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("title_ua")] + public string TitleUa { get; set; } + + [JsonPropertyName("title_en")] + public string TitleEn { get; set; } + + [JsonPropertyName("year")] + public string Year { get; set; } + + [JsonPropertyName("imdb_id")] + public string ImdbId { get; set; } + + [JsonPropertyName("season")] + public int Season { get; set; } + + [JsonPropertyName("voices")] + public List Voices { get; set; } + } +} \ No newline at end of file diff --git a/AnimeON/Models/Voice.cs b/AnimeON/Models/Voice.cs new file mode 100644 index 0000000..4c25ce7 --- /dev/null +++ b/AnimeON/Models/Voice.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AnimeON.Models +{ + public class Voice + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("players")] + public List Players { get; set; } + } + + public class VoicePlayer + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/AnimeON/OnlineApi.cs b/AnimeON/OnlineApi.cs new file mode 100644 index 0000000..2664bda --- /dev/null +++ b/AnimeON/OnlineApi.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace AnimeON +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.AnimeON; + + // Визначаємо isAnime згідно стандарту Lampac (Deepwiki): + // isanime = true якщо original_language == "ja" або "zh" + bool hasLang = !string.IsNullOrEmpty(original_language); + bool isanime = hasLang && (original_language == "ja" || original_language == "zh"); + + // AnimeON — аніме-провайдер. Додаємо його: + // - при загальному пошуку (serial == -1), або + // - якщо контент визначений як аніме (isanime), або + // - якщо мова невідома (відсутній original_language) + if (init.enable && !init.rip && (serial == -1 || isanime || !hasLang)) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/animeon"; + + online.Add((init.displayname, url, "animeon", init.displayindex)); + } + + return online; + } + } +} diff --git a/AnimeON/UpdateService.cs b/AnimeON/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/AnimeON/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/AnimeON/manifest.json b/AnimeON/manifest.json new file mode 100644 index 0000000..2d473c2 --- /dev/null +++ b/AnimeON/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "AnimeON.ModInit", + "online": "AnimeON.OnlineApi" +} \ No newline at end of file diff --git a/Bamboo/ApnHelper.cs b/Bamboo/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/Bamboo/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/Bamboo/Bamboo.csproj b/Bamboo/Bamboo.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/Bamboo/Bamboo.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/Bamboo/BambooInvoke.cs b/Bamboo/BambooInvoke.cs new file mode 100644 index 0000000..c22ad96 --- /dev/null +++ b/Bamboo/BambooInvoke.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Bamboo.Models; +using HtmlAgilityPack; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; + +namespace Bamboo +{ + public class BambooInvoke + { + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + public BambooInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string title, string original_title) + { + string query = !string.IsNullOrEmpty(title) ? title : original_title; + if (string.IsNullOrEmpty(query)) + return null; + + string memKey = $"Bamboo:search:{query}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + try + { + string searchUrl = $"{_init.host}/index.php?do=search&subaction=search&story={HttpUtility.UrlEncode(query)}"; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + _onLog?.Invoke($"Bamboo search: {searchUrl}"); + string html = await Http.Get(searchUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var results = new List(); + var nodes = doc.DocumentNode.SelectNodes("//li[contains(@class,'slide-item')]"); + if (nodes != null) + { + foreach (var node in nodes) + { + string itemTitle = CleanText(node.SelectSingleNode(".//h6")?.InnerText); + string href = ExtractHref(node); + string poster = ExtractPoster(node); + + if (string.IsNullOrEmpty(itemTitle) || string.IsNullOrEmpty(href)) + continue; + + results.Add(new SearchResult + { + Title = itemTitle, + Url = href, + Poster = poster + }); + } + } + + if (results.Count > 0) + _hybridCache.Set(memKey, results, cacheTime(20, init: _init)); + + return results; + } + catch (Exception ex) + { + _onLog?.Invoke($"Bamboo search error: {ex.Message}"); + return null; + } + } + + public async Task GetSeriesEpisodes(string href) + { + if (string.IsNullOrEmpty(href)) + return null; + + string memKey = $"Bamboo:series:{href}"; + if (_hybridCache.TryGetValue(memKey, out SeriesEpisodes cached)) + return cached; + + try + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + _onLog?.Invoke($"Bamboo series page: {href}"); + string html = await Http.Get(href, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var result = new SeriesEpisodes(); + bool foundBlocks = false; + + var blocks = doc.DocumentNode.SelectNodes("//div[contains(@class,'mt-4')]"); + if (blocks != null) + { + foreach (var block in blocks) + { + string header = CleanText(block.SelectSingleNode(".//h3[contains(@class,'my-4')]")?.InnerText); + var episodes = ParseEpisodeSpans(block); + if (episodes.Count == 0) + continue; + + foundBlocks = true; + if (!string.IsNullOrEmpty(header) && header.Contains("Субтитри", StringComparison.OrdinalIgnoreCase)) + { + result.Sub.AddRange(episodes); + } + else if (!string.IsNullOrEmpty(header) && header.Contains("Озвучення", StringComparison.OrdinalIgnoreCase)) + { + result.Dub.AddRange(episodes); + } + } + } + + if (!foundBlocks || (result.Sub.Count == 0 && result.Dub.Count == 0)) + { + var fallback = ParseEpisodeSpans(doc.DocumentNode); + if (fallback.Count > 0) + result.Dub.AddRange(fallback); + } + + if (result.Sub.Count == 0) + { + var fallback = ParseEpisodeSpans(doc.DocumentNode); + if (fallback.Count > 0) + result.Sub.AddRange(fallback); + } + + _hybridCache.Set(memKey, result, cacheTime(30, init: _init)); + return result; + } + catch (Exception ex) + { + _onLog?.Invoke($"Bamboo series error: {ex.Message}"); + return null; + } + } + + public async Task> GetMovieStreams(string href) + { + if (string.IsNullOrEmpty(href)) + return null; + + string memKey = $"Bamboo:movie:{href}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + try + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + _onLog?.Invoke($"Bamboo movie page: {href}"); + string html = await Http.Get(href, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var streams = new List(); + var nodes = doc.DocumentNode.SelectNodes("//span[contains(@class,'mr-3') and @data-file]"); + if (nodes != null) + { + foreach (var node in nodes) + { + string dataFile = node.GetAttributeValue("data-file", ""); + if (string.IsNullOrEmpty(dataFile)) + continue; + + string title = node.GetAttributeValue("data-title", ""); + title = string.IsNullOrEmpty(title) ? CleanText(node.InnerText) : title; + + streams.Add(new StreamInfo + { + Title = title, + Url = NormalizeUrl(dataFile) + }); + } + } + + if (streams.Count > 0) + _hybridCache.Set(memKey, streams, cacheTime(30, init: _init)); + + return streams; + } + catch (Exception ex) + { + _onLog?.Invoke($"Bamboo movie error: {ex.Message}"); + return null; + } + } + + private List ParseEpisodeSpans(HtmlNode scope) + { + var episodes = new List(); + var nodes = scope.SelectNodes(".//span[@data-file]"); + if (nodes == null) + return episodes; + + foreach (var node in nodes) + { + string dataFile = node.GetAttributeValue("data-file", ""); + if (string.IsNullOrEmpty(dataFile)) + continue; + + string title = node.GetAttributeValue("data-title", ""); + if (string.IsNullOrEmpty(title)) + title = CleanText(node.InnerText); + + int? episodeNum = ExtractEpisodeNumber(title); + + episodes.Add(new EpisodeInfo + { + Title = string.IsNullOrEmpty(title) ? "Episode" : title, + Url = NormalizeUrl(dataFile), + Episode = episodeNum + }); + } + + return episodes; + } + + private string ExtractHref(HtmlNode node) + { + var link = node.SelectSingleNode(".//a[contains(@class,'hover-buttons')]") + ?? node.SelectSingleNode(".//a[@href]"); + if (link == null) + return string.Empty; + + string href = link.GetAttributeValue("href", ""); + return NormalizeUrl(href); + } + + private string ExtractPoster(HtmlNode node) + { + var img = node.SelectSingleNode(".//img"); + if (img == null) + return string.Empty; + + string src = img.GetAttributeValue("src", ""); + if (string.IsNullOrEmpty(src)) + src = img.GetAttributeValue("data-src", ""); + + return NormalizeUrl(src); + } + + 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(); + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + } +} diff --git a/Bamboo/Controller.cs b/Bamboo/Controller.cs new file mode 100644 index 0000000..908c0b7 --- /dev/null +++ b/Bamboo/Controller.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Bamboo.Models; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; + +namespace Bamboo.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.Bamboo); + } + + [HttpGet] + [Route("bamboo")] + 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) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Bamboo); + if (!init.enable) + return Forbid(); + + var invoke = new BambooInvoke(init, hybridCache, OnLog, proxyManager); + + string itemUrl = href; + if (string.IsNullOrEmpty(itemUrl)) + { + var searchResults = await invoke.Search(title, original_title); + if (searchResults == null || searchResults.Count == 0) + return OnError("bamboo", proxyManager); + + if (searchResults.Count > 1) + { + var similar_tpl = new SimilarTpl(searchResults.Count); + foreach (var res in searchResults) + { + string link = $"{host}/bamboo?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, string.Empty, string.Empty, 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; + } + + if (serial == 1) + { + var series = await invoke.GetSeriesEpisodes(itemUrl); + if (series == null || (series.Sub.Count == 0 && series.Dub.Count == 0)) + return OnError("bamboo", proxyManager); + + var voice_tpl = new VoiceTpl(); + var episode_tpl = new EpisodeTpl(); + + var availableVoices = new List<(string key, string name, List episodes)>(); + if (series.Sub.Count > 0) + availableVoices.Add(("sub", "Субтитри", series.Sub)); + if (series.Dub.Count > 0) + availableVoices.Add(("dub", "Озвучення", series.Dub)); + + if (string.IsNullOrEmpty(t)) + t = availableVoices.First().key; + + foreach (var voice in availableVoices) + { + string voiceLink = $"{host}/bamboo?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&t={voice.key}&href={HttpUtility.UrlEncode(itemUrl)}"; + voice_tpl.Append(voice.name, voice.key == t, voiceLink); + } + + var selected = availableVoices.FirstOrDefault(v => v.key == t); + if (selected.episodes == null || selected.episodes.Count == 0) + return OnError("bamboo", proxyManager); + + int index = 1; + foreach (var ep in selected.episodes.OrderBy(e => e.Episode ?? int.MaxValue)) + { + int episodeNumber = ep.Episode ?? index; + string episodeName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {episodeNumber}" : ep.Title; + string streamUrl = BuildStreamUrl(init, ep.Url); + episode_tpl.Append(episodeName, title ?? original_title, "1", episodeNumber.ToString("D2"), streamUrl); + index++; + } + + if (rjson) + return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8"); + + return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else + { + var streams = await invoke.GetMovieStreams(itemUrl); + if (streams == null || streams.Count == 0) + return OnError("bamboo", proxyManager); + + var movie_tpl = new MovieTpl(title, original_title); + for (int i = 0; i < streams.Count; i++) + { + var stream = streams[i]; + string label = !string.IsNullOrEmpty(stream.Title) ? stream.Title : $"Варіант {i + 1}"; + string streamUrl = BuildStreamUrl(init, stream.Url); + 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 = accsArgs(streamLink); + 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); + } + } +} diff --git a/Bamboo/ModInit.cs b/Bamboo/ModInit.cs new file mode 100644 index 0000000..a9bddc2 --- /dev/null +++ b/Bamboo/ModInit.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +namespace Bamboo +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings Bamboo; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => Bamboo; + set => Bamboo = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + Bamboo = new OnlinesSettings("Bamboo", "https://bambooua.com", streamproxy: false, useproxy: false) + { + displayname = "BambooUA", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + var conf = ModuleInvoke.Conf("Bamboo", Bamboo); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + Bamboo = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, Bamboo); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + Bamboo.streamproxy = false; + } + else if (Bamboo.streamproxy) + { + Bamboo.apnstream = false; + Bamboo.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("bamboo"); + } + } +} diff --git a/Bamboo/Models/BambooModels.cs b/Bamboo/Models/BambooModels.cs new file mode 100644 index 0000000..af99cb7 --- /dev/null +++ b/Bamboo/Models/BambooModels.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Bamboo.Models +{ + public class SearchResult + { + public string Title { get; set; } + public string Url { get; set; } + public string Poster { get; set; } + } + + public class EpisodeInfo + { + public string Title { get; set; } + public string Url { get; set; } + public int? Episode { get; set; } + } + + public class StreamInfo + { + public string Title { get; set; } + public string Url { get; set; } + } + + public class SeriesEpisodes + { + public List Sub { get; set; } = new(); + public List Dub { get; set; } = new(); + } +} diff --git a/Bamboo/OnlineApi.cs b/Bamboo/OnlineApi.cs new file mode 100644 index 0000000..4d94ae5 --- /dev/null +++ b/Bamboo/OnlineApi.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace Bamboo +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.Bamboo; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/bamboo"; + + online.Add((init.displayname, url, "bamboo", init.displayindex)); + } + + return online; + } + } +} diff --git a/Bamboo/UpdateService.cs b/Bamboo/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/Bamboo/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/Bamboo/manifest.json b/Bamboo/manifest.json new file mode 100644 index 0000000..b6ce2fe --- /dev/null +++ b/Bamboo/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "Bamboo.ModInit", + "online": "Bamboo.OnlineApi" +} \ No newline at end of file diff --git a/CikavaIdeya/ApnHelper.cs b/CikavaIdeya/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/CikavaIdeya/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/CikavaIdeya/CikavaIdeya.csproj b/CikavaIdeya/CikavaIdeya.csproj new file mode 100644 index 0000000..7befc43 --- /dev/null +++ b/CikavaIdeya/CikavaIdeya.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + \ No newline at end of file diff --git a/CikavaIdeya/CikavaIdeyaInvoke.cs b/CikavaIdeya/CikavaIdeyaInvoke.cs new file mode 100644 index 0000000..8e47514 --- /dev/null +++ b/CikavaIdeya/CikavaIdeyaInvoke.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shared; +using Shared.Models.Online.Settings; +using Shared.Models; +using System.Text.RegularExpressions; +using System.Text; +using HtmlAgilityPack; +using CikavaIdeya.Models; +using Shared.Engine; +using System.Linq; + +namespace CikavaIdeya +{ + public class CikavaIdeyaInvoke + { + private OnlinesSettings _init; + private IHybridCache _hybridCache; + private Action _onLog; + private ProxyManager _proxyManager; + + public CikavaIdeyaInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + string AshdiRequestUrl(string url) + { + if (!ApnHelper.IsAshdiUrl(url)) + return url; + + return ApnHelper.WrapUrl(_init, url); + } + + public async Task> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year, bool isfilm = false) + { + string filmTitle = !string.IsNullOrEmpty(title) ? title : original_title; + string memKey = $"CikavaIdeya:search:{filmTitle}:{year}:{isfilm}"; + if (_hybridCache.TryGetValue(memKey, out List res)) + return res; + + try + { + // Спочатку шукаємо по title + res = await PerformSearch(title, year); + + // Якщо нічого не знайдено і є original_title, шукаємо по ньому + if ((res == null || res.Count == 0) && !string.IsNullOrEmpty(original_title) && original_title != title) + { + _onLog($"No results for '{title}', trying search by original title '{original_title}'"); + res = await PerformSearch(original_title, year); + // Оновлюємо ключ кешу для original_title + if (res != null && res.Count > 0) + { + memKey = $"CikavaIdeya:search:{original_title}:{year}:{isfilm}"; + } + } + + if (res != null && res.Count > 0) + { + _hybridCache.Set(memKey, res, cacheTime(20)); + return res; + } + } + catch (Exception ex) + { + _onLog($"CikavaIdeya search error: {ex.Message}"); + } + return null; + } + + async Task> PerformSearch(string searchTitle, int year) + { + try + { + string searchUrl = $"{_init.host}/index.php?do=search&subaction=search&story={System.Web.HttpUtility.UrlEncode(searchTitle)}"; + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }; + + var searchHtml = await Http.Get(searchUrl, headers: headers, proxy: _proxyManager.Get()); + // Перевіряємо, чи є результати пошуку + if (searchHtml.Contains("На жаль, пошук на сайті не дав жодних результатів")) + { + _onLog($"No search results for '{searchTitle}'"); + return new List(); + } + + var doc = new HtmlDocument(); + doc.LoadHtml(searchHtml); + + var filmNodes = doc.DocumentNode.SelectNodes("//div[@class='th-item']"); + if (filmNodes == null) + { + _onLog($"No film nodes found for '{searchTitle}'"); + return new List(); + } + + string filmUrl = null; + foreach (var filmNode in filmNodes) + { + var titleNode = filmNode.SelectSingleNode(".//div[@class='th-title']"); + if (titleNode == null || !titleNode.InnerText.Trim().ToLower().Contains(searchTitle.ToLower())) continue; + + var descNode = filmNode.SelectSingleNode(".//div[@class='th-subtitle']"); + if (year > 0 && (descNode?.InnerText ?? "").Contains(year.ToString())) + { + var linkNode = filmNode.SelectSingleNode(".//a[@class='th-in']"); + if (linkNode != null) + { + filmUrl = linkNode.GetAttributeValue("href", ""); + break; + } + } + } + + if (filmUrl == null) + { + var firstNode = filmNodes.FirstOrDefault()?.SelectSingleNode(".//a[@class='th-in']"); + if (firstNode != null) + filmUrl = firstNode.GetAttributeValue("href", ""); + } + + if (filmUrl == null) + { + _onLog($"No film URL found for '{searchTitle}'"); + return new List(); + } + + if (!filmUrl.StartsWith("http")) + filmUrl = _init.host + filmUrl; + + // Отримуємо список епізодів (для фільмів - один епізод, для серіалів - всі епізоди) + var filmHtml = await Http.Get(filmUrl, headers: headers, proxy: _proxyManager.Get()); + // Перевіряємо, чи не видалено контент + if (filmHtml.Contains("Видалено на прохання правовласника")) + { + _onLog($"Content removed on copyright holder request: {filmUrl}"); + return new List(); + } + + doc.LoadHtml(filmHtml); + + // Знаходимо JavaScript з даними про епізоди + var scriptNodes = doc.DocumentNode.SelectNodes("//script"); + if (scriptNodes != null) + { + foreach (var scriptNode in scriptNodes) + { + var scriptContent = scriptNode.InnerText; + if (scriptContent.Contains("switches = Object")) + { + _onLog($"Found switches script: {scriptContent}"); + // Парсимо структуру switches + var match = Regex.Match(scriptContent, @"switches = Object\((\{.*\})\);", RegexOptions.Singleline); + if (match.Success) + { + string switchesJson = match.Groups[1].Value; + _onLog($"Parsed switches JSON: {switchesJson}"); + // Спрощений парсинг JSON-подібної структури + var res = ParseSwitchesJson(switchesJson, _init.host, filmUrl); + _onLog($"Parsed episodes count: {res.Count}"); + foreach (var ep in res) + { + _onLog($"Episode: season={ep.season}, episode={ep.episode}, title={ep.title}, url={ep.url}"); + } + return res; + } + } + } + } + } + catch (Exception ex) + { + _onLog($"PerformSearch error for '{searchTitle}': {ex.Message}"); + } + return new List(); + } + + List ParseSwitchesJson(string json, string host, string baseUrl) + { + var result = new List(); + + try + { + _onLog($"Parsing switches JSON: {json}"); + // Спрощений парсинг JSON-подібної структури + // Приклад для серіалу: {"Player1":{"1 сезон":{"1 серія":"https://ashdi.vip/vod/57364",...},"2 сезон":{"1 серія":"https://ashdi.vip/vod/118170",...}}} + // Приклад для фільму: {"Player1":"https://ashdi.vip/vod/162246"} + + // Знаходимо плеєр Player1 + // Спочатку спробуємо знайти об'єкт Player1 + var playerObjectMatch = Regex.Match(json, @"""Player1""\s*:\s*(\{(?:[^{}]|(?\{)|(?<-open>\}))+(?(open)(?!)))", RegexOptions.Singleline); + if (playerObjectMatch.Success) + { + string playerContent = playerObjectMatch.Groups[1].Value; + _onLog($"Player1 object content: {playerContent}"); + + // Це серіал, парсимо сезони + var seasonMatches = Regex.Matches(playerContent, @"""([^""]+?сезон[^""]*?)""\s*:\s*\{((?:[^{}]|(?\{)|(?<-open>\}))+(?(open)(?!)))\}", RegexOptions.Singleline); + _onLog($"Found {seasonMatches.Count} seasons"); + foreach (Match seasonMatch in seasonMatches) + { + string seasonName = seasonMatch.Groups[1].Value; + string seasonContent = seasonMatch.Groups[2].Value; + _onLog($"Season: {seasonName}, Content: {seasonContent}"); + + // Витягуємо номер сезону + var seasonNumMatch = Regex.Match(seasonName, @"(\d+)"); + int seasonNum = seasonNumMatch.Success ? int.Parse(seasonNumMatch.Groups[1].Value) : 1; + _onLog($"Season number: {seasonNum}"); + + // Парсимо епізоди + var episodeMatches = Regex.Matches(seasonContent, @"""([^""]+?)""\s*:\s*""([^""]+?)""", RegexOptions.Singleline); + _onLog($"Found {episodeMatches.Count} episodes in season {seasonNum}"); + foreach (Match episodeMatch in episodeMatches) + { + string episodeName = episodeMatch.Groups[1].Value; + string episodeUrl = episodeMatch.Groups[2].Value; + _onLog($"Episode: {episodeName}, URL: {episodeUrl}"); + + // Витягуємо номер епізоду + var episodeNumMatch = Regex.Match(episodeName, @"(\d+)"); + int episodeNum = episodeNumMatch.Success ? int.Parse(episodeNumMatch.Groups[1].Value) : 1; + + result.Add(new CikavaIdeya.Models.EpisodeLinkInfo + { + url = episodeUrl, + title = episodeName, + season = seasonNum, + episode = episodeNum + }); + } + } + } + else + { + // Якщо не знайшли об'єкт, спробуємо знайти просте значення + var playerStringMatch = Regex.Match(json, @"""Player1""\s*:\s*(""([^""]+)"")", RegexOptions.Singleline); + if (playerStringMatch.Success) + { + string playerContent = playerStringMatch.Groups[1].Value; + _onLog($"Player1 string content: {playerContent}"); + + // Якщо це фільм (просте значення) + if (playerContent.StartsWith("\"") && playerContent.EndsWith("\"")) + { + string filmUrl = playerContent.Trim('"'); + result.Add(new CikavaIdeya.Models.EpisodeLinkInfo + { + url = filmUrl, + title = "Фільм", + season = 1, + episode = 1 + }); + } + } + else + { + _onLog("Player1 not found"); + } + } + } + catch (Exception ex) + { + _onLog($"ParseSwitchesJson error: {ex.Message}"); + } + + return result; + } + + public async Task ParseEpisode(string url) + { + var result = new CikavaIdeya.Models.PlayResult() { streams = new List<(string, string)>() }; + try + { + // Якщо це вже iframe URL (наприклад, з switches), повертаємо його + if (url.Contains("ashdi.vip")) + { + _onLog($"ParseEpisode: URL contains ashdi.vip, calling GetStreamUrlFromAshdi"); + string streamUrl = await GetStreamUrlFromAshdi(url); + _onLog($"ParseEpisode: GetStreamUrlFromAshdi returned {streamUrl}"); + if (!string.IsNullOrEmpty(streamUrl)) + { + result.streams.Add((streamUrl, "hls")); + _onLog($"ParseEpisode: added stream URL to result.streams"); + return result; + } + // Якщо не вдалося отримати посилання на поток, повертаємо iframe URL + _onLog($"ParseEpisode: stream URL is null or empty, setting iframe_url"); + result.iframe_url = url; + return result; + } + + // Інакше парсимо сторінку + string html = await Http.Get(url, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }, proxy: _proxyManager.Get()); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var iframe = doc.DocumentNode.SelectSingleNode("//div[@class='video-box']//iframe"); + if (iframe != null) + { + string iframeUrl = iframe.GetAttributeValue("src", "").Replace("&", "&"); + if (iframeUrl.StartsWith("//")) + iframeUrl = "https:" + iframeUrl; + + result.iframe_url = iframeUrl; + return result; + } + } + catch (Exception ex) + { + _onLog($"ParseEpisode error: {ex.Message}"); + } + return result; + } + public async Task GetStreamUrlFromAshdi(string url) + { + try + { + _onLog($"GetStreamUrlFromAshdi: trying to get stream URL from {url}"); + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://ashdi.vip/") }; + string html = await Http.Get(AshdiRequestUrl(url), headers: headers, proxy: _proxyManager.Get()); + _onLog($"GetStreamUrlFromAshdi: received HTML, length={html.Length}"); + + // Знаходимо JavaScript код з об'єктом player + var match = Regex.Match(html, @"var\s+player\s*=\s*new\s+Playerjs[\s\S]*?\(\s*({[\s\S]*?})\s*\)", RegexOptions.Multiline | RegexOptions.IgnoreCase); + if (match.Success) + { + _onLog($"GetStreamUrlFromAshdi: found player object"); + string playerJson = match.Groups[1].Value; + _onLog($"GetStreamUrlFromAshdi: playerJson={playerJson}"); + // Знаходимо поле file + var fileMatch = Regex.Match(playerJson, @"file\s*:\s*['""]([^'""]+)['""]", RegexOptions.Multiline | RegexOptions.IgnoreCase); + if (fileMatch.Success) + { + _onLog($"GetStreamUrlFromAshdi: found file URL: {fileMatch.Groups[1].Value}"); + return fileMatch.Groups[1].Value; + } + else + { + _onLog($"GetStreamUrlFromAshdi: file URL not found in playerJson"); + } + } + else + { + _onLog($"GetStreamUrlFromAshdi: player object not found in HTML"); + } + } + catch (Exception ex) + { + _onLog($"GetStreamUrlFromAshdi error: {ex.Message}"); + } + return null; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + } +} diff --git a/CikavaIdeya/Controller.cs b/CikavaIdeya/Controller.cs new file mode 100644 index 0000000..e757b9e --- /dev/null +++ b/CikavaIdeya/Controller.cs @@ -0,0 +1,406 @@ +using Shared.Engine; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Web; +using System.Linq; +using HtmlAgilityPack; +using Shared; +using Shared.Models.Templates; +using System.Text.RegularExpressions; +using System.Text; +using Shared.Models.Online.Settings; +using Shared.Models; +using CikavaIdeya.Models; + +namespace CikavaIdeya.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.CikavaIdeya); + } + + [HttpGet] + [Route("cikavaideya")] + 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, int e = -1, bool play = false, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.CikavaIdeya); + if (!init.enable) + return Forbid(); + + var invoke = new CikavaIdeyaInvoke(init, hybridCache, OnLog, proxyManager); + + var episodesInfo = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, serial == 0); + if (episodesInfo == null) + return Content("CikavaIdeya", "text/html; charset=utf-8"); + + if (play) + { + var episode = episodesInfo.FirstOrDefault(ep => ep.season == s && ep.episode == e); + if (serial == 0) // для фильма берем первый + episode = episodesInfo.FirstOrDefault(); + + if (episode == null) + return UpdateService.Validate(Content("CikavaIdeya", "text/html; charset=utf-8")); + + OnLog($"Controller: calling invoke.ParseEpisode with URL: {episode.url}"); + var playResult = await invoke.ParseEpisode(episode.url); + OnLog($"Controller: invoke.ParseEpisode returned playResult with streams.Count={playResult.streams?.Count ?? 0}, iframe_url={playResult.iframe_url}"); + + if (playResult.streams != null && playResult.streams.Count > 0) + { + string streamLink = playResult.streams.First().link; + string streamUrl = BuildStreamUrl(init, streamLink); + OnLog($"Controller: redirecting to stream URL: {streamUrl}"); + return UpdateService.Validate(Redirect(streamUrl)); + } + + if (!string.IsNullOrEmpty(playResult.iframe_url)) + { + OnLog($"Controller: redirecting to iframe URL: {playResult.iframe_url}"); + // Для CikavaIdeya ми просто повертаємо iframe URL + return UpdateService.Validate(Redirect(playResult.iframe_url)); + } + + if (playResult.streams != null && playResult.streams.Count > 0) + return UpdateService.Validate(Redirect(BuildStreamUrl(init, playResult.streams.First().link))); + + return UpdateService.Validate(Content("CikavaIdeya", "text/html; charset=utf-8")); + } + + if (serial == 1) + { + if (s == -1) // Выбор сезона + { + var seasons = episodesInfo.GroupBy(ep => ep.season).ToDictionary(k => k.Key, v => v.ToList()); + OnLog($"Grouped seasons count: {seasons.Count}"); + foreach (var season in seasons) + { + OnLog($"Season {season.Key}: {season.Value.Count} episodes"); + } + var season_tpl = new SeasonTpl(seasons.Count); + foreach (var season in seasons.OrderBy(i => i.Key)) + { + string link = $"{host}/cikavaideya?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season.Key}"; + season_tpl.Append($"Сезон {season.Key}", link, $"{season.Key}"); + } + OnLog("Before generating season template HTML"); + string htmlContent = season_tpl.ToHtml(); + OnLog($"Season template HTML: {htmlContent}"); + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(htmlContent, "text/html; charset=utf-8"); + } + + // Выбор эпизода + var episodes = episodesInfo.Where(ep => ep.season == s).OrderBy(ep => ep.episode).ToList(); + var movie_tpl = new MovieTpl(title, original_title, episodes.Count); + foreach(var ep in episodes) + { + string link = $"{host}/cikavaideya?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&e={ep.episode}&play=true"; + movie_tpl.Append(ep.title, accsArgs(link), method: "play"); + } + return rjson ? Content(movie_tpl.ToJson(), "application/json; charset=utf-8") : Content(movie_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else // Фильм + { + string link = $"{host}/cikavaideya?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&play=true"; + var tpl = new MovieTpl(title, original_title, 1); + tpl.Append(title, accsArgs(link), method: "play"); + return rjson ? Content(tpl.ToJson(), "application/json; charset=utf-8") : Content(tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + async ValueTask> search(OnlinesSettings init, string imdb_id, long kinopoisk_id, string title, string original_title, int year, bool isfilm = false) + { + string filmTitle = !string.IsNullOrEmpty(title) ? title : original_title; + string memKey = $"CikavaIdeya:search:{filmTitle}:{year}:{isfilm}"; + if (hybridCache.TryGetValue(memKey, out List res)) + return res; + + try + { + // Спочатку шукаємо по title + res = await PerformSearch(init, title, year); + + // Якщо нічого не знайдено і є original_title, шукаємо по ньому + if ((res == null || res.Count == 0) && !string.IsNullOrEmpty(original_title) && original_title != title) + { + OnLog($"No results for '{title}', trying search by original title '{original_title}'"); + res = await PerformSearch(init, original_title, year); + // Оновлюємо ключ кешу для original_title + if (res != null && res.Count > 0) + { + memKey = $"CikavaIdeya:search:{original_title}:{year}:{isfilm}"; + } + } + + if (res != null && res.Count > 0) + { + hybridCache.Set(memKey, res, cacheTime(20)); + return res; + } + } + catch (Exception ex) + { + OnLog($"CikavaIdeya search error: {ex.Message}"); + } + return null; + } + + async Task> PerformSearch(OnlinesSettings init, string searchTitle, int year) + { + try + { + string searchUrl = $"{init.host}/index.php?do=search&subaction=search&story={HttpUtility.UrlEncode(searchTitle)}"; + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", init.host) }; + + var searchHtml = await Http.Get(searchUrl, headers: headers); + // Перевіряємо, чи є результати пошуку + if (searchHtml.Contains("На жаль, пошук на сайті не дав жодних результатів")) + { + OnLog($"No search results for '{searchTitle}'"); + return new List(); + } + + var doc = new HtmlDocument(); + doc.LoadHtml(searchHtml); + + var filmNodes = doc.DocumentNode.SelectNodes("//div[@class='th-item']"); + if (filmNodes == null) + { + OnLog($"No film nodes found for '{searchTitle}'"); + return new List(); + } + + string filmUrl = null; + foreach (var filmNode in filmNodes) + { + var titleNode = filmNode.SelectSingleNode(".//div[@class='th-title']"); + if (titleNode == null || !titleNode.InnerText.Trim().ToLower().Contains(searchTitle.ToLower())) continue; + + var descNode = filmNode.SelectSingleNode(".//div[@class='th-subtitle']"); + if (year > 0 && (descNode?.InnerText ?? "").Contains(year.ToString())) + { + var linkNode = filmNode.SelectSingleNode(".//a[@class='th-in']"); + if (linkNode != null) + { + filmUrl = linkNode.GetAttributeValue("href", ""); + break; + } + } + } + + if (filmUrl == null) + { + var firstNode = filmNodes.FirstOrDefault()?.SelectSingleNode(".//a[@class='th-in']"); + if (firstNode != null) + filmUrl = firstNode.GetAttributeValue("href", ""); + } + + if (filmUrl == null) + { + OnLog($"No film URL found for '{searchTitle}'"); + return new List(); + } + + if (!filmUrl.StartsWith("http")) + filmUrl = init.host + filmUrl; + + // Отримуємо список епізодів (для фільмів - один епізод, для серіалів - всі епізоди) + var filmHtml = await Http.Get(filmUrl, headers: headers); + // Перевіряємо, чи не видалено контент + if (filmHtml.Contains("Видалено на прохання правовласника")) + { + OnLog($"Content removed on copyright holder request: {filmUrl}"); + return new List(); + } + + doc.LoadHtml(filmHtml); + + // Знаходимо JavaScript з даними про епізоди + var scriptNodes = doc.DocumentNode.SelectNodes("//script"); + if (scriptNodes != null) + { + foreach (var scriptNode in scriptNodes) + { + var scriptContent = scriptNode.InnerText; + if (scriptContent.Contains("switches = Object")) + { + OnLog($"Found switches script: {scriptContent}"); + // Парсимо структуру switches + var match = Regex.Match(scriptContent, @"switches = Object\((\{.*\})\);", RegexOptions.Singleline); + if (match.Success) + { + string switchesJson = match.Groups[1].Value; + OnLog($"Parsed switches JSON: {switchesJson}"); + // Спрощений парсинг JSON-подібної структури + var res = ParseSwitchesJson(switchesJson, init.host, filmUrl); + OnLog($"Parsed episodes count: {res.Count}"); + foreach (var ep in res) + { + OnLog($"Episode: season={ep.season}, episode={ep.episode}, title={ep.title}, url={ep.url}"); + } + return res; + } + } + } + } + } + catch (Exception ex) + { + OnLog($"PerformSearch error for '{searchTitle}': {ex.Message}"); + } + return new List(); + } + + List ParseSwitchesJson(string json, string host, string baseUrl) + { + var result = new List(); + + try + { + OnLog($"Parsing switches JSON: {json}"); + // Спрощений парсинг JSON-подібної структури + // Приклад для серіалу: {"Player1":{"1 сезон":{"1 серія":"https://ashdi.vip/vod/57364",...},"2 сезон":{"1 серія":"https://ashdi.vip/vod/118170",...}}} + // Приклад для фільму: {"Player1":"https://ashdi.vip/vod/162246"} + + // Знаходимо плеєр Player1 + // Спочатку спробуємо знайти об'єкт Player1 + var playerObjectMatch = Regex.Match(json, @"""Player1""\s*:\s*(\{(?:[^{}]|(?\{)|(?<-open>\}))+(?(open)(?!)))", RegexOptions.Singleline); + if (playerObjectMatch.Success) + { + string playerContent = playerObjectMatch.Groups[1].Value; + OnLog($"Player1 object content: {playerContent}"); + + // Це серіал, парсимо сезони + var seasonMatches = Regex.Matches(playerContent, @"""([^""]+?сезон[^""]*?)""\s*:\s*\{((?:[^{}]|(?\{)|(?<-open>\}))+(?(open)(?!)))\}", RegexOptions.Singleline); + OnLog($"Found {seasonMatches.Count} seasons"); + foreach (Match seasonMatch in seasonMatches) + { + string seasonName = seasonMatch.Groups[1].Value; + string seasonContent = seasonMatch.Groups[2].Value; + OnLog($"Season: {seasonName}, Content: {seasonContent}"); + + // Витягуємо номер сезону + var seasonNumMatch = Regex.Match(seasonName, @"(\d+)"); + int seasonNum = seasonNumMatch.Success ? int.Parse(seasonNumMatch.Groups[1].Value) : 1; + OnLog($"Season number: {seasonNum}"); + + // Парсимо епізоди + var episodeMatches = Regex.Matches(seasonContent, @"""([^""]+?)""\s*:\s*""([^""]+?)""", RegexOptions.Singleline); + OnLog($"Found {episodeMatches.Count} episodes in season {seasonNum}"); + foreach (Match episodeMatch in episodeMatches) + { + string episodeName = episodeMatch.Groups[1].Value; + string episodeUrl = episodeMatch.Groups[2].Value; + OnLog($"Episode: {episodeName}, URL: {episodeUrl}"); + + // Витягуємо номер епізоду + var episodeNumMatch = Regex.Match(episodeName, @"(\d+)"); + int episodeNum = episodeNumMatch.Success ? int.Parse(episodeNumMatch.Groups[1].Value) : 1; + + result.Add(new EpisodeLinkInfo + { + url = episodeUrl, + title = episodeName, + season = seasonNum, + episode = episodeNum + }); + } + } + } + else + { + // Якщо не знайшли об'єкт, спробуємо знайти просте значення + var playerStringMatch = Regex.Match(json, @"""Player1""\s*:\s*(""([^""]+)"")", RegexOptions.Singleline); + if (playerStringMatch.Success) + { + string playerContent = playerStringMatch.Groups[1].Value; + OnLog($"Player1 string content: {playerContent}"); + + // Якщо це фільм (просте значення) + if (playerContent.StartsWith("\"") && playerContent.EndsWith("\"")) + { + string filmUrl = playerContent.Trim('"'); + result.Add(new EpisodeLinkInfo + { + url = filmUrl, + title = "Фільм", + season = 1, + episode = 1 + }); + } + } + else + { + OnLog("Player1 not found"); + } + } + } + catch (Exception ex) + { + OnLog($"ParseSwitchesJson error: {ex.Message}"); + } + + return result; + } + + async Task ParseEpisode(OnlinesSettings init, string url) + { + var result = new PlayResult() { streams = new List<(string, string)>() }; + try + { + // Якщо це вже iframe URL (наприклад, з switches), повертаємо його + if (url.Contains("ashdi.vip")) + { + result.iframe_url = url; + return result; + } + + // Інакше парсимо сторінку + string html = await Http.Get(url, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", init.host) }); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var iframe = doc.DocumentNode.SelectSingleNode("//div[@class='video-box']//iframe"); + if (iframe != null) + { + string iframeUrl = iframe.GetAttributeValue("src", "").Replace("&", "&"); + if (iframeUrl.StartsWith("//")) + iframeUrl = "https:" + iframeUrl; + + result.iframe_url = iframeUrl; + return result; + } + } + catch (Exception ex) + { + OnLog($"ParseEpisode error: {ex.Message}"); + } + return result; + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = accsArgs(streamLink); + 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); + } + } +} diff --git a/CikavaIdeya/ModInit.cs b/CikavaIdeya/ModInit.cs new file mode 100644 index 0000000..be3c5fa --- /dev/null +++ b/CikavaIdeya/ModInit.cs @@ -0,0 +1,70 @@ +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; + +namespace CikavaIdeya +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings CikavaIdeya; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => CikavaIdeya; + set => CikavaIdeya = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + CikavaIdeya = new OnlinesSettings("CikavaIdeya", "https://cikava-ideya.top", streamproxy: false, useproxy: false) + { + displayname = "ЦікаваІдея", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "a", + password = "a", + list = new string[] { "socks5://IP:PORT" } + } + }; + var conf = ModuleInvoke.Conf("CikavaIdeya", CikavaIdeya); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + CikavaIdeya = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, CikavaIdeya); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + CikavaIdeya.streamproxy = false; + } + else if (CikavaIdeya.streamproxy) + { + CikavaIdeya.apnstream = false; + CikavaIdeya.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("cikavaideya"); + } + } +} diff --git a/CikavaIdeya/Models/EpisodeModel.cs b/CikavaIdeya/Models/EpisodeModel.cs new file mode 100644 index 0000000..46d0a11 --- /dev/null +++ b/CikavaIdeya/Models/EpisodeModel.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace CikavaIdeya.Models +{ + public class EpisodeModel + { + [JsonPropertyName("episode_number")] + public int EpisodeNumber { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("url")] + public string Url { get; set; } + } +} \ No newline at end of file diff --git a/CikavaIdeya/Models/Models.cs b/CikavaIdeya/Models/Models.cs new file mode 100644 index 0000000..188475f --- /dev/null +++ b/CikavaIdeya/Models/Models.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace CikavaIdeya.Models +{ + public class EpisodeLinkInfo + { + public string url { get; set; } + public string title { get; set; } + public int season { get; set; } + public int episode { get; set; } + } + + public class PlayResult + { + public string iframe_url { get; set; } + public List<(string link, string quality)> streams { get; set; } + } +} \ No newline at end of file diff --git a/CikavaIdeya/Models/PlayerModel.cs b/CikavaIdeya/Models/PlayerModel.cs new file mode 100644 index 0000000..3e871f9 --- /dev/null +++ b/CikavaIdeya/Models/PlayerModel.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CikavaIdeya.Models +{ + public class CikavaIdeyaPlayerModel + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("qualities")] + public List<(string link, string quality)> Qualities { get; set; } + + [JsonPropertyName("subtitles")] + public Shared.Models.Templates.SubtitleTpl? Subtitles { get; set; } + } +} \ No newline at end of file diff --git a/CikavaIdeya/Models/SeasonModel.cs b/CikavaIdeya/Models/SeasonModel.cs new file mode 100644 index 0000000..570c7e0 --- /dev/null +++ b/CikavaIdeya/Models/SeasonModel.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CikavaIdeya.Models +{ + public class SeasonModel + { + [JsonPropertyName("season_number")] + public int SeasonNumber { get; set; } + + [JsonPropertyName("episodes")] + public List Episodes { get; set; } + } +} \ No newline at end of file diff --git a/CikavaIdeya/OnlineApi.cs b/CikavaIdeya/OnlineApi.cs new file mode 100644 index 0000000..821020a --- /dev/null +++ b/CikavaIdeya/OnlineApi.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace CikavaIdeya +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.CikavaIdeya; + + // Визначення isAnime згідно Lampac (Deepwiki): original_language == "ja" або "zh" + bool hasLang = !string.IsNullOrEmpty(original_language); + bool isanime = hasLang && (original_language == "ja" || original_language == "zh"); + + // CikavaIdeya — не-аніме провайдер. Додаємо якщо: + // - загальний пошук (serial == -1), або + // - контент НЕ аніме (!isanime), або + // - мова невідома (немає original_language) + if (init.enable && !init.rip && (serial == -1 || !isanime || !hasLang)) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/cikavaideya"; + + online.Add((init.displayname, url, "cikavaideya", init.displayindex)); + } + + return online; + } + } +} diff --git a/CikavaIdeya/UpdateService.cs b/CikavaIdeya/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/CikavaIdeya/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/CikavaIdeya/manifest.json b/CikavaIdeya/manifest.json new file mode 100644 index 0000000..3664d83 --- /dev/null +++ b/CikavaIdeya/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "CikavaIdeya.ModInit", + "online": "CikavaIdeya.OnlineApi" +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Mikai/ApnHelper.cs b/Mikai/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/Mikai/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/Mikai/Controller.cs b/Mikai/Controller.cs new file mode 100644 index 0000000..3fcd339 --- /dev/null +++ b/Mikai/Controller.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Mvc; +using Mikai.Models; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; + +namespace Mikai.Controllers +{ + public class Controller : BaseOnlineController + { + private readonly ProxyManager _proxyManager; + + public Controller() : base(ModInit.Settings) + { + _proxyManager = new ProxyManager(ModInit.Mikai); + } + + [HttpGet] + [Route("mikai")] + public async 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) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Mikai); + if (!init.enable) + return Forbid(); + + var invoke = new MikaiInvoke(init, hybridCache, OnLog, _proxyManager); + OnLog($"Mikai Index: title={title}, original_title={original_title}, serial={serial}, s={s}, t={t}, year={year}"); + + var searchResults = await invoke.Search(title, original_title, year); + if (searchResults == null || searchResults.Count == 0) + return OnError("mikai", _proxyManager); + + var selected = searchResults.FirstOrDefault(); + if (selected == null) + return OnError("mikai", _proxyManager); + + var details = await invoke.GetDetails(selected.Id); + if (details == null || details.Players == null || details.Players.Count == 0) + return OnError("mikai", _proxyManager); + + bool isSerial = serial == 1 || (serial == -1 && !string.Equals(details.Format, "movie", StringComparison.OrdinalIgnoreCase)); + var voices = BuildVoices(details); + if (voices.Count == 0) + return OnError("mikai", _proxyManager); + + string displayTitle = title ?? details.Details?.Names?.Name ?? original_title; + + if (isSerial) + { + const int seasonNumber = 1; + if (s == -1) + { + var seasonTpl = new SeasonTpl(1); + string link = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={seasonNumber}"; + seasonTpl.Append($"{seasonNumber}", link, seasonNumber.ToString()); + + return rjson + ? Content(seasonTpl.ToJson(), "application/json; charset=utf-8") + : Content(seasonTpl.ToHtml(), "text/html; charset=utf-8"); + } + + var voicesForSeason = voices + .Where(v => v.Value.Seasons.ContainsKey(s)) + .ToList(); + + if (!voicesForSeason.Any()) + return OnError("mikai", _proxyManager); + + if (string.IsNullOrEmpty(t)) + t = voicesForSeason[0].Key; + + var voiceTpl = new VoiceTpl(); + foreach (var voice in voicesForSeason) + { + string voiceLink = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&t={HttpUtility.UrlEncode(voice.Key)}"; + voiceTpl.Append(voice.Key, voice.Key == t, voiceLink); + } + + if (!voices.ContainsKey(t) || !voices[t].Seasons.ContainsKey(s)) + return OnError("mikai", _proxyManager); + + var episodeTpl = new EpisodeTpl(); + foreach (var ep in voices[t].Seasons[s].OrderBy(e => e.Number)) + { + string episodeName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {ep.Number}" : ep.Title; + string streamLink = ep.Url; + + if (string.IsNullOrEmpty(streamLink)) + continue; + + if (NeedsResolve(voices[t].ProviderName, streamLink)) + { + string callUrl = $"{host}/mikai/play?url={HttpUtility.UrlEncode(streamLink)}&title={HttpUtility.UrlEncode(displayTitle)}"; + episodeTpl.Append(episodeName, displayTitle, s.ToString(), ep.Number.ToString(), accsArgs(callUrl), "call"); + } + else + { + string playUrl = HostStreamProxy(init, accsArgs(streamLink)); + episodeTpl.Append(episodeName, displayTitle, s.ToString(), ep.Number.ToString(), playUrl); + } + } + + if (rjson) + return Content(episodeTpl.ToJson(voiceTpl), "application/json; charset=utf-8"); + + return Content(voiceTpl.ToHtml() + episodeTpl.ToHtml(), "text/html; charset=utf-8"); + } + + var movieTpl = new MovieTpl(displayTitle, original_title); + foreach (var voice in voices.Values) + { + var episode = voice.Seasons.Values.SelectMany(v => v).OrderBy(e => e.Number).FirstOrDefault(); + if (episode == null || string.IsNullOrEmpty(episode.Url)) + continue; + + if (NeedsResolve(voice.ProviderName, episode.Url)) + { + string callUrl = $"{host}/mikai/play?url={HttpUtility.UrlEncode(episode.Url)}&title={HttpUtility.UrlEncode(displayTitle)}"; + movieTpl.Append(voice.DisplayName, accsArgs(callUrl), "call"); + } + else + { + string playUrl = HostStreamProxy(init, accsArgs(episode.Url)); + movieTpl.Append(voice.DisplayName, playUrl); + } + } + + if (movieTpl.data == null || movieTpl.data.Count == 0) + return OnError("mikai", _proxyManager); + + return rjson + ? Content(movieTpl.ToJson(), "application/json; charset=utf-8") + : Content(movieTpl.ToHtml(), "text/html; charset=utf-8"); + } + + [HttpGet("mikai/play")] + public async Task Play(string url, string title = null) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Mikai); + if (!init.enable) + return Forbid(); + + if (string.IsNullOrEmpty(url)) + return OnError("mikai", _proxyManager); + + var invoke = new MikaiInvoke(init, hybridCache, OnLog, _proxyManager); + OnLog($"Mikai Play: url={url}"); + + string streamLink = await invoke.ResolveVideoUrl(url); + if (string.IsNullOrEmpty(streamLink)) + return OnError("mikai", _proxyManager); + + List streamHeaders = null; + bool forceProxy = false; + if (streamLink.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) + { + streamHeaders = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://ashdi.vip/") + }; + forceProxy = true; + } + + string streamUrl = BuildStreamUrl(init, streamLink, streamHeaders, forceProxy); + string jsonResult = $"{{\"method\":\"play\",\"url\":\"{streamUrl}\",\"title\":\"{title ?? string.Empty}\"}}"; + return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); + } + + private Dictionary BuildVoices(MikaiAnime details) + { + var voices = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (details?.Players == null) + return voices; + + int totalProviders = details.Players.Sum(p => p?.Providers?.Count ?? 0); + + foreach (var player in details.Players) + { + if (player?.Providers == null || player.Providers.Count == 0) + continue; + + string teamName = player.Team?.Name; + if (string.IsNullOrWhiteSpace(teamName)) + teamName = "Озвучка"; + + string baseName = player.IsSubs ? $"{teamName} (Субтитри)" : teamName; + + foreach (var provider in player.Providers) + { + if (provider?.Episodes == null || provider.Episodes.Count == 0) + continue; + + string displayName = baseName; + if (totalProviders > 1 && !string.IsNullOrWhiteSpace(provider.Name)) + displayName = $"[{provider.Name}] {displayName}"; + + displayName = EnsureUniqueName(voices, displayName); + + var voice = new MikaiVoiceInfo + { + DisplayName = displayName, + ProviderName = provider.Name, + IsSubs = player.IsSubs + }; + + var episodes = new List(); + int fallbackIndex = 1; + foreach (var ep in provider.Episodes.OrderBy(e => e.Number)) + { + if (string.IsNullOrWhiteSpace(ep.PlayLink)) + continue; + + int number = ep.Number > 0 ? ep.Number : fallbackIndex++; + episodes.Add(new MikaiEpisodeInfo + { + Number = number, + Title = $"Епізод {number}", + Url = ep.PlayLink + }); + } + + if (episodes.Count == 0) + continue; + + voice.Seasons[1] = episodes; + voices[displayName] = voice; + } + } + + return voices; + } + + private static string EnsureUniqueName(Dictionary voices, string name) + { + if (!voices.ContainsKey(name)) + return name; + + int index = 2; + string candidate = $"{name} {index}"; + while (voices.ContainsKey(candidate)) + { + index++; + candidate = $"{name} {index}"; + } + + return candidate; + } + + private static bool NeedsResolve(string providerName, string streamLink) + { + if (!string.IsNullOrEmpty(providerName)) + { + if (providerName.Equals("ASHDI", StringComparison.OrdinalIgnoreCase) || + providerName.Equals("MOONANIME", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return streamLink.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase) || + streamLink.Contains("moonanime.art", StringComparison.OrdinalIgnoreCase); + } + + private string BuildStreamUrl(OnlinesSettings init, string streamLink, List headers, bool forceProxy) + { + string link = accsArgs(streamLink); + 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, headers: headers, force_streamproxy: forceProxy); + } + + return HostStreamProxy(init, link, headers: headers, force_streamproxy: forceProxy); + } + } +} diff --git a/Mikai/Mikai.csproj b/Mikai/Mikai.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/Mikai/Mikai.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/Mikai/MikaiInvoke.cs b/Mikai/MikaiInvoke.cs new file mode 100644 index 0000000..ac88ebc --- /dev/null +++ b/Mikai/MikaiInvoke.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Web; +using Mikai.Models; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; + +namespace Mikai +{ + public class MikaiInvoke + { + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + public MikaiInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string title, string original_title, int year) + { + string memKey = $"Mikai:search:{title}:{original_title}:{year}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + try + { + async Task> FindAnime(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return null; + + string searchUrl = $"{_init.apihost}/anime/search?page=1&limit=24&sort=year&order=desc&name={HttpUtility.UrlEncode(query)}"; + var headers = DefaultHeaders(); + + _onLog($"Mikai: using proxy {_proxyManager.CurrentProxyIp} for {searchUrl}"); + string json = await Http.Get(searchUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(json)) + return null; + + var response = JsonSerializer.Deserialize(json); + if (response?.Result == null || response.Result.Count == 0) + return null; + + if (year > 0) + { + var byYear = response.Result.Where(r => r.Year == year).ToList(); + if (byYear.Count > 0) + return byYear; + } + + return response.Result; + } + + var results = await FindAnime(title) ?? await FindAnime(original_title); + if (results == null || results.Count == 0) + return null; + + _hybridCache.Set(memKey, results, cacheTime(10, init: _init)); + return results; + } + catch (Exception ex) + { + _onLog($"Mikai Search error: {ex.Message}"); + return null; + } + } + + public async Task GetDetails(int id) + { + string memKey = $"Mikai:details:{id}"; + if (_hybridCache.TryGetValue(memKey, out MikaiAnime cached)) + return cached; + + try + { + string url = $"{_init.apihost}/anime/{id}"; + var headers = DefaultHeaders(); + + _onLog($"Mikai: using proxy {_proxyManager.CurrentProxyIp} for {url}"); + string json = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(json)) + return null; + + var response = JsonSerializer.Deserialize(json); + if (response?.Result == null) + return null; + + _hybridCache.Set(memKey, response.Result, cacheTime(20, init: _init)); + return response.Result; + } + catch (Exception ex) + { + _onLog($"Mikai Details error: {ex.Message}"); + return null; + } + } + + public async Task ResolveVideoUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + if (url.Contains("moonanime.art", StringComparison.OrdinalIgnoreCase)) + return await ParseMoonAnimePage(url); + + if (url.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) + return await ParseAshdiPage(url); + + return url; + } + + public async Task ParseMoonAnimePage(string url) + { + try + { + string requestUrl = url; + if (!requestUrl.Contains("player=", StringComparison.OrdinalIgnoreCase)) + { + requestUrl = requestUrl.Contains("?") + ? $"{requestUrl}&player=mikai.me" + : $"{requestUrl}?player=mikai.me"; + } + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + _onLog($"Mikai: using proxy {_proxyManager.CurrentProxyIp} for {requestUrl}"); + string html = await Http.Get(requestUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var match = System.Text.RegularExpressions.Regex.Match(html, @"file:\s*""([^""]+\.m3u8)"""); + if (match.Success) + return match.Groups[1].Value; + } + catch (Exception ex) + { + _onLog($"Mikai ParseMoonAnimePage error: {ex.Message}"); + } + + return null; + } + + string AshdiRequestUrl(string url) + { + if (!ApnHelper.IsAshdiUrl(url)) + return url; + + return ApnHelper.WrapUrl(_init, url); + } + + public async Task ParseAshdiPage(string url) + { + try + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://ashdi.vip/") + }; + + string requestUrl = AshdiRequestUrl(url); + _onLog($"Mikai: using proxy {_proxyManager.CurrentProxyIp} for {requestUrl}"); + string html = await Http.Get(requestUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) + return null; + + var match = System.Text.RegularExpressions.Regex.Match(html, @"file\s*:\s*['""]([^'""]+)['""]"); + if (match.Success) + return match.Groups[1].Value; + } + catch (Exception ex) + { + _onLog($"Mikai ParseAshdiPage error: {ex.Message}"); + } + + return null; + } + + private List DefaultHeaders() + { + return new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host), + new HeadersModel("Accept", "application/json") + }; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + } +} diff --git a/Mikai/ModInit.cs b/Mikai/ModInit.cs new file mode 100644 index 0000000..22705c1 --- /dev/null +++ b/Mikai/ModInit.cs @@ -0,0 +1,65 @@ +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +namespace Mikai +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings Mikai; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => Mikai; + set => Mikai = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + Mikai = new OnlinesSettings("Mikai", "https://mikai.me", streamproxy: false, useproxy: false) + { + displayname = "Mikai", + displayindex = 0, + apihost = "https://api.mikai.me/v1", + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + + var conf = ModuleInvoke.Conf("Mikai", Mikai); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + Mikai = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, Mikai); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + Mikai.streamproxy = false; + } + else if (Mikai.streamproxy) + { + Mikai.apnstream = false; + Mikai.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("mikai"); + } + } +} diff --git a/Mikai/Models/MikaiModels.cs b/Mikai/Models/MikaiModels.cs new file mode 100644 index 0000000..b1e6c6f --- /dev/null +++ b/Mikai/Models/MikaiModels.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Mikai.Models +{ + public class SearchResponse + { + [JsonPropertyName("ok")] + public bool Ok { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("result")] + public List Result { get; set; } + } + + public class DetailResponse + { + [JsonPropertyName("ok")] + public bool Ok { get; set; } + + [JsonPropertyName("result")] + public MikaiAnime Result { get; set; } + } + + public class MikaiAnime + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("format")] + public string Format { get; set; } + + [JsonPropertyName("season")] + public string Season { get; set; } + + [JsonPropertyName("year")] + public int Year { get; set; } + + [JsonPropertyName("startDate")] + public string StartDate { get; set; } + + [JsonPropertyName("isAdult")] + public bool IsAdult { get; set; } + + [JsonPropertyName("isDisabled")] + public bool IsDisabled { get; set; } + + [JsonPropertyName("media")] + public MikaiMedia Media { get; set; } + + [JsonPropertyName("details")] + public MikaiDetails Details { get; set; } + + [JsonPropertyName("players")] + public List Players { get; set; } + } + + public class MikaiMedia + { + [JsonPropertyName("posterUid")] + public string PosterUid { get; set; } + + [JsonPropertyName("bannerUid")] + public string BannerUid { get; set; } + + [JsonPropertyName("youtubeId")] + public string YoutubeId { get; set; } + } + + public class MikaiDetails + { + [JsonPropertyName("source")] + public string Source { get; set; } + + [JsonPropertyName("genres")] + public List Genres { get; set; } + + [JsonPropertyName("episodesInformation")] + public MikaiEpisodesInfo EpisodesInformation { get; set; } + + [JsonPropertyName("names")] + public MikaiNames Names { get; set; } + + [JsonPropertyName("rating")] + public MikaiRating Rating { get; set; } + + [JsonPropertyName("views")] + public int Views { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + } + + public class MikaiNames + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("nameNative")] + public string NameNative { get; set; } + + [JsonPropertyName("nameEnglish")] + public string NameEnglish { get; set; } + } + + public class MikaiGenre + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("ukrainian")] + public string Ukrainian { get; set; } + } + + public class MikaiEpisodesInfo + { + [JsonPropertyName("durationMin")] + public int DurationMin { get; set; } + + [JsonPropertyName("episodes")] + public int Episodes { get; set; } + + [JsonPropertyName("episodesAired")] + public int EpisodesAired { get; set; } + + [JsonPropertyName("episodesAdapted")] + public int EpisodesAdapted { get; set; } + } + + public class MikaiRating + { + [JsonPropertyName("rating")] + public double Rating { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + } + + public class MikaiPlayer + { + [JsonPropertyName("team")] + public MikaiTeam Team { get; set; } + + [JsonPropertyName("isSubs")] + public bool IsSubs { get; set; } + + [JsonPropertyName("providers")] + public List Providers { get; set; } + } + + public class MikaiTeam + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("isGroup")] + public bool IsGroup { get; set; } + + [JsonPropertyName("teams")] + public List Teams { get; set; } + } + + public class MikaiTeamInfo + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("avatarUid")] + public string AvatarUid { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + } + + public class MikaiProvider + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("episodes")] + public List Episodes { get; set; } + } + + public class MikaiProviderEpisode + { + [JsonPropertyName("number")] + public int Number { get; set; } + + [JsonPropertyName("playLink")] + public string PlayLink { get; set; } + } +} diff --git a/Mikai/Models/MikaiStructure.cs b/Mikai/Models/MikaiStructure.cs new file mode 100644 index 0000000..e861a70 --- /dev/null +++ b/Mikai/Models/MikaiStructure.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Mikai.Models +{ + public class MikaiVoiceInfo + { + public string DisplayName { get; set; } + public string ProviderName { get; set; } + public bool IsSubs { get; set; } + public Dictionary> Seasons { get; set; } = new(); + } + + public class MikaiEpisodeInfo + { + public int Number { get; set; } + public string Title { get; set; } + public string Url { get; set; } + } +} diff --git a/Mikai/OnlineApi.cs b/Mikai/OnlineApi.cs new file mode 100644 index 0000000..baaabf3 --- /dev/null +++ b/Mikai/OnlineApi.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace Mikai +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.Mikai; + + // Визначаємо isAnime згідно стандарту Lampac (Deepwiki): + // isanime = true якщо original_language == "ja" або "zh" + bool hasLang = !string.IsNullOrEmpty(original_language); + bool isanime = hasLang && (original_language == "ja" || original_language == "zh"); + + // Mikai — аніме-провайдер. Додаємо його: + // - при загальному пошуку (serial == -1), або + // - якщо контент визначений як аніме (isanime), або + // - якщо мова невідома (відсутній original_language) + if (init.enable && !init.rip && (serial == -1 || isanime || !hasLang)) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/mikai"; + + online.Add((init.displayname, url, "mikai", init.displayindex)); + } + + return online; + } + } +} diff --git a/Mikai/UpdateService.cs b/Mikai/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/Mikai/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/Mikai/manifest.json b/Mikai/manifest.json new file mode 100644 index 0000000..fa85320 --- /dev/null +++ b/Mikai/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "Mikai.ModInit", + "online": "Mikai.OnlineApi" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a846d5a --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Ukraine online source for Lampac + +- [x] AnimeON +- [x] BambooUA +- [x] CikavaIdeya +- [x] StarLight +- [x] UAKino +- [x] UAFlix +- [x] UATuTFun +- [x] Unimay + + +## Installation + +1. Clone the repository: + ```bash + git clone https://github.com/lampac-ukraine/lampac-ukraine.git . + ``` + +2. Move the modules to the correct directory: + - If Lampac is installed system-wide, move the modules to the `module` directory. + - If Lampac is running in Docker, mount the volume: + ```bash + -v /path/to/your/cloned/repo/Uaflix:/home/module/Uaflix + ``` + +## Auto installation + +If Lampac version 148.1 and newer + +Create or update the module/repository.yaml file + +```YAML +- repository: https://github.com/lampame/lampac-ukraine + branch: main + modules: + - AnimeON + - Unimay + - CikavaIdeya + - Uaflix + - UaTUT + - Bamboo + - UAKino + - StarLight +``` + +branch - optional, default main + +modules - optional, if not specified, all modules from the repository will be installed + +## Init support + +```json +"Uaflix": { + "enable": true, + "domain": "https://uaflix.net", + "displayname": "Uaflix", + "streamproxy": false, + "useproxy": false, + "proxy": { + "useAuth": true, + "username": "FooBAR", + "password": "Strong_password", + "list": [ + "socks5://adress:port" + ] + }, + "displayindex": 1, + "apn": true, + "apn_host": "domaine.com/{encodeurl}" + } +``` + +## APN support + +Sources with APN support: +- AnimeON +- Uaflix +- UaTUT +- CikavaIdeya +- UAKino + +## Donate + +Support the author: https://lampame.donatik.me diff --git a/StarLight/ApnHelper.cs b/StarLight/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/StarLight/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/StarLight/Controller.cs b/StarLight/Controller.cs new file mode 100644 index 0000000..32bddf4 --- /dev/null +++ b/StarLight/Controller.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; +using StarLight.Models; + +namespace StarLight.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.StarLight); + } + + [HttpGet] + [Route("starlight")] + 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, int s = -1, bool rjson = false, string href = null) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.StarLight); + if (!init.enable) + return Forbid(); + + var invoke = new StarLightInvoke(init, hybridCache, OnLog, proxyManager); + + string itemUrl = href; + if (string.IsNullOrEmpty(itemUrl)) + { + var searchResults = await invoke.Search(title, original_title); + if (searchResults == null || searchResults.Count == 0) + return OnError("starlight", proxyManager); + + if (searchResults.Count > 1) + { + var similar_tpl = new SimilarTpl(searchResults.Count); + foreach (var res in searchResults) + { + string link = $"{host}/starlight?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.Href)}"; + similar_tpl.Append(res.Title, string.Empty, string.Empty, link, string.Empty); + } + + return rjson ? Content(similar_tpl.ToJson(), "application/json; charset=utf-8") : Content(similar_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + itemUrl = searchResults[0].Href; + } + + var project = await invoke.GetProject(itemUrl); + if (project == null) + return OnError("starlight", proxyManager); + + if (serial == 1 && project.Seasons.Count > 0) + { + if (s == -1) + { + var season_tpl = new SeasonTpl(project.Seasons.Count); + for (int i = 0; i < project.Seasons.Count; i++) + { + var seasonInfo = project.Seasons[i]; + string seasonName = string.IsNullOrEmpty(seasonInfo.Title) ? $"Сезон {i + 1}" : seasonInfo.Title; + string link = $"{host}/starlight?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={i}&href={HttpUtility.UrlEncode(itemUrl)}"; + season_tpl.Append(seasonName, link, i.ToString()); + } + + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + if (s < 0 || s >= project.Seasons.Count) + return OnError("starlight", proxyManager); + + var season = project.Seasons[s]; + string seasonSlug = season.Slug; + var episodes = invoke.GetEpisodes(project, seasonSlug); + if (episodes == null || episodes.Count == 0) + return OnError("starlight", proxyManager); + + var episode_tpl = new EpisodeTpl(); + int index = 1; + string seasonNumber = GetSeasonNumber(season, s); + var orderedEpisodes = episodes + .Select(ep => new { Episode = ep, Number = GetEpisodeNumber(ep), Date = GetEpisodeDate(ep) }) + .OrderBy(ep => ep.Number ?? int.MaxValue) + .ThenBy(ep => ep.Date ?? DateTime.MaxValue) + .Select(ep => ep.Episode) + .ToList(); + + foreach (var ep in orderedEpisodes) + { + if (string.IsNullOrEmpty(ep.Hash)) + continue; + + string episodeName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {index}" : ep.Title; + string callUrl = $"{host}/starlight/play?hash={HttpUtility.UrlEncode(ep.Hash)}&title={HttpUtility.UrlEncode(title ?? original_title)}"; + episode_tpl.Append(episodeName, title ?? original_title, seasonNumber, index.ToString("D2"), accsArgs(callUrl), "call"); + index++; + } + + return rjson ? Content(episode_tpl.ToJson(), "application/json; charset=utf-8") : Content(episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else + { + string hash = project.Hash; + if (string.IsNullOrEmpty(hash) && project.Episodes.Count > 0) + hash = project.Episodes.FirstOrDefault(e => !string.IsNullOrEmpty(e.Hash))?.Hash; + + if (string.IsNullOrEmpty(hash)) + return OnError("starlight", proxyManager); + + string callUrl = $"{host}/starlight/play?hash={HttpUtility.UrlEncode(hash)}&title={HttpUtility.UrlEncode(title ?? original_title)}"; + var movie_tpl = new MovieTpl(title, original_title, 1); + movie_tpl.Append(string.IsNullOrEmpty(title) ? "StarLight" : title, accsArgs(callUrl), "call"); + + return rjson ? Content(movie_tpl.ToJson(), "application/json; charset=utf-8") : Content(movie_tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + [HttpGet] + [Route("starlight/play")] + async public Task Play(string hash, string title) + { + await UpdateService.ConnectAsync(host); + + if (string.IsNullOrEmpty(hash)) + return OnError("starlight", proxyManager); + + var init = await loadKit(ModInit.StarLight); + if (!init.enable) + return Forbid(); + + var invoke = new StarLightInvoke(init, hybridCache, OnLog, proxyManager); + var result = await invoke.ResolveStream(hash); + if (result == null || string.IsNullOrEmpty(result.Stream)) + return OnError("starlight", proxyManager); + + string videoTitle = title ?? result.Name ?? ""; + + if (result.Streams != null && result.Streams.Count > 0) + { + var streamQuality = new StreamQualityTpl(); + foreach (var item in result.Streams) + { + string streamLink = BuildStreamUrl(init, item.link); + streamQuality.Append(streamLink, item.quality); + } + + var first = streamQuality.Firts(); + string streamUrl = string.IsNullOrEmpty(first.link) + ? BuildStreamUrl(init, result.Stream) + : first.link; + + return UpdateService.Validate(Content(VideoTpl.ToJson("play", streamUrl, videoTitle, streamquality: streamQuality), "application/json; charset=utf-8")); + } + + string defaultUrl = BuildStreamUrl(init, result.Stream); + return UpdateService.Validate(Content(VideoTpl.ToJson("play", defaultUrl, videoTitle), "application/json; charset=utf-8")); + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = accsArgs(streamLink); + 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, proxy: proxyManager.Get()); + } + + return HostStreamProxy(init, link, proxy: proxyManager.Get()); + } + + private static string GetSeasonNumber(SeasonInfo season, int fallbackIndex) + { + if (season?.Title == null) + return (fallbackIndex + 1).ToString(); + + var digits = new string(season.Title.Where(char.IsDigit).ToArray()); + return string.IsNullOrEmpty(digits) ? (fallbackIndex + 1).ToString() : digits; + } + + private static int? GetEpisodeNumber(EpisodeInfo episode) + { + if (episode == null) + return null; + + if (episode.Number.HasValue) + return episode.Number.Value; + + if (string.IsNullOrEmpty(episode.Title)) + return null; + + var title = episode.Title; + var markers = new[] { "випуск", "серия", "серія" }; + foreach (var marker in markers) + { + var markerIndex = title.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (markerIndex <= 0) + continue; + + var prefix = title.Substring(0, markerIndex); + var matches = Regex.Matches(prefix, "\\d+"); + if (matches.Count == 0) + continue; + + var last = matches[matches.Count - 1].Value; + if (int.TryParse(last, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) + return number; + } + + return null; + } + + private static DateTime? GetEpisodeDate(EpisodeInfo episode) + { + if (episode == null || string.IsNullOrEmpty(episode.Date)) + return null; + + if (DateTime.TryParseExact(episode.Date, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) + return dt; + + return DateTime.TryParse(episode.Date, CultureInfo.InvariantCulture, DateTimeStyles.None, out dt) ? dt : null; + } + } +} diff --git a/StarLight/ModInit.cs b/StarLight/ModInit.cs new file mode 100644 index 0000000..dea2df8 --- /dev/null +++ b/StarLight/ModInit.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Module; +using Shared.Models.Online.Settings; + +namespace StarLight +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings StarLight; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => StarLight; + set => StarLight = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + StarLight = new OnlinesSettings("StarLight", "https://tp-back.starlight.digital", streamproxy: false, useproxy: false) + { + displayname = "StarLight", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + var conf = ModuleInvoke.Conf("StarLight", StarLight); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + StarLight = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, StarLight); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + StarLight.streamproxy = false; + } + else if (StarLight.streamproxy) + { + StarLight.apnstream = false; + StarLight.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("starlight"); + } + } +} diff --git a/StarLight/Models/StarLightModels.cs b/StarLight/Models/StarLightModels.cs new file mode 100644 index 0000000..4f88897 --- /dev/null +++ b/StarLight/Models/StarLightModels.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace StarLight.Models +{ + public class SearchResult + { + public string Title { get; set; } + public string Type { get; set; } + public string Href { get; set; } + public string Channel { get; set; } + public string Project { get; set; } + } + + public class SeasonInfo + { + public string Title { get; set; } + public string Slug { get; set; } + } + + public class EpisodeInfo + { + public string Title { get; set; } + public string Hash { get; set; } + public string VideoSlug { get; set; } + public string Date { get; set; } + public string SeasonSlug { get; set; } + public int? Number { get; set; } + } + + public class ProjectInfo + { + public string Title { get; set; } + public string Description { get; set; } + public string Poster { get; set; } + public string Hash { get; set; } + public string Type { get; set; } + public string Channel { get; set; } + public List Seasons { get; set; } = new(); + public List Episodes { get; set; } = new(); + } + + public class StreamResult + { + public string Stream { get; set; } + public string Poster { get; set; } + public string Name { get; set; } + public List<(string link, string quality)> Streams { get; set; } + } +} diff --git a/StarLight/OnlineApi.cs b/StarLight/OnlineApi.cs new file mode 100644 index 0000000..80d91fc --- /dev/null +++ b/StarLight/OnlineApi.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System; +using System.Collections.Generic; + +namespace StarLight +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + if (!string.Equals(original_language, "uk", StringComparison.OrdinalIgnoreCase)) + return online; + + var init = ModInit.StarLight; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/starlight"; + + online.Add((init.displayname, url, "starlight", init.displayindex)); + } + + return online; + } + } +} diff --git a/StarLight/StarLight.csproj b/StarLight/StarLight.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/StarLight/StarLight.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/StarLight/StarLightInvoke.cs b/StarLight/StarLightInvoke.cs new file mode 100644 index 0000000..5e909b5 --- /dev/null +++ b/StarLight/StarLightInvoke.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using StarLight.Models; + +namespace StarLight +{ + public class StarLightInvoke + { + private const string PlayerApi = "https://vcms-api2.starlight.digital/player-api"; + private const string PlayerReferer = "https://teleportal.ua/"; + private const string Language = "ua"; + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + public StarLightInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string title, string original_title) + { + string query = !string.IsNullOrEmpty(title) ? title : original_title; + if (string.IsNullOrEmpty(query)) + return null; + + string memKey = $"StarLight:search:{query}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + string url = $"{_init.host}/{Language}/live-search?q={HttpUtility.UrlEncode(query)}"; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + try + { + _onLog?.Invoke($"StarLight search: {url}"); + string payload = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(payload)) + return null; + + var results = new List(); + using var document = JsonDocument.Parse(payload); + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in document.RootElement.EnumerateArray()) + { + string typeSlug = item.TryGetProperty("typeSlug", out var typeProp) ? typeProp.GetString() : null; + string channelSlug = item.TryGetProperty("channelSlug", out var channelProp) ? channelProp.GetString() : null; + string projectSlug = item.TryGetProperty("projectSlug", out var projectProp) ? projectProp.GetString() : null; + if (string.IsNullOrEmpty(typeSlug) || string.IsNullOrEmpty(channelSlug) || string.IsNullOrEmpty(projectSlug)) + continue; + + string href = $"{_init.host}/{Language}/{typeSlug}/{channelSlug}/{projectSlug}"; + results.Add(new SearchResult + { + Title = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null, + Type = typeSlug, + Href = href, + Channel = channelSlug, + Project = projectSlug + }); + } + } + + if (results.Count > 0) + _hybridCache.Set(memKey, results, cacheTime(15, init: _init)); + + return results; + } + catch (Exception ex) + { + _onLog?.Invoke($"StarLight search error: {ex.Message}"); + return null; + } + } + + public async Task GetProject(string href) + { + if (string.IsNullOrEmpty(href)) + return null; + + string memKey = $"StarLight:project:{href}"; + if (_hybridCache.TryGetValue(memKey, out ProjectInfo cached)) + return cached; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + try + { + _onLog?.Invoke($"StarLight project: {href}"); + string payload = await Http.Get(href, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(payload)) + return null; + + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + + var project = new ProjectInfo + { + Title = root.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null, + Description = root.TryGetProperty("description", out var descProp) ? descProp.GetString() : null, + Poster = NormalizeImage(root.TryGetProperty("image", out var imageProp) ? imageProp.GetString() : null), + Hash = root.TryGetProperty("hash", out var hashProp) ? hashProp.GetString() : null, + Type = root.TryGetProperty("typeSlug", out var typeProp) ? typeProp.GetString() : null, + Channel = root.TryGetProperty("channelTitle", out var channelProp) ? channelProp.GetString() : null + }; + + if (root.TryGetProperty("seasons", out var seasonsListProp) && seasonsListProp.ValueKind == JsonValueKind.Array) + { + foreach (var seasonItem in seasonsListProp.EnumerateArray()) + { + string seasonTitle = seasonItem.TryGetProperty("title", out var sTitle) ? sTitle.GetString() : null; + string seasonSlug = seasonItem.TryGetProperty("seasonSlug", out var sSlug) ? sSlug.GetString() : null; + AddSeason(project, seasonTitle, seasonSlug); + } + } + + if (root.TryGetProperty("seasonsGallery", out var seasonsProp) && seasonsProp.ValueKind == JsonValueKind.Array) + { + foreach (var seasonItem in seasonsProp.EnumerateArray()) + { + string seasonTitle = seasonItem.TryGetProperty("title", out var sTitle) ? sTitle.GetString() : null; + string seasonSlug = seasonItem.TryGetProperty("seasonSlug", out var sSlug) ? sSlug.GetString() : null; + AddSeason(project, seasonTitle, seasonSlug); + + if (seasonItem.TryGetProperty("items", out var itemsProp) && itemsProp.ValueKind == JsonValueKind.Array) + { + foreach (var item in itemsProp.EnumerateArray()) + { + project.Episodes.Add(new EpisodeInfo + { + Title = item.TryGetProperty("title", out var eTitle) ? eTitle.GetString() : null, + Hash = item.TryGetProperty("hash", out var eHash) ? eHash.GetString() : null, + VideoSlug = item.TryGetProperty("videoSlug", out var eSlug) ? eSlug.GetString() : null, + Date = item.TryGetProperty("dateOfBroadcast", out var eDate) ? eDate.GetString() : (item.TryGetProperty("timeUploadVideo", out var eDate2) ? eDate2.GetString() : null), + SeasonSlug = seasonSlug, + Number = ParseEpisodeNumber(item.TryGetProperty("seriesTitle", out var eSeries) ? eSeries.GetString() : null) + }); + } + } + } + } + + await LoadMissingSeasonEpisodes(project, href, headers); + + _hybridCache.Set(memKey, project, cacheTime(10, init: _init)); + return project; + } + catch (Exception ex) + { + _onLog?.Invoke($"StarLight project error: {ex.Message}"); + return null; + } + } + + private async Task LoadMissingSeasonEpisodes(ProjectInfo project, string href, List headers) + { + if (project == null || string.IsNullOrEmpty(href)) + return; + + var missing = project.Seasons + .Where(s => !string.IsNullOrEmpty(s.Slug)) + .Where(s => !project.Episodes.Any(e => e.SeasonSlug == s.Slug)) + .ToList(); + + foreach (var seasonInfo in missing) + { + string seasonUrl = $"{href}/{seasonInfo.Slug}"; + try + { + _onLog?.Invoke($"StarLight season: {seasonUrl}"); + string payload = await Http.Get(seasonUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(payload)) + continue; + + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + + if (root.TryGetProperty("items", out var itemsProp) && itemsProp.ValueKind == JsonValueKind.Array) + { + foreach (var item in itemsProp.EnumerateArray()) + { + string hash = item.TryGetProperty("hash", out var eHash) ? eHash.GetString() : null; + if (string.IsNullOrEmpty(hash)) + continue; + + if (project.Episodes.Any(e => e.SeasonSlug == seasonInfo.Slug && e.Hash == hash)) + continue; + + project.Episodes.Add(new EpisodeInfo + { + Title = item.TryGetProperty("title", out var eTitle) ? eTitle.GetString() : null, + Hash = hash, + VideoSlug = item.TryGetProperty("videoSlug", out var eSlug) ? eSlug.GetString() : null, + Date = item.TryGetProperty("dateOfBroadcast", out var eDate) ? eDate.GetString() : (item.TryGetProperty("timeUploadVideo", out var eDate2) ? eDate2.GetString() : null), + SeasonSlug = seasonInfo.Slug, + Number = ParseEpisodeNumber(item.TryGetProperty("seriesTitle", out var eSeries) ? eSeries.GetString() : null) + }); + } + } + } + catch (Exception ex) + { + _onLog?.Invoke($"StarLight season error: {ex.Message}"); + } + } + } + + private static void AddSeason(ProjectInfo project, string title, string slug) + { + if (project == null || string.IsNullOrEmpty(slug)) + return; + + if (project.Seasons.Any(s => s.Slug == slug)) + return; + + project.Seasons.Add(new SeasonInfo { Title = title, Slug = slug }); + } + + public List GetEpisodes(ProjectInfo project, string seasonSlug) + { + if (project == null || project.Seasons.Count == 0) + return new List(); + + if (string.IsNullOrEmpty(seasonSlug)) + return project.Episodes; + + return project.Episodes.Where(e => e.SeasonSlug == seasonSlug).ToList(); + } + + private static int? ParseEpisodeNumber(string value) + { + if (string.IsNullOrEmpty(value)) + return null; + + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) + return number; + + return null; + } + + public async Task ResolveStream(string hash) + { + if (string.IsNullOrEmpty(hash)) + return null; + + string url = $"{PlayerApi}/{hash}?referer={HttpUtility.UrlEncode(PlayerReferer)}&lang={Language}"; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", PlayerReferer) + }; + + try + { + _onLog?.Invoke($"StarLight stream: {url}"); + string payload = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(payload)) + return null; + + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + + string stream = null; + if (root.TryGetProperty("video", out var videoProp) && videoProp.ValueKind == JsonValueKind.Array) + { + var video = videoProp.EnumerateArray().FirstOrDefault(); + if (video.ValueKind != JsonValueKind.Undefined) + { + if (video.TryGetProperty("mediaHlsNoAdv", out var hlsNoAdv)) + stream = hlsNoAdv.GetString(); + if (string.IsNullOrEmpty(stream) && video.TryGetProperty("mediaHls", out var hls)) + stream = hls.GetString(); + if (string.IsNullOrEmpty(stream) && video.TryGetProperty("media", out var mediaProp) && mediaProp.ValueKind == JsonValueKind.Array) + { + var media = mediaProp.EnumerateArray().FirstOrDefault(); + if (media.TryGetProperty("url", out var mediaUrl)) + stream = mediaUrl.GetString(); + } + } + } + + if (string.IsNullOrEmpty(stream)) + return null; + + var multiStreams = ParseMultiHlsStreams(stream); + if (multiStreams != null && multiStreams.Count > 0) + stream = multiStreams[0].link; + + return new StreamResult + { + Stream = stream, + Poster = root.TryGetProperty("poster", out var posterProp) ? posterProp.GetString() : null, + Name = root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null, + Streams = multiStreams + }; + } + catch (Exception ex) + { + _onLog?.Invoke($"StarLight stream error: {ex.Message}"); + return null; + } + } + + private string NormalizeImage(string path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return path; + + return $"{_init.host}{path}"; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + + private static List<(string link, string quality)> ParseMultiHlsStreams(string streamUrl) + { + if (string.IsNullOrEmpty(streamUrl)) + return null; + + if (!streamUrl.Contains("/hls/multi", StringComparison.OrdinalIgnoreCase)) + return null; + + if (!Uri.TryCreate(streamUrl, UriKind.Absolute, out var uri)) + return null; + + var query = HttpUtility.ParseQueryString(uri.Query); + var files = query.GetValues("file"); + if (files == null || files.Length == 0) + return null; + + var result = new List<(string link, string quality)>(files.Length); + var qualityCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in files) + { + if (string.IsNullOrEmpty(file)) + continue; + + var decoded = HttpUtility.UrlDecode(file); + var quality = DetectQuality(decoded); + quality = EnsureUniqueQuality(quality, qualityCounts); + result.Add((decoded, quality)); + } + + return result.Count > 0 ? result : null; + } + + private static string DetectQuality(string url) + { + if (string.IsNullOrEmpty(url)) + return "auto"; + + var lower = url.ToLowerInvariant(); + if (lower.Contains("/lq.")) + return "360"; + if (lower.Contains("/mq.")) + return "480"; + if (lower.Contains("/hq.")) + return "720"; + if (lower.Contains("/sd.")) + return "480"; + if (lower.Contains("/hd.")) + return "720"; + + var match = Regex.Match(lower, "(\\d{3,4})p"); + if (match.Success) + return match.Groups[1].Value; + + return "auto"; + } + + private static string EnsureUniqueQuality(string quality, Dictionary counts) + { + if (string.IsNullOrEmpty(quality)) + quality = "auto"; + + if (!counts.TryGetValue(quality, out var count)) + { + counts[quality] = 1; + return quality; + } + + count++; + counts[quality] = count; + return $"{quality}_{count}"; + } + } +} diff --git a/StarLight/UpdateService.cs b/StarLight/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/StarLight/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/StarLight/manifest.json b/StarLight/manifest.json new file mode 100644 index 0000000..726195a --- /dev/null +++ b/StarLight/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "StarLight.ModInit", + "online": "StarLight.OnlineApi" +} \ No newline at end of file diff --git a/UAKino/ApnHelper.cs b/UAKino/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/UAKino/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/UAKino/Controller.cs b/UAKino/Controller.cs new file mode 100644 index 0000000..84253b1 --- /dev/null +++ b/UAKino/Controller.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; +using UAKino.Models; + +namespace UAKino.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.UAKino); + } + + [HttpGet] + [Route("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, bool rjson = false, string href = null) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.UAKino); + if (!init.enable) + return Forbid(); + + var invoke = new UAKinoInvoke(init, hybridCache, OnLog, proxyManager); + + string itemUrl = href; + if (string.IsNullOrEmpty(itemUrl)) + { + var searchResults = await invoke.Search(title, original_title, serial); + if (searchResults == null || searchResults.Count == 0) + return OnError("uakino", proxyManager); + + if (searchResults.Count > 1) + { + var similar_tpl = new SimilarTpl(searchResults.Count); + foreach (var res in searchResults) + { + string link = $"{host}/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, string.Empty, string.Empty, 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; + } + + if (serial == 1) + { + var playlist = await invoke.GetPlaylist(itemUrl); + if (playlist == null || playlist.Count == 0) + return OnError("uakino", proxyManager); + + var voiceGroups = playlist + .GroupBy(p => string.IsNullOrEmpty(p.Voice) ? "Основне" : p.Voice) + .Select(g => (key: g.Key, episodes: g.ToList())) + .ToList(); + + if (voiceGroups.Count == 0) + return OnError("uakino", proxyManager); + + if (string.IsNullOrEmpty(t)) + t = voiceGroups.First().key; + + var voice_tpl = new VoiceTpl(); + foreach (var voice in voiceGroups) + { + string voiceLink = $"{host}/uakino?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&t={HttpUtility.UrlEncode(voice.key)}&href={HttpUtility.UrlEncode(itemUrl)}"; + voice_tpl.Append(voice.key, voice.key == t, voiceLink); + } + + var selected = voiceGroups.FirstOrDefault(v => v.key == t); + if (selected.episodes == null || selected.episodes.Count == 0) + return OnError("uakino", proxyManager); + + var episode_tpl = new EpisodeTpl(); + int index = 1; + foreach (var ep in selected.episodes.OrderBy(e => UAKinoInvoke.TryParseEpisodeNumber(e.Title) ?? int.MaxValue)) + { + int episodeNumber = UAKinoInvoke.TryParseEpisodeNumber(ep.Title) ?? index; + string episodeName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {episodeNumber}" : ep.Title; + string callUrl = $"{host}/uakino/play?url={HttpUtility.UrlEncode(ep.Url)}&title={HttpUtility.UrlEncode(title ?? original_title)}"; + episode_tpl.Append(episodeName, title ?? original_title, "1", episodeNumber.ToString("D2"), accsArgs(callUrl), "call"); + index++; + } + + if (rjson) + return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8"); + + return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else + { + string playerUrl = await invoke.GetPlayerUrl(itemUrl); + if (string.IsNullOrEmpty(playerUrl)) + { + var playlist = await invoke.GetPlaylist(itemUrl); + playerUrl = playlist?.FirstOrDefault()?.Url; + } + + if (string.IsNullOrEmpty(playerUrl)) + return OnError("uakino", proxyManager); + + var movie_tpl = new MovieTpl(title, original_title); + string callUrl = $"{host}/uakino/play?url={HttpUtility.UrlEncode(playerUrl)}&title={HttpUtility.UrlEncode(title ?? original_title)}"; + movie_tpl.Append(string.IsNullOrEmpty(title) ? "UAKino" : title, accsArgs(callUrl), "call"); + + return rjson ? Content(movie_tpl.ToJson(), "application/json; charset=utf-8") : Content(movie_tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + [HttpGet] + [Route("uakino/play")] + async public Task Play(string url, string title) + { + await UpdateService.ConnectAsync(host); + + if (string.IsNullOrEmpty(url)) + return OnError("uakino", proxyManager); + + var init = await loadKit(ModInit.UAKino); + if (!init.enable) + return Forbid(); + + var invoke = new UAKinoInvoke(init, hybridCache, OnLog, proxyManager); + var result = await invoke.ParsePlayer(url); + if (result == null || string.IsNullOrEmpty(result.File)) + return OnError("uakino", proxyManager); + + string streamUrl = BuildStreamUrl(init, result.File); + string jsonResult = $"{{\"method\":\"play\",\"url\":\"{streamUrl}\",\"title\":\"{title ?? ""}\"}}"; + return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = accsArgs(streamLink); + 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, proxy: proxyManager.Get()); + } + + return HostStreamProxy(init, link, proxy: proxyManager.Get()); + } + } +} diff --git a/UAKino/ModInit.cs b/UAKino/ModInit.cs new file mode 100644 index 0000000..a664345 --- /dev/null +++ b/UAKino/ModInit.cs @@ -0,0 +1,62 @@ +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +namespace UAKino +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings UAKino; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => UAKino; + set => UAKino = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + UAKino = new OnlinesSettings("UAKino", "https://uakino.best", 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 conf = ModuleInvoke.Conf("UAKino", UAKino); + 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); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + UAKino.streamproxy = false; + } + else if (UAKino.streamproxy) + { + UAKino.apnstream = false; + UAKino.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("uakino"); + } + } +} diff --git a/UAKino/Models/UAKinoModels.cs b/UAKino/Models/UAKinoModels.cs new file mode 100644 index 0000000..4b2f6ea --- /dev/null +++ b/UAKino/Models/UAKinoModels.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace UAKino.Models +{ + public class SearchResult + { + public string Title { get; set; } + public string Url { get; set; } + public string Poster { get; set; } + public string Season { get; set; } + } + + public class PlaylistItem + { + public string Title { get; set; } + public string Url { get; set; } + public string Voice { get; set; } + } + + public class SubtitleInfo + { + public string Lang { get; set; } + public string Url { get; set; } + } + + public class PlayerResult + { + public string File { get; set; } + public List Subtitles { get; set; } = new(); + } +} diff --git a/UAKino/OnlineApi.cs b/UAKino/OnlineApi.cs new file mode 100644 index 0000000..17a1980 --- /dev/null +++ b/UAKino/OnlineApi.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace UAKino +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.UAKino; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/uakino"; + + online.Add((init.displayname, url, "uakino", init.displayindex)); + } + + return online; + } + } +} diff --git a/UAKino/UAKino.csproj b/UAKino/UAKino.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/UAKino/UAKino.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/UAKino/UAKinoInvoke.cs b/UAKino/UAKinoInvoke.cs new file mode 100644 index 0000000..fc45d4a --- /dev/null +++ b/UAKino/UAKinoInvoke.cs @@ -0,0 +1,498 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using HtmlAgilityPack; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using UAKino.Models; + +namespace UAKino +{ + public class UAKinoInvoke + { + private const string PlaylistPath = "/engine/ajax/playlists.php"; + private const string PlaylistField = "playlist"; + private const string BlacklistRegex = "(/news/)|(/franchise/)"; + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + public UAKinoInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string title, string original_title, int serial) + { + var queries = new List(); + if (!string.IsNullOrEmpty(title)) + queries.Add(title); + if (!string.IsNullOrEmpty(original_title) && !queries.Contains(original_title)) + queries.Add(original_title); + + if (queries.Count == 0) + return null; + + string memKey = $"UAKino:search:{string.Join("|", queries)}:{serial}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var query in queries) + { + try + { + string searchUrl = $"{_init.host}/index.php?do=search&subaction=search&story={HttpUtility.UrlEncode(query)}"; + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + _onLog?.Invoke($"UAKino search: {searchUrl}"); + string html = await GetString(searchUrl, headers); + if (string.IsNullOrEmpty(html)) + continue; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var nodes = doc.DocumentNode.SelectNodes("//div[contains(@class,'movie-item') and contains(@class,'short-item')]"); + if (nodes == null) + continue; + + foreach (var node in nodes) + { + var titleNode = node.SelectSingleNode(".//a[contains(@class,'movie-title')] | .//a[contains(@class,'full-movie')]"); + string itemTitle = CleanText(titleNode?.InnerText); + string href = NormalizeUrl(titleNode?.GetAttributeValue("href", "")); + if (string.IsNullOrEmpty(itemTitle)) + { + var altTitle = node.SelectSingleNode(".//div[contains(@class,'full-movie-title')]"); + itemTitle = CleanText(altTitle?.InnerText); + } + + if (string.IsNullOrEmpty(itemTitle) || string.IsNullOrEmpty(href) || IsBlacklisted(href)) + continue; + + if (serial == 1 && !IsSeriesUrl(href)) + continue; + if (serial == 0 && !IsMovieUrl(href)) + continue; + + string seasonText = CleanText(node.SelectSingleNode(".//div[contains(@class,'full-season')]")?.InnerText); + if (!string.IsNullOrEmpty(seasonText) && !itemTitle.Contains(seasonText, StringComparison.OrdinalIgnoreCase)) + itemTitle = $"{itemTitle} ({seasonText})"; + + string poster = ExtractPoster(node); + + if (seen.Contains(href)) + continue; + seen.Add(href); + + results.Add(new SearchResult + { + Title = itemTitle, + Url = href, + Poster = poster, + Season = seasonText + }); + } + + if (results.Count > 0) + break; + } + catch (Exception ex) + { + _onLog?.Invoke($"UAKino search error: {ex.Message}"); + } + } + + if (results.Count > 0) + _hybridCache.Set(memKey, results, cacheTime(20, init: _init)); + + return results; + } + + public async Task> GetPlaylist(string href) + { + string newsId = ExtractNewsId(href); + if (string.IsNullOrEmpty(newsId)) + return null; + + string memKey = $"UAKino:playlist:{newsId}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + string url = BuildPlaylistUrl(newsId); + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", href ?? _init.host), + new HeadersModel("X-Requested-With", "XMLHttpRequest") + }; + + try + { + _onLog?.Invoke($"UAKino playlist: {url}"); + string payload = await GetString(url, headers); + if (string.IsNullOrEmpty(payload)) + return null; + + using var document = JsonDocument.Parse(payload); + if (!document.RootElement.TryGetProperty("success", out var successProp) || !successProp.GetBoolean()) + return null; + + if (!document.RootElement.TryGetProperty("response", out var responseProp)) + return null; + + string html = responseProp.GetString(); + if (string.IsNullOrEmpty(html)) + return null; + + var items = ParsePlaylistHtml(html); + if (items.Count > 0) + _hybridCache.Set(memKey, items, cacheTime(10, init: _init)); + + return items; + } + catch (Exception ex) + { + _onLog?.Invoke($"UAKino playlist error: {ex.Message}"); + return null; + } + } + + public async Task GetPlayerUrl(string href) + { + if (string.IsNullOrEmpty(href)) + return null; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + try + { + _onLog?.Invoke($"UAKino movie page: {href}"); + string html = await GetString(href, headers); + if (string.IsNullOrEmpty(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var playlistNode = doc.DocumentNode.SelectSingleNode($"//div[contains(@class,'playlists-ajax') and @data-xfname='{PlaylistField}']"); + if (playlistNode != null) + return null; + + var iframe = doc.DocumentNode.SelectSingleNode("//iframe[@id='pre' and not(ancestor::*[@id='overroll'])]") ?? + doc.DocumentNode.SelectSingleNode("//iframe[@id='pre']"); + if (iframe == null) + return null; + + string src = iframe.GetAttributeValue("src", ""); + if (string.IsNullOrEmpty(src)) + src = iframe.GetAttributeValue("data-src", ""); + + if (src.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || + src.Contains("youtu.be", StringComparison.OrdinalIgnoreCase)) + return null; + + return NormalizeUrl(src); + } + catch (Exception ex) + { + _onLog?.Invoke($"UAKino player url error: {ex.Message}"); + return null; + } + } + + public async Task ParsePlayer(string url) + { + if (string.IsNullOrEmpty(url)) + return null; + + if (LooksLikeDirectStream(url)) + { + return new PlayerResult { File = url }; + } + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + try + { + _onLog?.Invoke($"UAKino parse player: {url}"); + string html = await GetString(url, headers); + if (string.IsNullOrEmpty(html)) + return null; + + string file = ExtractPlayerFile(html); + if (string.IsNullOrEmpty(file)) + return null; + + return new PlayerResult + { + File = NormalizeUrl(file), + Subtitles = ExtractSubtitles(html) + }; + } + catch (Exception ex) + { + _onLog?.Invoke($"UAKino parse player error: {ex.Message}"); + return null; + } + } + + private async Task GetString(string url, List headers, int timeoutSeconds = 15) + { + string requestUrl = ApnHelper.IsAshdiUrl(url) && ApnHelper.IsEnabled(_init) + ? ApnHelper.WrapUrl(_init, url) + : url; + + var handler = new SocketsHttpHandler + { + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate, + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + var proxy = _proxyManager.Get(); + if (proxy != null) + { + handler.UseProxy = true; + handler.Proxy = proxy; + } + else + { + handler.UseProxy = false; + } + + using var client = new HttpClient(handler); + using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl); + + if (headers != null) + { + foreach (var h in headers) + req.Headers.TryAddWithoutValidation(h.name, h.val); + } + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds))); + using var response = await client.SendAsync(req, cts.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + return await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + } + + private List ParsePlaylistHtml(string html) + { + var items = new List(); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var nodes = doc.DocumentNode.SelectNodes("//li[@data-file]"); + if (nodes == null) + return items; + + foreach (var node in nodes) + { + string dataFile = node.GetAttributeValue("data-file", ""); + if (string.IsNullOrEmpty(dataFile)) + continue; + + string title = CleanText(node.InnerText); + string voice = node.GetAttributeValue("data-voice", ""); + + items.Add(new PlaylistItem + { + Title = string.IsNullOrEmpty(title) ? "Episode" : title, + Url = NormalizeUrl(dataFile), + Voice = voice + }); + } + + return items; + } + + private string BuildPlaylistUrl(string newsId) + { + long ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return $"{_init.host}{PlaylistPath}?news_id={newsId}&xfield={PlaylistField}&time={ts}"; + } + + private static string ExtractNewsId(string href) + { + if (string.IsNullOrEmpty(href)) + return null; + + string tail = href.TrimEnd('/').Split('/').LastOrDefault(); + if (string.IsNullOrEmpty(tail)) + return null; + + string newsId = tail.Split('-')[0]; + return string.IsNullOrEmpty(newsId) ? null : newsId; + } + + private static string ExtractPlayerFile(string html) + { + var match = Regex.Match(html, "file\\s*:\\s*['\"]([^'\"]+)['\"]", RegexOptions.IgnoreCase); + if (match.Success) + { + string value = match.Groups[1].Value.Trim(); + if (!value.StartsWith("[", StringComparison.Ordinal)) + return value; + } + + var sourceMatch = Regex.Match(html, "]+src=['\"]([^'\"]+)['\"]", RegexOptions.IgnoreCase); + if (sourceMatch.Success) + return sourceMatch.Groups[1].Value; + + var m3u8Match = Regex.Match(html, "(https?://[^\"'\\s>]+\\.m3u8[^\"'\\s>]*)", RegexOptions.IgnoreCase); + if (m3u8Match.Success) + return m3u8Match.Groups[1].Value; + + return null; + } + + private List ExtractSubtitles(string html) + { + var subtitles = new List(); + var match = Regex.Match(html, "subtitle\\s*:\\s*['\"]([^'\"]+)['\"]", RegexOptions.IgnoreCase); + if (!match.Success) + return subtitles; + + string value = match.Groups[1].Value.Trim(); + if (string.IsNullOrEmpty(value)) + return subtitles; + + if (value.StartsWith("[", StringComparison.Ordinal) && value.Contains(']')) + { + int endIdx = value.LastIndexOf(']'); + string label = value.Substring(1, endIdx - 1).Trim(); + string url = value[(endIdx + 1)..].Trim(); + url = NormalizeUrl(url); + if (!string.IsNullOrEmpty(url)) + subtitles.Add(new SubtitleInfo { Lang = string.IsNullOrEmpty(label) ? "unknown" : label, Url = url }); + } + else if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + subtitles.Add(new SubtitleInfo { Lang = "unknown", Url = value }); + } + + return subtitles; + } + + 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 bool LooksLikeDirectStream(string url) + { + return url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) || url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBlacklisted(string url) + { + return Regex.IsMatch(url ?? string.Empty, BlacklistRegex, RegexOptions.IgnoreCase); + } + + private static bool IsSeriesUrl(string url) + { + return url.Contains("/seriesss/") || url.Contains("/anime-series/") || url.Contains("/cartoonseries/"); + } + + private static bool IsMovieUrl(string url) + { + return url.Contains("/filmy/") || url.Contains("/anime-solo/") || url.Contains("/features/"); + } + + private string ExtractPoster(HtmlNode node) + { + var img = node.SelectSingleNode(".//img"); + if (img == null) + return string.Empty; + + string src = img.GetAttributeValue("src", ""); + if (string.IsNullOrEmpty(src)) + src = img.GetAttributeValue("data-src", ""); + + return NormalizeUrl(src); + } + + private static string CleanText(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return HtmlEntity.DeEntitize(value).Trim(); + } + + 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 number)) + return number; + + return null; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + + public static int? TryParseEpisodeNumber(string title) + { + return ExtractEpisodeNumber(title); + } + } +} diff --git a/UAKino/UpdateService.cs b/UAKino/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/UAKino/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/UAKino/manifest.json b/UAKino/manifest.json new file mode 100644 index 0000000..ec31836 --- /dev/null +++ b/UAKino/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "UAKino.ModInit", + "online": "UAKino.OnlineApi" +} \ No newline at end of file diff --git a/UaTUT/ApnHelper.cs b/UaTUT/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/UaTUT/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/UaTUT/Controller.cs b/UaTUT/Controller.cs new file mode 100644 index 0000000..80494f1 --- /dev/null +++ b/UaTUT/Controller.cs @@ -0,0 +1,450 @@ +using Shared.Engine; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Web; +using System.Linq; +using Shared; +using Shared.Models.Templates; +using UaTUT.Models; +using System.Text.RegularExpressions; +using Shared.Models.Online.Settings; +using Shared.Models; + +namespace UaTUT +{ + [Route("uatut")] + public class UaTUTController : BaseOnlineController + { + ProxyManager proxyManager; + + public UaTUTController() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.UaTUT); + } + + [HttpGet] + 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, int season = -1, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.UaTUT); + if (!init.enable) + return OnError(); + Initialization(init); + + OnLog($"UaTUT: {title} (serial={serial}, s={s}, season={season}, t={t})"); + + var invoke = new UaTUTInvoke(init, hybridCache, OnLog, proxyManager); + + // Використовуємо кеш для пошуку, щоб уникнути дублювання запитів + string searchCacheKey = $"uatut:search:{imdb_id ?? original_title ?? title}"; + var searchResults = await InvokeCache>(searchCacheKey, TimeSpan.FromMinutes(10), async () => + { + return await invoke.Search(original_title ?? title, imdb_id); + }); + + if (searchResults == null || !searchResults.Any()) + { + OnLog("UaTUT: No search results found"); + return OnError(); + } + + if (serial == 1) + { + return await HandleSeries(searchResults, imdb_id, kinopoisk_id, title, original_title, year, s, season, t, rjson, invoke); + } + else + { + return await HandleMovie(searchResults, rjson, invoke); + } + } + + private async Task HandleSeries(List searchResults, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int s, int season, string t, bool rjson, UaTUTInvoke invoke) + { + var init = ModInit.UaTUT; + + // Фільтруємо тільки серіали та аніме + var seriesResults = searchResults.Where(r => r.Category == "Серіал" || r.Category == "Аніме").ToList(); + + if (!seriesResults.Any()) + { + OnLog("UaTUT: No series found in search results"); + return OnError(); + } + + if (s == -1) // Крок 1: Відображення списку серіалів + { + var season_tpl = new SeasonTpl(); + for (int i = 0; i < seriesResults.Count; i++) + { + var series = seriesResults[i]; + string seasonName = $"{series.Title} ({series.Year})"; + string link = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={i}"; + season_tpl.Append(seasonName, link, i.ToString()); + } + + OnLog($"UaTUT: generated {seriesResults.Count} series options"); + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else if (season == -1) // Крок 2: Відображення сезонів для вибраного серіалу + { + if (s >= seriesResults.Count) + return OnError(); + + var selectedSeries = seriesResults[s]; + + // Використовуємо кеш для уникнення повторних запитів + string cacheKey = $"uatut:player_data:{selectedSeries.Id}"; + var playerData = await InvokeCache(cacheKey, TimeSpan.FromMinutes(10), async () => + { + return await GetPlayerDataCached(selectedSeries, invoke); + }); + + if (playerData?.Voices == null || !playerData.Voices.Any()) + return OnError(); + + // Використовуємо першу озвучку для отримання списку сезонів + var firstVoice = playerData.Voices.First(); + + var season_tpl = new SeasonTpl(); + for (int i = 0; i < firstVoice.Seasons.Count; i++) + { + var seasonItem = firstVoice.Seasons[i]; + string seasonName = seasonItem.Title ?? $"Сезон {i + 1}"; + string link = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&season={i}"; + season_tpl.Append(seasonName, link, i.ToString()); + } + + OnLog($"UaTUT: found {firstVoice.Seasons.Count} seasons"); + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); + } + else // Крок 3: Відображення озвучок та епізодів для вибраного сезону + { + if (s >= seriesResults.Count) + return OnError(); + + var selectedSeries = seriesResults[s]; + + // Використовуємо той самий кеш + string cacheKey = $"uatut:player_data:{selectedSeries.Id}"; + var playerData = await InvokeCache(cacheKey, TimeSpan.FromMinutes(10), async () => + { + return await GetPlayerDataCached(selectedSeries, invoke); + }); + + if (playerData?.Voices == null || !playerData.Voices.Any()) + return OnError(); + + // Перевіряємо чи існує вибраний сезон + if (season >= playerData.Voices.First().Seasons.Count) + return OnError(); + + var voice_tpl = new VoiceTpl(); + var episode_tpl = new EpisodeTpl(); + + // Автоматично вибираємо першу озвучку якщо не вибрана + string selectedVoice = t; + if (string.IsNullOrEmpty(selectedVoice) && playerData.Voices.Any()) + { + selectedVoice = "0"; // Перша озвучка + } + + // Додаємо всі озвучки + for (int i = 0; i < playerData.Voices.Count; i++) + { + var voice = playerData.Voices[i]; + string voiceName = voice.Name ?? $"Озвучка {i + 1}"; + string voiceLink = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&season={season}&t={i}"; + bool isActive = selectedVoice == i.ToString(); + voice_tpl.Append(voiceName, isActive, voiceLink); + } + + // Додаємо епізоди тільки для вибраного сезону та озвучки + if (!string.IsNullOrEmpty(selectedVoice) && int.TryParse(selectedVoice, out int voiceIndex) && voiceIndex < playerData.Voices.Count) + { + var selectedVoiceData = playerData.Voices[voiceIndex]; + + if (season < selectedVoiceData.Seasons.Count) + { + var selectedSeason = selectedVoiceData.Seasons[season]; + + // Сортуємо епізоди та додаємо правильну нумерацію + var sortedEpisodes = selectedSeason.Episodes.OrderBy(e => ExtractEpisodeNumber(e.Title)).ToList(); + + for (int i = 0; i < sortedEpisodes.Count; i++) + { + var episode = sortedEpisodes[i]; + string episodeName = episode.Title; + string episodeFile = episode.File; + + if (!string.IsNullOrEmpty(episodeFile)) + { + // Створюємо прямий лінк на епізод через play action + string episodeLink = $"{host}/uatut/play?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&s={s}&season={season}&t={selectedVoice}&episodeId={episode.Id}"; + + // Використовуємо правильний синтаксис EpisodeTpl.Append без poster параметра + episode_tpl.Append(episodeName, title ?? original_title, season.ToString(), (i + 1).ToString("D2"), episodeLink, "call"); + } + } + } + } + + int voiceCount = playerData.Voices.Count; + int episodeCount = 0; + if (!string.IsNullOrEmpty(selectedVoice) && int.TryParse(selectedVoice, out int vIndex) && vIndex < playerData.Voices.Count) + { + var selectedVoiceData = playerData.Voices[vIndex]; + if (season < selectedVoiceData.Seasons.Count) + { + episodeCount = selectedVoiceData.Seasons[season].Episodes.Count; + } + } + + OnLog($"UaTUT: generated {voiceCount} voices, {episodeCount} episodes"); + + if (rjson) + return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8"); + + return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + // Допоміжний метод для кешованого отримання даних плеєра + private async Task GetPlayerDataCached(SearchResult selectedSeries, UaTUTInvoke invoke) + { + var pageContent = await invoke.GetMoviePageContent(selectedSeries.Id); + if (string.IsNullOrEmpty(pageContent)) + return null; + + var playerUrl = await invoke.GetPlayerUrl(pageContent); + if (string.IsNullOrEmpty(playerUrl)) + return null; + + return await invoke.GetPlayerData(playerUrl); + } + + // Допоміжний метод для витягування номера епізоду з назви + private int ExtractEpisodeNumber(string title) + { + if (string.IsNullOrEmpty(title)) + return 0; + + var match = Regex.Match(title, @"(\d+)"); + return match.Success ? int.Parse(match.Groups[1].Value) : 0; + } + + private async Task HandleMovie(List searchResults, bool rjson, UaTUTInvoke invoke) + { + var init = ModInit.UaTUT; + + // Фільтруємо тільки фільми + var movieResults = searchResults.Where(r => r.Category == "Фільм").ToList(); + + if (!movieResults.Any()) + { + OnLog("UaTUT: No movies found in search results"); + return OnError(); + } + + var movie_tpl = new MovieTpl(title: "UaTUT Movies", original_title: "UaTUT Movies"); + + foreach (var movie in movieResults) + { + var pageContent = await invoke.GetMoviePageContent(movie.Id); + if (string.IsNullOrEmpty(pageContent)) + continue; + + var playerUrl = await invoke.GetPlayerUrl(pageContent); + if (string.IsNullOrEmpty(playerUrl)) + continue; + + var playerData = await invoke.GetPlayerData(playerUrl); + if (playerData?.File == null) + continue; + + string movieName = $"{movie.Title} ({movie.Year})"; + string movieLink = $"{host}/uatut/play/movie?imdb_id={movie.Id}&title={HttpUtility.UrlEncode(movie.Title)}&year={movie.Year}"; + movie_tpl.Append(movieName, movieLink, "call"); + } + + if (movie_tpl.data == null || movie_tpl.data.Count == 0) + { + OnLog("UaTUT: No playable movies found"); + return OnError(); + } + + OnLog($"UaTUT: found {movieResults.Count} movies"); + return rjson ? Content(movie_tpl.ToJson(), "application/json; charset=utf-8") : Content(movie_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + [HttpGet] + [Route("play/movie")] + async public Task PlayMovie(long imdb_id, string title, int year, bool play = false, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.UaTUT); + if (!init.enable) + return OnError(); + Initialization(init); + + OnLog($"UaTUT PlayMovie: {title} ({year}) play={play}"); + + var invoke = new UaTUTInvoke(init, hybridCache, OnLog, proxyManager); + + // Використовуємо кеш для пошуку + string searchCacheKey = $"uatut:search:{title}"; + var searchResults = await InvokeCache>(searchCacheKey, TimeSpan.FromMinutes(10), async () => + { + return await invoke.Search(title, null); + }); + + if (searchResults == null || !searchResults.Any()) + { + OnLog("UaTUT PlayMovie: No search results found"); + return OnError(); + } + + // Шукаємо фільм за ID + var movie = searchResults.FirstOrDefault(r => r.Id == imdb_id.ToString() && r.Category == "Фільм"); + if (movie == null) + { + OnLog("UaTUT PlayMovie: Movie not found"); + return OnError(); + } + + var pageContent = await invoke.GetMoviePageContent(movie.Id); + if (string.IsNullOrEmpty(pageContent)) + return OnError(); + + var playerUrl = await invoke.GetPlayerUrl(pageContent); + if (string.IsNullOrEmpty(playerUrl)) + return OnError(); + + var playerData = await invoke.GetPlayerData(playerUrl); + if (playerData?.File == null) + return OnError(); + + OnLog($"UaTUT PlayMovie: Found direct file: {playerData.File}"); + + string streamUrl = BuildStreamUrl(init, playerData.File); + + // Якщо play=true, робимо Redirect, інакше повертаємо JSON + if (play) + return UpdateService.Validate(Redirect(streamUrl)); + else + return UpdateService.Validate(Content(VideoTpl.ToJson("play", streamUrl, title), "application/json; charset=utf-8")); + } + + [HttpGet] + [Route("play")] + async public Task Play(long id, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int s, int season, string t, string episodeId, bool play = false, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.UaTUT); + if (!init.enable) + return OnError(); + Initialization(init); + + OnLog($"UaTUT Play: {title} (s={s}, season={season}, t={t}, episodeId={episodeId}) play={play}"); + + var invoke = new UaTUTInvoke(init, hybridCache, OnLog, proxyManager); + + // Використовуємо кеш для пошуку + string searchCacheKey = $"uatut:search:{imdb_id ?? original_title ?? title}"; + var searchResults = await InvokeCache>(searchCacheKey, TimeSpan.FromMinutes(10), async () => + { + return await invoke.Search(original_title ?? title, imdb_id); + }); + + if (searchResults == null || !searchResults.Any()) + { + OnLog("UaTUT Play: No search results found"); + return OnError(); + } + + // Фільтруємо тільки серіали та аніме + var seriesResults = searchResults.Where(r => r.Category == "Серіал" || r.Category == "Аніме").ToList(); + + if (!seriesResults.Any() || s >= seriesResults.Count) + { + OnLog("UaTUT Play: No series found or invalid series index"); + return OnError(); + } + + var selectedSeries = seriesResults[s]; + + // Використовуємо той самий кеш як і в HandleSeries + string cacheKey = $"uatut:player_data:{selectedSeries.Id}"; + var playerData = await InvokeCache(cacheKey, TimeSpan.FromMinutes(10), async () => + { + return await GetPlayerDataCached(selectedSeries, invoke); + }); + + if (playerData?.Voices == null || !playerData.Voices.Any()) + { + OnLog("UaTUT Play: No player data or voices found"); + return OnError(); + } + + // Знаходимо потрібний епізод в конкретному сезоні та озвучці + if (int.TryParse(t, out int voiceIndex) && voiceIndex < playerData.Voices.Count) + { + var selectedVoice = playerData.Voices[voiceIndex]; + + if (season >= 0 && season < selectedVoice.Seasons.Count) + { + var selectedSeasonData = selectedVoice.Seasons[season]; + + foreach (var episode in selectedSeasonData.Episodes) + { + if (episode.Id == episodeId && !string.IsNullOrEmpty(episode.File)) + { + OnLog($"UaTUT Play: Found episode {episode.Title}, stream: {episode.File}"); + + string streamUrl = BuildStreamUrl(init, episode.File); + string episodeTitle = $"{title ?? original_title} - {episode.Title}"; + + // Якщо play=true, робимо Redirect, інакше повертаємо JSON + if (play) + return UpdateService.Validate(Redirect(streamUrl)); + else + return UpdateService.Validate(Content(VideoTpl.ToJson("play", streamUrl, episodeTitle), "application/json; charset=utf-8")); + } + } + } + else + { + OnLog($"UaTUT Play: Invalid season {season}, available seasons: {selectedVoice.Seasons.Count}"); + } + } + else + { + OnLog($"UaTUT Play: Invalid voice index {t}, available voices: {playerData.Voices.Count}"); + } + + OnLog("UaTUT Play: Episode not found"); + return OnError(); + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = accsArgs(streamLink); + 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); + } + } +} diff --git a/UaTUT/ModInit.cs b/UaTUT/ModInit.cs new file mode 100644 index 0000000..a30a848 --- /dev/null +++ b/UaTUT/ModInit.cs @@ -0,0 +1,65 @@ +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +namespace UaTUT +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings UaTUT; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => UaTUT; + set => UaTUT = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + UaTUT = new OnlinesSettings("UaTUT", "https://uk.uatut.fun", streamproxy: false, useproxy: false) + { + displayname = "🇺🇦 UaTUT", + displayindex = 0, + apihost = "https://uk.uatut.fun/watch", + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "a", + password = "a", + list = new string[] { "socks5://IP:PORT" } + } + }; + var conf = ModuleInvoke.Conf("UaTUT", UaTUT); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + UaTUT = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, UaTUT); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + UaTUT.streamproxy = false; + } + else if (UaTUT.streamproxy) + { + UaTUT.apnstream = false; + UaTUT.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("uatut"); + } + } +} diff --git a/UaTUT/Models/UaTUTModels.cs b/UaTUT/Models/UaTUTModels.cs new file mode 100644 index 0000000..c9ea23d --- /dev/null +++ b/UaTUT/Models/UaTUTModels.cs @@ -0,0 +1,61 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace UaTUT.Models +{ + public class SearchResult + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("imdb_id")] + public string ImdbId { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("title_alt")] + public string TitleAlt { get; set; } + + [JsonProperty("title_en")] + public string TitleEn { get; set; } + + [JsonProperty("title_ru")] + public string TitleRu { get; set; } + + [JsonProperty("year")] + public string Year { get; set; } + + [JsonProperty("category")] + public string Category { get; set; } + } + + public class PlayerData + { + public string File { get; set; } + public string Poster { get; set; } + public List Voices { get; set; } + public List Seasons { get; set; } // Залишаємо для зворотної сумісності + } + + public class Voice + { + public string Name { get; set; } + public List Seasons { get; set; } + } + + public class Season + { + public string Title { get; set; } + public List Episodes { get; set; } + } + + public class Episode + { + public string Title { get; set; } + public string File { get; set; } + public string Id { get; set; } + public string Poster { get; set; } + public string Subtitle { get; set; } + } +} diff --git a/UaTUT/OnlineApi.cs b/UaTUT/OnlineApi.cs new file mode 100644 index 0000000..f00f93b --- /dev/null +++ b/UaTUT/OnlineApi.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace UaTUT +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.UaTUT; + // UaTUT: змішаний контент (аніме + не-аніме) — завжди включати при enable && !rip + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/uatut"; + + online.Add((init.displayname, url, "uatut", init.displayindex)); + } + + return online; + } + } +} diff --git a/UaTUT/UaTUT.csproj b/UaTUT/UaTUT.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/UaTUT/UaTUT.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/UaTUT/UaTUTInvoke.cs b/UaTUT/UaTUTInvoke.cs new file mode 100644 index 0000000..96ae3d2 --- /dev/null +++ b/UaTUT/UaTUTInvoke.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Newtonsoft.Json; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models; +using UaTUT.Models; + +namespace UaTUT +{ + public class UaTUTInvoke + { + private OnlinesSettings _init; + private IHybridCache _hybridCache; + private Action _onLog; + private ProxyManager _proxyManager; + + public UaTUTInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string query, string imdbId = null) + { + try + { + string searchUrl = $"{_init.apihost}/search.php"; + + // Поступовий пошук: спочатку по imdbId, потім по назві + if (!string.IsNullOrEmpty(imdbId)) + { + var imdbResults = await PerformSearch(searchUrl, imdbId); + if (imdbResults?.Any() == true) + return imdbResults; + } + + // Пошук по назві + if (!string.IsNullOrEmpty(query)) + { + var titleResults = await PerformSearch(searchUrl, query); + return titleResults ?? new List(); + } + + return new List(); + } + catch (Exception ex) + { + _onLog($"UaTUT Search error: {ex.Message}"); + return new List(); + } + } + + private async Task> PerformSearch(string searchUrl, string query) + { + string url = $"{searchUrl}?q={HttpUtility.UrlEncode(query)}"; + _onLog($"UaTUT searching: {url}"); + + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") }; + var response = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + + if (string.IsNullOrEmpty(response)) + return null; + + try + { + var results = JsonConvert.DeserializeObject>(response); + _onLog($"UaTUT found {results?.Count ?? 0} results for query: {query}"); + return results; + } + catch (Exception ex) + { + _onLog($"UaTUT parse error: {ex.Message}"); + return null; + } + } + + public async Task GetMoviePageContent(string movieId) + { + try + { + string url = $"{_init.apihost}/{movieId}"; + _onLog($"UaTUT getting movie page: {url}"); + + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") }; + var response = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + + return response; + } + catch (Exception ex) + { + _onLog($"UaTUT GetMoviePageContent error: {ex.Message}"); + return null; + } + } + + public async Task GetPlayerUrl(string moviePageContent) + { + try + { + // Шукаємо iframe з id="vip-player" та class="tab-content" + var match = Regex.Match(moviePageContent, @"]*id=[""']vip-player[""'][^>]*src=[""']([^""']+)[""']", RegexOptions.IgnoreCase); + if (match.Success) + { + string playerUrl = match.Groups[1].Value; + _onLog($"UaTUT found player URL: {playerUrl}"); + return playerUrl; + } + + _onLog("UaTUT: vip-player iframe not found"); + return null; + } + catch (Exception ex) + { + _onLog($"UaTUT GetPlayerUrl error: {ex.Message}"); + return null; + } + } + + public async Task GetPlayerData(string playerUrl) + { + try + { + string requestUrl = playerUrl; + if (ApnHelper.IsAshdiUrl(playerUrl) && ApnHelper.IsEnabled(_init)) + requestUrl = ApnHelper.WrapUrl(_init, playerUrl); + + _onLog($"UaTUT getting player data from: {requestUrl}"); + + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") }; + var response = await Http.Get(requestUrl, headers: headers, proxy: _proxyManager.Get()); + + if (string.IsNullOrEmpty(response)) + return null; + + return ParsePlayerData(response); + } + catch (Exception ex) + { + _onLog($"UaTUT GetPlayerData error: {ex.Message}"); + return null; + } + } + + private PlayerData ParsePlayerData(string playerHtml) + { + try + { + var playerData = new PlayerData(); + + // Для фільмів шукаємо прямий file + var fileMatch = Regex.Match(playerHtml, @"file:'([^']+)'", RegexOptions.IgnoreCase); + if (fileMatch.Success && !fileMatch.Groups[1].Value.StartsWith("[")) + { + playerData.File = fileMatch.Groups[1].Value; + _onLog($"UaTUT found direct file: {playerData.File}"); + + // Шукаємо poster + var posterMatch = Regex.Match(playerHtml, @"poster:[""']([^""']+)[""']", RegexOptions.IgnoreCase); + if (posterMatch.Success) + playerData.Poster = posterMatch.Groups[1].Value; + + return playerData; + } + + // Для серіалів шукаємо JSON структуру з сезонами та озвучками + var jsonMatch = Regex.Match(playerHtml, @"file:'(\[.*?\])'", RegexOptions.Singleline); + if (jsonMatch.Success) + { + string jsonData = jsonMatch.Groups[1].Value; + _onLog($"UaTUT found JSON data for series"); + + playerData.Voices = ParseVoicesJson(jsonData); + return playerData; + } + + _onLog("UaTUT: No player data found"); + return null; + } + catch (Exception ex) + { + _onLog($"UaTUT ParsePlayerData error: {ex.Message}"); + return null; + } + } + + private List ParseVoicesJson(string jsonData) + { + try + { + // Декодуємо JSON структуру озвучок + dynamic voicesData = JsonConvert.DeserializeObject(jsonData); + var voices = new List(); + + if (voicesData != null) + { + foreach (var voiceGroup in voicesData) + { + var voice = new Voice + { + Name = voiceGroup.title?.ToString(), + Seasons = new List() + }; + + if (voiceGroup.folder != null) + { + foreach (var seasonData in voiceGroup.folder) + { + var season = new Season + { + Title = seasonData.title?.ToString(), + Episodes = new List() + }; + + if (seasonData.folder != null) + { + foreach (var episodeData in seasonData.folder) + { + var episode = new Episode + { + Title = episodeData.title?.ToString(), + File = episodeData.file?.ToString(), + Id = episodeData.id?.ToString(), + Poster = episodeData.poster?.ToString(), + Subtitle = episodeData.subtitle?.ToString() + }; + season.Episodes.Add(episode); + } + } + + voice.Seasons.Add(season); + } + } + + voices.Add(voice); + } + } + + _onLog($"UaTUT parsed {voices.Count} voices"); + return voices; + } + catch (Exception ex) + { + _onLog($"UaTUT ParseVoicesJson error: {ex.Message}"); + return new List(); + } + } + } +} diff --git a/UaTUT/UpdateService.cs b/UaTUT/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/UaTUT/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/UaTUT/manifest.json b/UaTUT/manifest.json new file mode 100644 index 0000000..e320417 --- /dev/null +++ b/UaTUT/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "UaTUT.ModInit", + "online": "UaTUT.OnlineApi" +} \ No newline at end of file diff --git a/Uaflix/ApnHelper.cs b/Uaflix/ApnHelper.cs new file mode 100644 index 0000000..394a5bc --- /dev/null +++ b/Uaflix/ApnHelper.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/Uaflix/Controller.cs b/Uaflix/Controller.cs new file mode 100644 index 0000000..07e3fe6 --- /dev/null +++ b/Uaflix/Controller.cs @@ -0,0 +1,353 @@ +using Shared.Models.Templates; +using Shared.Engine; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Web; +using System.Linq; +using HtmlAgilityPack; +using Shared; +using Shared.Models.Templates; +using System.Text.RegularExpressions; +using System.Text; +using Shared.Models.Online.Settings; +using Shared.Models; +using Uaflix.Models; + +namespace Uaflix.Controllers +{ + + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.UaFlix); + } + + [HttpGet] + [Route("uaflix")] + 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, int e = -1, bool play = false, bool rjson = false, string href = null, bool checksearch = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.UaFlix); + if (await IsBadInitialization(init)) + return Forbid(); + + OnLog($"=== UAFLIX INDEX START ==="); + OnLog($"Uaflix Index: title={title}, serial={serial}, s={s}, play={play}, href={href}, checksearch={checksearch}"); + OnLog($"Uaflix Index: kinopoisk_id={kinopoisk_id}, imdb_id={imdb_id}, id={id}"); + OnLog($"Uaflix Index: year={year}, source={source}, t={t}, e={e}, rjson={rjson}"); + + var invoke = new UaflixInvoke(init, hybridCache, OnLog, proxyManager); + + // Обробка параметра checksearch - повертаємо спеціальну відповідь для валідації + if (checksearch) + { + try + { + string filmTitle = !string.IsNullOrEmpty(title) ? title : original_title; + string searchUrl = $"{init.host}/index.php?do=search&subaction=search&story={System.Web.HttpUtility.UrlEncode(filmTitle)}"; + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", init.host) }; + + var searchHtml = await Http.Get(searchUrl, headers: headers, proxy: proxyManager.Get(), timeoutSeconds: 10); + + // Швидка перевірка наявності результатів без повного парсингу + if (!string.IsNullOrEmpty(searchHtml) && + (searchHtml.Contains("sres-wrap") || searchHtml.Contains("sres-item") || searchHtml.Contains("search-results"))) + { + // Якщо знайдено контент, повертаємо "data-json=" для валідації + OnLog("checksearch: Content found, returning validation response"); + OnLog("=== RETURN: checksearch validation (data-json=) ==="); + return Content("data-json=", "text/plain; charset=utf-8"); + } + else + { + // Якщо нічого не знайдено, повертаємо OnError + OnLog("checksearch: No content found"); + OnLog("=== RETURN: checksearch OnError ==="); + return OnError("uaflix", proxyManager); + } + } + catch (Exception ex) + { + OnLog($"checksearch error: {ex.Message}"); + OnLog("=== RETURN: checksearch exception OnError ==="); + return OnError("uaflix", proxyManager); + } + } + + if (play) + { + // Визначаємо URL для парсингу - або з параметра t, або з episode_url + string urlToParse = !string.IsNullOrEmpty(t) ? t : Request.Query["episode_url"]; + + var playResult = await invoke.ParseEpisode(urlToParse); + if (playResult.streams != null && playResult.streams.Count > 0) + { + OnLog("=== RETURN: play redirect ==="); + return UpdateService.Validate(Redirect(BuildStreamUrl(init, playResult.streams.First().link))); + } + + OnLog("=== RETURN: play no streams ==="); + return UpdateService.Validate(Content("Uaflix", "text/html; charset=utf-8")); + } + + // Якщо є episode_url але немає play=true, це виклик для отримання інформації про стрім (для method: 'call') + string episodeUrl = Request.Query["episode_url"]; + if (!string.IsNullOrEmpty(episodeUrl)) + { + var playResult = await invoke.ParseEpisode(episodeUrl); + if (playResult.streams != null && playResult.streams.Count > 0) + { + // Повертаємо JSON з інформацією про стрім для методу 'play' + string streamUrl = BuildStreamUrl(init, playResult.streams.First().link); + string jsonResult = $"{{\"method\":\"play\",\"url\":\"{streamUrl}\",\"title\":\"{title ?? original_title}\"}}"; + OnLog($"=== RETURN: call method JSON for episode_url ==="); + return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); + } + + OnLog("=== RETURN: call method no streams ==="); + return UpdateService.Validate(Content("Uaflix", "text/html; charset=utf-8")); + } + + string filmUrl = href; + + if (string.IsNullOrEmpty(filmUrl)) + { + var searchResults = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, title); + if (searchResults == null || searchResults.Count == 0) + { + OnLog("No search results found"); + OnLog("=== RETURN: no search results OnError ==="); + return OnError("uaflix", proxyManager); + } + + // Для фільмів і серіалів показуємо вибір тільки якщо більше одного результату + if (searchResults.Count > 1) + { + var similar_tpl = new SimilarTpl(searchResults.Count); + foreach (var res in searchResults) + { + string link = $"{host}/uaflix?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(), string.Empty, link, res.PosterUrl); + } + OnLog($"=== RETURN: similar items ({searchResults.Count}) ==="); + return rjson ? Content(similar_tpl.ToJson(), "application/json; charset=utf-8") : Content(similar_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + filmUrl = searchResults[0].Url; + OnLog($"Auto-selected first search result: {filmUrl}"); + } + + if (serial == 1) + { + // Агрегуємо всі озвучки з усіх плеєрів + var structure = await invoke.AggregateSerialStructure(filmUrl); + if (structure == null || !structure.Voices.Any()) + { + OnLog("No voices found in aggregated structure"); + OnLog("=== RETURN: no voices OnError ==="); + return OnError("uaflix", proxyManager); + } + + OnLog($"Structure aggregated successfully: {structure.Voices.Count} voices, URL: {filmUrl}"); + foreach (var voice in structure.Voices) + { + OnLog($"Voice: {voice.Key}, Type: {voice.Value.PlayerType}, Seasons: {voice.Value.Seasons.Count}"); + foreach (var season in voice.Value.Seasons) + { + OnLog($" Season {season.Key}: {season.Value.Count} episodes"); + } + } + + // s == -1: Вибір сезону + if (s == -1) + { + var allSeasons = structure.Voices + .SelectMany(v => v.Value.Seasons.Keys) + .Distinct() + .OrderBy(sn => sn) + .ToList(); + + OnLog($"Found {allSeasons.Count} seasons in structure: {string.Join(", ", allSeasons)}"); + + // Перевіряємо чи сезони містять валідні епізоди з файлами + var seasonsWithValidEpisodes = allSeasons.Where(season => + structure.Voices.Values.Any(v => + v.Seasons.ContainsKey(season) && + v.Seasons[season].Any(ep => !string.IsNullOrEmpty(ep.File)) + ) + ).ToList(); + + OnLog($"Seasons with valid episodes: {seasonsWithValidEpisodes.Count}"); + foreach (var season in allSeasons) + { + var episodesInSeason = structure.Voices.Values + .Where(v => v.Seasons.ContainsKey(season)) + .SelectMany(v => v.Seasons[season]) + .Where(ep => !string.IsNullOrEmpty(ep.File)) + .ToList(); + OnLog($"Season {season}: {episodesInSeason.Count} valid episodes"); + } + + if (!seasonsWithValidEpisodes.Any()) + { + OnLog("No seasons with valid episodes found in structure"); + OnLog("=== RETURN: no valid seasons OnError ==="); + return OnError("uaflix", proxyManager); + } + + var season_tpl = new SeasonTpl(seasonsWithValidEpisodes.Count); + foreach (var season in seasonsWithValidEpisodes) + { + string link = $"{host}/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season}&href={HttpUtility.UrlEncode(filmUrl)}"; + season_tpl.Append($"{season}", link, season.ToString()); + OnLog($"Added season {season} to template"); + } + + OnLog($"Returning season template with {seasonsWithValidEpisodes.Count} seasons"); + + var htmlContent = rjson ? season_tpl.ToJson() : season_tpl.ToHtml(); + OnLog($"Season template response length: {htmlContent.Length}"); + OnLog($"Season template HTML (first 300): {htmlContent.Substring(0, Math.Min(300, htmlContent.Length))}"); + OnLog($"=== RETURN: season template ({seasonsWithValidEpisodes.Count} seasons) ==="); + + return Content(htmlContent, rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8"); + } + // s >= 0: Показуємо озвучки + епізоди + else if (s >= 0) + { + var voicesForSeason = structure.Voices + .Where(v => v.Value.Seasons.ContainsKey(s)) + .Select(v => new { DisplayName = v.Key, Info = v.Value }) + .ToList(); + + if (!voicesForSeason.Any()) + { + OnLog($"No voices found for season {s}"); + OnLog("=== RETURN: no voices for season OnError ==="); + return OnError("uaflix", proxyManager); + } + + // Автоматично вибираємо першу озвучку якщо не вказана + if (string.IsNullOrEmpty(t)) + { + t = voicesForSeason[0].DisplayName; + OnLog($"Auto-selected first voice: {t}"); + } + + // Створюємо VoiceTpl з усіма озвучками + var voice_tpl = new VoiceTpl(); + foreach (var voice in voicesForSeason) + { + string voiceLink = $"{host}/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}&href={HttpUtility.UrlEncode(filmUrl)}"; + bool isActive = voice.DisplayName == t; + voice_tpl.Append(voice.DisplayName, isActive, voiceLink); + } + OnLog($"Created VoiceTpl with {voicesForSeason.Count} voices, active: {t}"); + + // Відображення епізодів для вибраної озвучки + if (!structure.Voices.ContainsKey(t)) + { + OnLog($"Voice '{t}' not found in structure"); + OnLog("=== RETURN: voice not found OnError ==="); + return OnError("uaflix", proxyManager); + } + + if (!structure.Voices[t].Seasons.ContainsKey(s)) + { + OnLog($"Season {s} not found for voice '{t}'"); + OnLog("=== RETURN: season not found for voice OnError ==="); + return OnError("uaflix", proxyManager); + } + + var episodes = structure.Voices[t].Seasons[s]; + var episode_tpl = new EpisodeTpl(); + + foreach (var ep in episodes) + { + // Для zetvideo-vod повертаємо URL епізоду з методом call + // Для ashdi/zetvideo-serial повертаємо готове посилання з play + var voice = structure.Voices[t]; + + if (voice.PlayerType == "zetvideo-vod" || voice.PlayerType == "ashdi-vod") + { + // Для zetvideo-vod та ashdi-vod використовуємо URL епізоду для виклику + // Потрібно передати URL епізоду в інший параметр, щоб не плутати з play=true + string callUrl = $"{host}/uaflix?episode_url={HttpUtility.UrlEncode(ep.File)}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&s={s}&e={ep.Number}"; + episode_tpl.Append( + name: ep.Title, + title: title, + s: s.ToString(), + e: ep.Number.ToString(), + link: accsArgs(callUrl), + method: "call", + streamlink: accsArgs($"{callUrl}&play=true") + ); + } + else + { + // Для багатосерійних плеєрів (ashdi-serial, zetvideo-serial) - пряме відтворення + string playUrl = BuildStreamUrl(init, ep.File); + episode_tpl.Append( + name: ep.Title, + title: title, + s: s.ToString(), + e: ep.Number.ToString(), + link: playUrl + ); + } + } + + OnLog($"Created EpisodeTpl with {episodes.Count} episodes"); + + // Повертаємо VoiceTpl + EpisodeTpl разом + if (rjson) + { + OnLog($"=== RETURN: episode template with voices JSON ({episodes.Count} episodes) ==="); + return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8"); + } + else + { + OnLog($"=== RETURN: voice + episode template HTML ({episodes.Count} episodes) ==="); + return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + // Fallback: якщо жоден з умов не виконався + OnLog($"Fallback: s={s}, t={t}"); + OnLog("=== RETURN: fallback OnError ==="); + return OnError("uaflix", proxyManager); + } + else // Фільм + { + string link = $"{host}/uaflix?t={HttpUtility.UrlEncode(filmUrl)}&play=true"; + var tpl = new MovieTpl(title, original_title, 1); + tpl.Append(title, accsArgs(link), method: "play"); + OnLog("=== RETURN: movie template ==="); + return rjson ? Content(tpl.ToJson(), "application/json; charset=utf-8") : Content(tpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = accsArgs(streamLink); + 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); + } + } +} diff --git a/Uaflix/ModInit.cs b/Uaflix/ModInit.cs new file mode 100644 index 0000000..885fe10 --- /dev/null +++ b/Uaflix/ModInit.cs @@ -0,0 +1,69 @@ +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +namespace Uaflix +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings UaFlix; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => UaFlix; + set => UaFlix = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + UaFlix = new OnlinesSettings("Uaflix", "https://uafix.net", streamproxy: false, useproxy: false) + { + displayname = "UaFlix", + group = 0, + group_hide = false, + globalnameproxy = null, + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "a", + password = "a", + list = new string[] { "socks5://IP:PORT" } + }, + // Note: OnlinesSettings не має властивості additional, використовуємо інший підхід + }; + + var conf = ModuleInvoke.Conf("Uaflix", UaFlix); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + UaFlix = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, UaFlix); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + UaFlix.streamproxy = false; + } + else if (UaFlix.streamproxy) + { + UaFlix.apnstream = false; + UaFlix.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("uaflix"); + } + } +} diff --git a/Uaflix/Models/EpisodeLinkInfo.cs b/Uaflix/Models/EpisodeLinkInfo.cs new file mode 100644 index 0000000..2180106 --- /dev/null +++ b/Uaflix/Models/EpisodeLinkInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace Uaflix.Models +{ + public class EpisodeLinkInfo + { + public string url { get; set; } + public string title { get; set; } + public int season { get; set; } + public int episode { get; set; } + + // Нові поля для підтримки змішаних плеєрів + public string playerType { get; set; } // "ashdi-serial", "zetvideo-serial", "zetvideo-vod", "ashdi-vod" + public string iframeUrl { get; set; } // URL iframe для цього епізоду + } +} \ No newline at end of file diff --git a/Uaflix/Models/FilmInfo.cs b/Uaflix/Models/FilmInfo.cs new file mode 100644 index 0000000..2b8d937 --- /dev/null +++ b/Uaflix/Models/FilmInfo.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Uaflix.Models +{ + /// + /// Модель для зберігання інформації про фільм + /// + public class FilmInfo + { + /// + /// URL сторінки фільму + /// + public string Url { get; set; } + + /// + /// Назва фільму + /// + public string Title { get; set; } + + /// + /// Рік випуску + /// + public int Year { get; set; } + + /// + /// Опис фільму + /// + public string Description { get; set; } + + /// + /// Постер фільму + /// + public string PosterUrl { get; set; } + + /// + /// Список акторів + /// + public List Actors { get; set; } = new List(); + + /// + /// Режисер + /// + public string Director { get; set; } + + /// + /// Тривалість у секундах + /// + public int Duration { get; set; } + } +} \ No newline at end of file diff --git a/Uaflix/Models/PaginationInfo.cs b/Uaflix/Models/PaginationInfo.cs new file mode 100644 index 0000000..495afe9 --- /dev/null +++ b/Uaflix/Models/PaginationInfo.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Uaflix.Models +{ + public class PaginationInfo + { + // Словник сезонів, де ключ - номер сезону, значення - кількість сторінок + public Dictionary Seasons { get; set; } = new Dictionary(); + + // Загальна кількість сторінок (якщо потрібно) + public int TotalPages { get; set; } + + // URL сторінки серіалу (базовий URL для пагінації) + public string SerialUrl { get; set; } + + public List Episodes { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Uaflix/Models/PlayResult.cs b/Uaflix/Models/PlayResult.cs new file mode 100644 index 0000000..2275af7 --- /dev/null +++ b/Uaflix/Models/PlayResult.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Shared.Models.Templates; + +namespace Uaflix.Models +{ + public class PlayResult + { + public string ashdi_url { get; set; } + public List<(string link, string quality)> streams { get; set; } + public SubtitleTpl? subtitles { get; set; } + } +} \ No newline at end of file diff --git a/Uaflix/Models/SearchResult.cs b/Uaflix/Models/SearchResult.cs new file mode 100644 index 0000000..832a22f --- /dev/null +++ b/Uaflix/Models/SearchResult.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace Uaflix.Models +{ + public class SearchResult + { + public string Title { get; set; } + public string Url { get; set; } + public int Year { get; set; } + public string PosterUrl { get; set; } + } +} \ No newline at end of file diff --git a/Uaflix/Models/SerialAggregatedStructure.cs b/Uaflix/Models/SerialAggregatedStructure.cs new file mode 100644 index 0000000..d0deede --- /dev/null +++ b/Uaflix/Models/SerialAggregatedStructure.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Uaflix.Models +{ + /// + /// Агрегована структура серіалу з озвучками з усіх джерел (ashdi, zetvideo-serial, zetvideo-vod, ashdi-vod) + /// + public class SerialAggregatedStructure + { + /// + /// URL головної сторінки серіалу + /// + public string SerialUrl { get; set; } + + /// + /// Словник озвучок: ключ - displayName озвучки (наприклад, "[Ashdi] DniproFilm"), значення - VoiceInfo + /// + public Dictionary Voices { get; set; } + + /// + /// Список всіх епізодів серіалу (використовується для zetvideo-vod) + /// + public List AllEpisodes { get; set; } + + public SerialAggregatedStructure() + { + Voices = new Dictionary(); + AllEpisodes = new List(); + } + } +} \ No newline at end of file diff --git a/Uaflix/Models/VoiceInfo.cs b/Uaflix/Models/VoiceInfo.cs new file mode 100644 index 0000000..2e6a474 --- /dev/null +++ b/Uaflix/Models/VoiceInfo.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; + +namespace Uaflix.Models +{ + /// + /// Модель для зберігання інформації про озвучку серіалу + /// + public class VoiceInfo + { + /// + /// Назва озвучки без префіксу (наприклад, "DniproFilm") + /// + public string Name { get; set; } + + /// + /// Тип плеєра: "ashdi-serial", "zetvideo-serial", "zetvideo-vod", "ashdi-vod" + /// + public string PlayerType { get; set; } + + /// + /// Назва для відображення з префіксом плеєра (наприклад, "[Ashdi] DniproFilm") + /// + public string DisplayName { get; set; } + + /// + /// Словник сезонів: ключ - номер сезону, значення - список епізодів + /// + public Dictionary> Seasons { get; set; } + + public VoiceInfo() + { + Seasons = new Dictionary>(); + } + } + + /// + /// Модель для зберігання інформації про окремий епізод + /// + public class EpisodeInfo + { + /// + /// Номер епізоду + /// + public int Number { get; set; } + + /// + /// Назва епізоду + /// + public string Title { get; set; } + + /// + /// Пряме посилання на відео файл (m3u8) + /// + public string File { get; set; } + + /// + /// ID епізоду у плеєрі + /// + public string Id { get; set; } + + /// + /// URL постера епізоду + /// + public string Poster { get; set; } + + /// + /// Субтитри у форматі Playerjs + /// + public string Subtitle { get; set; } + } +} \ No newline at end of file diff --git a/Uaflix/OnlineApi.cs b/Uaflix/OnlineApi.cs new file mode 100644 index 0000000..7452f36 --- /dev/null +++ b/Uaflix/OnlineApi.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace Uaflix +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.UaFlix; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/uaflix"; + + online.Add((init.displayname, url, "uaflix", init.displayindex)); + } + + return online; + } + } +} diff --git a/Uaflix/Uaflix.csproj b/Uaflix/Uaflix.csproj new file mode 100644 index 0000000..c26a806 --- /dev/null +++ b/Uaflix/Uaflix.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + \ No newline at end of file diff --git a/Uaflix/UaflixInvoke.cs b/Uaflix/UaflixInvoke.cs new file mode 100644 index 0000000..112f279 --- /dev/null +++ b/Uaflix/UaflixInvoke.cs @@ -0,0 +1,1036 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shared; +using Shared.Models.Online.Settings; +using Shared.Models; +using System.Text.RegularExpressions; +using HtmlAgilityPack; +using Uaflix.Controllers; +using Shared.Engine; +using Uaflix.Models; +using System.Linq; +using Shared.Models.Templates; +using System.Net; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Text; + +namespace Uaflix +{ + public class UaflixInvoke + { + private OnlinesSettings _init; + private IHybridCache _hybridCache; + private Action _onLog; + private ProxyManager _proxyManager; + + public UaflixInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + string AshdiRequestUrl(string url) + { + if (!ApnHelper.IsAshdiUrl(url)) + return url; + + return ApnHelper.WrapUrl(_init, url); + } + + #region Методи для визначення та парсингу різних типів плеєрів + + /// + /// Визначити тип плеєра з URL iframe + /// + private string DeterminePlayerType(string iframeUrl) + { + if (string.IsNullOrEmpty(iframeUrl)) + return null; + + // Перевіряємо на підтримувані типи плеєрів + if (iframeUrl.Contains("ashdi.vip/serial/")) + return "ashdi-serial"; + else if (iframeUrl.Contains("ashdi.vip/vod/")) + return "ashdi-vod"; + else if (iframeUrl.Contains("zetvideo.net/serial/")) + return "zetvideo-serial"; + else if (iframeUrl.Contains("zetvideo.net/vod/")) + return "zetvideo-vod"; + + // Перевіряємо на небажані типи плеєрів (трейлери, реклама тощо) + if (iframeUrl.Contains("youtube.com/embed/") || + iframeUrl.Contains("youtu.be/") || + iframeUrl.Contains("vimeo.com/") || + iframeUrl.Contains("dailymotion.com/")) + return "trailer"; // Ігноруємо відеохостинги з трейлерами + + return null; + } + + /// + /// Парсинг багатосерійного плеєра (ashdi-serial або zetvideo-serial) + /// + private async Task> ParseMultiEpisodePlayer(string iframeUrl, string playerType) + { + string referer = "https://uafix.net/"; + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", referer) + }; + + try + { + // Для ashdi видаляємо параметри season та episode для отримання всіх озвучок + string requestUrl = iframeUrl; + if (playerType == "ashdi-serial" && iframeUrl.Contains("ashdi.vip/serial/")) + { + // Витягуємо базовий URL без параметрів + var baseUrlMatch = Regex.Match(iframeUrl, @"(https://ashdi\.vip/serial/\d+)"); + if (baseUrlMatch.Success) + { + requestUrl = baseUrlMatch.Groups[1].Value; + _onLog($"ParseMultiEpisodePlayer: Using base ashdi URL without parameters: {requestUrl}"); + } + } + + string html = await Http.Get(AshdiRequestUrl(requestUrl), headers: headers, proxy: _proxyManager.Get()); + + // Знайти JSON у new Playerjs({file:'...'}) + var match = Regex.Match(html, @"file:'(\[.+?\])'", RegexOptions.Singleline); + if (!match.Success) + { + _onLog($"ParseMultiEpisodePlayer: JSON not found in iframe {iframeUrl}"); + return new List(); + } + + string jsonStr = match.Groups[1].Value + .Replace("\\'", "'") + .Replace("\\\"", "\""); + + var voicesArray = JsonConvert.DeserializeObject>(jsonStr); + var voices = new List(); + + string playerPrefix = playerType == "ashdi-serial" ? "Ashdi" : "Zetvideo"; + + // Для формування унікальних назв озвучок + var voiceCounts = new Dictionary(); + + foreach (var voiceObj in voicesArray) + { + string voiceName = voiceObj["title"]?.ToString().Trim(); + if (string.IsNullOrEmpty(voiceName)) + continue; + + // Перевіряємо, чи вже існує така назва озвучки + if (voiceCounts.ContainsKey(voiceName)) + { + voiceCounts[voiceName]++; + // Якщо є дублікат, додаємо номер + voiceName = $"{voiceName} {voiceCounts[voiceName]}"; + } + else + { + // Ініціалізуємо лічильник для нової озвучки + voiceCounts[voiceObj["title"]?.ToString().Trim()] = 1; + } + + var voiceInfo = new VoiceInfo + { + Name = voiceObj["title"]?.ToString().Trim(), // Зберігаємо оригінальну назву для внутрішнього використання + PlayerType = playerType, + DisplayName = voiceName, // Відображаємо унікальну назву + Seasons = new Dictionary>() + }; + + var seasons = voiceObj["folder"] as JArray; + if (seasons != null) + { + foreach (var seasonObj in seasons) + { + string seasonTitle = seasonObj["title"]?.ToString(); + var seasonMatch = Regex.Match(seasonTitle, @"Сезон\s+(\d+)", RegexOptions.IgnoreCase); + + if (!seasonMatch.Success) + continue; + + int seasonNumber = int.Parse(seasonMatch.Groups[1].Value); + var episodes = new List(); + var episodesArray = seasonObj["folder"] as JArray; + + if (episodesArray != null) + { + int episodeNum = 1; + foreach (var epObj in episodesArray) + { + episodes.Add(new EpisodeInfo + { + Number = episodeNum++, + Title = epObj["title"]?.ToString(), + File = epObj["file"]?.ToString(), + Id = epObj["id"]?.ToString(), + Poster = epObj["poster"]?.ToString(), + Subtitle = epObj["subtitle"]?.ToString() + }); + } + } + + voiceInfo.Seasons[seasonNumber] = episodes; + } + } + + voices.Add(voiceInfo); + } + + _onLog($"ParseMultiEpisodePlayer: Found {voices.Count} voices in {playerType}"); + return voices; + } + catch (Exception ex) + { + _onLog($"ParseMultiEpisodePlayer error: {ex.Message}"); + return new List(); + } + } + + /// + /// Парсинг одного епізоду з zetvideo-vod + /// + private async Task<(string file, string voiceName)> ParseSingleEpisodePlayer(string iframeUrl) + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://uafix.net/") + }; + + try + { + string html = await Http.Get(iframeUrl, headers: headers, proxy: _proxyManager.Get()); + + // Знайти file:"url" + var match = Regex.Match(html, @"file:\s*""([^""]+\.m3u8)"""); + if (!match.Success) + return (null, null); + + string fileUrl = match.Groups[1].Value; + + // Визначити озвучку з URL + string voiceName = ExtractVoiceFromUrl(fileUrl); + + return (fileUrl, voiceName); + } + catch (Exception ex) + { + _onLog($"ParseSingleEpisodePlayer error: {ex.Message}"); + return (null, null); + } + } + + /// + /// Парсинг одного епізоду з ashdi-vod (новий метод для обробки окремих епізодів з ashdi.vip/vod/) + /// + private async Task<(string file, string voiceName)> ParseAshdiVodEpisode(string iframeUrl) + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://uafix.net/") + }; + + try + { + string html = await Http.Get(iframeUrl, headers: headers, proxy: _proxyManager.Get()); + + // Шукаємо Playerjs конфігурацію з file параметром + var match = Regex.Match(html, @"file:\s*'?([^'""\s,}]+\.m3u8)'?"); + if (!match.Success) + { + // Якщо не знайдено, шукаємо в іншому форматі + match = Regex.Match(html, @"file['""]?\s*:\s*['""]([^'""}]+\.m3u8)['""]"); + } + + if (!match.Success) + return (null, null); + + string fileUrl = match.Groups[1].Value; + + // Визначити озвучку з URL + string voiceName = ExtractVoiceFromUrl(fileUrl); + + return (fileUrl, voiceName); + } + catch (Exception ex) + { + _onLog($"ParseAshdiVodEpisode error: {ex.Message}"); + return (null, null); + } + } + + /// + /// Витягнути назву озвучки з URL файлу + /// + private string ExtractVoiceFromUrl(string fileUrl) + { + if (string.IsNullOrEmpty(fileUrl)) + return "Невідомо"; + + if (fileUrl.Contains("uaflix")) + return "Uaflix"; + else if (fileUrl.Contains("dniprofilm")) + return "DniproFilm"; + else if (fileUrl.Contains("newstudio")) + return "NewStudio"; + + return "Невідомо"; + } + + #endregion + + #region Агрегація структури серіалу з усіх джерел + + /// + /// Агрегує озвучки з усіх епізодів серіалу (ashdi, zetvideo-serial, zetvideo-vod) + /// + public async Task AggregateSerialStructure(string serialUrl) + { + string memKey = $"UaFlix:aggregated:{serialUrl}"; + if (_hybridCache.TryGetValue(memKey, out SerialAggregatedStructure cached)) + { + _onLog($"AggregateSerialStructure: Using cached structure for {serialUrl}"); + return cached; + } + + try + { + // Edge Case 1: Перевірка валідності URL + if (string.IsNullOrEmpty(serialUrl) || !Uri.IsWellFormedUriString(serialUrl, UriKind.Absolute)) + { + _onLog($"AggregateSerialStructure: Invalid URL: {serialUrl}"); + return null; + } + + // Отримати список всіх епізодів + var paginationInfo = await GetPaginationInfo(serialUrl); + if (paginationInfo?.Episodes == null || !paginationInfo.Episodes.Any()) + { + _onLog($"AggregateSerialStructure: No episodes found for {serialUrl}"); + return null; + } + + var structure = new SerialAggregatedStructure + { + SerialUrl = serialUrl, + Voices = new Dictionary(), + AllEpisodes = paginationInfo.Episodes + }; + + // Групуємо епізоди по сезонах + var episodesBySeason = paginationInfo.Episodes + .GroupBy(e => e.season) + .ToDictionary(g => g.Key, g => g.ToList()); + + _onLog($"AggregateSerialStructure: Processing {episodesBySeason.Count} seasons"); + + // Для кожного сезону беремо перший епізод та визначаємо тип плеєра + foreach (var seasonGroup in episodesBySeason) + { + int season = seasonGroup.Key; + var firstEpisode = seasonGroup.Value.First(); + + _onLog($"AggregateSerialStructure: Processing season {season}, first episode: {firstEpisode.url}"); + + // Отримати HTML епізоду та знайти iframe + var headers = new List() { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + + string html = await Http.Get(firstEpisode.url, headers: headers, proxy: _proxyManager.Get()); + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + var iframe = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'video-box')]//iframe"); + + if (iframe == null) + { + _onLog($"AggregateSerialStructure: No iframe found for season {season}"); + continue; + } + + string iframeUrl = iframe.GetAttributeValue("src", "").Replace("&", "&"); + if (iframeUrl.StartsWith("//")) + iframeUrl = "https:" + iframeUrl; + + // Edge Case 2: Перевірка валідності iframe URL + if (string.IsNullOrEmpty(iframeUrl)) + { + _onLog($"AggregateSerialStructure: Empty iframe URL for season {season}"); + continue; + } + + string playerType = DeterminePlayerType(iframeUrl); + _onLog($"AggregateSerialStructure: Season {season} has playerType: {playerType}"); + + // Edge Case 3: Невідомий тип плеєра або YouTube трейлер + if (string.IsNullOrEmpty(playerType)) + { + _onLog($"AggregateSerialStructure: Unknown player type for iframe {iframeUrl} in season {season}"); + continue; + } + + // Ігноруємо трейлери та небажані відеохостинги + if (playerType == "trailer") + { + _onLog($"AggregateSerialStructure: Ignoring trailer/video host for iframe {iframeUrl} in season {season}"); + continue; + } + + if (playerType == "ashdi-serial" || playerType == "zetvideo-serial") + { + // Парсимо багатосерійний плеєр + var voices = await ParseMultiEpisodePlayer(iframeUrl, playerType); + + // Edge Case 4: Порожній результат парсингу + if (voices == null || !voices.Any()) + { + _onLog($"AggregateSerialStructure: No voices found in {playerType} for season {season}"); + continue; + } + + foreach (var voice in voices) + { + // Edge Case 5: Перевірка валідності озвучки + if (voice == null || string.IsNullOrEmpty(voice.DisplayName)) + { + _onLog($"AggregateSerialStructure: Invalid voice data in season {season}"); + continue; + } + + // Додаємо або об'єднуємо з існуючою озвучкою + if (!structure.Voices.ContainsKey(voice.DisplayName)) + { + structure.Voices[voice.DisplayName] = voice; + } + else + { + // Об'єднуємо сезони + foreach (var seasonEpisodes in voice.Seasons) + { + structure.Voices[voice.DisplayName].Seasons[seasonEpisodes.Key] = seasonEpisodes.Value; + } + } + } + } + else if (playerType == "zetvideo-vod") + { + _onLog($"AggregateSerialStructure: Processing zetvideo-vod for season {season} with {seasonGroup.Value.Count} episodes"); + + // Для zetvideo-vod створюємо озвучку з реальними епізодами + string displayName = "Uaflix #2"; + + if (!structure.Voices.ContainsKey(displayName)) + { + structure.Voices[displayName] = new VoiceInfo + { + Name = "Uaflix", + PlayerType = "zetvideo-vod", + DisplayName = displayName, + Seasons = new Dictionary>() + }; + } + + // Створюємо епізоди для цього сезону з посиланнями на сторінки епізодів + var episodes = new List(); + foreach (var episodeInfo in seasonGroup.Value) + { + episodes.Add(new EpisodeInfo + { + Number = episodeInfo.episode, + Title = episodeInfo.title, + File = episodeInfo.url, // URL сторінки епізоду для використання в call + Id = episodeInfo.url, + Poster = null, + Subtitle = null + }); + } + + structure.Voices[displayName].Seasons[season] = episodes; + + _onLog($"AggregateSerialStructure: Created voice with {episodes.Count} episodes for season {season} in zetvideo-vod"); + } + else if (playerType == "ashdi-vod") + { + _onLog($"AggregateSerialStructure: Processing ashdi-vod for season {season} with {seasonGroup.Value.Count} episodes"); + + // Для ashdi-vod створюємо озвучку з реальними епізодами + string displayName = "Uaflix #3"; + + if (!structure.Voices.ContainsKey(displayName)) + { + structure.Voices[displayName] = new VoiceInfo + { + Name = "Uaflix", + PlayerType = "ashdi-vod", + DisplayName = displayName, + Seasons = new Dictionary>() + }; + } + + // Створюємо епізоди для цього сезону з посиланнями на сторінки епізодів + var episodes = new List(); + foreach (var episodeInfo in seasonGroup.Value) + { + episodes.Add(new EpisodeInfo + { + Number = episodeInfo.episode, + Title = episodeInfo.title, + File = episodeInfo.url, // URL сторінки епізоду для використання в call + Id = episodeInfo.url, + Poster = null, + Subtitle = null + }); + } + + structure.Voices[displayName].Seasons[season] = episodes; + + _onLog($"AggregateSerialStructure: Created voice with {episodes.Count} episodes for season {season} in ashdi-vod"); + } + } + + // Edge Case 8: Перевірка наявності озвучок після агрегації + if (!structure.Voices.Any()) + { + _onLog($"AggregateSerialStructure: No voices found after aggregation for {serialUrl}"); + return null; + } + + NormalizeUaflixVoiceNames(structure); + + // Edge Case 9: Перевірка наявності епізодів у озвучках + bool hasEpisodes = structure.Voices.Values.Any(v => v.Seasons.Values.Any(s => s.Any())); + if (!hasEpisodes) + { + _onLog($"AggregateSerialStructure: No episodes found in any voice for {serialUrl}"); + _onLog($"AggregateSerialStructure: Voices count: {structure.Voices.Count}"); + foreach (var voice in structure.Voices) + { + _onLog($" Voice {voice.Key}: {voice.Value.Seasons.Sum(s => s.Value.Count)} total episodes"); + } + return null; + } + + _hybridCache.Set(memKey, structure, cacheTime(40)); + _onLog($"AggregateSerialStructure: Cached structure with {structure.Voices.Count} total voices"); + + // Детальне логування структури для діагностики + foreach (var voice in structure.Voices) + { + _onLog($" Voice: {voice.Key} ({voice.Value.PlayerType}) - Seasons: {voice.Value.Seasons.Count}"); + foreach (var season in voice.Value.Seasons) + { + _onLog($" Season {season.Key}: {season.Value.Count} episodes"); + foreach (var episode in season.Value.Take(3)) // Показуємо тільки перші 3 епізоди + { + _onLog($" Episode {episode.Number}: {episode.Title} - {episode.File}"); + } + if (season.Value.Count > 3) + _onLog($" ... and {season.Value.Count - 3} more episodes"); + } + } + + return structure; + } + catch (Exception ex) + { + _onLog($"AggregateSerialStructure error: {ex.Message}"); + return null; + } + } + + #endregion + + public async Task> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year, string search_query) + { + string memKey = $"UaFlix:search:{kinopoisk_id}:{imdb_id}:{search_query}"; + if (_hybridCache.TryGetValue(memKey, out List res)) + return res; + + try + { + string filmTitle = !string.IsNullOrEmpty(original_title) ? original_title : (!string.IsNullOrEmpty(title) ? title : search_query); + string searchUrl = $"{_init.host}/index.php?do=search&subaction=search&story={System.Web.HttpUtility.UrlEncode(filmTitle)}"; + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }; + + var searchHtml = await Http.Get(searchUrl, headers: headers, proxy: _proxyManager.Get()); + var doc = new HtmlDocument(); + doc.LoadHtml(searchHtml); + + // Спробуємо різні селектори для пошуку результатів + var filmNodes = doc.DocumentNode.SelectNodes("//a[contains(@class, 'sres-wrap')]") ?? + doc.DocumentNode.SelectNodes("//div[contains(@class, 'sres-item')]//a") ?? + doc.DocumentNode.SelectNodes("//div[contains(@class, 'search-result')]//a") ?? + doc.DocumentNode.SelectNodes("//a[contains(@href, '/serials/') or contains(@href, '/films/')]"); + + if (filmNodes == null || filmNodes.Count == 0) + { + _onLog($"Search: No search results found with any selector for query: {filmTitle}"); + return null; + } + + res = new List(); + foreach (var filmNode in filmNodes) + { + try + { + var h2Node = filmNode.SelectSingleNode(".//h2") ?? filmNode.SelectSingleNode(".//h3"); + if (h2Node == null) continue; + + string filmUrl = filmNode.GetAttributeValue("href", ""); + if (string.IsNullOrEmpty(filmUrl)) continue; + + if (!filmUrl.StartsWith("http")) + filmUrl = _init.host + filmUrl; + + // Спробуємо різні способи отримати рік + int filmYear = 0; + var descNode = filmNode.SelectSingleNode(".//div[contains(@class, 'sres-desc')]") ?? + filmNode.SelectSingleNode(".//span[contains(@class, 'year')]") ?? + filmNode.SelectSingleNode(".//*[contains(text(), '20')]"); + + if (descNode != null) + { + string yearText = descNode.InnerText ?? ""; + var yearMatch = Regex.Match(yearText, @"(?:19|20)\d{2}"); + if (yearMatch.Success) + int.TryParse(yearMatch.Value, out filmYear); + } + + // Спробуємо різні селектори для постера + var posterNode = filmNode.SelectSingleNode(".//img[@src]") ?? + filmNode.SelectSingleNode(".//img[@data-src]") ?? + filmNode.SelectSingleNode(".//div[contains(@class, 'poster')]//img"); + + string posterUrl = posterNode?.GetAttributeValue("src", "") ?? posterNode?.GetAttributeValue("data-src", ""); + if (!string.IsNullOrEmpty(posterUrl) && !posterUrl.StartsWith("http")) + posterUrl = _init.host + posterUrl; + + res.Add(new SearchResult + { + Title = h2Node.InnerText.Trim(), + Url = filmUrl, + Year = filmYear, + PosterUrl = posterUrl + }); + + _onLog($"Search: Found result - {h2Node.InnerText.Trim()}, URL: {filmUrl}"); + } + catch (Exception ex) + { + _onLog($"Search: Error processing film node: {ex.Message}"); + continue; + } + } + + if (res.Count > 0) + { + _hybridCache.Set(memKey, res, cacheTime(20)); + return res; + } + } + catch (Exception ex) + { + _onLog($"UaFlix search error: {ex.Message}"); + } + return null; + } + + public async Task GetFilmInfo(string filmUrl) + { + string memKey = $"UaFlix:filminfo:{filmUrl}"; + if (_hybridCache.TryGetValue(memKey, out FilmInfo res)) + return res; + + try + { + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }; + var filmHtml = await Http.Get(filmUrl, headers: headers, proxy: _proxyManager.Get()); + var doc = new HtmlDocument(); + doc.LoadHtml(filmHtml); + + var result = new FilmInfo + { + Url = filmUrl + }; + + var titleNode = doc.DocumentNode.SelectSingleNode("//h1[@class='h1-title']"); + if (titleNode != null) + { + result.Title = titleNode.InnerText.Trim(); + } + + var metaDuration = doc.DocumentNode.SelectSingleNode("//meta[@property='og:video:duration']"); + if (metaDuration != null) + { + string durationStr = metaDuration.GetAttributeValue("content", ""); + if (int.TryParse(durationStr, out int duration)) + { + result.Duration = duration; + } + } + + var metaActors = doc.DocumentNode.SelectSingleNode("//meta[@property='og:video:actor']"); + if (metaActors != null) + { + string actorsStr = metaActors.GetAttributeValue("content", ""); + result.Actors = actorsStr.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(a => a.Trim()) + .ToList(); + } + + var metaDirector = doc.DocumentNode.SelectSingleNode("//meta[@property='og:video:director']"); + if (metaDirector != null) + { + result.Director = metaDirector.GetAttributeValue("content", ""); + } + + var descNode = doc.DocumentNode.SelectSingleNode("//div[@id='main-descr']//div[@itemprop='description']"); + if (descNode != null) + { + result.Description = descNode.InnerText.Trim(); + } + + var posterNode = doc.DocumentNode.SelectSingleNode("//img[@itemprop='image']"); + if (posterNode != null) + { + result.PosterUrl = posterNode.GetAttributeValue("src", ""); + if (!result.PosterUrl.StartsWith("http") && !string.IsNullOrEmpty(result.PosterUrl)) + { + result.PosterUrl = _init.host + result.PosterUrl; + } + } + + _hybridCache.Set(memKey, result, cacheTime(60)); + return result; + } + catch (Exception ex) + { + _onLog($"UaFlix GetFilmInfo error: {ex.Message}"); + } + return null; + } + + public async Task GetPaginationInfo(string filmUrl) + { + string memKey = $"UaFlix:pagination:{filmUrl}"; + if (_hybridCache.TryGetValue(memKey, out PaginationInfo res)) + return res; + + try + { + var headers = new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }; + var filmHtml = await Http.Get(filmUrl, headers: headers, proxy: _proxyManager.Get()); + var filmDoc = new HtmlDocument(); + filmDoc.LoadHtml(filmHtml); + + var paginationInfo = new PaginationInfo + { + SerialUrl = filmUrl + }; + + var allEpisodes = new List(); + var seasonUrls = new HashSet(); + + var seasonNodes = filmDoc.DocumentNode.SelectNodes("//div[contains(@class, 'sez-wr')]//a"); + if (seasonNodes == null) + seasonNodes = filmDoc.DocumentNode.SelectNodes("//div[contains(@class, 'fss-box')]//a"); + if (seasonNodes != null && seasonNodes.Count > 0) + { + foreach (var node in seasonNodes) + { + string pageUrl = node.GetAttributeValue("href", null); + if (!string.IsNullOrEmpty(pageUrl)) + { + if (!pageUrl.StartsWith("http")) + pageUrl = _init.host + pageUrl; + + seasonUrls.Add(pageUrl); + } + } + } + else + { + seasonUrls.Add(filmUrl); + } + + var safeSeasonUrls = seasonUrls.ToList(); + if (safeSeasonUrls.Count == 0) + return null; + + var seasonTasks = safeSeasonUrls.Select(url => Http.Get(url, headers: headers, proxy: _proxyManager.Get())); + var seasonPagesHtml = await Task.WhenAll(seasonTasks); + + foreach (var html in seasonPagesHtml) + { + var pageDoc = new HtmlDocument(); + pageDoc.LoadHtml(html); + + var episodeNodes = pageDoc.DocumentNode.SelectNodes("//div[contains(@class, 'frels')]//a[contains(@class, 'vi-img')]"); + if (episodeNodes != null) + { + foreach (var episodeNode in episodeNodes) + { + string episodeUrl = episodeNode.GetAttributeValue("href", ""); + if (!episodeUrl.StartsWith("http")) + episodeUrl = _init.host + episodeUrl; + + var match = Regex.Match(episodeUrl, @"season-(\d+).*?episode-(\d+)"); + if (match.Success) + { + allEpisodes.Add(new EpisodeLinkInfo + { + url = episodeUrl, + title = episodeNode.SelectSingleNode(".//div[@class='vi-rate']")?.InnerText.Trim() ?? $"Епізод {match.Groups[2].Value}", + season = int.Parse(match.Groups[1].Value), + episode = int.Parse(match.Groups[2].Value) + }); + } + } + } + } + + paginationInfo.Episodes = allEpisodes.OrderBy(e => e.season).ThenBy(e => e.episode).ToList(); + + if (paginationInfo.Episodes.Any()) + { + var uniqueSeasons = paginationInfo.Episodes.Select(e => e.season).Distinct().OrderBy(se => se); + foreach (var season in uniqueSeasons) + { + paginationInfo.Seasons[season] = 1; + } + } + + if (paginationInfo.Episodes.Count > 0) + { + _hybridCache.Set(memKey, paginationInfo, cacheTime(20)); + return paginationInfo; + } + } + catch (Exception ex) + { + _onLog($"UaFlix GetPaginationInfo error: {ex.Message}"); + } + return null; + } + + public async Task ParseEpisode(string url) + { + var result = new Uaflix.Models.PlayResult() { streams = new List<(string, string)>() }; + try + { + string html = await Http.Get(url, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", _init.host) }, proxy: _proxyManager.Get()); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var videoNode = doc.DocumentNode.SelectSingleNode("//video"); + if (videoNode != null) + { + string videoUrl = videoNode.GetAttributeValue("src", ""); + if (!string.IsNullOrEmpty(videoUrl)) + { + result.streams.Add((videoUrl, "1080p")); + return result; + } + } + + var iframe = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'video-box')]//iframe"); + if (iframe != null) + { + string iframeUrl = iframe.GetAttributeValue("src", "").Replace("&", "&"); + if (iframeUrl.StartsWith("//")) + iframeUrl = "https:" + iframeUrl; + + if (iframeUrl.Contains("ashdi.vip/serial/")) + { + result.ashdi_url = iframeUrl; + return result; + } + + // Ігноруємо YouTube трейлери + if (iframeUrl.Contains("youtube.com/embed/")) + { + _onLog($"ParseEpisode: Ignoring YouTube trailer iframe: {iframeUrl}"); + return result; + } + + if (iframeUrl.Contains("zetvideo.net")) + result.streams = await ParseAllZetvideoSources(iframeUrl); + else if (iframeUrl.Contains("ashdi.vip")) + { + // Перевіряємо, чи це ashdi-vod (окремий епізод) або ashdi-serial (багатосерійний плеєр) + if (iframeUrl.Contains("/vod/")) + { + // Це окремий епізод на ashdi.vip/vod/, обробляємо як ashdi-vod + var (file, voiceName) = await ParseAshdiVodEpisode(iframeUrl); + if (!string.IsNullOrEmpty(file)) + { + result.streams.Add((file, "1080p")); + } + } + else + { + // Це багатосерійний плеєр, обробляємо як і раніше + result.streams = await ParseAllAshdiSources(iframeUrl); + var idMatch = Regex.Match(iframeUrl, @"_(\d+)|vod/(\d+)"); + if (idMatch.Success) + { + string ashdiId = idMatch.Groups[1].Success ? idMatch.Groups[1].Value : idMatch.Groups[2].Value; + result.subtitles = await GetAshdiSubtitles(ashdiId); + } + } + } + } + } + catch (Exception ex) + { + _onLog($"ParseEpisode error: {ex.Message}"); + } + _onLog($"ParseEpisode result: streams.count={result.streams.Count}, ashdi_url={result.ashdi_url}"); + return result; + } + + private void NormalizeUaflixVoiceNames(SerialAggregatedStructure structure) + { + const string baseName = "Uaflix"; + const string zetName = "Uaflix #2"; + const string ashdiName = "Uaflix #3"; + + if (structure == null || structure.Voices == null || structure.Voices.Count == 0) + return; + + bool hasBase = structure.Voices.ContainsKey(baseName); + bool hasZet = structure.Voices.ContainsKey(zetName); + bool hasAshdi = structure.Voices.ContainsKey(ashdiName); + + if (hasBase) + return; + + if (hasZet && !hasAshdi) + { + var voice = structure.Voices[zetName]; + voice.DisplayName = baseName; + structure.Voices.Remove(zetName); + structure.Voices[baseName] = voice; + } + else if (hasAshdi && !hasZet) + { + var voice = structure.Voices[ashdiName]; + voice.DisplayName = baseName; + structure.Voices.Remove(ashdiName); + structure.Voices[baseName] = voice; + } + } + + async Task> ParseAllZetvideoSources(string iframeUrl) + { + var result = new List<(string link, string quality)>(); + var html = await Http.Get(iframeUrl, headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://zetvideo.net/") }, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) return result; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var script = doc.DocumentNode.SelectSingleNode("//script[contains(text(), 'file:')]"); + if (script != null) + { + var match = Regex.Match(script.InnerText, @"file:\s*""([^""]+\.m3u8)"); + if (match.Success) + { + result.Add((match.Groups[1].Value, "1080p")); + return result; + } + } + + var sourceNodes = doc.DocumentNode.SelectNodes("//source[contains(@src, '.m3u8')]"); + if (sourceNodes != null) + { + foreach (var node in sourceNodes) + { + result.Add((node.GetAttributeValue("src", null), node.GetAttributeValue("label", null) ?? node.GetAttributeValue("res", null) ?? "1080p")); + } + } + return result; + } + + async Task> ParseAllAshdiSources(string iframeUrl) + { + var result = new List<(string link, string quality)>(); + var html = await Http.Get(AshdiRequestUrl(iframeUrl), headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://ashdi.vip/") }, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(html)) return result; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var sourceNodes = doc.DocumentNode.SelectNodes("//source[contains(@src, '.m3u8')]"); + if (sourceNodes != null) + { + foreach (var node in sourceNodes) + { + result.Add((node.GetAttributeValue("src", null), node.GetAttributeValue("label", null) ?? node.GetAttributeValue("res", null) ?? "1080p")); + } + } + return result; + } + + async Task GetAshdiSubtitles(string id) + { + string url = $"https://ashdi.vip/vod/{id}"; + var html = await Http.Get(AshdiRequestUrl(url), headers: new List() { new HeadersModel("User-Agent", "Mozilla/5.0"), new HeadersModel("Referer", "https://ashdi.vip/") }, proxy: _proxyManager.Get()); + string subtitle = new Regex("subtitle(\")?:\"([^\"]+)\"").Match(html).Groups[2].Value; + if (!string.IsNullOrEmpty(subtitle)) + { + var match = new Regex("\\[([^\\]]+)\\](https?://[^\\,]+)").Match(subtitle); + var st = new Shared.Models.Templates.SubtitleTpl(); + while (match.Success) + { + st.Append(match.Groups[1].Value, match.Groups[2].Value); + match = match.NextMatch(); + } + if (st.data != null && st.data.Count > 0) + return st; + } + return null; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + + /// + /// Оновлений метод кешування згідно стандарту Lampac + /// + public static TimeSpan GetCacheTime(OnlinesSettings init, int multiaccess = 20, int home = 5, int mikrotik = 2, int rhub = -1) + { + if (init != null && init.rhub && rhub != -1) + return TimeSpan.FromMinutes(rhub); + + int ctime = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (init != null && ctime > init.cache_time && init.cache_time > 0) + ctime = init.cache_time; + + return TimeSpan.FromMinutes(ctime); + } + } +} diff --git a/Uaflix/UpdateService.cs b/Uaflix/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/Uaflix/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/Uaflix/manifest.json b/Uaflix/manifest.json new file mode 100644 index 0000000..f6a854f --- /dev/null +++ b/Uaflix/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "Uaflix.ModInit", + "online": "Uaflix.OnlineApi" +} \ No newline at end of file diff --git a/Unimay/Controllers/Controller.cs b/Unimay/Controllers/Controller.cs new file mode 100644 index 0000000..49d34e0 --- /dev/null +++ b/Unimay/Controllers/Controller.cs @@ -0,0 +1,144 @@ +using Shared.Engine; +using System; +using System.Threading.Tasks; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Web; +using Newtonsoft.Json.Linq; +using Shared.Models.Templates; +using Shared.Models.Online.Settings; +using Shared; + +namespace Unimay.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.Unimay); + } + + [HttpGet] + [Route("unimay")] + async public ValueTask Index(string title, string original_title, string code, int serial = -1, int s = -1, int e = -1, bool play = false, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Unimay); + if (await IsBadInitialization(init, rch: false)) + return badInitMsg; + + var invoke = new UnimayInvoke(init, hybridCache, OnLog, proxyManager); + + if (!string.IsNullOrEmpty(code)) + { + // Fetch release details + return await Release(invoke, init, code, title, original_title, serial, s, e, play, rjson); + } + else + { + // Search + return await Search(invoke, init, title, original_title, serial, rjson); + } + } + + async ValueTask Search(UnimayInvoke invoke, OnlinesSettings init, string title, string original_title, int serial, bool rjson) + { + string memKey = $"unimay:search:{title}:{original_title}:{serial}"; + + return await InvkSemaphore(memKey, async () => + { + var searchResults = await invoke.Search(title, original_title, serial); + if (searchResults == null || searchResults.Content.Count == 0) + return OnError("no results"); + + var stpl = new SimilarTpl(searchResults.Content.Count); + var results = invoke.GetSearchResults(host, searchResults, title, original_title, serial); + + foreach (var (itemTitle, itemYear, itemType, releaseUrl) in results) + { + stpl.Append(itemTitle, itemYear, itemType, releaseUrl); + } + + return ContentTo(rjson ? stpl.ToJson() : stpl.ToHtml()); + }); + } + + async ValueTask Release(UnimayInvoke invoke, OnlinesSettings init, string code, string title, string original_title, int serial, int s, int e, bool play, bool rjson) + { + string memKey = $"unimay:release:{code}"; + + return await InvkSemaphore(memKey, async () => + { + var releaseDetail = await invoke.Release(code); + if (releaseDetail == null) + return OnError("no release detail"); + + string itemType = releaseDetail.Type; + var playlist = releaseDetail.Playlist; + + if (playlist == null || playlist.Count == 0) + return OnError("no playlist"); + + if (play) + { + // Get specific episode + Unimay.Models.Episode episode = null; + if (itemType == "Телесеріал") + { + if (s <= 0 || e <= 0) return OnError("invalid episode"); + episode = playlist.FirstOrDefault(ep => ep.Number == e); + } + else // Movie + { + episode = playlist[0]; + } + + if (episode == null) + return OnError("episode not found"); + + string masterUrl = invoke.GetStreamUrl(episode); + if (string.IsNullOrEmpty(masterUrl)) + return OnError("no stream"); + + return UpdateService.Validate(Redirect(HostStreamProxy(init, accsArgs(masterUrl), proxy: proxyManager.Get()))); + } + + if (itemType == "Фільм") + { + var (movieTitle, movieLink) = invoke.GetMovieResult(host, releaseDetail, title, original_title); + var mtpl = new MovieTpl(title, original_title, 1); + mtpl.Append(movieTitle, accsArgs(movieLink), method: "play"); + return ContentTo(rjson ? mtpl.ToJson() : mtpl.ToHtml()); + } + else if (itemType == "Телесеріал") + { + if (s == -1) + { + // Assume single season + var (seasonName, seasonUrl, seasonId) = invoke.GetSeasonInfo(host, code, title, original_title); + var stpl = new SeasonTpl(); + stpl.Append(seasonName, seasonUrl, seasonId); + return ContentTo(rjson ? stpl.ToJson() : stpl.ToHtml()); + } + else + { + // Episodes for season 1 + var episodes = invoke.GetEpisodesForSeason(host, releaseDetail, title, original_title); + var mtpl = new MovieTpl(title, original_title, episodes.Count); + foreach (var (epTitle, epLink) in episodes) + { + mtpl.Append(epTitle, accsArgs(epLink), method: "play"); + } + return ContentTo(rjson ? mtpl.ToJson() : mtpl.ToHtml()); + } + } + + return OnError("unsupported type"); + }); + } + } +} diff --git a/Unimay/ModInit.cs b/Unimay/ModInit.cs new file mode 100644 index 0000000..36bc976 --- /dev/null +++ b/Unimay/ModInit.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Models.Online.Settings; +using Shared.Models.Module; + +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; + +namespace Unimay +{ + public class ModInit + { + public static double Version => 3.1; + + public static OnlinesSettings Unimay; + + public static OnlinesSettings Settings + { + get => Unimay; + set => Unimay = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + UpdateService.Start(initspace.memoryCache, initspace.nws); + + Unimay = new OnlinesSettings("Unimay", "https://api.unimay.media/v1", streamproxy: false, useproxy: false) + { + displayname = "Unimay", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "a", + password = "a", + list = new string[] { "socks5://IP:PORT" } + } + }; + Unimay = ModuleInvoke.Conf("Unimay", Unimay).ToObject(); + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("unimay"); + } + } +} diff --git a/Unimay/Models/Episode.cs b/Unimay/Models/Episode.cs new file mode 100644 index 0000000..f837ebf --- /dev/null +++ b/Unimay/Models/Episode.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Unimay.Models +{ + public class Episode + { + [JsonPropertyName("number")] + public int Number { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("hls")] + public Hls Hls { get; set; } + } + + public class Hls + { + [JsonPropertyName("master")] + public string Master { get; set; } + } +} \ No newline at end of file diff --git a/Unimay/Models/ReleaseResponse.cs b/Unimay/Models/ReleaseResponse.cs new file mode 100644 index 0000000..c9e6bd4 --- /dev/null +++ b/Unimay/Models/ReleaseResponse.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unimay.Models +{ + public class ReleaseResponse + { + [JsonPropertyName("code")] + public string Code { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("year")] + public string Year { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } // "Фільм" або "Телесеріал" + + [JsonPropertyName("playlist")] + public List Playlist { get; set; } + } +} \ No newline at end of file diff --git a/Unimay/Models/SearchResponse.cs b/Unimay/Models/SearchResponse.cs new file mode 100644 index 0000000..615278c --- /dev/null +++ b/Unimay/Models/SearchResponse.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unimay.Models +{ + public class SearchResponse + { + [JsonPropertyName("content")] + public List Content { get; set; } + + [JsonPropertyName("totalElements")] + public int TotalElements { get; set; } + } + + public class ReleaseInfo + { + [JsonPropertyName("code")] + public string Code { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("year")] + public string Year { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } // "Фільм" або "Телесеріал" + + [JsonPropertyName("names")] + public Names Names { get; set; } + } + + public class Names + { + [JsonPropertyName("ukr")] + public string Ukr { get; set; } + + [JsonPropertyName("eng")] + public string Eng { get; set; } + } +} \ No newline at end of file diff --git a/Unimay/OnlineApi.cs b/Unimay/OnlineApi.cs new file mode 100644 index 0000000..5468041 --- /dev/null +++ b/Unimay/OnlineApi.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace Unimay +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + 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); + } + + public static List<(string name, string url, string plugin, int index)> 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<(string name, string url, string plugin, int index)>(); + + var init = ModInit.Unimay; + + // Визначення isAnime згідно стандарту Lampac (Deepwiki): + // isanime = true якщо original_language == "ja" або "zh" + bool hasLang = !string.IsNullOrEmpty(original_language); + bool isanime = hasLang && (original_language == "ja" || original_language == "zh"); + + // Unimay — аніме-провайдер. Додаємо якщо: + // - загальний пошук (serial == -1), або + // - контент є аніме (isanime), або + // - мова невідома (немає original_language) + if (init.enable && !init.rip && (serial == -1 || isanime || !hasLang)) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/unimay"; + + online.Add((init.displayname, url, "unimay", init.displayindex)); + } + + return online; + } + } +} diff --git a/Unimay/Unimay.csproj b/Unimay/Unimay.csproj new file mode 100644 index 0000000..9ced83e --- /dev/null +++ b/Unimay/Unimay.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + false + latest + + + + + ..\..\Shared.dll + + + + \ No newline at end of file diff --git a/Unimay/UnimayInvoke.cs b/Unimay/UnimayInvoke.cs new file mode 100644 index 0000000..7706fd9 --- /dev/null +++ b/Unimay/UnimayInvoke.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shared; +using Shared.Models.Online.Settings; +using Shared.Models; +using System.Linq; +using Unimay.Models; +using Shared.Engine; +using System.Net; +using System.Text; + +namespace Unimay +{ + public class UnimayInvoke + { + private OnlinesSettings _init; + private ProxyManager _proxyManager; + private IHybridCache _hybridCache; + private Action _onLog; + + public UnimayInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task Search(string title, string original_title, int serial) + { + string memKey = $"unimay:search:{title}:{original_title}:{serial}"; + if (_hybridCache.TryGetValue(memKey, out SearchResponse searchResults)) + return searchResults; + + try + { + string searchQuery = System.Web.HttpUtility.UrlEncode(title ?? original_title ?? ""); + string searchUrl = $"{_init.host}/release/search?page=0&page_size=10&title={searchQuery}"; + + var headers = httpHeaders(_init); + SearchResponse root = await Http.Get(searchUrl, timeoutSeconds: 8, proxy: _proxyManager.Get(), headers: headers); + + if (root == null || root.Content == null || root.Content.Count == 0) + { + // Refresh proxy on failure + _proxyManager.Refresh(); + return null; + } + + _hybridCache.Set(memKey, root, cacheTime(30, init: _init)); + return root; + } + catch (Exception ex) + { + _onLog($"Unimay search error: {ex.Message}"); + return null; + } + } + + public async Task Release(string code) + { + string memKey = $"unimay:release:{code}"; + if (_hybridCache.TryGetValue(memKey, out ReleaseResponse releaseDetail)) + return releaseDetail; + + try + { + string releaseUrl = $"{_init.host}/release?code={code}"; + + var headers = httpHeaders(_init); + ReleaseResponse root = await Http.Get(releaseUrl, timeoutSeconds: 8, proxy: _proxyManager.Get(), headers: headers); + + if (root == null) + { + // Refresh proxy on failure + _proxyManager.Refresh(); + return null; + } + + _hybridCache.Set(memKey, root, cacheTime(60, init: _init)); + return root; + } + catch (Exception ex) + { + _onLog($"Unimay release error: {ex.Message}"); + return null; + } + } + + public List<(string title, string year, string type, string url)> GetSearchResults(string host, SearchResponse searchResults, string title, string original_title, int serial) + { + var results = new List<(string title, string year, string type, string url)>(); + + foreach (var item in searchResults.Content) + { + // Filter by serial if specified (0: movie "Фільм", 1: serial "Телесеріал") + if (serial != -1) + { + bool isMovie = item.Type == "Фільм"; + if ((serial == 0 && !isMovie) || (serial == 1 && isMovie)) + continue; + } + + string itemTitle = item.Names?.Ukr ?? item.Names?.Eng ?? item.Title; + string releaseUrl = $"{host}/unimay?code={item.Code}&title={System.Web.HttpUtility.UrlEncode(itemTitle)}&original_title={System.Web.HttpUtility.UrlEncode(original_title ?? "")}&serial={serial}"; + results.Add((itemTitle, item.Year, item.Type, releaseUrl)); + } + + return results; + } + + public (string title, string link) GetMovieResult(string host, ReleaseResponse releaseDetail, string title, string original_title) + { + if (releaseDetail.Playlist == null || releaseDetail.Playlist.Count == 0) + return (null, null); + + var movieEpisode = releaseDetail.Playlist[0]; + string movieLink = $"{host}/unimay?code={releaseDetail.Code}&title={System.Web.HttpUtility.UrlEncode(title)}&original_title={System.Web.HttpUtility.UrlEncode(original_title ?? "")}&serial=0&play=true"; + string movieTitle = movieEpisode.Title ?? title; + + return (movieTitle, movieLink); + } + + public (string seasonName, string seasonUrl, string seasonId) GetSeasonInfo(string host, string code, string title, string original_title) + { + string seasonUrl = $"{host}/unimay?code={code}&title={System.Web.HttpUtility.UrlEncode(title)}&original_title={System.Web.HttpUtility.UrlEncode(original_title ?? "")}&serial=1&s=1"; + return ("Сезон 1", seasonUrl, "1"); + } + + public List<(string episodeTitle, string episodeUrl)> GetEpisodesForSeason(string host, ReleaseResponse releaseDetail, string title, string original_title) + { + var episodes = new List<(string episodeTitle, string episodeUrl)>(); + + if (releaseDetail.Playlist == null) + return episodes; + + foreach (var ep in releaseDetail.Playlist.Where(ep => ep.Number >= 1 && ep.Number <= 24).OrderBy(ep => ep.Number)) + { + string epTitle = ep.Title ?? $"Епізод {ep.Number}"; + string epLink = $"{host}/unimay?code={releaseDetail.Code}&title={System.Web.HttpUtility.UrlEncode(title)}&original_title={System.Web.HttpUtility.UrlEncode(original_title ?? "")}&serial=1&s=1&e={ep.Number}&play=true"; + episodes.Add((epTitle, epLink)); + } + + return episodes; + } + + public string GetStreamUrl(Episode episode) + { + return episode.Hls?.Master; + } + + private List httpHeaders(OnlinesSettings init) + { + return new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", init.host), + new HeadersModel("Accept", "application/json") + }; + } + + 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home; + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + } +} diff --git a/Unimay/UpdateService.cs b/Unimay/UpdateService.cs new file mode 100644 index 0000000..fa1494f --- /dev/null +++ b/Unimay/UpdateService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +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 UaTUT +{ + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} \ No newline at end of file diff --git a/Unimay/manifest.json b/Unimay/manifest.json new file mode 100644 index 0000000..eb31389 --- /dev/null +++ b/Unimay/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "Unimay.ModInit", + "online": "Unimay.OnlineApi" +} \ No newline at end of file