Compare commits

..

6 Commits

Author SHA1 Message Date
Felix
a7dbdbce2e
Merge pull request #20 from lampame/JackTor
Jacktor
2026-03-02 16:48:26 +02:00
Felix
75560709a0 JackTor 2026-03-02 16:47:35 +02:00
Felix
910b2588db feat(jacktor): add result deduplication and improve release identification
Add CollapseNearDuplicates method to filter near-duplicate torrent results based on quality, size, seeders, peers, season, voice, and title. Improve BuildRid to use guid/details as stable identifiers when infohash is unavailable. Add season labels to release display in Controller. Add Guid property to JackettResult model.
2026-03-02 16:36:08 +02:00
Felix
459e9e5dda Try fix 503 and add logs 2026-03-02 16:27:33 +02:00
Felix
8c576249b3 refactor: extract and validate HTTP timeout, ensuring a positive default value. 2026-03-02 16:11:51 +02:00
Felix
208aad107e feat: Add JackTor online module for torrent search and streaming. 2026-03-02 16:07:54 +02:00
8 changed files with 1748 additions and 0 deletions

478
JackTor/Controller.cs Normal file
View File

@ -0,0 +1,478 @@
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<JackTorSettings>
{
ProxyManager proxyManager;
public Controller() : base(ModInit.Settings)
{
proxyManager = new ProxyManager(ModInit.Settings);
}
[HttpGet]
[Route("jacktor")]
async public Task<ActionResult> 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)
{
string debugInfo = $"title={title}\noriginal_title={original_title}\nyear={year}\nserial={serial}\njackett={MaskSensitiveUrl(init.jackett)}\nmin_sid={init.min_sid}\nmin_peers={init.min_peers}";
return OnError("jacktor", refresh_proxy: true, weblog: debugInfo);
}
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 seasonLabel = (torrent.Seasons != null && torrent.Seasons.Length > 0)
? $"S{string.Join(",", torrent.Seasons.OrderBy(i => i))}"
: $"S{targetSeason}";
string releaseName = string.IsNullOrWhiteSpace(torrent.Voice)
? $"{seasonLabel} • {(torrent.Tracker ?? "Без назви")}"
: $"{seasonLabel} • {torrent.Voice}";
string qualityInfo = $"{torrent.Tracker} / {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<ActionResult> 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<Stat>(
$"{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<ActionResult> 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<ActionResult> AuthStream(string tsHost, string login, string passwd, string uhost = null, Dictionary<string, string> 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<HeadersModel> 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,
};
}
private static string MaskSensitiveUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return string.Empty;
return Regex.Replace(url, "(apikey=)[^&]+", "$1***", RegexOptions.IgnoreCase);
}
}
}

15
JackTor/JackTor.csproj Normal file
View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Shared">
<HintPath>..\..\Shared.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

739
JackTor/JackTorInvoke.cs Normal file
View File

