v0.4.1 — Rename detection rewrite + separator normalization fix
This commit is contained in:
1 parent
64ccb2094d
commit
a20181a0cd
2 files changed
+338
-86
No files matched your search
@@ -4,6 +4,36 @@ All notable changes to the DarkPeers Mod Queue Helper will be documented in this
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-04-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Separator normalization in rename detection** — dot-separated filenames (e.g. `DTS-HD.MA.5.1`) now parse identically to space-separated titles (e.g. `DTS-HD MA 5.1`), eliminating false positives when the only difference is formatting
|
||||||
|
- Dots between words are normalized to spaces before tokenization
|
||||||
|
- Codec dots preserved (H.264, H.265)
|
||||||
|
- Channel dots preserved (5.1, 2.0, 7.1)
|
||||||
|
- Fixes false positive on: `The Indian Runner 1991 1080p BluRay REMUX AVC DTS-HD MA 5.1-TDD` vs `The.Indian.Runner.1991.BluRay.Remux.1080p.AVC.DTS-HD.MA.5.1-TDD.mkv`
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-04-06
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rename detection rewrite** — replaced brittle word-overlap scoring and stripped-string cross-seed comparison with confidence-based `RenameDetector` module
|
||||||
|
- Weighted field-based scoring using `H.extractTitleElements` (title, year, resolution, source, codecs)
|
||||||
|
- Release group differences no longer penalize relevance score
|
||||||
|
- Codec alias matching (x264 = H.264, DD = AC-3, etc.)
|
||||||
|
- TV scope awareness — MediaInfo filename comparison skipped for season packs to prevent false positives
|
||||||
|
- Self-consistency checks (folder/file vs torrent name) as primary rename signal
|
||||||
|
- Five-level confidence model: `match`, `likely_match`, `uncertain`, `likely_renamed`, `renamed`
|
||||||
|
- Prowlarr results are now advisory (maximum: warn) — never auto-recommend REJECT
|
||||||
|
- Element ordering and file extension differences no longer cause false positives
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **False positive on element ordering** — `BluRay.1080p` vs `1080p.BluRay` no longer flagged as renamed
|
||||||
|
- **False positive on group name differences** — different release group on indexer no longer treated as rename evidence
|
||||||
|
- **TV season pack false positives** — single-episode MediaInfo filename no longer compared against season-level Prowlarr results
|
||||||
|
|
||||||
## [0.3.2] - 2026-04-05
|
## [0.3.2] - 2026-04-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+308
-86
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name UNIT3D Mod Queue Helper — DarkPeers
|
// @name UNIT3D Mod Queue Helper — DarkPeers
|
||||||
// @namespace https://gitea.computerliebe.org/Procuria/dp-modq-helper
|
// @namespace https://gitea.computerliebe.org/Procuria/dp-modq-helper
|
||||||
// @version 0.3.2
|
// @version 0.4.1
|
||||||
// @description Quality-gate checks for DarkPeers — extended moderation rules, title validation, SRRDB & Prowlarr integrations
|
// @description Quality-gate checks for DarkPeers — extended moderation rules, title validation, SRRDB & Prowlarr integrations
|
||||||
// @author TQG Contributors
|
// @author TQG Contributors
|
||||||
// @updateURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js
|
// @updateURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js
|
||||||
@@ -3754,6 +3754,297 @@ const Integrations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
* RENAME DETECTOR — Confidence-based rename detection for Prowlarr
|
||||||
|
* Replaces the brittle word-overlap + stripped-string comparison.
|
||||||
|
* Uses H.extractTitleElements for structured field-based scoring.
|
||||||
|
* ======================================================================== */
|
||||||
|
|
||||||
|
const RenameDetector = {
|
||||||
|
|
||||||
|
/** Codec aliases for fuzzy matching */
|
||||||
|
_codecAliases: {
|
||||||
|
"x264": "h.264", "h.264": "h.264", "avc": "h.264",
|
||||||
|
"x265": "h.265", "h.265": "h.265", "hevc": "h.265",
|
||||||
|
"dd": "ac-3", "ac-3": "ac-3",
|
||||||
|
"ddp": "e-ac-3", "dd+": "e-ac-3", "e-ac-3": "e-ac-3",
|
||||||
|
"truehd": "truehd", "atmos": "truehd",
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tokenize — Parse a release name into structured fields.
|
||||||
|
* Wraps H.extractTitleElements and adds titleName + container.
|
||||||
|
*/
|
||||||
|
tokenize(name) {
|
||||||
|
if (!name) return { raw: "", titleName: "", elements: [], positions: {}, group: null, year: null, resolution: null, source: null, vcodec: null, acodec: null, hdr: null, container: null };
|
||||||
|
|
||||||
|
let raw = name;
|
||||||
|
// Strip file extension
|
||||||
|
let container = null;
|
||||||
|
const extMatch = raw.match(/\.(mkv|mp4|avi|wmv|m4v|ts|m2ts|mov|flv|webm)$/i);
|
||||||
|
if (extMatch) {
|
||||||
|
container = extMatch[1].toLowerCase();
|
||||||
|
raw = raw.slice(0, -extMatch[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize separators before parsing — ensures dot-separated filenames
|
||||||
|
// (e.g. DTS-HD.MA.5.1) parse identically to space-separated titles
|
||||||
|
// (e.g. DTS-HD MA 5.1). Without this, multi-word codecs like DTS-HD MA
|
||||||
|
// only match when space-separated.
|
||||||
|
// Preserve dots in codec patterns like H.264, H.265 where the dot is
|
||||||
|
// semantically meaningful (letter.digits pattern).
|
||||||
|
const normalized = raw
|
||||||
|
.replace(/[_]/g, " ")
|
||||||
|
.replace(/(?<=[A-Za-z])\.(?=\d{3})/g, "\x00") // protect codec dots (H.264, H.265)
|
||||||
|
.replace(/(?<=\d)\.(?=\d)/g, "\x01") // protect channel dots (5.1, 7.1, 2.0)
|
||||||
|
.replace(/\./g, " ")
|
||||||
|
.replace(/\x00/g, ".")
|
||||||
|
.replace(/\x01/g, ".");
|
||||||
|
|
||||||
|
const { elements, positions } = H.extractTitleElements(normalized);
|
||||||
|
|
||||||
|
// Extract individual fields from elements array
|
||||||
|
const fieldOf = (type) => {
|
||||||
|
const el = elements.find(e => e.type === type);
|
||||||
|
return el ? el.value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive titleName: text before the first structural token
|
||||||
|
let titleEnd = raw.length;
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.position < titleEnd) titleEnd = el.position;
|
||||||
|
}
|
||||||
|
const titleName = raw.slice(0, titleEnd).replace(/[.\-_]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: name,
|
||||||
|
titleName,
|
||||||
|
elements,
|
||||||
|
positions,
|
||||||
|
group: fieldOf("group"),
|
||||||
|
year: fieldOf("year"),
|
||||||
|
resolution: fieldOf("resolution"),
|
||||||
|
source: fieldOf("source") || fieldOf("type"),
|
||||||
|
vcodec: fieldOf("vcodec"),
|
||||||
|
acodec: fieldOf("acodec"),
|
||||||
|
hdr: fieldOf("hdr"),
|
||||||
|
container,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _jaccardWords — Jaccard similarity on normalized word sets.
|
||||||
|
*/
|
||||||
|
_jaccardWords(a, b) {
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
||||||
|
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
||||||
|
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
||||||
|
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||||
|
let intersection = 0;
|
||||||
|
for (const w of wordsA) if (wordsB.has(w)) intersection++;
|
||||||
|
const union = new Set([...wordsA, ...wordsB]).size;
|
||||||
|
return union === 0 ? 0 : intersection / union;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _normalizeCodec — Normalize a codec string via alias table.
|
||||||
|
*/
|
||||||
|
_normalizeCodec(codec) {
|
||||||
|
if (!codec) return null;
|
||||||
|
const key = codec.toLowerCase().replace(/[.\s-]/g, "").replace("dts", "dts");
|
||||||
|
// Direct alias lookup
|
||||||
|
for (const [alias, canonical] of Object.entries(this._codecAliases)) {
|
||||||
|
if (key === alias.replace(/[.\s-]/g, "")) return canonical;
|
||||||
|
}
|
||||||
|
return codec.toLowerCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scoreMatch — Weighted field comparison between upload and result tokens.
|
||||||
|
* Group is excluded from relevance score (tracked separately).
|
||||||
|
*/
|
||||||
|
scoreMatch(uploadTokens, resultTokens) {
|
||||||
|
const weights = { titleName: 3.0, year: 2.0, resolution: 1.5, source: 1.0, vcodec: 1.0, acodec: 0.5 };
|
||||||
|
let totalWeight = 0;
|
||||||
|
let weightedSum = 0;
|
||||||
|
const fieldScores = {};
|
||||||
|
|
||||||
|
// titleName — Jaccard similarity
|
||||||
|
const titleScore = this._jaccardWords(uploadTokens.titleName, resultTokens.titleName);
|
||||||
|
fieldScores.titleName = titleScore;
|
||||||
|
weightedSum += titleScore * weights.titleName;
|
||||||
|
totalWeight += weights.titleName;
|
||||||
|
|
||||||
|
// Exact-match fields
|
||||||
|
const exactFields = ["year", "resolution"];
|
||||||
|
for (const f of exactFields) {
|
||||||
|
if (uploadTokens[f] || resultTokens[f]) {
|
||||||
|
const score = uploadTokens[f] && resultTokens[f] && uploadTokens[f].toLowerCase() === resultTokens[f].toLowerCase() ? 1.0 : 0.0;
|
||||||
|
fieldScores[f] = score;
|
||||||
|
weightedSum += score * weights[f];
|
||||||
|
totalWeight += weights[f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source — exact match
|
||||||
|
if (uploadTokens.source || resultTokens.source) {
|
||||||
|
const score = uploadTokens.source && resultTokens.source && uploadTokens.source.toLowerCase().replace(/[.\s-]/g, "") === resultTokens.source.toLowerCase().replace(/[.\s-]/g, "") ? 1.0 : 0.0;
|
||||||
|
fieldScores.source = score;
|
||||||
|
weightedSum += score * weights.source;
|
||||||
|
totalWeight += weights.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codec fields — alias-aware
|
||||||
|
for (const f of ["vcodec", "acodec"]) {
|
||||||
|
if (uploadTokens[f] || resultTokens[f]) {
|
||||||
|
const a = this._normalizeCodec(uploadTokens[f]);
|
||||||
|
const b = this._normalizeCodec(resultTokens[f]);
|
||||||
|
const score = a && b && a === b ? 1.0 : 0.0;
|
||||||
|
fieldScores[f] = score;
|
||||||
|
weightedSum += score * weights[f];
|
||||||
|
totalWeight += weights[f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevanceScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||||
|
const groupMatch = !!(uploadTokens.group && resultTokens.group &&
|
||||||
|
uploadTokens.group.toLowerCase() === resultTokens.group.toLowerCase());
|
||||||
|
|
||||||
|
return { relevanceScore, fieldScores, groupMatch };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* findBestMatch — Select the best Prowlarr result by relevance score.
|
||||||
|
*/
|
||||||
|
findBestMatch(uploadTokens, results) {
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const r of results) {
|
||||||
|
const tokens = this.tokenize(r.title || "");
|
||||||
|
const score = this.scoreMatch(uploadTokens, tokens);
|
||||||
|
if (score.relevanceScore > bestScore) {
|
||||||
|
bestScore = score.relevanceScore;
|
||||||
|
best = { title: r.title, indexer: r.indexer, size: r.size, tokens, score, relevanceScore: score.relevanceScore };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* classifyTVScope — Determine upload scope and adapt search query.
|
||||||
|
*/
|
||||||
|
classifyTVScope(torrentName, fileStructure) {
|
||||||
|
const se = H.parseSeasonEpisode(torrentName);
|
||||||
|
if (se.season !== null && se.episode !== null) {
|
||||||
|
return { scope: "episode", season: se.season, searchQuery: torrentName, canCompareMediaInfoFile: true };
|
||||||
|
}
|
||||||
|
if (se.isSeasonPack) {
|
||||||
|
// For season packs, build a simpler query: title + year + S##
|
||||||
|
const year = H.extractYear(torrentName);
|
||||||
|
const titleEnd = torrentName.indexOf(se.raw);
|
||||||
|
const titlePart = titleEnd > 0 ? torrentName.slice(0, titleEnd).replace(/[.\-_]/g, " ").trim() : torrentName;
|
||||||
|
const query = [titlePart, year, se.raw].filter(Boolean).join(" ");
|
||||||
|
return { scope: "season_pack", season: se.season, searchQuery: query, canCompareMediaInfoFile: false };
|
||||||
|
}
|
||||||
|
// Not TV
|
||||||
|
return { scope: "movie", season: null, searchQuery: torrentName, canCompareMediaInfoFile: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* structurallyEquivalent — Compare two tokenized names by element sets.
|
||||||
|
* Ignores element order, separator style, and container extension.
|
||||||
|
* optionally ignores group differences.
|
||||||
|
*/
|
||||||
|
structurallyEquivalent(tokensA, tokensB, { ignoreGroup = false } = {}) {
|
||||||
|
// Compare title name words
|
||||||
|
const titleSim = this._jaccardWords(tokensA.titleName, tokensB.titleName);
|
||||||
|
if (titleSim < 0.5) return false;
|
||||||
|
|
||||||
|
// Compare structural fields — must match if both present
|
||||||
|
const fields = ["year", "resolution"];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (tokensA[f] && tokensB[f] && tokensA[f].toLowerCase() !== tokensB[f].toLowerCase()) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codec comparison with aliases
|
||||||
|
for (const f of ["vcodec", "acodec"]) {
|
||||||
|
if (tokensA[f] && tokensB[f]) {
|
||||||
|
const a = this._normalizeCodec(tokensA[f]);
|
||||||
|
const b = this._normalizeCodec(tokensB[f]);
|
||||||
|
if (a !== b) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group comparison
|
||||||
|
if (!ignoreGroup && tokensA.group && tokensB.group) {
|
||||||
|
if (tokensA.group.toLowerCase() !== tokensB.group.toLowerCase()) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* assessRename — Full rename assessment with confidence levels.
|
||||||
|
* Returns { level, action, note, issues, fieldScores }
|
||||||
|
*/
|
||||||
|
assessRename(data, bestMatch, tvScope) {
|
||||||
|
const issues = [];
|
||||||
|
const uploadTokens = this.tokenize(data.torrentName);
|
||||||
|
|
||||||
|
// Phase 1: Self-consistency checks (strongest signal)
|
||||||
|
if (data.fileStructure?.folderName) {
|
||||||
|
const folderTokens = this.tokenize(data.fileStructure.folderName);
|
||||||
|
if (!this.structurallyEquivalent(uploadTokens, folderTokens)) {
|
||||||
|
issues.push({
|
||||||
|
type: "folder", severity: "high",
|
||||||
|
expected: data.torrentName,
|
||||||
|
found: data.fileStructure.folderName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.mediaInfoFilename && tvScope.canCompareMediaInfoFile) {
|
||||||
|
const miTokens = this.tokenize(data.mediaInfoFilename);
|
||||||
|
if (!this.structurallyEquivalent(uploadTokens, miTokens)) {
|
||||||
|
issues.push({
|
||||||
|
type: "filename", severity: "high",
|
||||||
|
expected: data.torrentName,
|
||||||
|
found: data.mediaInfoFilename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Confidence assignment
|
||||||
|
const score = bestMatch.score;
|
||||||
|
const selfConsistent = issues.filter(i => i.severity === "high").length === 0;
|
||||||
|
|
||||||
|
if (selfConsistent && score.relevanceScore >= 0.80) {
|
||||||
|
const note = score.groupMatch ? null : "Best Prowlarr match has a different release group";
|
||||||
|
return { level: "match", action: "pass", note, issues, fieldScores: score.fieldScores };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfConsistent && score.relevanceScore >= 0.60) {
|
||||||
|
return { level: "likely_match", action: "pass", note: "Minor field differences with indexed release", issues, fieldScores: score.fieldScores };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selfConsistent) {
|
||||||
|
const highCount = issues.filter(i => i.severity === "high").length;
|
||||||
|
if (highCount >= 2) {
|
||||||
|
return { level: "renamed", action: "warn", note: "Folder and filename both differ from torrent name", issues, fieldScores: score.fieldScores };
|
||||||
|
}
|
||||||
|
return { level: "likely_renamed", action: "warn", note: "File or folder name differs from torrent name", issues, fieldScores: score.fieldScores };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score.relevanceScore < 0.60) {
|
||||||
|
return { level: "uncertain", action: "advisory", note: "No strong Prowlarr match — cannot assess rename status", issues, fieldScores: score.fieldScores };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { level: "uncertain", action: "advisory", note: null, issues, fieldScores: score.fieldScores };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
* MESSAGE BUILDER — Corrective message generation
|
* MESSAGE BUILDER — Corrective message generation
|
||||||
* Ported from the original G object. Rules URL is now configurable.
|
* Ported from the original G object. Rules URL is now configurable.
|
||||||
@@ -3873,7 +4164,7 @@ const U={getStatusIcon(e){switch(e){case"pass":return'<i class="fas fa-check-cir
|
|||||||
</div>
|
</div>
|
||||||
<div id="mh-filename-result" class="mh-filename-result"></div>
|
<div id="mh-filename-result" class="mh-filename-result"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel__body mh-body">${T}${this._buildIntegrationPlaceholders(e)}</div>`,n},injectPanel(e){const n=E.getModerationPanel();if(n)n.parentNode.insertBefore(e,n);else{const t=document.querySelector(_resolvedSelectors.torrentTags);t&&t.parentNode.insertBefore(e,t.nextSibling)}this.attachEvents(e)},attachEvents(e){const n=e.querySelector("#mh-toggle-all");if(n){let D=!1;n.addEventListener("click",()=>{D=!D,e.querySelectorAll(".mh-accordion, .mh-section").forEach(x=>x.open=D),n.querySelector("i").className=D?"fas fa-angles-up":"fas fa-angles-down",n.title=D?"Collapse all sections":"Expand all sections"})}const t=e.querySelector("#mh-corrective-text"),s=e.querySelector("#mh-corrective-editor");t&&s&&(t.addEventListener("click",()=>{t.style.display="none",s.style.display="block",s.focus()}),s.addEventListener("blur",()=>{t.textContent=s.value,t.style.display="",s.style.display=""}));const a=()=>s?s.value:"",o=e.querySelector("#mh-copy-message");o&&o.addEventListener("click",()=>{const D=a();navigator.clipboard.writeText(D).then(()=>{const x=o.querySelector("i");x.className="fas fa-check",o.classList.add("mh-btn--success"),setTimeout(()=>{x.className="fas fa-copy",o.classList.remove("mh-btn--success")},2e3)})});const c=D=>{const x=document.querySelectorAll(_resolvedSelectors.moderationForms);for(const p of x){const y=p.querySelector(_resolvedSelectors.moderationStatus);if(y&&y.value===D)return p.querySelector(_resolvedSelectors.moderationMessage)}return null},r=(D,x)=>{const p=e.querySelector(D);p&&p.addEventListener("click",()=>{const y=a(),C=c(x);if(C){C.value=y,C.dispatchEvent(new Event("input",{bubbles:!0}));const h=p.querySelector("i");h.className="fas fa-check",p.classList.add("mh-btn--success"),setTimeout(()=>{h.className="fas fa-paste",p.classList.remove("mh-btn--success")},2e3)}else{const h=p.querySelector("i");h.className="fas fa-xmark",p.classList.add("mh-btn--fail"),setTimeout(()=>{h.className="fas fa-paste",p.classList.remove("mh-btn--fail")},2e3)}})};r("#mh-fill-postpone",_resolvedModStatuses.postpone),r("#mh-fill-reject",_resolvedModStatuses.reject);const d=e.querySelector("#mh-filename-compare-btn"),f=e.querySelector("#mh-filename-block"),A=e.querySelector("#mh-filename-uploaded"),T=e.querySelector("#mh-filename-input"),b=e.querySelector("#mh-filename-run"),u=e.querySelector("#mh-filename-result");if(d&&f&&d.addEventListener("click",()=>{const D=f.style.display!=="none";if(f.style.display=D?"none":"",d.classList.toggle("mh-btn--active",!D),!D&&A){const x=E.getMediaInfoFilename()||"(not found)";A.textContent=x}}),b&&T&&u){const D=()=>{const x=T.value.trim();if(!x){u.innerHTML='<span class="mh-diff-error">Please enter a reference filename.</span>';return}const p=E.getMediaInfoFilename();if(!p||p==="(not found)"){u.innerHTML='<span class="mh-diff-error">Could not read MediaInfo filename from page.</span>';return}const y=z.diff(x,p);u.innerHTML=z.renderDiff(y)};b.addEventListener("click",D),T.addEventListener("keydown",x=>{x.key==="Enter"&&D()})}},_buildIntegrationPlaceholders(results){if(!results.nogroup&&!results.dpTitle)return"";return`<div class="mh-section-group"><details class="mh-section" open><summary class="mh-section__header"><i class="fas fa-plug mh-section__icon"></i><span class="mh-section__title">External Integrations</span></summary><div class="mh-section__body"><div data-integration="srrdb" class="mh-integration mh-integration--loading"><span class="mh-integration__icon"><i class="fas fa-spinner fa-spin"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Checking scene database...</span></div><div data-integration="prowlarr" class="mh-integration mh-integration--loading"><span class="mh-integration__icon"><i class="fas fa-spinner fa-spin"></i></span><span class="mh-integration__label">Prowlarr</span><span class="mh-integration__msg">Searching indexers...</span></div></div></details></div>`},renderIntegrationResult(name,result){const esc=(s)=>String(s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");if(result.error){return`<div class="mh-integration mh-integration--warn"><span class="mh-integration__icon"><i class="fas fa-exclamation-triangle mh-icon--warn"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">${esc(result.error)}</span></div>`}if(result.notConfigured){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">Not configured — open ModQ Helper Settings</span></div>`}if(name==="SRRDB"){if(!result.found){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Not found — may not be a scene release</span></div>Line truncated
|
<div class="panel__body mh-body">${T}${this._buildIntegrationPlaceholders(e)}</div>`,n},injectPanel(e){const n=E.getModerationPanel();if(n)n.parentNode.insertBefore(e,n);else{const t=document.querySelector(_resolvedSelectors.torrentTags);t&&t.parentNode.insertBefore(e,t.nextSibling)}this.attachEvents(e)},attachEvents(e){const n=e.querySelector("#mh-toggle-all");if(n){let D=!1;n.addEventListener("click",()=>{D=!D,e.querySelectorAll(".mh-accordion, .mh-section").forEach(x=>x.open=D),n.querySelector("i").className=D?"fas fa-angles-up":"fas fa-angles-down",n.title=D?"Collapse all sections":"Expand all sections"})}const t=e.querySelector("#mh-corrective-text"),s=e.querySelector("#mh-corrective-editor");t&&s&&(t.addEventListener("click",()=>{t.style.display="none",s.style.display="block",s.focus()}),s.addEventListener("blur",()=>{t.textContent=s.value,t.style.display="",s.style.display=""}));const a=()=>s?s.value:"",o=e.querySelector("#mh-copy-message");o&&o.addEventListener("click",()=>{const D=a();navigator.clipboard.writeText(D).then(()=>{const x=o.querySelector("i");x.className="fas fa-check",o.classList.add("mh-btn--success"),setTimeout(()=>{x.className="fas fa-copy",o.classList.remove("mh-btn--success")},2e3)})});const c=D=>{const x=document.querySelectorAll(_resolvedSelectors.moderationForms);for(const p of x){const y=p.querySelector(_resolvedSelectors.moderationStatus);if(y&&y.value===D)return p.querySelector(_resolvedSelectors.moderationMessage)}return null},r=(D,x)=>{const p=e.querySelector(D);p&&p.addEventListener("click",()=>{const y=a(),C=c(x);if(C){C.value=y,C.dispatchEvent(new Event("input",{bubbles:!0}));const h=p.querySelector("i");h.className="fas fa-check",p.classList.add("mh-btn--success"),setTimeout(()=>{h.className="fas fa-paste",p.classList.remove("mh-btn--success")},2e3)}else{const h=p.querySelector("i");h.className="fas fa-xmark",p.classList.add("mh-btn--fail"),setTimeout(()=>{h.className="fas fa-paste",p.classList.remove("mh-btn--fail")},2e3)}})};r("#mh-fill-postpone",_resolvedModStatuses.postpone),r("#mh-fill-reject",_resolvedModStatuses.reject);const d=e.querySelector("#mh-filename-compare-btn"),f=e.querySelector("#mh-filename-block"),A=e.querySelector("#mh-filename-uploaded"),T=e.querySelector("#mh-filename-input"),b=e.querySelector("#mh-filename-run"),u=e.querySelector("#mh-filename-result");if(d&&f&&d.addEventListener("click",()=>{const D=f.style.display!=="none";if(f.style.display=D?"none":"",d.classList.toggle("mh-btn--active",!D),!D&&A){const x=E.getMediaInfoFilename()||"(not found)";A.textContent=x}}),b&&T&&u){const D=()=>{const x=T.value.trim();if(!x){u.innerHTML='<span class="mh-diff-error">Please enter a reference filename.</span>';return}const p=E.getMediaInfoFilename();if(!p||p==="(not found)"){u.innerHTML='<span class="mh-diff-error">Could not read MediaInfo filename from page.</span>';return}const y=z.diff(x,p);u.innerHTML=z.renderDiff(y)};b.addEventListener("click",D),T.addEventListener("keydown",x=>{x.key==="Enter"&&D()})}},_buildIntegrationPlaceholders(results){if(!results.nogroup&&!results.dpTitle)return"";return`<div class="mh-section-group"><details class="mh-section" open><summary class="mh-section__header"><i class="fas fa-plug mh-section__icon"></i><span class="mh-section__title">External Integrations</span></summary><div class="mh-section__body"><div data-integration="srrdb" class="mh-integration mh-integration--loading"><span class="mh-integration__icon"><i class="fas fa-spinner fa-spin"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Checking scene database...</span></div><div data-integration="prowlarr" class="mh-integration mh-integration--loading"><span class="mh-integration__icon"><i class="fas fa-spinner fa-spin"></i></span><span class="mh-integration__label">Prowlarr</span><span class="mh-integration__msg">Searching indexers...</span></div></div></details></div>`},renderIntegrationResult(name,result){const esc=(s)=>String(s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");if(result.error){return`<div class="mh-integration mh-integration--warn"><span class="mh-integration__icon"><i class="fas fa-exclamation-triangle mh-icon--warn"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">${esc(result.error)}</span></div>`}if(result.notConfigured){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">Not configured — open ModQ Helper Settings</span></div>`}if(name==="SRRDB"){if(!result.found){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Not found — may not be a scene release</span></div>Line truncated
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
* CSS — Panel styles (injected via GM_addStyle)
|
* CSS — Panel styles (injected via GM_addStyle)
|
||||||
@@ -4610,97 +4901,28 @@ function main() {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prowlarr: search + rename / cross-seed detection
|
// Prowlarr: confidence-based rename detection via RenameDetector
|
||||||
if (dpFeatures.prowlarr && settings.prowlarr?.enabled && settings.prowlarr?.url) {
|
if (dpFeatures.prowlarr && settings.prowlarr?.enabled && settings.prowlarr?.url) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const searchResult = await Integrations.prowlarr.search(settings.prowlarr, data.torrentName);
|
const uploadTokens = RenameDetector.tokenize(data.torrentName);
|
||||||
|
const tvScope = RenameDetector.classifyTVScope(data.torrentName, data.fileStructure);
|
||||||
|
|
||||||
|
const searchResult = await Integrations.prowlarr.search(settings.prowlarr, tvScope.searchQuery);
|
||||||
|
|
||||||
if (searchResult.found && searchResult.results.length > 0) {
|
if (searchResult.found && searchResult.results.length > 0) {
|
||||||
// --- Best match by word-overlap similarity ---
|
const bestMatch = RenameDetector.findBestMatch(uploadTokens, searchResult.results);
|
||||||
const normalize = (t) => t.toLowerCase().replace(/[.\-_]/g, " ").replace(/\s+/g, " ").trim();
|
|
||||||
const uploadedNorm = normalize(data.torrentName);
|
|
||||||
|
|
||||||
let bestScore = -1;
|
|
||||||
let bestMatch = null;
|
|
||||||
for (const r of searchResult.results) {
|
|
||||||
const rNorm = normalize(r.title || "");
|
|
||||||
const uploadedWords = new Set(uploadedNorm.split(" "));
|
|
||||||
const rWords = rNorm.split(" ");
|
|
||||||
const matchCount = rWords.filter(w => uploadedWords.has(w)).length;
|
|
||||||
const score = matchCount / Math.max(uploadedWords.size, rWords.length);
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
bestMatch = { title: r.title, indexer: r.indexer, size: r.size, score };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestMatch) {
|
if (bestMatch) {
|
||||||
bestMatch.uploadedTitle = data.torrentName;
|
const assessment = RenameDetector.assessRename(data, bestMatch, tvScope);
|
||||||
bestMatch.renameWarning = bestMatch.score < 0.6;
|
searchResult.bestMatch = {
|
||||||
|
title: bestMatch.title,
|
||||||
// --- Cross-seed / file rename detection ---
|
indexer: bestMatch.indexer,
|
||||||
// Compare local filenames and folder name against the Prowlarr
|
size: bestMatch.size,
|
||||||
// best-match title to detect renames that break cross-seed.
|
uploadedTitle: data.torrentName,
|
||||||
// Cross-seed tools match on exact folder/file names; if the
|
relevanceScore: bestMatch.relevanceScore,
|
||||||
// uploader renamed files, cross-seeding is impossible.
|
confidence: assessment,
|
||||||
const crossSeed = { issues: [] };
|
};
|
||||||
|
|
||||||
// Expected base: the Prowlarr title IS the expected folder/file stem
|
|
||||||
const expectedStem = (bestMatch.title || "").replace(/\.[a-z0-9]{2,4}$/i, "");
|
|
||||||
const expectedNorm = normalize(expectedStem);
|
|
||||||
|
|
||||||
// Check folder name if present
|
|
||||||
if (data.fileStructure?.folderName) {
|
|
||||||
const folderNorm = normalize(data.fileStructure.folderName);
|
|
||||||
if (expectedNorm && folderNorm !== expectedNorm) {
|
|
||||||
// Tolerate minor separator differences (. vs space)
|
|
||||||
const stripped = (s) => s.replace(/[\s.\-_]/g, "").toLowerCase();
|
|
||||||
if (stripped(data.fileStructure.folderName) !== stripped(expectedStem)) {
|
|
||||||
crossSeed.issues.push({
|
|
||||||
type: "folder",
|
|
||||||
expected: expectedStem,
|
|
||||||
found: data.fileStructure.folderName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check MediaInfo filename (most reliable single-file indicator)
|
|
||||||
const miFilename = data.mediaInfoFilename;
|
|
||||||
if (miFilename && expectedStem) {
|
|
||||||
const miStem = miFilename.replace(/\.[a-z0-9]{2,4}$/i, "");
|
|
||||||
const stripped = (s) => s.replace(/[\s.\-_]/g, "").toLowerCase();
|
|
||||||
if (stripped(miStem) !== stripped(expectedStem)) {
|
|
||||||
crossSeed.issues.push({
|
|
||||||
type: "filename",
|
|
||||||
expected: expectedStem,
|
|
||||||
found: miStem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check individual files in the file list
|
|
||||||
if (data.fileStructure?.files?.length > 0 && data.fileStructure.files.length <= 5) {
|
|
||||||
// For small packs, check each file starts with the expected stem
|
|
||||||
const stripped = (s) => s.replace(/[\s.\-_]/g, "").toLowerCase();
|
|
||||||
const expStripped = stripped(expectedStem);
|
|
||||||
for (const f of data.fileStructure.files) {
|
|
||||||
const fname = f.split("/").pop().replace(/\.[a-z0-9]{2,4}$/i, "");
|
|
||||||
if (!stripped(fname).startsWith(expStripped.substring(0, Math.min(20, expStripped.length)))) {
|
|
||||||
// Only flag if the file doesn't even share the first ~20 chars
|
|
||||||
crossSeed.issues.push({
|
|
||||||
type: "file",
|
|
||||||
expected: expectedStem + ".*",
|
|
||||||
found: fname,
|
|
||||||
});
|
|
||||||
break; // One example is enough
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bestMatch.crossSeed = crossSeed;
|
|
||||||
searchResult.bestMatch = bestMatch;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user