diff --git a/CHANGELOG.md b/CHANGELOG.md index 9616f4c..c671de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ 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.6.0] - 2026-04-09 + +### Fixed + +- **Banned groups** — replaced Luminarr list (87 groups) with DarkPeers authoritative list (60 groups + EVO/HDT conditional exceptions). False positives for Kira, 4K4U, d3g, iVy eliminated. EVO allowed for WEB-DL, HDT allowed for REMUX. +- **Encode resolution** — removed fake "720p minimum" rule. DarkPeers allows all resolutions including 360p, 480i, 480p, 576i, 576p. +- **Opus/FLAC** — removed fake mono/stereo restriction. DarkPeers has no channel limit on Opus or FLAC. +- **Dual-Audio** — corrected to DarkPeers definition: any 2 languages = Dual-Audio, 3+ = MULTi. Removed incorrect English-origin restriction. +- **Release group** — missing group tag is now a warning, not an error. DarkPeers allows uploads without a group tag. +- **Atmos detection** — now checks only the default audio track, not all tracks. Eliminates false positives from Atmos on commentary/secondary tracks. +- **HDR validation** — no longer gated behind 2160p/4320p. Validates HDR tags whenever they appear in the title, catching "HDR10" (should be "HDR") at any resolution. +- **SRRDB search** — added scene-name normalization (strip colons/apostrophes, & → and) and fallback keyword search when exact match fails. +- **Prowlarr rename detection** — self-consistency checks now ignore release group differences between torrent title and folder/file names, reducing false rename warnings. +- **Corrective messages** — replaced Luminarr §section references with plain "Naming Guide" link to DarkPeers wiki. Removed `img.luminarr.me` from image hosts. + +### Added + +- **Ignored indexers** — new setting to exclude specific indexers from Prowlarr results (e.g. TorrentLeech). Comma-separated, case-insensitive. +- **Manual re-search** — "Re-search" button in External Integrations section header. Prevents unwanted auto-searches after page edits. Click to re-query SRRDB and Prowlarr on demand. +- **Expanded indexer view** — all matching indexers shown (no longer capped at 3). First 3 inline, rest in collapsible "+N more" section. +- **Torrent age** — relative age shown next to each alternative indexer ("3d ago", "2mo ago") when Prowlarr provides publish dates. +- **Dynamic match summaries** — Prowlarr summary now lists only fields that actually match instead of hardcoded "title, year, resolution, codecs all consistent". Mismatched fields are excluded from the claim. +- **Alternative hyperlinks** — "Also found on" indexers are now clickable links to the source tracker page when URL is available. + ## [0.5.0] - 2026-04-06 ### Added diff --git a/modq-helper-darkpeers.user.js b/modq-helper-darkpeers.user.js index c6b489f..d3a7879 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.5.0 +// @version 0.6.0 // @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 @@ -175,20 +175,21 @@ const g = { "DSR" ] }, + // DarkPeers banned groups — sourced from https://darkpeers.org/pages/9 (2026-04-08) bannedGroups: [ - "1000", "24xHD", "41RGB", "4K4U", "AG", "AOC", "AROMA", "aXXo", "AZAZE", "BARC0DE", "BAUCKLEY", - "BdC", "beAst", "BRiNK", "BTM", "C1NEM4", "C4K", "CDDHD", "CHAOS", "CHD", "CHX", "CiNE", - "COLLECTiVE", "CREATiVE24", "CrEwSaDe", "CTFOH", "d3g", "DDR", "DepraveD", "DNL", "DRX", "EPiC", - "EuReKA", "EVO", "FaNGDiNG0", "Feranki1980", "FGT", "flower", "FMD", "FRDS", "FZHD", "GalaxyRG", - "GHD", "GHOSTS", "GPTHD", "HDHUB4U", "HDS", "HDT", "HDTime", "HDWinG", "HiQVE", "iNTENSO", - "iPlanet", "iVy", "jennaortegaUHD", "JFF", "KC", "KiNGDOM", "KIRA", "L0SERNIGHT", "LAMA", - "Leffe", "Liber8", "LiGaS", "LT", "LUCY", "MarkII", "MeGusta", "Mesc", "mHD", "mSD", "MT", - "MTeam", "MySiLU", "NhaNc3", "nhanc3", "nHD", "nikt0", "nSD", "OFT", "Paheph", "PATOMiEL", - "PRODJi", "PSA", "PTNK", "RARBG", "RDN", "Rifftrax", "RU4HD", "SANTi", "SasukeducK", "Scene", - "SHD", "ShieldBearer", "STUTTERSHIT", "SUNSCREEN", "TBS", "TEKNO3D", "TG", "Tigole", "TIKO", - "VIDEOHOLE", "VISIONPLUSHDR", "WAF", "WiKi", "worldmkv", "x0r", "XLF", "YIFY", "YTSMX", "Zero00", - "Zeus" + "ARCADE", "aXXo", "BANDOLEROS", "BONE", "BRrip", "CM8", "CrEwSaDe", "CTFOH", "dAV1nci", "DNL", + "eranger2", "FGT", "FiSTER", "flower", "GalaxyTV", "HD2DVD", "HDTime", "HorribleSubs", + "iHYTECH", "ION10", "iPlanet", "KiNGDOM", "LAMA", "MeGusta", "mHD", "mSD", "NaNi", "NhaNc3", + "nHD", "nikt0", "nSD", "OFT", "PiTBULL", "PRODJi", "PSA", "RARBG", "Rifftrax", + "ROCKETRACCOON", "SANTi", "SasukeducK", "SEEDSTER", "ShAaNiG", "Sicario", "STUTTERSHIT", + "Subsplease", "TAoE", "TGALAXY", "TGx", "TORRENTGALAXY", "ToVaR", "Trix", "TSP", "TSPxL", + "ViSION", "VXT", "WAF", "WKS", "X0r", "YIFY", "YTS" ], + // Groups with conditional exceptions (not in bannedGroups — checked separately) + bannedGroupExceptions: { + "EVO": { allowedTypes: ["WEB-DL"] }, + "HDT": { allowedTypes: ["REMUX"] }, + }, exceptionGroupNames: [ "DiscoD HONE", "DarQ HONE", "Eml HDTeam", "BEN THE MEN", "D-Z0N3", "ZØNEHD", "Anime Time", "Project Angel", "Hakata Ramen", "-ZR-" @@ -202,7 +203,7 @@ const g = { ], releaseGroupSuffixes: /(?:-(RP|1|NZBGeek|Obfuscated|Obfuscation|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen|RePACKPOST))+$/i, imageHosts: [ - "imgbb.com", "imgur.com", "ptpimg.me", "imgbox.com", "beyondhd.co", "img.luminarr.me", + "imgbb.com", "imgur.com", "ptpimg.me", "imgbox.com", "beyondhd.co", "slowpic.", "pixhost.", "ibb.co", "postimg.", "funkyimg.", "image.tmdb.org" ], imageExtensions: [ @@ -1226,7 +1227,7 @@ let _resolvedModStatuses = DEFAULT_MOD_STATUSES; const Settings = { _KEY: "modq_settings", _defaults: { - prowlarr: { url: "", apiKey: "", enabled: false, preferredIndexers: [] }, + prowlarr: { url: "", apiKey: "", enabled: false, preferredIndexers: [], ignoredIndexers: [] }, srrdb: { enabled: true }, checks: { tmdbMatch: true, seasonEpisode: true, namingGuide: true, @@ -1355,6 +1356,12 @@ const Settings = { style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:4px 8px;font-size:12px;"> Comma-separated. When tied matches exist on multiple indexers, prefer these. +
@@ -1411,7 +1418,7 @@ const Settings = { : el.type === "number" ? parseInt(el.value, 10) : el.value; // Parse comma-separated list settings into arrays - if (key === "prowlarr.preferredIndexers") { + if (key === "prowlarr.preferredIndexers" || key === "prowlarr.ignoredIndexers") { val = typeof val === "string" ? val.split(",").map(s => s.trim()).filter(Boolean) : []; } const parts = key.split("."); @@ -1443,7 +1450,7 @@ Settings.init(); * Ported from the original H object + z (diff tool) * ======================================================================== */ -const H={extractReleaseGroup(e){if(!e)return null;let n=e.replace(g.releaseGroupSuffixes,"");const t=g.exceptionGroupNames.find(o=>n.endsWith("-"+o)||n.endsWith("- "+o));if(t)return t;const s=n.match(/[(\[]([^\]()]+)[)\]]$/);if(s){const o=s[1];if(g.bracketGroupNames.some(r=>r.toLowerCase()===o.toLowerCase()))return o}const a=n.match(/-([A-Za-z0-9$!._&+\$™]+)$/i);return a?a[1]:null},findTieredGroup(e,n){if(!e)return null;const t=n?"sonarr":"radarr",s=e.toLowerCase(),a=[];for(const o of g._tieredGroupsRaw)o.source===t&&o.groups.some(c=>c.toLowerCase()===s)&&a.push(o.name);return a.length>0?a:null},extractYear(e){if(!e)return null;const n=e.match(/\b(19|20)\d{2}\b/);return n?n[0]:null},countScreenshots(e){if(!e)return{count:0,urls:[]};const n=[],t=/\[img\](.*?)\[\/img\]/gi;let s;for(;(s=t.exec(e))!==null;)n.push(s[1]);const a=/]+src=["']([^"']+)["']/gi;for(;(s=a.exec(e))!==null;)n.push(s[1]);const o=n.filter(r=>{const d=r.toLowerCase(),f=g.imageExtensions.some(b=>d.includes(b)),A=g.imageHosts.some(b=>d.includes(b)),T=d.includes("image.tmdb.org")&&(d.includes("/w342/")||d.includes("/w500/")||d.includes("/w1280/")||d.includes("/w138"));return(f||A)&&!T}),c=[...new Set(o)];return{count:c.length,urls:c}},parseSeasonEpisode(e){if(!e)return{season:null,episode:null,raw:null,isSeasonPack:!1};const n=e.match(/S(\d{1,2})E(\d{1,2})/i);if(n)return{season:parseInt(n[1],10),episode:parseInt(n[2],10),raw:n[0],isSeasonPack:!1};const t=e.match(/\bS(\d{1,2})\b(?!E)/i);return t?{season:parseInt(t[1],10),episode:null,raw:t[0],isSeasonPack:!0}:{season:null,episode:null,raw:null,isSeasonPack:!1}},normalizeForComparison(e){return e?e.toLowerCase().replace(/['']/g,"'").replace(/[""]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim():""},normalizeForComparisonPreserveCase(e){return e?e.replace(/['']/g,"'").replace(/[""]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim():""},detectAudioObject(e){if(!e)return null;const n=e.replace(/^Title\s*:.*$/gm,"");return/(Dolby\s?Atmos|E-AC-3\s?JOC)/i.test(n)?"Atmos":/(Auro\s?3D)/i.test(n)?"Auro3D":null},extractTitleElements(e,n){if(!e)return{elements:[],positions:{}};const t=[],s={},a=e,o=(h,i,l)=>{i!==null&&l!==-1&&(t.push({type:h,value:i,position:l}),s[h]=l)},c=a.match(/\b(19|20)\d{2}\b/);c&&o("year",c[0],c.index);const r=a.match(/\bS(\d{1,2})(?:E(\d{1,2}))?\b/i);r&&o("season",r[0],r.index);for(const h of g.validResolutions){const i=a.indexOf(h);if(i!==-1){o("resolution",h,i);break}}const d=[...g.hdrFormats].sort((h,i)=>i.length-h.length);for(const h of d){const i=new RegExp("\\b"+h.replace(/[+]/g,"\\+")+"\\b","i"),l=a.match(i);if(l){o("hdr",l[0],l.index);break}}const f=[...g.validVideoCodecs].sort((h,i)=>i.length-h.length);for(const h of f){const i=new RegExp(h.replace(/[.]/g,"\\.?"),"i"),l=a.match(i);if(l){o("vcodec",l[0],l.index);break}}const A=[...g.validAudioCodecs].sort((h,i)=>i.length-h.length);for(const h of A){const i=h.replace(/[+]/g,"\\+").replace(/[-.]/g,"[-.]?"),l=new RegExp("(?i.length-h.length);for(const h of x){const i=new RegExp(h.replace(/[-.]/g,"[-. ]?"),"i"),l=a.match(i);if(l){o("source",l[0],l.index);break}}const p=a.match(/\b(REMUX|WEB-DL|WEBRip)\b/i);p&&o("type",p[0],p.index);for(const h of g.dubs){const i=new RegExp(`\\b${h.replace("-","[-]?")}\\b`,"i"),l=a.match(i);if(l){o("dub",l[0],l.index);break}}for(const h of g.cuts){const i=new RegExp(h.replace(/'/g,"[']?"),"i"),l=a.match(i);if(l){o("cut",l[0],l.index);break}}for(const h of g.ratios){const i=new RegExp(` ${h} `,"i"),l=a.match(i);if(l){o("ratio",h,l.index+1);break}}for(const h of g.repacks){const i=new RegExp(`\\b${h}\\b`,"i"),l=a.match(i);if(l){o("repack",l[0],l.index);break}}for(const h of g.editions){const i=new RegExp(h.replace(/'/g,"[']?"),"i"),l=a.match(i);if(l){o("edition",l[0],l.index);break}}const y=a.match(/\b3D\b/);y&&o("3d","3D",y.index);const C=a.match(/-([A-Za-z0-9$!._&+\$™]+)$/i);return C&&o("group",C[1],C.index),t.sort((h,i)=>h.position-i.position),{elements:t,positions:s}}}; +const H={extractReleaseGroup(e){if(!e)return null;let n=e.replace(g.releaseGroupSuffixes,"");const t=g.exceptionGroupNames.find(o=>n.endsWith("-"+o)||n.endsWith("- "+o));if(t)return t;const s=n.match(/[(\[]([^\]()]+)[)\]]$/);if(s){const o=s[1];if(g.bracketGroupNames.some(r=>r.toLowerCase()===o.toLowerCase()))return o}const a=n.match(/-([A-Za-z0-9$!._&+\$™]+)$/i);return a?a[1]:null},findTieredGroup(e,n){if(!e)return null;const t=n?"sonarr":"radarr",s=e.toLowerCase(),a=[];for(const o of g._tieredGroupsRaw)o.source===t&&o.groups.some(c=>c.toLowerCase()===s)&&a.push(o.name);return a.length>0?a:null},extractYear(e){if(!e)return null;const n=e.match(/\b(19|20)\d{2}\b/);return n?n[0]:null},countScreenshots(e){if(!e)return{count:0,urls:[]};const n=[],t=/\[img\](.*?)\[\/img\]/gi;let s;for(;(s=t.exec(e))!==null;)n.push(s[1]);const a=/]+src=["']([^"']+)["']/gi;for(;(s=a.exec(e))!==null;)n.push(s[1]);const o=n.filter(r=>{const d=r.toLowerCase(),f=g.imageExtensions.some(b=>d.includes(b)),A=g.imageHosts.some(b=>d.includes(b)),T=d.includes("image.tmdb.org")&&(d.includes("/w342/")||d.includes("/w500/")||d.includes("/w1280/")||d.includes("/w138"));return(f||A)&&!T}),c=[...new Set(o)];return{count:c.length,urls:c}},parseSeasonEpisode(e){if(!e)return{season:null,episode:null,raw:null,isSeasonPack:!1};const n=e.match(/S(\d{1,2})E(\d{1,2})/i);if(n)return{season:parseInt(n[1],10),episode:parseInt(n[2],10),raw:n[0],isSeasonPack:!1};const t=e.match(/\bS(\d{1,2})\b(?!E)/i);return t?{season:parseInt(t[1],10),episode:null,raw:t[0],isSeasonPack:!0}:{season:null,episode:null,raw:null,isSeasonPack:!1}},normalizeForComparison(e){return e?e.toLowerCase().replace(/['']/g,"'").replace(/[""]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim():""},normalizeForComparisonPreserveCase(e){return e?e.replace(/['']/g,"'").replace(/[""]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim():""},detectAudioObject(e){if(!e)return null;const n=e.replace(/^Title\s*:.*$/gm,"");return/(Dolby\s?Atmos|E-AC-3\s?JOC)/i.test(n)?"Atmos":/(Auro\s?3D)/i.test(n)?"Auro3D":null},detectAudioObjectFromTracks(tracks){if(!tracks||tracks.length===0)return null;const def=tracks.find(t=>t.isDefault)||tracks[0];if(!def)return null;const codec=(def.codec||"").toLowerCase();const comm=(def.commercialName||"").toLowerCase();const title=(def.title||"").toLowerCase();if(comm.includes("atmos")||title.includes("atmos")||codec.includes("joc")||(codec.includes("e-ac-3")&&title.includes("joc")))return"Atmos";if(comm.includes("auro")||title.includes("auro 3d")||title.includes("auro3d"))return"Auro3D";return null},extractTitleElements(e,n){if(!e)return{elements:[],positions:{}};const t=[],s={},a=e,o=(h,i,l)=>{i!==null&&l!==-1&&(t.push({type:h,value:i,position:l}),s[h]=l)},c=a.match(/\b(19|20)\d{2}\b/);c&&o("year",c[0],c.index);const r=a.match(/\bS(\d{1,2})(?:E(\d{1,2}))?\b/i);r&&o("season",r[0],r.index);for(const h of g.validResolutions){const i=a.indexOf(h);if(i!==-1){o("resolution",h,i);break}}const d=[...g.hdrFormats].sort((h,i)=>i.length-h.length);for(const h of d){const i=new RegExp("\\b"+h.replace(/[+]/g,"\\+")+"\\b","i"),l=a.match(i);if(l){o("hdr",l[0],l.index);break}}const f=[...g.validVideoCodecs].sort((h,i)=>i.length-h.length);for(const h of f){const i=new RegExp(h.replace(/[.]/g,"\\.?"),"i"),l=a.match(i);if(l){o("vcodec",l[0],l.index);break}}const A=[...g.validAudioCodecs].sort((h,i)=>i.length-h.length);for(const h of A){const i=h.replace(/[+]/g,"\\+").replace(/[-.]/g,"[-.]?"),l=new RegExp("(?i.length-h.length);for(const h of x){const i=new RegExp(h.replace(/[-.]/g,"[-. ]?"),"i"),l=a.match(i);if(l){o("source",l[0],l.index);break}}const p=a.match(/\b(REMUX|WEB-DL|WEBRip)\b/i);p&&o("type",p[0],p.index);for(const h of g.dubs){const i=new RegExp(`\\b${h.replace("-","[-]?")}\\b`,"i"),l=a.match(i);if(l){o("dub",l[0],l.index);break}}for(const h of g.cuts){const i=new RegExp(h.replace(/'/g,"[']?"),"i"),l=a.match(i);if(l){o("cut",l[0],l.index);break}}for(const h of g.ratios){const i=new RegExp(` ${h} `,"i"),l=a.match(i);if(l){o("ratio",h,l.index+1);break}}for(const h of g.repacks){const i=new RegExp(`\\b${h}\\b`,"i"),l=a.match(i);if(l){o("repack",l[0],l.index);break}}for(const h of g.editions){const i=new RegExp(h.replace(/'/g,"[']?"),"i"),l=a.match(i);if(l){o("edition",l[0],l.index);break}}const y=a.match(/\b3D\b/);y&&o("3d","3D",y.index);const C=a.match(/-([A-Za-z0-9$!._&+\$™]+)$/i);return C&&o("group",C[1],C.index),t.sort((h,i)=>h.position-i.position),{elements:t,positions:s}}}; const z={tokenize(e){return e.trim().split(/([.\s]+)/).filter(n=>n.length>0)},diff(e,n){const t=this.tokenize(e),s=this.tokenize(n),a=t.length,o=s.length,c=Array.from({length:a+1},()=>new Array(o+1).fill(0));for(let A=1;A<=a;A++)for(let T=1;T<=o;T++)t[A-1].toLowerCase()===s[T-1].toLowerCase()?c[A][T]=c[A-1][T-1]+1:c[A][T]=Math.max(c[A-1][T],c[A][T-1]);const r=[];let d=a,f=o;for(;d>0||f>0;)d>0&&f>0&&t[d-1].toLowerCase()===s[f-1].toLowerCase()?(r.unshift({type:"match",text:t[d-1]}),d--,f--):f>0&&(d===0||c[d][f-1]>=c[d-1][f])?(r.unshift({type:"extra",text:s[f-1]}),f--):(r.unshift({type:"missing",text:t[d-1]}),d--);return r},matchPercent(e){const n=e.filter(o=>o.type==="match").length,t=e.filter(o=>o.type==="missing").length,s=e.filter(o=>o.type==="extra").length,a=n+t+s;return a===0?100:Math.round(n/a*100)},escapeHtml(e){return e.replace(/&/g,"&").replace(//g,">")},renderLine(e,n){return e.map(t=>{let s;return t.type==="match"?s="mh-diff-token--ctx":n==="del"?s="mh-diff-token--del":s="mh-diff-token--add",`${this.escapeHtml(t.text)}`}).join("")},renderDiff(e){const n=this.matchPercent(e),t=e.some(c=>c.type!=="match"),s=`
@@ -1871,7 +1878,7 @@ const k = { checks: [] }, o = e || "", - c = E.isTV(), + c = (typeof E !== "undefined" && E?.isTV) ? E.isTV() : !1, r = H.extractYear(o); let d = "fail", f = "No year found"; @@ -1920,12 +1927,14 @@ const k = { const l = H.extractReleaseGroup(o); a.checks.push({ name: "Release Group", - status: l ? "pass" : b ? "na" : "fail", - message: l ? `Found: ${l}` : b ? "N/A for Full Disc" : "No release group tag found (should end with -GROUP)", - required: !0 + status: l ? "pass" : b ? "na" : "warn", + message: l ? `Found: ${l}` : b ? "N/A for Full Disc" : "No release group tag detected (allowed on DarkPeers if none exists)", + required: !1 }); - const m = g.fullDiscTypes.some(R => n?.includes(R)), - S = m ? null : H.detectAudioObject(t), + const m = g.fullDiscTypes.some(R => n?.includes(R)); + // Prefer track-based detection (default track only) over raw-text detection (all tracks) + const _audioTracks = (typeof E !== "undefined" && E?.getAudioTracksFromMediaInfo) ? E.getAudioTracksFromMediaInfo() : []; + const S = m ? null : (_audioTracks.length > 0 ? H.detectAudioObjectFromTracks(_audioTracks) : H.detectAudioObject(t)), N = /Atmos/i.test(o), $ = /Auro/i.test(o); let M = "pass", @@ -1950,8 +1959,8 @@ const k = { status: V ? "pass" : K ? "na" : "warn", message: V ? `Found: ${V}` : K ? "N/A for Full Disc/REMUX" : "No video codec found (may be implied)", required: !0 - }), o.includes("2160p") || o.includes("4320p")) { - const R = E.getHdrFromMediaInfo(), + }), (function(){ const _hdrInTitle = g.hdrFormats.some(fmt => new RegExp("\\b" + fmt.replace(/[+]/g, "\\+") + "\\b", "i").test(o)); const _hdrInMI = (typeof E !== "undefined" && E?.getHdrFromMediaInfo) ? E.getHdrFromMediaInfo() : []; return o.includes("2160p") || o.includes("4320p") || _hdrInTitle || _hdrInMI.length > 0; })()) { + const R = (typeof E !== "undefined" && E?.getHdrFromMediaInfo) ? E.getHdrFromMediaInfo() : [], P = [...g.hdrFormats].sort((L, O) => O.length - L.length); let v = null; for (const L of P) @@ -2083,41 +2092,22 @@ const k = { return l === m || l.startsWith(m) || l.includes(m) || m.includes(l) ? !0 : (g.languageAliases[m] || []).some(N => l.includes(N) || N.includes(l)) }; if (u) - if (y) { - const i = t.filter(m => !C(m)); - let l = ""; - x >= 2 && i.length > 0 ? i.length > 1 ? l = `. Found ${x} audio tracks (${t.join(", ")}). Use "Multi" instead` : /^[a-z]{2,3}$/i.test(i[0]) ? l = `. Found ${x} audio tracks (${t.join(", ")}), use "{Language_Name} Multi" instead` : l = `. Found ${x} audio tracks (${t.join(", ")}). Use "${i[0]} Multi" instead` : x >= 2 ? l = `. Found ${x} audio tracks but non-English languages not recognized by MediaInfo parser. Use "{Language} Multi" instead (or "Multi" if 3+ languages)` : l = '. Only 1 recognized language found — non-English track may not be recognized by MediaInfo parser. Use "{Language} Multi" if a second language is present (or "Multi" if 3+ languages)', r.push({ - name: "Language Tags", - status: "fail", - message: `Dual-Audio is reserved for non-English original content with an English dub${l}` - }) - } else if (x > 2) r.push({ - name: "Language Tags", - status: "fail", - message: `Tagged Dual-Audio but found ${x} languages. Should be "Multi"` - }); - else if (x < 2) r.push({ - name: "Language Tags", - status: "fail", - message: `Tagged Dual-Audio but found only ${x} language` - }); - else { - const i = t.some(C), - l = t.some(h); - i ? l ? r.push({ - name: "Language Tags", - status: "pass", - message: `Dual-Audio correct (English + ${p})` - }) : r.push({ - name: "Language Tags", - status: "warn", - message: `Dual-Audio implies Original Language (${p}) present` - }) : r.push({ + if (x > 2) r.push({ name: "Language Tags", status: "fail", - message: "Dual-Audio requires English track" - }) - } else if (D) x < 2 ? r.push({ + message: `Tagged Dual-Audio but found ${x} languages (${t.join(", ")}). Use "MULTi" for 3+ languages` + }); + else if (x < 2) r.push({ + name: "Language Tags", + status: "fail", + message: `Tagged Dual-Audio but found only ${x} language` + }); + else r.push({ + name: "Language Tags", + status: "pass", + message: `Dual-Audio correct (${t.join(" + ")})` + }); + else if (D) x < 2 ? r.push({ name: "Language Tags", status: "fail", message: `"Multi" used but found only ${x} language` @@ -2149,7 +2139,7 @@ const k = { message: `Audio languages OK (${x})` }) } - const d = E.getAudioTracksFromMediaInfo(); + const d = (typeof E !== "undefined" && E?.getAudioTracksFromMediaInfo) ? E.getAudioTracksFromMediaInfo() : []; if (d.length > 0) { const b = g.remuxTypes.some(i => s?.toUpperCase().includes(i.toUpperCase())) || /\b(HDTV|PDTV|SDTV)\b/i.test(s || "") || /\bDVD\b/i.test(s || ""), u = /\b(HDTV|PDTV|SDTV|DVD)\b/i.test(s || "") || b, @@ -2209,8 +2199,8 @@ const k = { const l = d[i], m = D(l.codec, l.commercialName), S = `Track ${i+1}: ${m}${l.channels?" "+l.channels:""}${l.language?" ("+l.language+")":""}`; - if (m === "FLAC" || m === "Opus" || m === "LPCM") { - const N = m === "LPCM" && b; + if (m === "LPCM") { + const N = b; // LPCM multichannel allowed for untouched sources !x(l.channels) && !N ? r.push({ name: S, status: "fail", @@ -2220,6 +2210,12 @@ const k = { status: "pass", message: x(l.channels) ? `${m} mono/stereo OK` : `${m} multichannel (untouched OK)` }) + } else if (m === "FLAC" || m === "Opus") { + r.push({ + name: S, + status: "pass", + message: `${m} ${l.channels || "unknown"} OK` + }) } else m === "MP2" ? u ? r.push({ name: S, status: "pass", @@ -2662,16 +2658,11 @@ const k = { message: "Title has H.265 — encodes typically use encoder name (x265) instead" }) } - const x = ["720p", "1080i", "1080p", "2160p", "4320p"], - p = g.validResolutions.find(i => o.includes(i)); - if (p ? x.includes(p) ? a.push({ + const p = g.validResolutions.find(i => o.includes(i)); + if (p ? a.push({ name: "Resolution", status: "pass", message: `Found: ${p}` - }) : a.push({ - name: "Resolution", - status: "fail", - message: `Found ${p} — encodes must be 720p or greater` }) : a.push({ name: "Resolution", status: "warn", @@ -2809,19 +2800,22 @@ const k = { tieredInfo: null }; const o = H.findTieredGroup(a, n); - return g.bannedGroups.some(r => r.toLowerCase() === a.toLowerCase()) ? { - status: "fail", - group: a, - message: `BANNED GROUP: ${a}`, - alert: !0, - tieredInfo: o - } : { - status: "pass", - group: a, - message: `Release Group: ${a}`, - alert: !1, - tieredInfo: o + const aLower = a.toLowerCase(); + // Check unconditional ban list + if (g.bannedGroups.some(r => r.toLowerCase() === aLower)) { + return { status: "fail", group: a, message: `BANNED GROUP: ${a}`, alert: !0, tieredInfo: o }; } + // Check conditional exceptions (EVO → WEB-DL allowed, HDT → REMUX allowed) + const exception = Object.entries(g.bannedGroupExceptions).find(([name]) => name.toLowerCase() === aLower); + if (exception) { + const [excName, excRule] = exception; + const typeUpper = (t || "").toUpperCase(); + if (excRule.allowedTypes.some(at => typeUpper.includes(at.toUpperCase()))) { + return { status: "pass", group: a, message: `Release Group: ${a} (${excName} allowed for ${t})`, alert: !1, tieredInfo: o }; + } + return { status: "fail", group: a, message: `BANNED GROUP: ${a} (only ${excRule.allowedTypes.join("/")} releases allowed)`, alert: !0, tieredInfo: o }; + } + return { status: "pass", group: a, message: `Release Group: ${a}`, alert: !1, tieredInfo: o }; } }; k.resolutionTypeMatch = function (e, n) { @@ -3651,14 +3645,34 @@ const Integrations = { }, srrdb: { + /** + * _normalizeForSrrdb — Convert a DP torrent title into scene-style format. + * SRRDB expects dot-separated scene names with specific formatting. + */ + _normalizeForSrrdb(name) { + if (!name) return ""; + return name + .replace(/[:']/g, "") // strip colons, apostrophes (scene format omits them) + .replace(/[,]/g, "") // strip commas + .replace(/&/g, "and") // scene convention + .replace(/\s+/g, ".") // spaces → dots + .replace(/\.{2,}/g, "."); // collapse multiple dots + }, + /** * search — look up a release name on SRRDB. + * Strategy: try exact match first (r: prefix), then fall back to general + * keyword search if exact match fails. This handles the common case where + * the DP torrent title differs slightly from the scene release name. */ async search(releaseName) { if (!releaseName) return { found: false, release: null, error: "No release name provided" }; + const sceneName = this._normalizeForSrrdb(releaseName); + try { - const encoded = encodeURIComponent(releaseName.replace(/\s+/g, ".")); + // Strategy 1: exact release match + const encoded = encodeURIComponent(sceneName); const result = await Integrations._request({ url: `https://www.srrdb.com/api/search/r:${encoded}`, timeout: 8000, @@ -3670,9 +3684,31 @@ const Integrations = { found: true, release: data.results[0], resultCount: data.results.length, + searchStrategy: "exact", error: null, }; } + + // Strategy 2: keyword search (without r: prefix) — catches partial matches + // and handles titles with minor formatting differences + console.log(`[ModQ Helper] SRRDB exact match failed for "${sceneName}", trying keyword search`); + const kwResult = await Integrations._request({ + url: `https://www.srrdb.com/api/search/${encoded}`, + timeout: 8000, + }); + + const kwData = kwResult.data; + if (kwData && kwData.results && kwData.results.length > 0) { + return { + found: true, + release: kwData.results[0], + resultCount: kwData.results.length, + searchStrategy: "keyword", + error: null, + }; + } + + console.log(`[ModQ Helper] SRRDB keyword search also returned no results for "${sceneName}"`); return { found: false, release: null, error: null }; } catch (e) { return { found: false, release: null, error: e.message }; @@ -3772,6 +3808,30 @@ const Integrations = { const RenameDetector = { + /** + * _relativeAge — Format a date string as a human-readable relative age. + * D5: Used for torrent age display next to Prowlarr matches. + */ + _relativeAge(dateStr) { + if (!dateStr) return null; + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return null; + const diffMs = Date.now() - d.getTime(); + if (diffMs < 0) return "just now"; + const mins = Math.floor(diffMs / 60000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(months / 12); + return `${years}y ago`; + } catch { return null; } + }, + /** Codec aliases for fuzzy matching */ _codecAliases: { "x264": "h.264", "h.264": "h.264", "avc": "h.264", @@ -3946,16 +4006,20 @@ const RenameDetector = { * * Also tracks up to 3 alternative candidates (score > 0.50) for display. */ - findBestMatch(uploadTokens, results, preferredIndexers) { + findBestMatch(uploadTokens, results, preferredIndexers, ignoredIndexers) { const EPSILON = 0.01; + const ignored = Array.isArray(ignoredIndexers) ? new Set(ignoredIndexers.map(n => n.toLowerCase())) : new Set(); const candidates = []; for (const r of results) { + // D4: Skip results from ignored indexers + if (ignored.size > 0 && ignored.has((r.indexer || "").toLowerCase())) continue; const tokens = this.tokenize(r.title || ""); const score = this.scoreMatch(uploadTokens, tokens); candidates.push({ title: r.title, indexer: r.indexer, size: r.size, infoUrl: r.infoUrl || null, guid: r.guid || null, seeders: r.seeders || 0, + publishDate: r.publishDate || null, // D5: torrent age tokens, score, relevanceScore: score.relevanceScore, }); } @@ -3977,14 +4041,14 @@ const RenameDetector = { }); const best = candidates[0]; - // Collect alternatives: distinct indexers, score > 0.50, not the best match + // D3: Collect ALL alternatives (distinct indexers, score > 0.50) — no cap const seen = new Set([best.indexer]); const alternatives = []; - for (let i = 1; i < candidates.length && alternatives.length < 3; i++) { + for (let i = 1; i < candidates.length; i++) { const c = candidates[i]; if (c.relevanceScore >= 0.50 && !seen.has(c.indexer)) { seen.add(c.indexer); - alternatives.push({ indexer: c.indexer, title: c.title, relevanceScore: c.relevanceScore }); + alternatives.push({ indexer: c.indexer, title: c.title, relevanceScore: c.relevanceScore, infoUrl: c.infoUrl || null, publishDate: c.publishDate || null }); } } best.alternatives = alternatives; @@ -4054,9 +4118,12 @@ const RenameDetector = { const uploadTokens = this.tokenize(data.torrentName); // Phase 1: Self-consistency checks (strongest signal) + // Use ignoreGroup for self-consistency — folder/file names may legitimately + // use different group formatting than the torrent title. The group comparison + // is handled separately via Prowlarr match scoring. if (data.fileStructure?.folderName) { const folderTokens = this.tokenize(data.fileStructure.folderName); - if (!this.structurallyEquivalent(uploadTokens, folderTokens)) { + if (!this.structurallyEquivalent(uploadTokens, folderTokens, { ignoreGroup: true })) { issues.push({ type: "folder", severity: "high", expected: data.torrentName, @@ -4067,7 +4134,7 @@ const RenameDetector = { if (data.mediaInfoFilename && tvScope.canCompareMediaInfoFile) { const miTokens = this.tokenize(data.mediaInfoFilename); - if (!this.structurallyEquivalent(uploadTokens, miTokens)) { + if (!this.structurallyEquivalent(uploadTokens, miTokens, { ignoreGroup: true })) { issues.push({ type: "filename", severity: "high", expected: data.torrentName, @@ -4114,9 +4181,9 @@ const RenameDetector = { const G={RULES_URL:null,collectIssues(e){const n=[],t=(s,a,o,c={})=>{(a==="fail"||a==="warn")&&n.push({id:s,status:a,raw:o,...c})};if(t("tmdb",e.tmdbMatch.status,e.tmdbMatch.message,{expected:e.tmdbMatch.details?.expected,found:e.tmdbMatch.details?.found}),e.seasonEpisode.status!=="na"&&t("season_episode",e.seasonEpisode.status,e.seasonEpisode.message),e.bannedGroup.alert&&t("banned_group",e.bannedGroup.status,e.bannedGroup.message,{group:e.bannedGroup.group}),t("screenshots",e.screenshots.status,e.screenshots.message,{count:e.screenshots.count}),t("resolution_type",e.resolutionTypeMatch.status,e.resolutionTypeMatch.message,{expected:e.resolutionTypeMatch.details?.expected,found:e.resolutionTypeMatch.details?.found}),e.elementOrder.status==="fail"||e.elementOrder.status==="warn"){const s=e.elementOrder.violations||[];t("element_order",e.elementOrder.status,e.elementOrder.message,{violations:s.map(a=>typeof a=="object"?a.message:a),orderType:e.elementOrder.details?.orderType})}if(e.namingGuide.checks)for(const s of e.namingGuide.checks){const a=s.name.toLowerCase().replace(/[^a-z0-9]+/g,"_");t("naming_"+a,s.status,s.message,{label:s.name})}if(e.folderStructure.status!=="na"&&t("folder",e.folderStructure.status,e.folderStructure.message),e.containerFormat.status!=="na"&&t("container",e.containerFormat.status,e.containerFormat.message,{found:e.containerFormat.details?.found}),t("mediainfo",e.mediaInfo.status,e.mediaInfo.message),e.subtitleRequirement.status!=="na"&&t("subtitles",e.subtitleRequirement.status,e.subtitleRequirement.message,{audio:e.subtitleRequirement.details?.audio}),t("upscale",e.upscaleDetection.status,e.upscaleDetection.message,{alert:e.upscaleDetection.alert}),e.audioTags.checks)for(const s of e.audioTags.checks)t("audio_"+s.name.replace(/[^a-zA-Z0-9]+/g,"_"),s.status,s.message,{label:s.name});if(e.encodeCompliance.checks)for(const s of e.encodeCompliance.checks)t("encode_"+s.name.toLowerCase().replace(/[^a-z0-9]+/g,"_"),s.status,s.message,{label:s.name});if(e.packUniformity.checks)for(const s of e.packUniformity.checks)t("pack_"+s.name.toLowerCase().replace(/[^a-z0-9]+/g,"_"),s.status,s.message,{label:s.name});return n},beautify(e){const n=e.id,t=e.raw||"",s=[];if(n==="tmdb")return/not found on page/i.test(t)||/torrent name not found/i.test(t)?null:e.expected?(s.push("9"),/capitalization/i.test(t)?{text:`Title capitalization should match TMDB: "${e.expected}".`,rules:s}:/without.*the.*prefix/i.test(t)?{text:`Title is missing the "The" prefix — TMDB title is "${e.expected}".`,rules:s}:{text:`Title does not match TMDB. The correct title is "${e.expected}".`,rules:s}):(s.push("9"),{text:"Title does not match TMDB.",rules:s});if(n==="season_episode"){if(s.push("8.2"),/zero-padded/i.test(t)){const a=t.match(/expected\s+(S\d+E?\d*)/i);return{text:`Season and episode numbers must be zero-padded (e.g. ${a?a[1]:"S01E01"}).`,rules:s}}return/no S##E##/i.test(t)?{text:"TV content must include the season/episode in S##E## or S## format.",rules:s}:{text:t,rules:s}}if(n==="banned_group")return s.push("2.11"),{text:`${e.group||"This release group"} is a banned release group.`,rules:s};if(n==="screenshots")return s.push("10.2"),e.count===0?{text:"Required screenshots are missing from the description.",rules:s}:{text:`Only ${e.count} screenshot${e.count===1?"":"s"} included — a minimum of 3 is required.`,rules:s};if(n==="element_order")return s.push("9"),e.violations&&e.violations.length>0?{text:`Title elements are in the wrong order: ${e.violations.map(o=>" • "+o).join(` `)}`,rules:s}:{text:"Title elements are not in the expected order. Please refer to the Naming Guide.",rules:s};if(n.startsWith("naming_")){if(s.push("9"),n==="naming_resolution"&&/non-standard resolution|tagged as other/i.test(t))return null;if(/remove parentheses/i.test(t))return{text:"Year should not be in parentheses.",rules:s};if(n==="naming_hdr_format"){if(/HDR10.*should be renamed to.*HDR/i.test(t))return{text:'"HDR10" in the title should be "HDR".',rules:s};if(/missing hdr tag/i.test(t)){const a=t.match(/should include:\s*(.+)/i);return{text:`Missing HDR format tag in title${a?" — should be "+a[1]:""}.`,rules:s}}if(/wrong hdr tag/i.test(t)){const a=t.match(/should be:\s*(.+)/i);return{text:`Incorrect HDR format tag in title${a?" — should be "+a[1]:""}.`,rules:s}}return/title has.*but mediainfo shows no hdr/i.test(t)?{text:"Title includes an HDR format tag but MediaInfo does not confirm.",rules:s}:{text:t,rules:s}}if(n==="naming_audio_object")return/missing from title/i.test(t)?{text:`${/atmos/i.test(t)?"Atmos":"Auro3D"} detected in MediaInfo but missing from the title.`,rules:s}:/not confirmed in mediainfo/i.test(t)?{text:"Object audio tag in the title is not confirmed by MediaInfo.",rules:s}:{text:t,rules:s};if(n==="naming_source"){if(/no valid source/i.test(t)){const a=t.match(/for\s+(.+?)\s+type/i);let o="";if(a){const c=a[1];o=` for ${/^[aeiouh]/i.test(c)?"an":"a"} ${c} upload`}return{text:`Missing or invalid source tag in the title${o}.`,rules:s}}return{text:t,rules:s}}if(/^No\s/i.test(t)){let a=(e.label||"").toLowerCase();return a==="channels"&&(a="audio channels"),{text:`Missing ${a} in title.`,rules:s,naming:!0,missingElement:a}}return{text:t,rules:s}}if(n==="folder")return s.push("1.6"),/should not have.*folder/i.test(t)?{text:"Single-file movies should not be inside a folder.",rules:s}:{text:t,rules:s};if(n==="container"){if(s.push("5.2.5"),/non-mkv/i.test(t)){const a=t.match(/detected:\s*(.+)/i);return{text:`All non-disc releases must use the MKV container${a?" (found "+a[1]+")":""}.`,rules:s}}return/bdinfo should be empty/i.test(t)?{text:"BDInfo should only be provided for Full Disc uploads.",rules:["1.8","1.9"]}:{text:t,rules:s}}if(n==="mediainfo")return/mediainfo required/i.test(t)?(s.push("1.8"),{text:"MediaInfo is required for all non-disc uploads.",rules:s}):/bdinfo required/i.test(t)?(s.push("1.9"),{text:"BDInfo is required for Full Disc uploads.",rules:s}):/bdinfo expected/i.test(t)?(s.push("1.9"),{text:"Full Disc uploads should provide BDInfo rather than MediaInfo.",rules:s}):/bdinfo should be empty/i.test(t)?(s.push("1.8"),{text:"BDInfo should only be provided for Full Disc uploads.",rules:s}):{text:t,rules:s};if(n==="subtitles")return s.push("5.2.1"),/no english audio.*no subtitles/i.test(t)?{text:"English subtitles are required when the audio is not in English.",rules:s}:/requires english subtitles/i.test(t)?{text:"English subtitles are required for non-English audio content.",rules:s}:{text:t,rules:s};if(n==="upscale")return s.push("2"),{text:"This release appears to be an upscale, which is not permitted.",rules:s};if(n.startsWith("audio_")){if(/dual-audio/i.test(t)&&/reserved for non-english/i.test(t)){s.push("9");const a=t.match(/use\s+"([^"]+)"/i);let o="Dual-Audio is reserved for non-English original content with an English dub.";return a&&(o+=` Use "${a[1]}" instead.`),{text:o,rules:s}}if(/tagged dual-audio but found \d+ languages.*should be/i.test(t))return s.push("9"),{text:'More than two audio languages detected — use "Multi" instead of "Dual-Audio".',rules:s};if(/tagged dual-audio but found only/i.test(t))return s.push("9"),{text:"Dual-Audio tag used but only one audio language detected.",rules:s};if(/dual-audio requires english/i.test(t))return s.push("9"),{text:"Dual-Audio releases must include an English audio track.",rules:s};if(/multi.*found only/i.test(t))return s.push("9"),{text:'"Multi" tag used but only one audio language detected.',rules:s};if(/found \d+ languages but no.*multi/i.test(t)){s.push("9");const a=t.match(/found (\d+)/);return{text:`${a?a[1]+" audio languages":"Multiple audio languages"} detected — consider adding a "Multi" tag.`,rules:s}}if(/consider.*dual-audio/i.test(t))return s.push("9"),{text:'English and original language audio detected — consider adding the "Dual-Audio" tag.',rules:s};if(/only allowed as mono\/stereo/i.test(t)){s.push("5.2.4");const a=t.match(/^(\w+)/);return{text:`${a?a[1]:"This codec"} is only allowed for mono or stereo audio, not multichannel.`,rules:s}}if(/mp2 only allowed/i.test(t))return s.push("5.2.4"),{text:"MP2 audio is only permitted for untouched HDTV or DVD sources.",rules:s};if(/mp3 only allowed/i.test(t))return s.push("5.2.4"),{text:"MP3 is only permitted for supplementary tracks such as commentary.",rules:s};if(/not an allowed audio codec/i.test(t)){s.push("5.2.4");const a=t.match(/^(\w+)/);return{text:`${a?a[1]:"This audio codec"} is not an allowed audio codec.`,rules:s}}if(/unrecognized codec/i.test(t))return s.push("5.2.4"),{text:"An audio track uses an unrecognized codec — please verify it is permitted.",rules:s};if(/title claims .* but primary audio track is/i.test(t)){s.push("5.2.4");const a=t.match(/title claims (\S+) but primary audio track is (\S+)/i);return{text:a?`Title claims ${a[1]} audio but the primary track in MediaInfo is ${a[2]} — correct the audio codec tag.`:"Audio codec in title does not match the primary track in MediaInfo.",rules:s}}return{text:t,rules:s}}if(n.startsWith("encode_")){if(/no mediainfo available/i.test(t)||/cannot verify/i.test(t)||/could not det/i.test(t))return null;if(/encodes must use x264/i.test(t)){s.push("5.5.3");const a=t.match(/found\s+(\S+)/i);return{text:`Encodes must use x264, x265, or SVT-AV1${a?" (found "+a[1]+")":""}.`,rules:s}}if(/no x264.*detected/i.test(t))return s.push("5.5.3"),{text:"No recognized encoder (x264, x265, or SVT-AV1) detected in the title.",rules:s};if(/no encoder metadata/i.test(t))return s.push("5.5.4"),{text:"Encoder metadata is required in MediaInfo for encodes.",rules:s};if(/use encoder name.*x264/i.test(t)||/use encoder name.*x265/i.test(t)){s.push("9");const a=/x265/i.test(t)?"x265":"x264";return{text:`Title uses ${/H\.265/i.test(t)?"H.265":"H.264"} but the encoder is ${a} — use the encoder name in the title.`,rules:s}}return/typically use encoder name/i.test(t)?(s.push("9"),{text:"Encodes should use the encoder name (x264/x265) rather than the codec name (H.264/H.265) in the title.",rules:s}):/encodes must be 720p/i.test(t)?(s.push("5.5.5"),{text:"Encodes must be 720p or greater in resolution.",rules:s}):/single-pass abr/i.test(t)?(s.push("5.5.6"),{text:"Single-pass ABR is not permitted — encodes must use CRF or multi-pass ABR.",rules:s}):/cbr.*detected/i.test(t)?(s.push("5.5.6"),{text:"CBR encoding is not permitted — encodes must use CRF or multi-pass ABR.",rules:s}):/target bitrate.*without multi-pass/i.test(t)?(s.push("5.5.6"),{text:"Target bitrate encoding without multi-pass is not permitted — use CRF or multi-pass ABR.",rules:s}):{text:t,rules:s}}return n.startsWith("pack_")?/could not detect/i.test(t)?null:(s.push("8.1"),/mixed/i.test(t)?{text:`Mixed ${(e.label||"").toLowerCase()} detected across files in this pack — all files must be uniform.`,rules:s}:{text:t,rules:s}):n==="resolution_type"?/could not detect/i.test(t)?null:(s.push("4"),/mismatch/i.test(t)?{text:`Resolution type tag does not match title — ${e.expected?`expected "${e.expected}" but found "${e.found}"`:t}.`,rules:s}:/should use.*Other/i.test(t)?{text:t,rules:s}:{text:t,rules:s}):{text:t,rules:[]}},buildMessage(e){if(e.length===0)return"";const n=e.filter(u=>u.status==="fail"),t=e.filter(u=>u.status==="warn"),s=new Set,a=[],o=n.map(u=>this.beautify(u)).filter(Boolean),c=t.map(u=>this.beautify(u)).filter(Boolean),r=[],d=[],f=[];for(const u of o)u.rules.forEach(D=>s.add(D)),u.missingElement?d.push(u.missingElement):u.text.startsWith("Title ")?r.push(u.text):f.push(u.text);for(const u of r)a.push(u);if(d.length>0)if(d.length===1)a.push(`Missing ${d[0]} in title.`);else{const u=d.pop();a.push(`Missing ${d.join(", ")} and ${u} in title.`)}for(const u of f)a.push(u);if(c.length>0){const u=[],D=[],x=[];for(const p of c)p.rules.forEach(y=>s.add(y)),p.missingElement?D.push(p.missingElement):p.text.startsWith("Title ")?u.push(p.text):x.push(p.text);for(const p of u)a.push(p);if(D.length>0)if(D.length===1)a.push(`Missing ${D[0]} in title.`);else{const p=D.pop();a.push(`Missing ${D.join(", ")} and ${p} in title.`)}for(const p of x)a.push(p)}const A=new Set;let b=a.filter(u=>A.has(u)?!1:(A.add(u),!0)).join(` -`);if(s.size>0){const D=[...s].sort((x,p)=>{const y=x.split(".").map(Number),C=p.split(".").map(Number);for(let h=0;h`§${x}`).join(", ");b+=` +`);if(s.size>0&&this.RULES_URL){b+=` -Please review the following [url]${this.RULES_URL}[/url]: ${D}.`}return b}}; +Please review the [url=${this.RULES_URL}]Naming Guide[/url].`}return b}}; /* ======================================================================== * UI — Panel rendering, injection, and event handling @@ -4225,15 +4292,24 @@ 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){const conf=bestMatch.confidence||{};const confLevel=conf.level||"uncertain";const fs=conf.fieldScores||{};const issues=conf.issues||[];const alts=bestMatch.alternatives||[]; +
    ${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";const fs=conf.fieldScores||{};const issues=conf.issues||[];const alts=bestMatch.alternatives||[]; // Best match title with optional link const titleText=esc(bestMatch.title);const indexerText=esc(bestMatch.indexer);const linkHtml=bestMatch.infoUrl?` `:""; matchHtml+=`
    Best match: ${titleText} [${indexerText}]${linkHtml}
    `; -// Summary line — specific to confidence level -const summaryMessages={"match":`Name matches release on ${indexerText} — title, year, resolution, codecs all consistent`,"likely_match":`Name likely matches release on ${indexerText} — minor field differences`,"uncertain":"No strong match found — cannot verify release name automatically","likely_renamed":issues.length>0?`Possible rename — ${issues[0].type==="folder"?"folder name differs from torrent name":"filename differs from torrent name"}`:"Possible rename detected — review filenames","renamed":"Likely renamed — folder and filename both differ from torrent name"}; +// Summary line — dynamically built from actual field scores (D9 + D11) +const _fieldLabelsShort={titleName:"title",year:"year",resolution:"resolution",source:"source",vcodec:"video codec",acodec:"audio codec"}; +const _matchedFields=Object.entries(fs).filter(([k,v])=>_fieldLabelsShort[k]&&v>=1.0).map(([k])=>_fieldLabelsShort[k]); +const _diffFields=Object.entries(fs).filter(([k,v])=>_fieldLabelsShort[k]&&v<1.0&&v!==undefined).map(([k])=>_fieldLabelsShort[k]); +let summaryText; +if(confLevel==="match"){summaryText=_matchedFields.length>0?`Name matches release on ${indexerText} — ${_matchedFields.join(", ")} consistent`:`Name matches release on ${indexerText}`} +else if(confLevel==="likely_match"){summaryText=_diffFields.length>0?`Name likely matches release on ${indexerText} — ${_diffFields.join(", ")} differ${_diffFields.length===1?"s":""}`:`Name likely matches release on ${indexerText} — minor field differences`} +else if(confLevel==="uncertain"){summaryText="No strong match found — cannot verify release name automatically"} +else if(confLevel==="likely_renamed"){summaryText=issues.length>0?`Possible rename — ${issues[0].type==="folder"?"folder name differs from torrent name":"filename differs from torrent name"}`:"Possible rename detected — review filenames"} +else if(confLevel==="renamed"){summaryText="Likely renamed — folder and filename both differ from torrent name"} +else{summaryText="Review manually"} const summaryIcons={"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 summaryClasses={"match":"pass","likely_match":"pass","uncertain":"advisory","likely_renamed":"warn","renamed":"warn"}; -matchHtml+=`
    ${summaryMessages[confLevel]||"Review manually"}
    `; +matchHtml+=`
    ${summaryText}
    `; // Rename issues — reframed labels if(confLevel==="likely_renamed"||confLevel==="renamed"){const issueItems=issues.map(i=>{if(i.type==="folder")return`
  • Torrent name: ${esc(i.expected)}
    Folder name: ${esc(i.found)}
  • `;if(i.type==="filename")return`
  • Torrent name: ${esc(i.expected)}
    Filename: ${esc(i.found)}
  • `;return`
  • ${esc(i.expected)} vs ${esc(i.found)}
  • `}).join("");if(issueItems)matchHtml+=`
      ${issueItems}
    `} // Group note @@ -4243,7 +4319,7 @@ const fieldLabels={titleName:"Title",year:"Year",resolution:"Resolution",source: // Self-consistency summary const selfIssues=issues.filter(i=>i.severity==="high");const folderOk=!selfIssues.some(i=>i.type==="folder");const fileOk=!selfIssues.some(i=>i.type==="filename");detailRows+=`
    Self-check: folder ${folderOk?"✓":"✗"} · filename ${fileOk?"✓":"✗"}
    `; // Alternatives -if(alts.length>0){const altNames=alts.map(a=>esc(a.indexer)).join(", ");detailRows+=`
    Also found on: ${altNames}
    `} +if(alts.length>0){const _fmtAlt=a=>{const name=a.infoUrl?`${esc(a.indexer)}`:esc(a.indexer);const age=RenameDetector._relativeAge(a.publishDate);return age?`${name} (${age})`:name};const topAlts=alts.slice(0,3);const extraAlts=alts.slice(3);detailRows+=`
    Also found on: ${topAlts.map(_fmtAlt).join(", ")}
    `;if(extraAlts.length>0){detailRows+=`
    +${extraAlts.length} more indexer${extraAlts.length>1?"s":""}
    ${extraAlts.map(a=>`
    ${_fmtAlt(a)}
    `).join("")}
    `}} matchHtml+=`
    Comparison details
    ${detailRows}
    `}} 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"; // Header: precise indexer wording @@ -4321,6 +4397,7 @@ const Z=` background: #2d2d2d; color: var(--text-color, #e5e5e5); } + .mh-btn--sm { font-size: 11px; padding: 2px 6px; margin-left: auto; } .mh-body { padding: 0 !important; @@ -4840,6 +4917,12 @@ const Z=` .mh-field-label { min-width: 80px; } .mh-field-row--self { margin-top: 4px; padding-top: 4px; border-top: 1px solid rgba(59, 61, 62, 0.40); color: var(--text-color, #8c8c8c); } .mh-field-row--alts { color: var(--text-color, #8c8c8c); } + .mh-age { color: var(--text-color, #64748b); font-size: 11px; } + .mh-alts-expand { margin-top: 2px; } + .mh-alts-expand summary { cursor: pointer; color: var(--text-color, #64748b); } + .mh-alts-expand summary:hover { color: var(--text-color, #94a3b8); } + .mh-alts-list { padding-left: 4px; } + .mh-alts-item { padding: 1px 0; } `;; /* ======================================================================== @@ -4951,9 +5034,21 @@ function main() { console.log("[ModQ Helper] Panel injected successfully"); // Async integration pipeline (DarkPeers) - if (dpFeatures.prowlarr || dpFeatures.srrdb) { + // Extracted into a function so the re-search button can call it again. + let _integrationSearchRun = false; + const runIntegrationSearches = async () => { + if (!dpFeatures.prowlarr && !dpFeatures.srrdb) return; const settings = Settings.load(); + // Reset placeholders to loading state on re-search + if (_integrationSearchRun) { + const sEl = document.querySelector('[data-integration="srrdb"]'); + if (sEl) sEl.outerHTML = '
    SRRDBChecking scene database...
    '; + const pEl = document.querySelector('[data-integration="prowlarr"]'); + if (pEl) pEl.outerHTML = '
    ProwlarrSearching indexers...
    '; + } + _integrationSearchRun = true; + // SRRDB: search + file comparison if (dpFeatures.srrdb && settings.srrdb?.enabled !== false) { (async () => { @@ -4964,7 +5059,6 @@ function main() { if (searchResult.found && searchResult.release) { const filesResult = await Integrations.srrdb.getFiles(searchResult.release.release); if (filesResult.files.length > 0 && data.fileStructure?.files?.length > 0) { - // Compare: normalize filenames for comparison const srrdbNames = new Set(filesResult.files.map(f => (f.name || f).toLowerCase().trim())); const localNames = data.fileStructure.files.map(f => { const parts = f.split("/"); @@ -4973,7 +5067,6 @@ function main() { const discrepancies = []; for (const local of localNames) { if (!srrdbNames.has(local)) { - // Check if it's close but renamed const close = [...srrdbNames].find(s => s.replace(/[.\-_ ]/g, "") === local.replace(/[.\-_ ]/g, "")); if (close) { discrepancies.push(`"${local}" differs from SRRDB "${close}"`); @@ -4988,7 +5081,7 @@ function main() { } else if (filesResult.error) { searchResult.fileCheck = { match: false, error: filesResult.error }; } else { - searchResult.fileCheck = { match: true }; // No files to compare = no mismatch + searchResult.fileCheck = { match: true }; } } @@ -5012,7 +5105,8 @@ function main() { if (searchResult.found && searchResult.results.length > 0) { const preferredIndexers = settings.prowlarr?.preferredIndexers || []; - const bestMatch = RenameDetector.findBestMatch(uploadTokens, searchResult.results, preferredIndexers); + const ignoredIndexers = settings.prowlarr?.ignoredIndexers || []; + const bestMatch = RenameDetector.findBestMatch(uploadTokens, searchResult.results, preferredIndexers, ignoredIndexers); if (bestMatch) { const assessment = RenameDetector.assessRename(data, bestMatch, tvScope); @@ -5040,6 +5134,19 @@ function main() { const el = document.querySelector('[data-integration="prowlarr"]'); if (el) el.outerHTML = U.renderIntegrationResult("Prowlarr", { notConfigured: true }); } + }; + + // Wire up re-search button + const refreshBtn = document.querySelector("#mh-integration-refresh"); + if (refreshBtn) { + refreshBtn.addEventListener("click", () => { + runIntegrationSearches(); + }); + } + + // Run initial search + if (dpFeatures.prowlarr || dpFeatures.srrdb) { + runIntegrationSearches(); } } catch (err) {