@ -0,0 +1,739 @@
using JackTor.Models;
using Shared.Engine;
using Shared.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
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);
}
if (rawResults.Count == 0 && categoryId > 0)
{
foreach (string query in queries)
{
var chunk = await SearchRaw(query, 0);
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 rawJackett = (_init.jackett ?? _init.host ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(rawJackett))
return null;
string endpoint = BuildJackettEndpoint(rawJackett);
if (string.IsNullOrWhiteSpace(endpoint))
return null;
string url = BuildJackettUrl(endpoint, query, categoryId);
string referer = BuildReferer(rawJackett);
var headers = new List<HeadersModel>()
{
new HeadersModel("User-Agent", "Mozilla/5.0"),
new HeadersModel("Referer", referer)
};
try
{
_onLog?.Invoke($"JackTor: запит до Jackett -> {query} (cat={categoryId})");
int timeoutSeconds = Convert.ToInt32(_init.httptimeout);
if (timeoutSeconds <= 0)
timeoutSeconds = 12;
var (root, response) = await Http.BaseGetAsync<JackettSearchRoot>(
_init.cors(url),
timeoutSeconds: timeoutSeconds,
headers: headers,
proxy: _proxyManager.Get(),
statusCodeOK: false,
IgnoreDeserializeObject: true
);
if (response == null || response.StatusCode != HttpStatusCode.OK)
{
_onLog?.Invoke($"JackTor: Jackett відповів статусом {(int?)response?.StatusCode} для {query}");
return null;
}
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 string BuildJackettEndpoint(string jackett)
{
string source = jackett.Trim().TrimEnd('/');
if (source.Contains("/api/v2.0/indexers/all/results", StringComparison.OrdinalIgnoreCase))
return source;
return $"{source}/api/v2.0/indexers/all/results";
}
private string BuildJackettUrl(string endpoint, string query, int categoryId)
{
var args = new List<string>(8);
if (!string.IsNullOrWhiteSpace(_init.apikey) && !HasParam(endpoint, "apikey"))
args.Add($"apikey={HttpUtility.UrlEncode(_init.apikey)}");
string encQuery = HttpUtility.UrlEncode(query ?? string.Empty);
args.Add($"Query={encQuery}");
args.Add($"query={encQuery}");
if (categoryId > 0)
{
args.Add($"Category[]={categoryId}");
args.Add($"cat={categoryId}");
}
string separator = endpoint.Contains("?") ? "&" : "?";
return endpoint + separator + string.Join("&", args);
}
private bool HasParam(string url, string name)
{
return Regex.IsMatch(url ?? string.Empty, $"[\\?&]{Regex.Escape(name)}=", RegexOptions.IgnoreCase);
}
private string BuildReferer(string jackett)
{
if (Uri.TryCreate(jackett, UriKind.Absolute, out Uri uri))
return $"{uri.Scheme}://{uri.Authority}/";
return jackett.Trim().TrimEnd('/') + "/";
}
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();
int seeders = raw.Seeders ?? 0;
int peers = raw.Peers ?? 0;
long size = raw.Size ?? 0;
DateTime publishDate = raw.PublishDate ?? DateTime.MinValue;
double gain = raw.Gain ?? 0;
if (allowTrackers.Count > 0 && !allowTrackers.Contains(trackerKey) && !allowTrackers.Contains(tracker.ToLowerInvariant()))
continue;
if (blockTrackers.Contains(trackerKey) || blockTrackers.Contains(tracker.ToLowerInvariant()))
continue;
if (seeders < _init.min_sid)
continue;
if (peers < _init.min_peers)
continue;
if (_init.max_serial_size > 0 && _init.max_size > 0)
{
if (serial == 1)
{
if (size > _init.max_serial_size)
continue;
}
else if (size > _init.max_size)
{
continue;
}
}
else if (_init.max_size > 0 && size > _init.max_size)
{
continue;
}
if (_init.max_age_days > 0 && publishDate > DateTime.MinValue)
{
DateTime pubUtc = publishDate.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(publishDate, DateTimeKind.Utc)
: 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, raw.Guid, raw.Details, 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(size, codec, isHdr, isDolbyVision),
CategoryDesc = raw.CategoryDesc,
Codec = codec,
IsHdr = isHdr,
IsDolbyVision = isDolbyVision,
Seeders = seeders,
Peers = peers,
Size = size,
PublishDate = publishDate,
Seasons = seasons,
ExtractedYear = extractedYear,
Gain = gain
};
if (byRid.TryGetValue(rid, out JackTorParsedResult exists))
{
if (Score(parsed) > Score(exists))
byRid[rid] = parsed;
}
else
{
byRid[rid] = parsed;
}
}
var result = CollapseNearDuplicates(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 IEnumerable<JackTorParsedResult> CollapseNearDuplicates(IEnumerable<JackTorParsedResult> items)
{
var dedup = new Dictionary<string, JackTorParsedResult>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
{
string seasonKey = (item.Seasons == null || item.Seasons.Length == 0)
? "-"
: string.Join(",", item.Seasons.OrderBy(i => i));
string key = $"{item.Quality}|{item.Size}|{item.Seeders}|{item.Peers}|{seasonKey}|{NormalizeVoice(item.Voice)}|{NormalizeTitle(item.Title)}";
if (dedup.TryGetValue(key, out JackTorParsedResult exists))
{
if (Score(item) > Score(exists))
dedup[key] = item;
}
else
{
dedup[key] = item;
}
}
return dedup.Values;
}
private string NormalizeVoice(string voice)
{
if (string.IsNullOrWhiteSpace(voice))
return string.Empty;
return string.Join(",",
voice.ToLowerInvariant()
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(i => i.Trim())
.Distinct()
.OrderBy(i => i));
}
private string NormalizeTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
return string.Empty;
string lower = title.ToLowerInvariant();
lower = Regex.Replace(lower, "\\s+", " ").Trim();
return lower;
}
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 guid, string details, string source)
{
string hash = (infoHash ?? string.Empty).Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(hash))
return hash;
string stableId = !string.IsNullOrWhiteSpace(guid)
? guid.Trim()
: !string.IsNullOrWhiteSpace(details)
? details.Trim()
: source ?? string.Empty;
byte[] sourceBytes = Encoding.UTF8.GetBytes(stableId);
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);
}
}
}
}

