diff --git a/JackTor/Controller.cs b/JackTor/Controller.cs new file mode 100644 index 0000000..e697988 --- /dev/null +++ b/JackTor/Controller.cs @@ -0,0 +1,463 @@ +using JackTor.Models; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.PiTor; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; + +namespace JackTor.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.Settings); + } + + [HttpGet] + [Route("jacktor")] + 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 = null, + int s = -1, + bool rjson = false, + bool checksearch = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Settings); + if (!init.enable) + return Forbid(); + + if (NoAccessGroup(init, out string error_msg)) + return Json(new { accsdb = true, error_msg }); + + var invoke = new JackTorInvoke(init, hybridCache, OnLog, proxyManager); + + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("jacktor", proxyManager); + + var check = await invoke.Search(title, original_title, year, serial, original_language); + if (check.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("jacktor", proxyManager); + } + + var torrents = await invoke.Search(title, original_title, year, serial, original_language); + if (torrents == null || torrents.Count == 0) + return OnError("jacktor", proxyManager); + + if (serial == 1) + { + var seasons = torrents + .Where(i => i.Seasons != null && i.Seasons.Length > 0) + .SelectMany(i => i.Seasons) + .Distinct() + .OrderBy(i => i) + .ToList(); + + string enTitle = HttpUtility.UrlEncode(title); + string enOriginal = HttpUtility.UrlEncode(original_title); + + if (s == -1 && seasons.Count > 0) + { + string quality = torrents.FirstOrDefault(i => i.Quality >= 2160)?.QualityLabel + ?? torrents.FirstOrDefault(i => i.Quality >= 1080)?.QualityLabel + ?? torrents.FirstOrDefault(i => i.Quality >= 720)?.QualityLabel + ?? "720p"; + + var seasonTpl = new SeasonTpl(quality: quality); + foreach (int season in seasons) + { + seasonTpl.Append( + $"{season} сезон", + $"{host}/jacktor?rjson={rjson}&title={enTitle}&original_title={enOriginal}&year={year}&original_language={original_language}&serial=1&s={season}", + season); + } + + return rjson + ? Content(seasonTpl.ToJson(), "application/json; charset=utf-8") + : Content(seasonTpl.ToHtml(), "text/html; charset=utf-8"); + } + + int targetSeason = s == -1 ? 1 : s; + var releases = torrents + .Where(i => i.Seasons == null || i.Seasons.Length == 0 || i.Seasons.Contains(targetSeason)) + .ToList(); + + if (releases.Count == 0) + releases = torrents; + + var similarTpl = new SimilarTpl(); + foreach (var torrent in releases) + { + string releaseName = string.IsNullOrWhiteSpace(torrent.Voice) + ? (torrent.Tracker ?? "Без назви") + : torrent.Voice; + + string qualityInfo = $"{torrent.QualityLabel} / {torrent.MediaInfo} / ↑{torrent.Seeders}"; + string releaseLink = accsArgs($"{host}/jacktor/serial/{torrent.Rid}?rjson={rjson}&title={enTitle}&original_title={enOriginal}&s={targetSeason}"); + + similarTpl.Append(releaseName, null, qualityInfo, releaseLink); + } + + return rjson + ? Content(similarTpl.ToJson(), "application/json; charset=utf-8") + : Content(similarTpl.ToHtml(), "text/html; charset=utf-8"); + } + else + { + var movieTpl = new MovieTpl(title, original_title); + + foreach (var torrent in torrents) + { + string voice = string.IsNullOrWhiteSpace(torrent.Voice) + ? (torrent.Tracker ?? "Торрент") + : torrent.Voice; + + string voiceName = $"{torrent.QualityLabel} / {torrent.MediaInfo} / ↑{torrent.Seeders}"; + string streamLink = accsArgs($"{host}/jacktor/s{torrent.Rid}"); + + movieTpl.Append( + voice, + streamLink, + voice_name: voiceName, + quality: torrent.Quality > 0 ? torrent.Quality.ToString() : null); + } + + return rjson + ? Content(movieTpl.ToJson(), "application/json; charset=utf-8") + : Content(movieTpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + [HttpGet] + [Route("jacktor/serial/{rid}")] + async public ValueTask Serial(string rid, string account_email, string title, string original_title, int s = 1, bool rjson = false) + { + var init = await loadKit(ModInit.Settings); + if (!init.enable) + return Forbid(); + + if (NoAccessGroup(init, out string error_msg)) + return Json(new { accsdb = true, error_msg }); + + var invoke = new JackTorInvoke(init, hybridCache, OnLog, proxyManager); + if (!invoke.TryGetSource(rid, out JackTorSourceCache source)) + return OnError("jacktor", proxyManager); + + string memKey = $"jacktor:serial:{rid}"; + + return await InvkSemaphore(memKey, null, async () => + { + if (!hybridCache.TryGetValue(memKey, out FileStat[] fileStats)) + { + var ts = ResolveProbeTorrentServer(init, account_email); + if (string.IsNullOrWhiteSpace(ts.host)) + return OnError("jacktor", proxyManager); + + string hashResponse = await Http.Post( + $"{ts.host}/torrents", + BuildAddPayload(source.SourceUri), + timeoutSeconds: 8, + headers: ts.headers); + + string hash = ExtractHash(hashResponse); + if (string.IsNullOrWhiteSpace(hash)) + return OnError("jacktor", proxyManager); + + Stat stat = null; + DateTime deadline = DateTime.Now.AddSeconds(20); + + while (true) + { + stat = await Http.Post( + $"{ts.host}/torrents", + BuildGetPayload(hash), + timeoutSeconds: 3, + headers: ts.headers); + + if (stat?.file_stats != null && stat.file_stats.Length > 0) + break; + + if (DateTime.Now > deadline) + { + _ = Http.Post($"{ts.host}/torrents", BuildRemovePayload(hash), headers: ts.headers); + return OnError("jacktor", proxyManager); + } + + await Task.Delay(250); + } + + _ = Http.Post($"{ts.host}/torrents", BuildRemovePayload(hash), headers: ts.headers); + + fileStats = stat.file_stats; + hybridCache.Set(memKey, fileStats, DateTime.Now.AddHours(36)); + } + + if (fileStats == null || fileStats.Length == 0) + return OnError("jacktor", proxyManager); + + var episodeTpl = new EpisodeTpl(); + int appended = 0; + + foreach (var file in fileStats.OrderBy(i => i.Id)) + { + if (!IsVideoFile(file.Path)) + continue; + + episodeTpl.Append( + Path.GetFileName(file.Path), + title ?? original_title, + s.ToString(), + file.Id.ToString(), + accsArgs($"{host}/jacktor/s{rid}?tsid={file.Id}")); + + appended++; + } + + if (appended == 0) + return OnError("jacktor", proxyManager); + + return rjson + ? Content(episodeTpl.ToJson(), "application/json; charset=utf-8") + : Content(episodeTpl.ToHtml(), "text/html; charset=utf-8"); + }); + } + + [HttpGet] + [Route("jacktor/s{rid}")] + async public ValueTask Stream(string rid, int tsid = -1, string account_email = null) + { + var init = await loadKit(ModInit.Settings); + if (!init.enable) + return Forbid(); + + if (NoAccessGroup(init, out string error_msg)) + return Json(new { accsdb = true, error_msg }); + + var invoke = new JackTorInvoke(init, hybridCache, OnLog, proxyManager); + if (!invoke.TryGetSource(rid, out JackTorSourceCache source)) + return OnError("jacktor", proxyManager); + + int index = tsid != -1 ? tsid : 1; + string country = requestInfo.Country; + + async ValueTask AuthStream(string tsHost, string login, string passwd, string uhost = null, Dictionary addheaders = null) + { + string memKey = $"jacktor:auth_stream:{rid}:{uhost ?? tsHost}"; + if (!hybridCache.TryGetValue(memKey, out string hash)) + { + login = (login ?? string.Empty).Replace("{account_email}", account_email ?? string.Empty); + + var headers = HeadersModel.Init("Authorization", $"Basic {CrypTo.Base64($"{login}:{passwd}")}"); + headers = HeadersModel.Join(headers, addheaders); + + string response = await Http.Post( + $"{tsHost}/torrents", + BuildAddPayload(source.SourceUri), + timeoutSeconds: 5, + headers: headers); + + hash = ExtractHash(response); + if (string.IsNullOrWhiteSpace(hash)) + return OnError("jacktor", proxyManager); + + hybridCache.Set(memKey, hash, DateTime.Now.AddMinutes(1)); + } + + return Redirect($"{uhost ?? tsHost}/stream?link={hash}&index={index}&play"); + } + + if ((init.torrs == null || init.torrs.Length == 0) && (init.auth_torrs == null || init.auth_torrs.Count == 0)) + { + if (TryReadLocalTorrServerPassword(out string localPassword)) + { + return await AuthStream( + $"http://{AppInit.conf.listen.localhost}:9080", + "ts", + localPassword, + uhost: $"{host}/ts"); + } + + return Redirect($"{host}/ts/stream?link={HttpUtility.UrlEncode(source.SourceUri)}&index={index}&play"); + } + + if (init.auth_torrs != null && init.auth_torrs.Count > 0) + { + string tsKey = $"jacktor:ts2:{rid}:{requestInfo.IP}"; + if (!hybridCache.TryGetValue(tsKey, out PidTorAuthTS ts)) + { + var servers = init.auth_torrs.Where(i => i.enable).ToList(); + if (country != null) + { + servers = servers + .Where(i => i.country == null || i.country.Contains(country)) + .Where(i => i.no_country == null || !i.no_country.Contains(country)) + .ToList(); + } + + if (servers.Count == 0) + return OnError("jacktor", proxyManager); + + ts = servers[Random.Shared.Next(0, servers.Count)]; + hybridCache.Set(tsKey, ts, DateTime.Now.AddHours(4)); + } + + return await AuthStream(ts.host, ts.login, ts.passwd, addheaders: ts.headers); + } + else + { + if (init.base_auth != null && init.base_auth.enable) + { + if (init.torrs == null || init.torrs.Length == 0) + return OnError("jacktor", proxyManager); + + string tsKey = $"jacktor:ts3:{rid}:{requestInfo.IP}"; + if (!hybridCache.TryGetValue(tsKey, out string tsHost)) + { + tsHost = init.torrs[Random.Shared.Next(0, init.torrs.Length)]; + hybridCache.Set(tsKey, tsHost, DateTime.Now.AddHours(4)); + } + + return await AuthStream(tsHost, init.base_auth.login, init.base_auth.passwd, addheaders: init.base_auth.headers); + } + + if (init.torrs == null || init.torrs.Length == 0) + return OnError("jacktor", proxyManager); + + string key = $"jacktor:ts4:{rid}:{requestInfo.IP}"; + if (!hybridCache.TryGetValue(key, out string torrentHost)) + { + torrentHost = init.torrs[Random.Shared.Next(0, init.torrs.Length)]; + hybridCache.Set(key, torrentHost, DateTime.Now.AddHours(4)); + } + + return Redirect($"{torrentHost}/stream?link={HttpUtility.UrlEncode(source.SourceUri)}&index={index}&play"); + } + } + + private (List headers, string host) ResolveProbeTorrentServer(JackTorSettings init, string account_email) + { + if ((init.torrs == null || init.torrs.Length == 0) && (init.auth_torrs == null || init.auth_torrs.Count == 0)) + { + if (TryReadLocalTorrServerPassword(out string localPassword)) + { + var headers = HeadersModel.Init("Authorization", $"Basic {CrypTo.Base64($"ts:{localPassword}")}"); + return (headers, $"http://{AppInit.conf.listen.localhost}:9080"); + } + + return (null, $"http://{AppInit.conf.listen.localhost}:9080"); + } + + if (init.auth_torrs != null && init.auth_torrs.Count > 0) + { + var ts = init.auth_torrs.FirstOrDefault(i => i.enable) ?? init.auth_torrs.First(); + string login = (ts.login ?? string.Empty).Replace("{account_email}", account_email ?? string.Empty); + var auth = HeadersModel.Init("Authorization", $"Basic {CrypTo.Base64($"{login}:{ts.passwd}")}"); + + return (httpHeaders(ts.host, HeadersModel.Join(auth, ts.headers)), ts.host); + } + + if (init.base_auth != null && init.base_auth.enable) + { + string tsHost = init.torrs?.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(tsHost)) + return (null, null); + + string login = (init.base_auth.login ?? string.Empty).Replace("{account_email}", account_email ?? string.Empty); + var auth = HeadersModel.Init("Authorization", $"Basic {CrypTo.Base64($"{login}:{init.base_auth.passwd}")}"); + + return (httpHeaders(tsHost, HeadersModel.Join(auth, init.base_auth.headers)), tsHost); + } + + return (null, init.torrs?.FirstOrDefault()); + } + + private bool TryReadLocalTorrServerPassword(out string password) + { + password = null; + + if (!System.IO.File.Exists("torrserver/accs.db")) + return false; + + string accs = System.IO.File.ReadAllText("torrserver/accs.db"); + password = Regex.Match(accs, "\"ts\":\"([^\"]+)\"").Groups[1].Value; + return !string.IsNullOrWhiteSpace(password); + } + + private static string BuildAddPayload(string sourceUri) + { + return JsonSerializer.Serialize(new + { + action = "add", + link = sourceUri, + title = string.Empty, + poster = string.Empty, + save_to_db = false + }); + } + + private static string BuildGetPayload(string hash) + { + return JsonSerializer.Serialize(new + { + action = "get", + hash + }); + } + + private static string BuildRemovePayload(string hash) + { + return JsonSerializer.Serialize(new + { + action = "rem", + hash + }); + } + + private static string ExtractHash(string response) + { + return Regex.Match(response ?? string.Empty, "\"hash\":\"([^\"]+)\"").Groups[1].Value; + } + + private static bool IsVideoFile(string path) + { + string ext = (Path.GetExtension(path) ?? string.Empty).ToLowerInvariant(); + return ext switch + { + ".srt" => false, + ".txt" => false, + ".jpg" => false, + ".jpeg" => false, + ".png" => false, + ".nfo" => false, + _ => true, + }; + } + } +} diff --git a/JackTor/JackTor.csproj b/JackTor/JackTor.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/JackTor/JackTor.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/JackTor/JackTorInvoke.cs b/JackTor/JackTorInvoke.cs new file mode 100644 index 0000000..e3fdc5a --- /dev/null +++ b/JackTor/JackTorInvoke.cs @@ -0,0 +1,612 @@ +using JackTor.Models; +using Shared.Engine; +using Shared.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; + +namespace JackTor +{ + public class JackTorInvoke + { + private readonly JackTorSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + private static readonly (string token, string label)[] _voiceTokens = new[] + { + ("ukr", "Українська"), + ("укр", "Українська"), + ("україн", "Українська"), + ("eng", "Англійська"), + ("rus", "Російська"), + ("дубляж", "Дубляж"), + ("dub", "Дубляж"), + ("mvo", "Багатоголосий"), + ("lostfilm", "LostFilm"), + ("newstudio", "NewStudio"), + ("hdrezka", "HDRezka"), + ("anilibria", "AniLibria") + }; + + public JackTorInvoke(JackTorSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task> Search(string title, string originalTitle, int year, int serial, string originalLanguage) + { + string memKey = $"jacktor:search:{serial}:{year}:{(title ?? string.Empty).Trim().ToLowerInvariant()}:{(originalTitle ?? string.Empty).Trim().ToLowerInvariant()}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + var queries = BuildQueries(title, originalTitle, year); + if (queries.Count == 0) + return new List(); + + var rawResults = new List(256); + int categoryId = ResolveCategory(serial, originalLanguage); + + foreach (string query in queries) + { + var chunk = await SearchRaw(query, categoryId); + if (chunk != null && chunk.Count > 0) + rawResults.AddRange(chunk); + } + + var normalized = NormalizeAndFilter(rawResults, year, serial); + CacheSources(normalized); + _hybridCache.Set(memKey, normalized, DateTime.Now.AddMinutes(5)); + + _onLog?.Invoke($"JackTor: отримано {rawResults.Count} сирих результатів, після фільтрації лишилось {normalized.Count}"); + return normalized; + } + + public bool TryGetSource(string rid, out JackTorSourceCache source) + { + return _hybridCache.TryGetValue($"jacktor:source:{rid}", out source); + } + + private async Task> SearchRaw(string query, int categoryId) + { + string jackettHost = (_init.jackett ?? _init.host ?? string.Empty).Trim().TrimEnd('/'); + if (string.IsNullOrWhiteSpace(jackettHost)) + return null; + + string apikeyArg = string.IsNullOrWhiteSpace(_init.apikey) ? string.Empty : $"apikey={HttpUtility.UrlEncode(_init.apikey)}&"; + string categoryArg = categoryId > 0 ? $"&Category[]={categoryId}" : string.Empty; + + string url = $"{jackettHost}/api/v2.0/indexers/all/results?{apikeyArg}Query={HttpUtility.UrlEncode(query)}{categoryArg}"; + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", $"{jackettHost}/") + }; + + try + { + _onLog?.Invoke($"JackTor: запит до Jackett -> {query}"); + var root = await Http.Get( + _init.cors(url), + timeoutSeconds: _init.httptimeout > 0 ? _init.httptimeout : 12, + headers: headers, + proxy: _proxyManager.Get() + ); + + if (root?.Results == null || root.Results.Length == 0) + return null; + + return root.Results.ToList(); + } + catch (Exception ex) + { + _onLog?.Invoke($"JackTor: помилка запиту до Jackett ({query}) -> {ex.Message}"); + return null; + } + } + + private List BuildQueries(string title, string originalTitle, int year) + { + string mode = (_init.query_mode ?? "both").Trim().ToLowerInvariant(); + string cleanTitle = (title ?? string.Empty).Trim(); + string cleanOriginal = (originalTitle ?? string.Empty).Trim(); + + var queries = new List(6); + var unique = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + string normalized = Regex.Replace(query, "\\s{2,}", " ").Trim(); + if (normalized.Length < 2) + return; + + if (unique.Add(normalized)) + queries.Add(normalized); + } + + void AddWithYear(string value) + { + Add(value); + if (year > 1900) + Add($"{value} {year}"); + } + + if (mode == "original") + { + AddWithYear(cleanOriginal); + AddWithYear(cleanTitle); + } + else if (mode == "title") + { + AddWithYear(cleanTitle); + AddWithYear(cleanOriginal); + } + else + { + AddWithYear(cleanOriginal); + AddWithYear(cleanTitle); + Add(cleanOriginal); + Add(cleanTitle); + } + + return queries; + } + + private int ResolveCategory(int serial, string originalLanguage) + { + string lang = (originalLanguage ?? string.Empty).Trim().ToLowerInvariant(); + if (lang == "ja" || lang == "jp" || lang.StartsWith("ja-")) + return 0; + + return serial == 1 ? 5000 : 2000; + } + + private List NormalizeAndFilter(List rawResults, int year, int serial) + { + if (rawResults == null || rawResults.Count == 0) + return new List(); + + int yearTolerance = _init.year_tolerance < 0 ? 0 : _init.year_tolerance; + var allowTrackers = (_init.trackers_allow ?? Array.Empty()) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Select(i => i.Trim().ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var blockTrackers = (_init.trackers_block ?? Array.Empty()) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Select(i => i.Trim().ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var byRid = new Dictionary(StringComparer.OrdinalIgnoreCase); + DateTime now = DateTime.UtcNow; + + foreach (var raw in rawResults) + { + if (raw == null || string.IsNullOrWhiteSpace(raw.Title)) + continue; + + string source = !string.IsNullOrWhiteSpace(raw.MagnetUri) ? raw.MagnetUri : raw.Link; + if (string.IsNullOrWhiteSpace(source)) + continue; + + string tracker = (raw.Tracker ?? string.Empty).Trim(); + string trackerId = (raw.TrackerId ?? string.Empty).Trim(); + string trackerKey = !string.IsNullOrWhiteSpace(trackerId) ? trackerId.ToLowerInvariant() : tracker.ToLowerInvariant(); + + if (allowTrackers.Count > 0 && !allowTrackers.Contains(trackerKey) && !allowTrackers.Contains(tracker.ToLowerInvariant())) + continue; + + if (blockTrackers.Contains(trackerKey) || blockTrackers.Contains(tracker.ToLowerInvariant())) + continue; + + if (raw.Seeders < _init.min_sid) + continue; + + if (raw.Peers < _init.min_peers) + continue; + + if (_init.max_serial_size > 0 && _init.max_size > 0) + { + if (serial == 1) + { + if (raw.Size > _init.max_serial_size) + continue; + } + else if (raw.Size > _init.max_size) + { + continue; + } + } + else if (_init.max_size > 0 && raw.Size > _init.max_size) + { + continue; + } + + if (_init.max_age_days > 0 && raw.PublishDate > DateTime.MinValue) + { + DateTime pubUtc = raw.PublishDate.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(raw.PublishDate, DateTimeKind.Utc) + : raw.PublishDate.ToUniversalTime(); + + if ((now - pubUtc).TotalDays > _init.max_age_days) + continue; + } + + string searchable = $"{raw.Title} {raw.Description} {raw.CategoryDesc}"; + int quality = ParseQuality(searchable); + if (!_init.forceAll && quality == 0) + continue; + + if (_init.quality_allow != null && _init.quality_allow.Length > 0 && quality > 0 && !_init.quality_allow.Contains(quality)) + continue; + + string codec = ParseCodec(searchable); + bool isHdr = IsHdr(searchable); + bool isDolbyVision = Regex.IsMatch(searchable, "dolby\\s*vision", RegexOptions.IgnoreCase); + + if (!IsHdrModeAllowed(isHdr)) + continue; + + if (!IsCodecAllowed(codec)) + continue; + + int extractedYear = ExtractYear(searchable); + if (year > 1900 && extractedYear > 1900 && Math.Abs(extractedYear - year) > yearTolerance) + continue; + + int[] seasons = ParseSeasons(searchable); + bool serialHint = IsSerialHint(searchable); + bool movieHint = IsMovieHint(searchable); + + if (serial == 1) + { + if (!serialHint && seasons.Length == 0 && movieHint && !_init.forceAll) + continue; + } + else + { + if (serialHint && !movieHint && !_init.forceAll) + continue; + } + + string voice = ParseVoice(searchable, trackerKey); + if (_init.emptyVoice == false && string.IsNullOrWhiteSpace(voice)) + continue; + + string regexSource = $"{raw.Title}:{voice}"; + if (!string.IsNullOrWhiteSpace(_init.filter) && !IsRegexMatch(regexSource, _init.filter)) + continue; + + if (!string.IsNullOrWhiteSpace(_init.filter_ignore) && IsRegexMatch(regexSource, _init.filter_ignore)) + continue; + + string rid = BuildRid(raw.InfoHash, source); + + var parsed = new JackTorParsedResult() + { + Rid = rid, + Title = raw.Title.Trim(), + Tracker = tracker, + TrackerId = trackerId, + SourceUri = source.Trim(), + Voice = voice, + AudioRank = CalculateAudioRank(voice), + Quality = quality, + QualityLabel = quality > 0 ? $"{quality}p" : "невідома", + MediaInfo = BuildMediaInfo(raw.Size, codec, isHdr, isDolbyVision), + CategoryDesc = raw.CategoryDesc, + Codec = codec, + IsHdr = isHdr, + IsDolbyVision = isDolbyVision, + Seeders = raw.Seeders, + Peers = raw.Peers, + Size = raw.Size, + PublishDate = raw.PublishDate, + Seasons = seasons, + ExtractedYear = extractedYear, + Gain = raw.Gain + }; + + if (byRid.TryGetValue(rid, out JackTorParsedResult exists)) + { + if (Score(parsed) > Score(exists)) + byRid[rid] = parsed; + } + else + { + byRid[rid] = parsed; + } + } + + var result = byRid.Values; + + IOrderedEnumerable ordered = result + .OrderByDescending(i => i.AudioRank) + .ThenByDescending(i => !string.IsNullOrWhiteSpace(i.Voice)) + .ThenByDescending(i => i.SourceUri.StartsWith("magnet:?xt=urn:btih:", StringComparison.OrdinalIgnoreCase)); + + string sort = (_init.sort ?? "publishdate").Trim().ToLowerInvariant(); + if (sort == "size") + { + ordered = ordered.ThenByDescending(i => i.Size); + } + else if (sort == "sid") + { + ordered = ordered.ThenByDescending(i => i.Seeders).ThenByDescending(i => i.Peers); + } + else + { + ordered = ordered.ThenByDescending(i => i.PublishDate).ThenByDescending(i => i.Seeders); + } + + return ordered.ToList(); + } + + private bool IsRegexMatch(string source, string pattern) + { + try + { + return Regex.IsMatch(source ?? string.Empty, pattern, RegexOptions.IgnoreCase); + } + catch (Exception ex) + { + _onLog?.Invoke($"JackTor: помилка regex '{pattern}' -> {ex.Message}"); + return false; + } + } + + private int ParseQuality(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return 0; + + if (Regex.IsMatch(text, "(2160p|\\b4k\\b|\\buhd\\b)", RegexOptions.IgnoreCase)) + return 2160; + + if (Regex.IsMatch(text, "1080p", RegexOptions.IgnoreCase)) + return 1080; + + if (Regex.IsMatch(text, "720p", RegexOptions.IgnoreCase)) + return 720; + + if (Regex.IsMatch(text, "480p", RegexOptions.IgnoreCase)) + return 480; + + return 0; + } + + private string ParseCodec(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + if (Regex.IsMatch(text, "(hevc|h\\.265|x265)", RegexOptions.IgnoreCase)) + return "H.265"; + + if (Regex.IsMatch(text, "(h\\.264|x264|avc)", RegexOptions.IgnoreCase)) + return "H.264"; + + return string.Empty; + } + + private bool IsHdr(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + return Regex.IsMatch(text, "(hdr10|\\bhdr\\b)", RegexOptions.IgnoreCase); + } + + private bool IsHdrModeAllowed(bool isHdr) + { + string mode = (_init.hdr_mode ?? "any").Trim().ToLowerInvariant(); + return mode switch + { + "hdr_only" => isHdr, + "sdr_only" => !isHdr, + _ => true, + }; + } + + private bool IsCodecAllowed(string codec) + { + string mode = (_init.codec_allow ?? "any").Trim().ToLowerInvariant(); + return mode switch + { + "h265" => codec == "H.265", + "h264" => codec == "H.264", + _ => true, + }; + } + + private int ExtractYear(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return 0; + + var match = Regex.Match(text, "\\b(19\\d{2}|20\\d{2})\\b"); + if (!match.Success) + return 0; + + if (int.TryParse(match.Value, out int year)) + return year; + + return 0; + } + + private int[] ParseSeasons(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return Array.Empty(); + + var seasons = new HashSet(); + + foreach (Match match in Regex.Matches(text, "\\b[sс](\\d{1,2})(?:e\\d{1,2})?\\b", RegexOptions.IgnoreCase)) + { + if (int.TryParse(match.Groups[1].Value, out int season) && season > 0 && season < 100) + seasons.Add(season); + } + + foreach (Match match in Regex.Matches(text, "(?:season|сезон)\\s*(\\d{1,2})", RegexOptions.IgnoreCase)) + { + if (int.TryParse(match.Groups[1].Value, out int season) && season > 0 && season < 100) + seasons.Add(season); + } + + return seasons.OrderBy(i => i).ToArray(); + } + + private bool IsSerialHint(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + return Regex.IsMatch(text, "(\\b[sс]\\d{1,2}e\\d{1,2}\\b|season|сезон|episodes|серії|серии|\\btv\\b)", RegexOptions.IgnoreCase); + } + + private bool IsMovieHint(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + return Regex.IsMatch(text, "(movies|movie|film|фільм|кино)", RegexOptions.IgnoreCase); + } + + private string ParseVoice(string text, string trackerKey) + { + if (string.IsNullOrWhiteSpace(text)) + text = string.Empty; + + string lowered = text.ToLowerInvariant(); + var voices = new List(); + + foreach (var (token, label) in _voiceTokens) + { + if (lowered.Contains(token) && !voices.Contains(label)) + voices.Add(label); + } + + if (voices.Count == 0 && trackerKey == "toloka") + voices.Add("Українська"); + + return string.Join(", ", voices); + } + + private int CalculateAudioRank(string voice) + { + if (string.IsNullOrWhiteSpace(voice)) + return 0; + + var prefs = _init.audio_pref ?? Array.Empty(); + if (prefs.Length == 0) + return 0; + + string lowered = voice.ToLowerInvariant(); + int best = 0; + + for (int i = 0; i < prefs.Length; i++) + { + string pref = (prefs[i] ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(pref)) + continue; + + if (lowered.Contains(pref)) + { + int rank = prefs.Length - i; + if (rank > best) + best = rank; + } + } + + return best; + } + + private string BuildMediaInfo(long size, string codec, bool isHdr, bool isDolbyVision) + { + var parts = new List(4) + { + FormatSize(size) + }; + + parts.Add(isHdr ? "HDR" : "SDR"); + + if (!string.IsNullOrWhiteSpace(codec)) + parts.Add(codec); + + if (isDolbyVision) + parts.Add("Dolby Vision"); + + return string.Join(" / ", parts.Where(i => !string.IsNullOrWhiteSpace(i))); + } + + private string FormatSize(long size) + { + if (size <= 0) + return "розмір невідомий"; + + double gb = size / 1073741824d; + if (gb >= 1) + return $"{gb:0.##} GB"; + + double mb = size / 1048576d; + return $"{mb:0.##} MB"; + } + + private int Score(JackTorParsedResult item) + { + int qualityScore = item.Quality * 100; + int seedScore = item.Seeders * 20 + item.Peers * 5; + int audioScore = item.AudioRank * 200; + int gainScore = (int)Math.Max(0, Math.Min(item.Gain, 1000)); + int dateScore = item.PublishDate > DateTime.MinValue ? 100 : 0; + + return qualityScore + seedScore + audioScore + gainScore + dateScore; + } + + private string BuildRid(string infoHash, string source) + { + string hash = (infoHash ?? string.Empty).Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(hash)) + return hash; + + byte[] sourceBytes = Encoding.UTF8.GetBytes(source ?? string.Empty); + byte[] digest = SHA1.HashData(sourceBytes); + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + private void CacheSources(List items) + { + if (items == null || items.Count == 0) + return; + + DateTime expires = DateTime.Now.AddHours(36); + + foreach (var item in items) + { + var cacheItem = new JackTorSourceCache() + { + Rid = item.Rid, + SourceUri = item.SourceUri, + Title = item.Title, + Voice = item.Voice, + Quality = item.Quality, + Seasons = item.Seasons + }; + + _hybridCache.Set($"jacktor:source:{item.Rid}", cacheItem, expires); + } + } + } +} diff --git a/JackTor/ModInit.cs b/JackTor/ModInit.cs new file mode 100644 index 0000000..78b2c85 --- /dev/null +++ b/JackTor/ModInit.cs @@ -0,0 +1,191 @@ +using JackTor.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Module; +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 JackTor +{ + public class ModInit + { + public static double Version => 1.0; + + public static JackTorSettings JackTor; + + public static JackTorSettings Settings + { + get => JackTor; + set => JackTor = value; + } + + /// + /// Модуль завантажено. + /// + public static void loaded(InitspaceModel initspace) + { + JackTor = new JackTorSettings("JackTor", "http://127.0.0.1:9117", streamproxy: false, useproxy: false) + { + displayname = "JackTor", + displayindex = 0, + group = 0, + group_hide = true, + + jackett = "http://127.0.0.1:9117", + apikey = string.Empty, + min_sid = 5, + min_peers = 0, + max_size = 0, + max_serial_size = 0, + emptyVoice = true, + forceAll = false, + sort = "sid", + max_age_days = 0, + quality_allow = new[] { 2160, 1080, 720 }, + trackers_allow = Array.Empty(), + trackers_block = Array.Empty(), + hdr_mode = "any", + codec_allow = "any", + audio_pref = new[] { "ukr", "eng", "rus" }, + year_tolerance = 1, + query_mode = "both", + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + + var conf = ModuleInvoke.Conf("JackTor", JackTor) ?? JObject.FromObject(JackTor); + JackTor = conf.ToObject(); + + if (string.IsNullOrWhiteSpace(JackTor.jackett)) + JackTor.jackett = JackTor.host; + + if (string.IsNullOrWhiteSpace(JackTor.host)) + JackTor.host = JackTor.jackett; + + // Показувати «уточнити пошук». + AppInit.conf.online.with_search.Add("jacktor"); + } + } + + 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 record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} diff --git a/JackTor/Models/JackTorModels.cs b/JackTor/Models/JackTorModels.cs new file mode 100644 index 0000000..d441bcd --- /dev/null +++ b/JackTor/Models/JackTorModels.cs @@ -0,0 +1,198 @@ +using Shared.Models.Online.Settings; +using System; +using System.Collections.Generic; + +namespace JackTor.Models +{ + public class JackTorSettings : OnlinesSettings, ICloneable + { + public JackTorSettings( + string plugin, + string host, + string apihost = null, + bool useproxy = false, + string token = null, + bool enable = true, + bool streamproxy = false, + bool rip = false, + bool forceEncryptToken = false, + string rch_access = null, + string stream_access = null) + : base(plugin, host, apihost, useproxy, token, enable, streamproxy, rip, forceEncryptToken, rch_access, stream_access) + { + } + + public string jackett { get; set; } + + public string apikey { get; set; } + + public int min_sid { get; set; } + + public int min_peers { get; set; } + + public long max_size { get; set; } + + public long max_serial_size { get; set; } + + public bool emptyVoice { get; set; } + + public bool forceAll { get; set; } + + public string filter { get; set; } + + public string filter_ignore { get; set; } + + public string sort { get; set; } + + public int max_age_days { get; set; } + + public string[] trackers_allow { get; set; } + + public string[] trackers_block { get; set; } + + public int[] quality_allow { get; set; } + + public string hdr_mode { get; set; } + + public string codec_allow { get; set; } + + public string[] audio_pref { get; set; } + + public int year_tolerance { get; set; } + + public string query_mode { get; set; } + + public PidTorAuthTS base_auth { get; set; } + + public string[] torrs { get; set; } + + public List auth_torrs { get; set; } + + public new JackTorSettings Clone() + { + return (JackTorSettings)MemberwiseClone(); + } + + object ICloneable.Clone() + { + return MemberwiseClone(); + } + } + + public class JackettSearchRoot + { + public JackettResult[] Results { get; set; } + + public JackettIndexer[] Indexers { get; set; } + } + + public class JackettResult + { + public string Tracker { get; set; } + + public string TrackerId { get; set; } + + public string TrackerType { get; set; } + + public string CategoryDesc { get; set; } + + public string Title { get; set; } + + public string Link { get; set; } + + public string Details { get; set; } + + public DateTime PublishDate { get; set; } + + public int[] Category { get; set; } + + public long Size { get; set; } + + public string Description { get; set; } + + public int Seeders { get; set; } + + public int Peers { get; set; } + + public string InfoHash { get; set; } + + public string MagnetUri { get; set; } + + public double Gain { get; set; } + } + + public class JackettIndexer + { + public string ID { get; set; } + + public string Name { get; set; } + + public int Status { get; set; } + + public int Results { get; set; } + + public string Error { get; set; } + + public int ElapsedTime { get; set; } + } + + public class JackTorParsedResult + { + public string Rid { get; set; } + + public string Title { get; set; } + + public string Tracker { get; set; } + + public string TrackerId { get; set; } + + public string SourceUri { get; set; } + + public string Voice { get; set; } + + public int AudioRank { get; set; } + + public int Quality { get; set; } + + public string QualityLabel { get; set; } + + public string MediaInfo { get; set; } + + public string CategoryDesc { get; set; } + + public string Codec { get; set; } + + public bool IsHdr { get; set; } + + public bool IsDolbyVision { get; set; } + + public int Seeders { get; set; } + + public int Peers { get; set; } + + public long Size { get; set; } + + public DateTime PublishDate { get; set; } + + public int[] Seasons { get; set; } + + public int ExtractedYear { get; set; } + + public double Gain { get; set; } + } + + public class JackTorSourceCache + { + public string Rid { get; set; } + + public string SourceUri { get; set; } + + public string Title { get; set; } + + public string Voice { get; set; } + + public int Quality { get; set; } + + public int[] Seasons { get; set; } + } +} diff --git a/JackTor/OnlineApi.cs b/JackTor/OnlineApi.cs new file mode 100644 index 0000000..3f366b3 --- /dev/null +++ b/JackTor/OnlineApi.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace JackTor +{ + 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.JackTor; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/jacktor"; + + online.Add((init.displayname, url, "jacktor", init.displayindex)); + } + + return online; + } + } +} diff --git a/JackTor/manifest.json b/JackTor/manifest.json new file mode 100644 index 0000000..ad576fa --- /dev/null +++ b/JackTor/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "JackTor.ModInit", + "online": "JackTor.OnlineApi" +}