From c483ac0baafd20e4384d71106387fc12980dd249 Mon Sep 17 00:00:00 2001 From: Procuria Date: Mon, 6 Apr 2026 00:56:09 +0200 Subject: [PATCH] =?UTF-8?q?v0.4.1=20=E2=80=94=20Rename=20detection=20rewri?= =?UTF-8?q?te=20+=20separator=20normalization=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 30 +++ modq-helper-darkpeers.user.js | 394 ++++++++++++++++++++++++++-------- 2 files changed, 338 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8396bf4..01f7578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/modq-helper-darkpeers.user.js b/modq-helper-darkpeers.user.js index f66337b..c852a6d 100644 --- a/modq-helper-darkpeers.user.js +++ b/modq-helper-darkpeers.user.js @@ -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' -
${T}${this._buildIntegrationPlaceholders(e)}
`,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='Please enter a reference filename.';return}const p=E.getMediaInfoFilename();if(!p||p==="(not found)"){u.innerHTML='Could not read MediaInfo filename from page.';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`
External Integrations
SRRDBChecking scene database...
ProwlarrSearching indexers...
`},renderIntegrationResult(name,result){const esc=(s)=>String(s||"").replace(/&/g,"&").replace(//g,">");if(result.error){return`
${esc(name)}${esc(result.error)}
`}if(result.notConfigured){return`
${esc(name)}Not configured — open ModQ Helper Settings
`}if(name==="SRRDB"){if(!result.found){return`
SRRDBNot found — may not be a scene release
`}const relName=esc(result.release?.release||"Unknown");let fileHtml="";if(result.fileCheck){if(result.fileCheck.match){fileHtml=`
File/folder names match SRRDB record
`}else if(result.fileCheck.error){fileHtml=`
Could not verify files: ${esc(result.fileCheck.error)}
`}else{const diffs=(result.fileCheck.discrepancies||[]).map(d=>`
  • ${esc(d)}
  • `).join("");fileHtml=`
    File names differ from SRRDB record — possible rename (REJECT per A1.1)${diffs?`
      ${diffs}
    `:""}
    `}}return`
    SRRDBScene release found: ${relName}${fileHtml}
    `}if(name==="Prowlarr"){if(!result.found){return`
    ProwlarrRelease not indexed — may be new or not tracked
    `}const count=result.results?.length||0;const bestMatch=result.bestMatch;let matchHtml="";if(bestMatch){matchHtml+=`
    Best match: ${esc(bestMatch.title)} [${esc(bestMatch.indexer)}]
    `;if(bestMatch.renameWarning){matchHtml+=`
    Title differs from indexed release — possible rename (REJECT per A1.1)
    Uploaded: ${esc(bestMatch.uploadedTitle)}
    Indexed: ${esc(bestMatch.title)}
    `}else{matchHtml+=`
    Title consistent with indexed release
    `}if(bestMatch.crossSeed){const cs=bestMatch.crossSeed;if(cs.issues&&cs.issues.length>0){const csItems=cs.issues.map(i=>{if(i.type==="folder")return`
  • Folder renamed: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `;if(i.type==="filename")return`
  • File renamed: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `;return`
  • File mismatch: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `}).join("");matchHtml+=`
    Cross-seed broken: files/folders have been renamed from the original release. A new torrent file is needed for cross-seed to work. (REJECT per A1.1)
      ${csItems}
    `}else{matchHtml+=`
    Cross-seed compatible — file/folder names match indexed release
    `}}}const hasCrossSeedIssue=bestMatch?.crossSeed?.issues?.length>0;const prowlStatus=hasCrossSeedIssue?"fail":bestMatch?.renameWarning?"warn":"pass";const prowlIcon=hasCrossSeedIssue?"times-circle mh-icon--fail":bestMatch?.renameWarning?"exclamation-triangle mh-icon--warn":"check-circle mh-icon--pass";return`
    ProwlarrFound on ${count} indexer(s)${matchHtml}
    `}return`
    ${esc(name)}${result.found?"Found":"Not found"}
    `}}; +
    ${T}${this._buildIntegrationPlaceholders(e)}
    `,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='Please enter a reference filename.';return}const p=E.getMediaInfoFilename();if(!p||p==="(not found)"){u.innerHTML='Could not read MediaInfo filename from page.';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`
    External Integrations
    SRRDBChecking scene database...
    ProwlarrSearching indexers...
    `},renderIntegrationResult(name,result){const esc=(s)=>String(s||"").replace(/&/g,"&").replace(//g,">");if(result.error){return`
    ${esc(name)}${esc(result.error)}
    `}if(result.notConfigured){return`
    ${esc(name)}Not configured — open ModQ Helper Settings
    `}if(name==="SRRDB"){if(!result.found){return`
    SRRDBNot found — may not be a scene release
    `}const relName=esc(result.release?.release||"Unknown");let fileHtml="";if(result.fileCheck){if(result.fileCheck.match){fileHtml=`
    File/folder names match SRRDB record
    `}else if(result.fileCheck.error){fileHtml=`
    Could not verify files: ${esc(result.fileCheck.error)}
    `}else{const diffs=(result.fileCheck.discrepancies||[]).map(d=>`
  • ${esc(d)}
  • `).join("");fileHtml=`
    File names differ from SRRDB record — possible rename (REJECT per A1.1)${diffs?`
      ${diffs}
    `:""}
    `}}return`
    SRRDBScene release found: ${relName}${fileHtml}
    `}if(name==="Prowlarr"){if(!result.found){return`
    ProwlarrRelease not indexed — may be new or not tracked
    `}const count=result.results?.length||0;const bestMatch=result.bestMatch;let matchHtml="";if(bestMatch){const conf=bestMatch.confidence||{};const confLevel=conf.level||"uncertain";matchHtml+=`
    Best match: ${esc(bestMatch.title)} [${esc(bestMatch.indexer)}]
    `;if(confLevel==="match"||confLevel==="likely_match"){matchHtml+=`
    Title consistent with indexed release
    `}else if(confLevel==="uncertain"){matchHtml+=`
    Cannot confidently assess rename status — review manually
    `}else if(confLevel==="likely_renamed"||confLevel==="renamed"){const issueItems=(conf.issues||[]).map(i=>{if(i.type==="folder")return`
  • Folder differs: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `;if(i.type==="filename")return`
  • Filename differs: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `;return`
  • Mismatch: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `}).join("");const renameMsg=confLevel==="renamed"?"Strong evidence of renaming — review filenames":"Possible rename detected — review filenames";matchHtml+=`
    ${renameMsg}${issueItems?`
      ${issueItems}
    `:""}
    `}if(conf.note){matchHtml+=`
    ${esc(conf.note)}
    `}}const prowlStatusMap={"match":"pass","likely_match":"pass","uncertain":"advisory","likely_renamed":"warn","renamed":"warn"};const prowlIconMap={"match":"check-circle mh-icon--pass","likely_match":"check-circle mh-icon--pass","uncertain":"info-circle mh-icon--advisory","likely_renamed":"exclamation-triangle mh-icon--warn","renamed":"exclamation-triangle mh-icon--warn"};const confLevel=bestMatch?.confidence?.level||"uncertain";const prowlStatus=prowlStatusMap[confLevel]||"advisory";const prowlIcon=prowlIconMap[confLevel]||"info-circle mh-icon--advisory";return`
    ProwlarrFound on ${count} indexer(s)${matchHtml}
    `}return`
    ${esc(name)}${result.found?"Found":"Not found"}
    `}}; /* ======================================================================== * 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, + }; } }