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/).
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
+308
-86
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name UNIT3D Mod Queue Helper — DarkPeers
|
||||
// @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
|
||||
// @author TQG Contributors
|
||||
// @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
|
||||
* 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 id="mh-filename-result" class="mh-filename-result"></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)
|
||||
@@ -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) {
|
||||
(async () => {
|
||||
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) {
|
||||
// --- Best match by word-overlap similarity ---
|
||||
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 };
|
||||
}
|
||||
}
|
||||
const bestMatch = RenameDetector.findBestMatch(uploadTokens, searchResult.results);
|
||||
|
||||
if (bestMatch) {
|
||||
bestMatch.uploadedTitle = data.torrentName;
|
||||
bestMatch.renameWarning = bestMatch.score < 0.6;
|
||||
|
||||
// --- Cross-seed / file rename detection ---
|
||||
// Compare local filenames and folder name against the Prowlarr
|
||||
// best-match title to detect renames that break cross-seed.
|
||||
// Cross-seed tools match on exact folder/file names; if the
|
||||
// uploader renamed files, cross-seeding is impossible.
|
||||
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;
|
||||
const assessment = RenameDetector.assessRename(data, bestMatch, tvScope);
|
||||
searchResult.bestMatch = {
|
||||
title: bestMatch.title,
|
||||
indexer: bestMatch.indexer,
|
||||
size: bestMatch.size,
|
||||
uploadedTitle: data.torrentName,
|
||||
relevanceScore: bestMatch.relevanceScore,
|
||||
confidence: assessment,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user