diff --git a/KlonFUN/ApnHelper.cs b/KlonFUN/ApnHelper.cs new file mode 100644 index 0000000..e063df4 --- /dev/null +++ b/KlonFUN/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/KlonFUN/Controller.cs b/KlonFUN/Controller.cs new file mode 100644 index 0000000..278fff3 --- /dev/null +++ b/KlonFUN/Controller.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using KlonFUN.Models; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; + +namespace KlonFUN.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.KlonFUN); + } + + [HttpGet] + [Route("klonfun")] + async public Task Index(long id, string imdb_id, long kinopoisk_id, string title, string original_title, string original_language, int year, string source, int serial, string account_email, string t, int s = -1, bool rjson = false, string href = null, bool checksearch = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.KlonFUN); + if (!init.enable) + return Forbid(); + + var invoke = new KlonFUNInvoke(init, hybridCache, OnLog, proxyManager); + + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("klonfun", proxyManager); + + var checkResults = await invoke.Search(imdb_id, title, original_title); + if (checkResults != null && checkResults.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("klonfun", proxyManager); + } + + string itemUrl = href; + if (string.IsNullOrWhiteSpace(itemUrl)) + { + var searchResults = await invoke.Search(imdb_id, title, original_title); + if (searchResults == null || searchResults.Count == 0) + return OnError("klonfun", proxyManager); + + if (searchResults.Count > 1) + { + var similarTpl = new SimilarTpl(searchResults.Count); + foreach (SearchResult result in searchResults) + { + string link = $"{host}/klonfun?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(result.Url)}"; + similarTpl.Append(result.Title, result.Year > 0 ? result.Year.ToString() : string.Empty, string.Empty, link, result.Poster); + } + + return rjson + ? Content(similarTpl.ToJson(), "application/json; charset=utf-8") + : Content(similarTpl.ToHtml(), "text/html; charset=utf-8"); + } + + itemUrl = searchResults[0].Url; + } + + var item = await invoke.GetItem(itemUrl); + if (item == null || string.IsNullOrWhiteSpace(item.PlayerUrl)) + { + OnLog($"KlonFUN: не знайдено iframe-плеєр для {itemUrl}"); + return OnError("klonfun", proxyManager); + } + + string contentTitle = !string.IsNullOrWhiteSpace(title) ? title : item.Title; + if (string.IsNullOrWhiteSpace(contentTitle)) + contentTitle = "KlonFUN"; + + string contentOriginalTitle = !string.IsNullOrWhiteSpace(original_title) ? original_title : contentTitle; + + bool isSerial = serial == 1 || item.IsSerialPlayer; + if (isSerial) + { + var serialStructure = await invoke.GetSerialStructure(item.PlayerUrl); + if (serialStructure == null || serialStructure.Voices.Count == 0) + return OnError("klonfun", proxyManager); + + if (s == -1) + { + List seasons; + if (!string.IsNullOrWhiteSpace(t)) + { + var selectedVoice = serialStructure.Voices.FirstOrDefault(v => v.Key.Equals(t, StringComparison.OrdinalIgnoreCase)); + if (selectedVoice != null) + { + seasons = selectedVoice.Seasons.Keys.OrderBy(sn => sn).ToList(); + } + else + { + seasons = serialStructure.Voices + .SelectMany(v => v.Seasons.Keys) + .Distinct() + .OrderBy(sn => sn) + .ToList(); + } + } + else + { + seasons = serialStructure.Voices + .SelectMany(v => v.Seasons.Keys) + .Distinct() + .OrderBy(sn => sn) + .ToList(); + } + + if (seasons.Count == 0) + return OnError("klonfun", proxyManager); + + var seasonTpl = new SeasonTpl(seasons.Count); + foreach (int seasonNumber in seasons) + { + string link = $"{host}/klonfun?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={seasonNumber}&href={HttpUtility.UrlEncode(itemUrl)}"; + if (!string.IsNullOrWhiteSpace(t)) + link += $"&t={HttpUtility.UrlEncode(t)}"; + + 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 = serialStructure.Voices + .Where(v => v.Seasons.ContainsKey(s)) + .ToList(); + + if (voicesForSeason.Count == 0) + return OnError("klonfun", proxyManager); + + var selectedVoiceForSeason = voicesForSeason + .FirstOrDefault(v => !string.IsNullOrWhiteSpace(t) && v.Key.Equals(t, StringComparison.OrdinalIgnoreCase)) + ?? voicesForSeason[0]; + + var voiceTpl = new VoiceTpl(voicesForSeason.Count); + foreach (var voice in voicesForSeason) + { + string voiceLink = $"{host}/klonfun?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)}&href={HttpUtility.UrlEncode(itemUrl)}"; + voiceTpl.Append(voice.DisplayName, voice.Key.Equals(selectedVoiceForSeason.Key, StringComparison.OrdinalIgnoreCase), voiceLink); + } + + if (!selectedVoiceForSeason.Seasons.TryGetValue(s, out List episodes) || episodes.Count == 0) + return OnError("klonfun", proxyManager); + + var episodeTpl = new EpisodeTpl(episodes.Count); + foreach (SerialEpisode episode in episodes.OrderBy(e => e.Number)) + { + string episodeTitle = !string.IsNullOrWhiteSpace(episode.Title) + ? episode.Title + : $"Серія {episode.Number}"; + + string streamUrl = BuildStreamUrl(init, episode.Link); + episodeTpl.Append(episodeTitle, contentTitle, s.ToString(), episode.Number.ToString("D2"), streamUrl); + } + + episodeTpl.Append(voiceTpl); + if (rjson) + return Content(episodeTpl.ToJson(), "application/json; charset=utf-8"); + + return Content(episodeTpl.ToHtml(), "text/html; charset=utf-8"); + } + else + { + var streams = await invoke.GetMovieStreams(item.PlayerUrl); + if (streams == null || streams.Count == 0) + return OnError("klonfun", proxyManager); + + var movieTpl = new MovieTpl(contentTitle, contentOriginalTitle, streams.Count); + for (int i = 0; i < streams.Count; i++) + { + var stream = streams[i]; + string label = !string.IsNullOrWhiteSpace(stream.Title) + ? stream.Title + : $"Варіант {i + 1}"; + + string streamUrl = BuildStreamUrl(init, stream.Link); + movieTpl.Append(label, streamUrl); + } + + return rjson + ? Content(movieTpl.ToJson(), "application/json; charset=utf-8") + : Content(movieTpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrWhiteSpace(link)) + return link; + + if (ApnHelper.IsEnabled(init)) + { + if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) + return ApnHelper.WrapUrl(init, link); + + var noApn = (OnlinesSettings)init.Clone(); + noApn.apnstream = false; + noApn.apn = null; + return HostStreamProxy(noApn, link); + } + + return HostStreamProxy(init, link); + } + + private static string StripLampacArgs(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + } +} diff --git a/KlonFUN/KlonFUN.csproj b/KlonFUN/KlonFUN.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/KlonFUN/KlonFUN.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/KlonFUN/KlonFUNInvoke.cs b/KlonFUN/KlonFUNInvoke.cs new file mode 100644 index 0000000..196c706 --- /dev/null +++ b/KlonFUN/KlonFUNInvoke.cs @@ -0,0 +1,654 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using HtmlAgilityPack; +using KlonFUN.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; + +namespace KlonFUN +{ + public class KlonFUNInvoke + { + private static readonly Regex DirectFileRegex = new Regex(@"file\s*:\s*['""](?https?://[^'"">\s]+\.m3u8[^'"">\s]*)['""]", RegexOptions.Singleline | RegexOptions.IgnoreCase); + private static readonly Regex YearRegex = new Regex(@"(19|20)\d{2}", RegexOptions.IgnoreCase); + private static readonly Regex NumberRegex = new Regex(@"(\d+)", RegexOptions.IgnoreCase); + + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + public KlonFUNInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string imdbId, string title, string originalTitle) + { + string cacheKey = $"KlonFUN:search:{imdbId}:{title}:{originalTitle}"; + if (_hybridCache.TryGetValue(cacheKey, out List cached)) + return cached; + + try + { + if (!string.IsNullOrWhiteSpace(imdbId)) + { + var byImdb = await SearchByQuery(imdbId); + if (byImdb?.Count > 0) + { + _hybridCache.Set(cacheKey, byImdb, cacheTime(20, init: _init)); + _onLog?.Invoke($"KlonFUN: знайдено {byImdb.Count} результат(ів) за imdb_id={imdbId}"); + return byImdb; + } + } + + var queries = new[] { originalTitle, title } + .Where(q => !string.IsNullOrWhiteSpace(q)) + .Select(q => q.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var query in queries) + { + var partial = await SearchByQuery(query); + if (partial == null) + continue; + + foreach (var item in partial) + { + if (!string.IsNullOrWhiteSpace(item?.Url) && seen.Add(item.Url)) + results.Add(item); + } + + if (results.Count > 0) + break; + } + + if (results.Count > 0) + { + _hybridCache.Set(cacheKey, results, cacheTime(20, init: _init)); + _onLog?.Invoke($"KlonFUN: знайдено {results.Count} результат(ів) за назвою"); + return results; + } + } + catch (Exception ex) + { + _onLog?.Invoke($"KlonFUN: помилка пошуку - {ex.Message}"); + } + + return null; + } + + public async Task GetItem(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + string cacheKey = $"KlonFUN:item:{url}"; + if (_hybridCache.TryGetValue(cacheKey, out KlonItem cached)) + return cached; + + try + { + var headers = DefaultHeaders(); + string html = await Http.Get(_init.cors(url), headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + string title = CleanText(doc.DocumentNode.SelectSingleNode("//h1[contains(@class,'seo-h1__position')]")?.InnerText); + + string poster = doc.DocumentNode + .SelectSingleNode("//img[contains(@class,'cover-image')]") + ?.GetAttributeValue("data-src", null); + + if (string.IsNullOrWhiteSpace(poster)) + { + poster = doc.DocumentNode + .SelectSingleNode("//img[contains(@class,'cover-image')]") + ?.GetAttributeValue("src", null); + } + + poster = NormalizeUrl(poster); + + string playerUrl = doc.DocumentNode + .SelectSingleNode("//div[contains(@class,'film-player')]//iframe") + ?.GetAttributeValue("data-src", null); + + if (string.IsNullOrWhiteSpace(playerUrl)) + { + playerUrl = doc.DocumentNode + .SelectSingleNode("//div[contains(@class,'film-player')]//iframe") + ?.GetAttributeValue("src", null); + } + + playerUrl = NormalizeUrl(playerUrl); + + int year = 0; + var yearNode = doc.DocumentNode.SelectSingleNode("//div[contains(@class,'table__category') and contains(.,'Рік')]/following-sibling::div"); + if (yearNode != null) + { + var yearMatch = YearRegex.Match(yearNode.InnerText ?? string.Empty); + if (yearMatch.Success) + int.TryParse(yearMatch.Value, out year); + } + + var result = new KlonItem + { + Url = url, + Title = title, + Poster = poster, + PlayerUrl = playerUrl, + IsSerialPlayer = IsSerialPlayer(playerUrl), + Year = year + }; + + _hybridCache.Set(cacheKey, result, cacheTime(40, init: _init)); + return result; + } + catch (Exception ex) + { + _onLog?.Invoke($"KlonFUN: помилка читання сторінки {url} - {ex.Message}"); + return null; + } + } + + public async Task> GetMovieStreams(string playerUrl) + { + if (string.IsNullOrWhiteSpace(playerUrl)) + return null; + + string cacheKey = $"KlonFUN:movie:{playerUrl}"; + if (_hybridCache.TryGetValue(cacheKey, out List cached)) + return cached; + + try + { + string playerHtml = await GetPlayerHtml(playerUrl); + if (string.IsNullOrWhiteSpace(playerHtml)) + return null; + + var streams = new List(); + + JArray playerArray = ParsePlayerArray(playerHtml); + if (playerArray != null) + { + int index = 1; + foreach (JObject item in playerArray.OfType()) + { + string link = item.Value("file"); + if (string.IsNullOrWhiteSpace(link)) + continue; + + string voiceTitle = CleanText(item.Value("title")); + if (string.IsNullOrWhiteSpace(voiceTitle)) + voiceTitle = $"Варіант {index}"; + + streams.Add(new MovieStream + { + Title = voiceTitle, + Link = link + }); + + index++; + } + } + + if (streams.Count == 0) + { + var directMatch = DirectFileRegex.Match(playerHtml); + if (directMatch.Success) + { + streams.Add(new MovieStream + { + Title = "Основне джерело", + Link = directMatch.Groups["url"].Value + }); + } + } + + if (streams.Count > 0) + { + _hybridCache.Set(cacheKey, streams, cacheTime(30, init: _init)); + return streams; + } + } + catch (Exception ex) + { + _onLog?.Invoke($"KlonFUN: помилка парсингу плеєра фільму - {ex.Message}"); + } + + return null; + } + + public async Task GetSerialStructure(string playerUrl) + { + if (string.IsNullOrWhiteSpace(playerUrl)) + return null; + + string cacheKey = $"KlonFUN:serial:{playerUrl}"; + if (_hybridCache.TryGetValue(cacheKey, out SerialStructure cached)) + return cached; + + try + { + string playerHtml = await GetPlayerHtml(playerUrl); + if (string.IsNullOrWhiteSpace(playerHtml)) + return null; + + JArray playerArray = ParsePlayerArray(playerHtml); + if (playerArray == null || playerArray.Count == 0) + return null; + + var structure = new SerialStructure(); + var voiceCounter = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (JObject voiceObj in playerArray.OfType()) + { + var seasonsRaw = voiceObj["folder"] as JArray; + if (seasonsRaw == null || seasonsRaw.Count == 0) + continue; + + string baseName = CleanText(voiceObj.Value("title")); + if (string.IsNullOrWhiteSpace(baseName)) + baseName = "Озвучення"; + + string displayName = BuildUniqueVoiceName(baseName, voiceCounter); + + var voice = new SerialVoice + { + Key = displayName, + DisplayName = displayName, + Seasons = new Dictionary>() + }; + + int seasonFallback = 1; + foreach (JObject seasonObj in seasonsRaw.OfType()) + { + string seasonTitle = seasonObj.Value("title"); + int seasonNumber = ParseNumber(seasonTitle, seasonFallback); + + var episodesRaw = seasonObj["folder"] as JArray; + if (episodesRaw == null || episodesRaw.Count == 0) + { + seasonFallback++; + continue; + } + + var episodes = new List(); + int episodeFallback = 1; + + foreach (JObject episodeObj in episodesRaw.OfType()) + { + string link = episodeObj.Value("file"); + if (string.IsNullOrWhiteSpace(link)) + continue; + + string episodeTitle = CleanText(episodeObj.Value("title")); + int episodeNumber = ParseNumber(episodeTitle, episodeFallback); + + episodes.Add(new SerialEpisode + { + Number = episodeNumber, + Title = string.IsNullOrWhiteSpace(episodeTitle) ? $"Серія {episodeNumber}" : episodeTitle, + Link = link + }); + + episodeFallback++; + } + + if (episodes.Count > 0) + voice.Seasons[seasonNumber] = episodes.OrderBy(e => e.Number).ToList(); + + seasonFallback++; + } + + if (voice.Seasons.Count > 0) + structure.Voices.Add(voice); + } + + if (structure.Voices.Count > 0) + { + structure.Voices = structure.Voices + .OrderBy(v => v.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + _hybridCache.Set(cacheKey, structure, cacheTime(30, init: _init)); + return structure; + } + } + catch (Exception ex) + { + _onLog?.Invoke($"KlonFUN: помилка парсингу структури серіалу - {ex.Message}"); + } + + return null; + } + + public bool IsSerialPlayer(string playerUrl) + { + return !string.IsNullOrWhiteSpace(playerUrl) + && playerUrl.IndexOf("/serial/", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private async Task> SearchByQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return null; + + string cacheKey = $"KlonFUN:query:{query}"; + if (_hybridCache.TryGetValue(cacheKey, out List cached)) + return cached; + + try + { + var headers = DefaultHeaders(); + + string form = $"do=search&subaction=search&story={HttpUtility.UrlEncode(query)}"; + string html = await Http.Post(_init.cors(_init.host), form, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var nodes = doc.DocumentNode.SelectNodes("//div[contains(@class,'short-news__slide-item')]"); + if (nodes != null) + { + foreach (var node in nodes) + { + string href = node.SelectSingleNode(".//a[contains(@class,'short-news__small-card__link')]")?.GetAttributeValue("href", null) + ?? node.SelectSingleNode(".//a[contains(@class,'card-link__style')]")?.GetAttributeValue("href", null); + + href = NormalizeUrl(href); + if (string.IsNullOrWhiteSpace(href) || !seen.Add(href)) + continue; + + string title = CleanText(node.SelectSingleNode(".//div[contains(@class,'card-link__text')]")?.InnerText); + if (string.IsNullOrWhiteSpace(title)) + title = CleanText(node.SelectSingleNode(".//a[contains(@class,'card-link__style')]")?.InnerText); + + string poster = node.SelectSingleNode(".//img[contains(@class,'card-poster__img')]")?.GetAttributeValue("data-src", null); + if (string.IsNullOrWhiteSpace(poster)) + poster = node.SelectSingleNode(".//img[contains(@class,'card-poster__img')]")?.GetAttributeValue("src", null); + + string meta = CleanText(node.SelectSingleNode(".//div[contains(@class,'subscribe-label-module')]")?.InnerText); + int year = 0; + if (!string.IsNullOrWhiteSpace(meta)) + { + var yearMatch = YearRegex.Match(meta); + if (yearMatch.Success) + int.TryParse(yearMatch.Value, out year); + } + + if (!string.IsNullOrWhiteSpace(title)) + { + results.Add(new SearchResult + { + Title = title, + Url = href, + Poster = NormalizeUrl(poster), + Year = year + }); + } + } + } + + if (results.Count == 0) + { + // Резервний парсер для спрощеної HTML-відповіді (наприклад, AJAX search). + var anchors = doc.DocumentNode.SelectNodes("//a[.//span[contains(@class,'searchheading')]]"); + if (anchors != null) + { + foreach (var anchor in anchors) + { + string href = NormalizeUrl(anchor.GetAttributeValue("href", null)); + if (string.IsNullOrWhiteSpace(href) || !seen.Add(href)) + continue; + + string title = CleanText(anchor.SelectSingleNode(".//span[contains(@class,'searchheading')]")?.InnerText); + if (string.IsNullOrWhiteSpace(title)) + continue; + + results.Add(new SearchResult + { + Title = title, + Url = href, + Poster = string.Empty, + Year = 0 + }); + } + } + } + + if (results.Count > 0) + { + _hybridCache.Set(cacheKey, results, cacheTime(20, init: _init)); + return results; + } + } + catch (Exception ex) + { + _onLog?.Invoke($"KlonFUN: помилка запиту пошуку '{query}' - {ex.Message}"); + } + + return null; + } + + private async Task GetPlayerHtml(string playerUrl) + { + if (string.IsNullOrWhiteSpace(playerUrl)) + return null; + + string requestUrl = playerUrl; + if (ApnHelper.IsAshdiUrl(playerUrl) && ApnHelper.IsEnabled(_init) && string.IsNullOrWhiteSpace(_init.webcorshost)) + requestUrl = ApnHelper.WrapUrl(_init, playerUrl); + + var headers = DefaultHeaders(); + return await Http.Get(_init.cors(requestUrl), headers: headers, proxy: _proxyManager.Get()); + } + + private static JArray ParsePlayerArray(string html) + { + if (string.IsNullOrWhiteSpace(html)) + return null; + + string json = ExtractFileArray(html); + if (string.IsNullOrWhiteSpace(json)) + return null; + + json = WebUtility.HtmlDecode(json).Replace("\\/", "/"); + + try + { + return JsonConvert.DeserializeObject(json); + } + catch + { + return null; + } + } + + private static string ExtractFileArray(string html) + { + int searchIndex = 0; + while (searchIndex >= 0 && searchIndex < html.Length) + { + int fileIndex = html.IndexOf("file", searchIndex, StringComparison.OrdinalIgnoreCase); + if (fileIndex < 0) + return null; + + int colonIndex = html.IndexOf(':', fileIndex); + if (colonIndex < 0) + return null; + + int startIndex = colonIndex + 1; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + + if (startIndex < html.Length && (html[startIndex] == '\'' || html[startIndex] == '"')) + { + startIndex++; + while (startIndex < html.Length && char.IsWhiteSpace(html[startIndex])) + startIndex++; + } + + if (startIndex >= html.Length || html[startIndex] != '[') + { + searchIndex = fileIndex + 4; + continue; + } + + int depth = 0; + bool inString = false; + bool escaped = false; + + for (int i = startIndex; i < html.Length; i++) + { + char ch = html[i]; + + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (ch == '"') + inString = false; + + continue; + } + + if (ch == '"') + { + inString = true; + continue; + } + + if (ch == '[') + { + depth++; + continue; + } + + if (ch == ']') + { + depth--; + if (depth == 0) + return html.Substring(startIndex, i - startIndex + 1); + } + } + + return null; + } + + return null; + } + + private List DefaultHeaders() + { + return new List + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", _init.host) + }; + } + + private string NormalizeUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return string.Empty; + + string value = WebUtility.HtmlDecode(url.Trim()); + + if (value.StartsWith("//")) + return "https:" + value; + + if (value.StartsWith("/")) + return _init.host.TrimEnd('/') + value; + + if (!value.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return _init.host.TrimEnd('/') + "/" + value.TrimStart('/'); + + return value; + } + + private static string CleanText(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + return HtmlEntity.DeEntitize(value).Trim(); + } + + private static int ParseNumber(string value, int fallback) + { + if (!string.IsNullOrWhiteSpace(value)) + { + var match = NumberRegex.Match(value); + if (match.Success && int.TryParse(match.Value, out int parsed) && parsed > 0) + return parsed; + } + + return fallback; + } + + private static string BuildUniqueVoiceName(string baseName, Dictionary voiceCounter) + { + if (!voiceCounter.TryGetValue(baseName, out int count)) + { + voiceCounter[baseName] = 1; + return baseName; + } + + count++; + voiceCounter[baseName] = count; + return $"{baseName} #{count}"; + } + + 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/KlonFUN/ModInit.cs b/KlonFUN/ModInit.cs new file mode 100644 index 0000000..d174c42 --- /dev/null +++ b/KlonFUN/ModInit.cs @@ -0,0 +1,193 @@ +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Online.Settings; +using Shared.Models.Module; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using System; +using System.Net.Http; +using System.Net.Mime; +using System.Net.Security; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace KlonFUN +{ + public class ModInit + { + public static double Version => 1.0; + + public static OnlinesSettings KlonFUN; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => KlonFUN; + set => KlonFUN = value; + } + + /// + /// Модуль завантажено. + /// + public static void loaded(InitspaceModel initspace) + { + KlonFUN = new OnlinesSettings("KlonFUN", "https://klon.fun", streamproxy: false, useproxy: false) + { + displayname = "KlonFUN", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + + var conf = ModuleInvoke.Conf("KlonFUN", KlonFUN); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + KlonFUN = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, KlonFUN); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + + if (hasApn && apnEnabled) + { + KlonFUN.streamproxy = false; + } + else if (KlonFUN.streamproxy) + { + KlonFUN.apnstream = false; + KlonFUN.apn = null; + } + + // Додаємо підтримку "уточнити пошук". + AppInit.conf.online.with_search.Add("klonfun"); + } + } + + 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, 4)) + : DateTime.UtcNow; + } + } + } + catch + { + 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); +} diff --git a/KlonFUN/Models/KlonFUNModels.cs b/KlonFUN/Models/KlonFUNModels.cs new file mode 100644 index 0000000..efc31b9 --- /dev/null +++ b/KlonFUN/Models/KlonFUNModels.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; + +namespace KlonFUN.Models +{ + public class SearchResult + { + public string Title { get; set; } + public string Url { get; set; } + public string Poster { get; set; } + public int Year { get; set; } + } + + public class KlonItem + { + public string Url { get; set; } + public string Title { get; set; } + public string Poster { get; set; } + public string PlayerUrl { get; set; } + public bool IsSerialPlayer { get; set; } + public int Year { get; set; } + } + + public class PlayerVoice + { + public string title { get; set; } + public string file { get; set; } + public string subtitle { get; set; } + public string id { get; set; } + public List folder { get; set; } + } + + public class PlayerSeason + { + public string title { get; set; } + public List folder { get; set; } + } + + public class PlayerEpisode + { + public string title { get; set; } + public string file { get; set; } + public string subtitle { get; set; } + public string id { get; set; } + } + + public class MovieStream + { + public string Title { get; set; } + public string Link { get; set; } + } + + public class SerialEpisode + { + public int Number { get; set; } + public string Title { get; set; } + public string Link { get; set; } + } + + public class SerialVoice + { + public string Key { get; set; } + public string DisplayName { get; set; } + public Dictionary> Seasons { get; set; } = new(); + } + + public class SerialStructure + { + public List Voices { get; set; } = new(); + } +} diff --git a/KlonFUN/OnlineApi.cs b/KlonFUN/OnlineApi.cs new file mode 100644 index 0000000..27b0955 --- /dev/null +++ b/KlonFUN/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 KlonFUN +{ + 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.KlonFUN; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/klonfun"; + + online.Add((init.displayname, url, "klonfun", init.displayindex)); + } + + return online; + } + } +} diff --git a/KlonFUN/manifest.json b/KlonFUN/manifest.json new file mode 100644 index 0000000..4350366 --- /dev/null +++ b/KlonFUN/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "KlonFUN.ModInit", + "online": "KlonFUN.OnlineApi" +}