191
JackTor/ModInit.cs Normal file
View File

@ -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;
}
/// <summary>
/// Модуль завантажено.
/// </summary>
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<string>(),
trackers_block = Array.Empty<string>(),
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<JackTorSettings>();
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<ConnectResponse>(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);
}

View File

@ -0,0 +1,200 @@
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<PidTorAuthTS> 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 Guid { get; set; }
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; }
}
}

50
JackTor/OnlineApi.cs Normal file
View File

@ -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;
}
}
}

6
JackTor/manifest.json Normal file
View File

@ -0,0 +1,6 @@
{
"enable": true,
"version": 3,
"initspace": "JackTor.ModInit",
"online": "JackTor.OnlineApi"
}

View File

@ -84,6 +84,75 @@ Parameter compatibility:
- `webcorshost` does not conflict with `streamproxy`: CORS is used for parsing, `streamproxy` is used for streaming.
- `webcorshost` does not conflict with `apn`: APN is used at the streaming stage, not for regular parsing.
## JackTor config example (`init.conf`)
```json
"JackTor": {
"enable": true,
"displayname": "JackTor",
"displayindex": 0,
"jackett": "jackett.app",
"apikey": "YOUR_JACKETT_API_KEY",
"min_sid": 5,
"min_peers": 0,
"max_size": 0,
"max_serial_size": 0,
"max_age_days": 0,
"forceAll": false,
"emptyVoice": true,
"sort": "sid",
"query_mode": "both",
"year_tolerance": 1,
"quality_allow": [2160, 1080, 720],
"hdr_mode": "any",
"codec_allow": "any",
"audio_pref": ["ukr", "eng", "rus"],
"trackers_allow": ["toloka", "rutracker", "noname-club"],
"trackers_block": ["selezen"],
"filter": "",
"filter_ignore": "(camrip|ts|telesync)",
"torrs": [
"http://127.0.0.1:8090"
],
"auth_torrs": [
{
"enable": true,
"host": "http://ts.example.com:8090",
"login": "{account_email}",
"passwd": "StrongPassword",
"country": "UA",
"no_country": null,
"headers": {
"x-api-key": "your-ts-key"
}
}
],
"base_auth": {
"enable": false,
"login": "{account_email}",
"passwd": "StrongPassword",
"headers": {}
},
"group": 0,
"group_hide": true
}
```
Key parameters at a glance:
- `jackett` + `apikey`: your Jackett host and API key.
- `min_sid` / `min_peers` / `max_size` / `max_serial_size`: base torrent filters.
- `quality_allow`, `hdr_mode`, `codec_allow`, `audio_pref`: quality/codec/language prioritization.
- `torrs`, `auth_torrs`, `base_auth`: TorrServer nodes used for playback.
- `filter` / `filter_ignore`: regex filters for release title and voice labels.
## APN support
Sources with APN support: