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.
This commit is contained in:
Felix 2026-03-02 16:36:08 +02:00
parent 459e9e5dda
commit 910b2588db
3 changed files with 68 additions and 8 deletions

View File

@ -118,11 +118,15 @@ namespace JackTor.Controllers
var similarTpl = new SimilarTpl(); var similarTpl = new SimilarTpl();
foreach (var torrent in releases) foreach (var torrent in releases)
{ {
string releaseName = string.IsNullOrWhiteSpace(torrent.Voice) string seasonLabel = (torrent.Seasons != null && torrent.Seasons.Length > 0)
? (torrent.Tracker ?? "Без назви") ? $"S{string.Join(",", torrent.Seasons.OrderBy(i => i))}"
: torrent.Voice; : $"S{targetSeason}";
string qualityInfo = $"{torrent.QualityLabel} / {torrent.MediaInfo} / ↑{torrent.Seeders}"; 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}"); string releaseLink = accsArgs($"{host}/jacktor/serial/{torrent.Rid}?rjson={rjson}&title={enTitle}&original_title={enOriginal}&s={targetSeason}");
similarTpl.Append(releaseName, null, qualityInfo, releaseLink); similarTpl.Append(releaseName, null, qualityInfo, releaseLink);

View File

@ -365,7 +365,7 @@ namespace JackTor
if (!string.IsNullOrWhiteSpace(_init.filter_ignore) && IsRegexMatch(regexSource, _init.filter_ignore)) if (!string.IsNullOrWhiteSpace(_init.filter_ignore) && IsRegexMatch(regexSource, _init.filter_ignore))
continue; continue;
string rid = BuildRid(raw.InfoHash, source); string rid = BuildRid(raw.InfoHash, raw.Guid, raw.Details, source);
var parsed = new JackTorParsedResult() var parsed = new JackTorParsedResult()
{ {
@ -403,7 +403,7 @@ namespace JackTor
} }
} }
var result = byRid.Values; var result = CollapseNearDuplicates(byRid.Values);
IOrderedEnumerable<JackTorParsedResult> ordered = result IOrderedEnumerable<JackTorParsedResult> ordered = result
.OrderByDescending(i => i.AudioRank) .OrderByDescending(i => i.AudioRank)
@ -637,6 +637,54 @@ namespace JackTor
return $"{mb:0.##} MB"; 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) private int Score(JackTorParsedResult item)
{ {
int qualityScore = item.Quality * 100; int qualityScore = item.Quality * 100;
@ -648,13 +696,19 @@ namespace JackTor
return qualityScore + seedScore + audioScore + gainScore + dateScore; return qualityScore + seedScore + audioScore + gainScore + dateScore;
} }
private string BuildRid(string infoHash, string source) private string BuildRid(string infoHash, string guid, string details, string source)
{ {
string hash = (infoHash ?? string.Empty).Trim().ToLowerInvariant(); string hash = (infoHash ?? string.Empty).Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(hash)) if (!string.IsNullOrWhiteSpace(hash))
return hash; return hash;
byte[] sourceBytes = Encoding.UTF8.GetBytes(source ?? string.Empty); 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); byte[] digest = SHA1.HashData(sourceBytes);
return Convert.ToHexString(digest).ToLowerInvariant(); return Convert.ToHexString(digest).ToLowerInvariant();
} }

View File

@ -88,6 +88,8 @@ namespace JackTor.Models
public class JackettResult public class JackettResult
{ {
public string Guid { get; set; }
public string Tracker { get; set; } public string Tracker { get; set; }
public string TrackerId { get; set; } public string TrackerId { get; set; }