lampac-ukraine/JackTor/JackTorInvoke.cs

617 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> _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<string> onLog, ProxyManager proxyManager)
{
_init = init;
_hybridCache = hybridCache;
_onLog = onLog;
_proxyManager = proxyManager;
}
public async Task<List<JackTorParsedResult>> 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<JackTorParsedResult> cached))
return cached;
var queries = BuildQueries(title, originalTitle, year);
if (queries.Count == 0)
return new List<JackTorParsedResult>();
var rawResults = new List<JackettResult>(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<List<JackettResult>> 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<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0"),
new HeadersModel("Referer", $"{jackettHost}/")
};
try
{
_onLog?.Invoke($"JackTor: запит до Jackett -> {query}");
int timeoutSeconds = Convert.ToInt32(_init.httptimeout);
if (timeoutSeconds <= 0)
timeoutSeconds = 12;
var root = await Http.Get<JackettSearchRoot>(
_init.cors(url),
timeoutSeconds: timeoutSeconds,
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<string> 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<string>(6);
var unique = new HashSet<string>(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<JackTorParsedResult> NormalizeAndFilter(List<JackettResult> rawResults, int year, int serial)
{
if (rawResults == null || rawResults.Count == 0)
return new List<JackTorParsedResult>();
int yearTolerance = _init.year_tolerance < 0 ? 0 : _init.year_tolerance;
var allowTrackers = (_init.trackers_allow ?? Array.Empty<string>())
.Where(i => !string.IsNullOrWhiteSpace(i))
.Select(i => i.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var blockTrackers = (_init.trackers_block ?? Array.Empty<string>())
.Where(i => !string.IsNullOrWhiteSpace(i))
.Select(i => i.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var byRid = new Dictionary<string, JackTorParsedResult>(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<JackTorParsedResult> 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<int>();
var seasons = new HashSet<int>();
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<string>();
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<string>();
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<string>(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<JackTorParsedResult> 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);
}
}
}
}