From 33af8f8be1309ab2da7a6c3d62c6419d27051aa3 Mon Sep 17 00:00:00 2001 From: Procuria Date: Fri, 25 Sep 2026 17:37:23 +0200 Subject: [PATCH] Zenith Mod Queue Helper v0.1.0 --- CHANGELOG.md | 24 + CHECKS.md | 77 + LICENSE | 18 + README.md | 55 + modq-helper-zenith.user.js | 5484 ++++++++++++++++++++++++++++++++++++ 5 files changed, 5658 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CHECKS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 modq-helper-zenith.user.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..135b45d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to the Zenith Mod Queue Helper. Format: [Keep a Changelog](https://keepachangelog.com/), versions follow [Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-09-25 + +First release, based on the DarkPeers Mod Queue Helper 0.6.7. + +### Added + +- Runs on `https://znth.cx/torrents/*` +- Zenith Rules section in the panel: archives (2.1), movie boxsets (2.2), multi-season packs (2.3), single episodes (2.4), full-disc packaging (3.3, 3.9), TrueHD compatibility track (3.8), advertising (1.3) +- Corrective message cites the Zenith Uploading Rules by section and links the naming guide only for naming issues +- Zenith banned groups from the tracker API (43 groups) +- Naming guide terms: `HDR Vivid`, `DD+ EX`, `DTS Headphone:X`, 3D sources + +### Changed + +- TMDB title and MediaInfo are read from the Zenith page layout +- Containers follow 3.5: AVI allowed, `.ts` for untouched HDTV, `.ps`/`.mpg` for untouched SDTV +- Checks without a Zenith rule are shown as Review and stay out of the message: English subtitles (1.5 makes them optional), encode settings, upscale, unknown language, single file in folder +- A missing release tag is Review, not Fail +- SRRDB and Prowlarr are off by default +- Check toggles in Settings apply to the Zenith Rules section diff --git a/CHECKS.md b/CHECKS.md new file mode 100644 index 0000000..73570ae --- /dev/null +++ b/CHECKS.md @@ -0,0 +1,77 @@ +# Zenith Mod Queue Helper: checks + +Sources: +- Uploading Rules, https://znth.cx/pages/8 (updated 2026-08-14) +- Movie/TV Naming Guide, https://znth.cx/wikis/7 (updated 2026-08-31) +- Banned groups, `GET /api/bannedReleaseGroups` (43 groups, 2026-09-25) + +Severity: +- **Fail**: breaks a written Zenith rule. Goes into the corrective message. +- **Warning**: likely problem, or the page could not be read. Goes into the message. +- **Review**: shown for the moderator only, never in the message. Used where Zenith has no rule, or where the rule depends on facts the page does not show. + +## Content and naming + +| Check | Rule | Severity | Notes | +|---|---|---|---| +| TMDB title match | 3.1, guide: Name | Fail/Warning | Title must start with the TMDB title shown on the page | +| Season/episode format | 3.1, guide: S##E## | Fail | `S##`, `S##E##`, zero-padded | +| Title element order | 3.1, guide: templates | Fail/Warning | Full Disc/REMUX and Encode/WEB templates | +| Naming convention | 3.1 | Fail/Warning | Resolution, source, codecs, HDR, channels, object | +| Title template | 3.1 | Fail | Required elements. A missing tag is Review ("If none exists, leave empty") | +| Release group | banned list | Fail/Warning | Zenith API list, 43 groups, no exceptions | +| Banned group in files | banned list | Fail | Group hidden in file names under a different title tag | +| Release tag | 1.4 | Review | Untagged or NOGROUP is allowed. A banned group in the files stays Fail | +| Screenshots | 3.4 | Fail/Warning | At least 3, TMDB posters excluded | + +## Technical + +| Check | Rule | Severity | Notes | +|---|---|---|---| +| MediaInfo | 3.2 | Fail/Warning | Found in the MediaInfo tab (Zenith panel has no heading) | +| BDInfo | 3.3 | Fail | Blu-ray full discs only | +| Container | 3.5 | Fail | MKV, MP4, AVI. `.ts` only for HDTV/UHDTV, `.ps`/`.mpg` only for SDTV | +| Extraneous files | 3.6 | Fail | NFO, txt, images, `.plexmatch` and similar. Games/Apps skipped | +| Folder structure | 3.6, 3.7 | Fail/Warning | | +| Resolution type | 3.1 | Fail/Warning | Title resolution matches the torrent's resolution field | +| Audio compliance | 3.1, guide: Dub/ACodec | Fail/Warning | Default track codec, channels, Atmos, Dual-Audio/Multi tags | +| Pack uniformity | | Fail/Warning | All episodes share specs | +| Subtitles | 1.5 | Review | English subs are optional on Zenith | +| Encode compliance | none | Review | Encoder settings. Zenith has no encode spec | +| Upscale detection | none | Review | | +| Unknown language | none | Review | Tracks without a language tag | +| Single file in folder | none | Review | | +| Category/type | | Warning | Category and type fit each other and the title | +| Missing episodes | 2.4 | Warning | Gaps in a season pack | +| Suspicion heuristics | none | Review | Group mismatch, raw `.ts`, unknown 4K REMUX group | + +## Zenith rules (new in this script) + +| Check | Rule | Severity | Fails when | +|---|---|---|---| +| Archives | 2.1 | Fail | `.rar`, `.r00`, `.zip`, `.7z` in the file list. Games/Apps skipped | +| Movie boxset | 2.2 | Fail | Title has Collection/Trilogy/Boxset, or video files from 2+ years. Full disc: Review (guide allows multi-title discs) | +| Season packaging | 2.3 | Fail | `S##-S##`, `COMPLETE`, or episodes from 2+ seasons. Full disc: Review (2.3.1) | +| Single episode | 2.4 | Review | `S##E##`. Allowed only while the season airs, which the page does not show | +| Full disc | 3.3, 3.9, 3.9.1 | Fail | Blu-ray without BDInfo, ISO for a normal disc, 3D/MGVC not as ISO, no BDMV/VIDEO_TS files | +| TrueHD compatibility track | 3.8 | Fail | REMUX whose default audio is TrueHD and no AC-3/E-AC-3 track exists | +| Advertising | 1.3 | Review | Description names another tracker or says "downloaded from" | + +Not checked: 1.1, 1.2 (exclusivity), 1.5 retail subs, 2.6 (XXX), 3.10, music, audiobooks, ebooks, games and software rules. + +## Tested + +- Offline unit tests: `tests/15_zenith_test.js`, every rule in both directions +- Full script in a headless browser on a saved Zenith torrent page (Encode, 1080p): all checks pass +- The same page, altered once per rule (no MediaInfo, banned group, RAR/NFO, no screenshots, S01-S03, boxset with AVI/WMV, TrueHD REMUX with and without AC-3, German audio without subs, tracker name in the description): each produced the expected result +- No network requests with the default settings + +## Not tested + +| Area | Why | How to close it | +|---|---|---| +| Postpone/Reject paste | Needs the staff view of a pending torrent | A Zenith moderator tries both buttons | +| BDInfo detection | No saved full-disc page | Save a full-disc torrent page | +| HDTV/SDTV container exceptions | Zenith type names for HDTV not confirmed | Save an HDTV torrent page | +| TV pages | No saved TV page | Save a season pack and an episode page | +| SRRDB/Prowlarr on Zenith | Integrations are off by default | Enable in settings and search once | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d8056c --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Procuria + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..20692c0 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Zenith Mod Queue Helper + +Tampermonkey userscript for Zenith (znth.cx). It checks a torrent page against the Zenith upload rules and naming guide and shows the result in a panel above the moderation section. + +## Install + +1. Install [Tampermonkey](https://www.tampermonkey.net/). +2. Open **[modq-helper-zenith.user.js](https://gitea.computerliebe.org/Procuria/zenith-modq-helper/raw/branch/main/modq-helper-zenith.user.js)** and click **Install**. + +The script runs on `https://znth.cx/torrents/*`. Tampermonkey checks for updates on its own. To force a check: Tampermonkey menu > Utilities > Check for userscript updates. + +If the DarkPeers Mod Queue Helper is installed, disable it. Both scripts use the same settings key. + +## What it checks + +- Title against TMDB and the [naming guide](https://znth.cx/wikis/7): element order, resolution, source, codecs, HDR, channels, tag +- Screenshots (3.4), MediaInfo (3.2), BDInfo (3.3), container (3.5), extraneous files (3.6) +- Banned release groups, in the title and in file names +- Packaging: archives (2.1), boxsets (2.2), multi-season packs (2.3), single episodes (2.4) +- Full discs: unpacked BDMV/VIDEO_TS, 3D and MGVC as ISO (3.9) +- TrueHD REMUX without an AC-3/E-AC-3 compatibility track (3.8) +- Other trackers mentioned in the description (1.3) + +Every check with its rule and severity: [CHECKS.md](CHECKS.md). + +Results are **Pass**, **Fail**, **Warning** or **Review**. Review is informational and never goes into the corrective message. + +## Corrective message + +The panel builds a message from all fails and warnings, with links to the Uploading Rules and the naming guide. Click it to edit, then copy it. The Postpone and Reject buttons paste it into the moderation form, which only staff see. The script never submits a form. + +## Settings + +Tampermonkey menu > **ModQ Helper Settings**. + +- Turn the Zenith rule checks on or off +- Optional integrations, both off by default: + - **SRRDB**: looks up scene releases and compares file names + - **Prowlarr**: your own Prowlarr URL and API key, used to detect renamed releases + +With both integrations off, the script makes no network requests. + +## Known limits + +- The Postpone/Reject paste targets the standard UNIT3D moderation form. It has not been tested on Zenith's staff view yet. +- BDInfo detection on full-disc pages has not been tested on Zenith. +- Checks read the rendered page. If Zenith changes its theme, some checks may report "not found" until the selectors are updated. + +## Feedback + +Open an issue in this repository or reply in the Zenith forum thread. + +## License + +MIT diff --git a/modq-helper-zenith.user.js b/modq-helper-zenith.user.js new file mode 100644 index 0000000..fae1e3d --- /dev/null +++ b/modq-helper-zenith.user.js @@ -0,0 +1,5484 @@ +// ==UserScript== +// @name UNIT3D Mod Queue Helper (Zenith) +// @namespace https://gitea.computerliebe.org/Procuria/zenith-modq-helper +// @version 0.1.0 +// @description Quality-gate checks for Zenith (znth.cx): upload rules, naming guide, banned groups; optional SRRDB & Prowlarr integrations +// @author TQG Contributors +// @updateURL https://gitea.computerliebe.org/Procuria/zenith-modq-helper/raw/branch/main/modq-helper-zenith.user.js +// @downloadURL https://gitea.computerliebe.org/Procuria/zenith-modq-helper/raw/branch/main/modq-helper-zenith.user.js +// @match https://znth.cx/torrents/* +// @grant GM_addStyle +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand +// @grant GM_xmlhttpRequest +// @connect www.srrdb.com +// @connect srrdb.com +// @connect * +// @run-at document-idle +// ==/UserScript== + +(function() { +'use strict'; + +/* ======================================================================== + * CONFIG — Quality-gate configuration data + * Ported from the original modq-helper g object. Pure data, no DOM. + * ======================================================================== */ + +const g = { + minScreenshots: 3, + validResolutions: [ + "480i", + "480p", + "576i", + "576p", + "720p", + "1080i", + "1080p", + "2160p", + "4320p" + ], + validAudioCodecs: [ + "DTS Headphone:X", + "DTS-HD MA", + "DTS-HD HRA", + "DTS:X", + "DTS-ES", + "DTS", + "TrueHD", + "DD+ EX", + "DD+", + "DDP", + "DD EX", + "DD", + "E-AC-3", + "AC-3", + "LPCM", + "PCM", + "FLAC", + "ALAC", + "AAC", + "MP3", + "MP2", + "Opus", + "Vorbis" + ], + validVideoCodecs: [ + "AVC", + "HEVC", + "H.264", + "H.265", + "x264", + "x265", + "MPEG-2", + "VC-1", + "VP9", + "AV1", + "XviD", + "DivX" + ], + hdrFormats: [ + "HDR Vivid", + "DV HDR10+", + "DV HDR", + "HDR10+", + "HDR10", + "HDR", + "DV", + "HLG", + "PQ10" + ], + fullDiscTypes: [ + "Full Disc", + "BD50", + "BD25", + "BD66", + "BD100" + ], + remuxTypes: [ + "REMUX" + ], + encodeTypes: [ + "Encode" + ], + webTypes: [ + "WEB-DL", + "WEBRip" + ], + hdtvTypes: [ + "HDTV", + "SDTV", + "UHDTV", + "PDTV", + "DSR" + ], + streamingServices: [ + "AMZN", + "NF", + "DSNP", + "HMAX", + "ATVP", + "PCOK", + "PMTP", + "HBO", + "HULU", + "iT", + "MA", + "STAN", + "RED", + "CRAV", + "CRITERION", + "SHO", + "STARZ", + "VUDU", + "MUBI", + "BCORE", + "PLAY", + "APTV" + ], + sources: { + fullDisc: [ + "3D Blu-ray", + "Blu-ray", + "UHD Blu-ray", + "HD DVD", + "DVD5", + "DVD9", + "NTSC DVD", + "PAL DVD" + ], + remux: [ + "3D BluRay", + "BluRay", + "UHD BluRay", + "HDDVD", + "NTSC DVD", + "PAL DVD" + ], + encode: [ + "3D BluRay", + "BluRay", + "UHD BluRay", + "DVDRip", + "HDDVD", + "BDRip", + "BRRip", + "WEB-DL", + "WEBRip", + "WEB" + ], + web: [ + "WEB-DL", + "WEBRip", + "WEB" + ], + hdtv: [ + "HDTV", + "SDTV", + "UHDTV", + "PDTV", + "DSR" + ] + }, + // Zenith banned groups — GET https://znth.cx/api/bannedReleaseGroups (2026-09-25, 43 groups) + bannedGroups: [ + "d3g", "FGT", "ION10", "MeGusta", "RARBG", "YIFY", "YTS", "AROMA", "DNL", "Hi10", "LAMA", "nikt0", + "x0r", "4K4U", "Alcaide_Kira", "aXXo", "BRrip", "CM8", "CrEwSaDe", "EVO", "FaNGDiNG0", "FRDS", + "HD2DVD", "HDTime", "iPlanet", "KiNGDOM", "mHD", "mSD", "NhaNc3", "nHD", "nSD", "OFT", "PRODJi", + "SANTi", "SPDVD", "STUTTERSHIT", "Telly", "TGx", "TSP", "TSPxL", "WAF", "GalaxyTV", "BiTOR" + ], + // Groups with conditional exceptions (not in bannedGroups — checked separately) + // Zenith's list has no conditional exceptions (EVO is banned outright) + bannedGroupExceptions: {}, + exceptionGroupNames: [ + "DiscoD HONE", "DarQ HONE", "Eml HDTeam", "BEN THE MEN", "D-Z0N3", "ZØNEHD", "Anime Time", + "Project Angel", "Hakata Ramen", "-ZR-" + ], + bracketGroupNames: [ + "Silence", "afm72", "Panda", "Ghost", "MONOLITH", "Tigole", "Joy", "ImE", "UTR", "t3nzin", + "Anime Time", "Project Angel", "Hakata Ramen", "HONE", "GiLG", "Vyndros", "SEV", "Garshasp", + "Kappa", "Natty", "RCVR", "SAMPA", "YOGI", "r00t", "EDGE2020", "RZeroX", "FreetheFish", "Anna", + "Bandi", "Qman", "theincognito", "HDO", "DusIctv", "DHD", "CtrlHD", "-ZR-", "ADC", "XZVN", "RH", + "Kametsu" + ], + 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", + "slowpic.", "pixhost.", "ibb.co", "postimg.", "funkyimg.", "image.tmdb.org" + ], + imageExtensions: [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp" + ], + languageMap: { + aa: "Afar", + ab: "Abkhazian", + ae: "Avestan", + af: "Afrikaans", + ak: "Akan", + am: "Amharic", + an: "Aragonese", + ar: "Arabic", + as: "Assamese", + av: "Avaric", + ay: "Aymara", + az: "Azerbaijani", + ba: "Bashkir", + be: "Belarusian", + bg: "Bulgarian", + bi: "Bislama", + bm: "Bambara", + bn: "Bengali", + bo: "Tibetan", + br: "Breton", + bs: "Bosnian", + ca: "Catalan", + ce: "Chechen", + ch: "Chamorro", + cn: "Cantonese", + co: "Corsican", + cr: "Cree", + cs: "Czech", + cu: "Slavic", + cv: "Chuvash", + cy: "Welsh", + da: "Danish", + de: "German", + dv: "Divehi", + dz: "Dzongkha", + ee: "Ewe", + el: "Greek", + en: "English", + eo: "Esperanto", + es: "Spanish", + et: "Estonian", + eu: "Basque", + fa: "Persian", + ff: "Fulah", + fi: "Finnish", + fj: "Fijian", + fo: "Faroese", + fr: "French", + fy: "Frisian", + ga: "Irish", + gd: "Gaelic", + gl: "Galician", + gn: "Guarani", + gu: "Gujarati", + gv: "Manx", + ha: "Hausa", + he: "Hebrew", + hi: "Hindi", + ho: "Hiri Motu", + hr: "Croatian", + ht: "Haitian", + hu: "Hungarian", + hy: "Armenian", + hz: "Herero", + ia: "Interlingua", + id: "Indonesian", + ie: "Interlingue", + ig: "Igbo", + ii: "Yi", + ik: "Inupiaq", + io: "Ido", + is: "Icelandic", + it: "Italian", + iu: "Inuktitut", + ja: "Japanese", + jv: "Javanese", + ka: "Georgian", + kg: "Kongo", + ki: "Kikuyu", + kj: "Kuanyama", + kk: "Kazakh", + kl: "Kalaallisut", + km: "Khmer", + kn: "Kannada", + ko: "Korean", + kr: "Kanuri", + ks: "Kashmiri", + ku: "Kurdish", + kv: "Komi", + kw: "Cornish", + ky: "Kirghiz", + la: "Latin", + lb: "Letzeburgesch", + lg: "Ganda", + li: "Limburgish", + ln: "Lingala", + lo: "Lao", + lt: "Lithuanian", + lu: "Luba-Katanga", + lv: "Latvian", + mg: "Malagasy", + mh: "Marshall", + mi: "Maori", + mk: "Macedonian", + ml: "Malayalam", + mn: "Mongolian", + mo: "Moldavian", + mr: "Marathi", + ms: "Malay", + mt: "Maltese", + my: "Burmese", + na: "Nauru", + nb: "Norwegian Bokmål", + nd: "Ndebele", + ne: "Nepali", + ng: "Ndonga", + nl: "Dutch", + nn: "Norwegian Nynorsk", + no: "Norwegian", + nr: "Ndebele", + nv: "Navajo", + ny: "Chichewa", + oc: "Occitan", + oj: "Ojibwa", + om: "Oromo", + or: "Oriya", + os: "Ossetian", + pa: "Punjabi", + pi: "Pali", + pl: "Polish", + ps: "Pushto", + pt: "Portuguese", + qu: "Quechua", + rm: "Raeto-Romance", + rn: "Rundi", + ro: "Romanian", + ru: "Russian", + rw: "Kinyarwanda", + sa: "Sanskrit", + sc: "Sardinian", + sd: "Sindhi", + se: "Northern Sami", + sg: "Sango", + sh: "Serbo-Croatian", + si: "Sinhalese", + sk: "Slovak", + sl: "Slovenian", + sm: "Samoan", + sn: "Shona", + so: "Somali", + sq: "Albanian", + sr: "Serbian", + ss: "Swati", + st: "Sotho", + su: "Sundanese", + sv: "Swedish", + sw: "Swahili", + ta: "Tamil", + te: "Telugu", + tg: "Tajik", + th: "Thai", + ti: "Tigrinya", + tk: "Turkmen", + tl: "Tagalog", + tn: "Tswana", + to: "Tonga", + tr: "Turkish", + ts: "Tsonga", + tt: "Tatar", + tw: "Twi", + ty: "Tahitian", + ug: "Uighur", + uk: "Ukrainian", + ur: "Urdu", + uz: "Uzbek", + ve: "Venda", + vi: "Vietnamese", + vo: "Volapük", + wa: "Walloon", + wo: "Wolof", + xh: "Xhosa", + xx: "No Language", + yi: "Yiddish", + yo: "Yoruba", + za: "Zhuang", + zh: "Mandarin", + zu: "Zulu" + }, + languageAliases: { + "mandarin": ["chinese"], + "cantonese": ["chinese"], + "norwegian bokmål": ["norwegian"], + "norwegian nynorsk": ["norwegian"], + "moldavian": ["romanian"], + "letzeburgesch": ["luxembourgish"], + "sinhalese": ["sinhala"], + "pushto": ["pashto"], + "raeto-romance": ["romansh"], + "slavic": ["church slavic"], + "frisian": ["western frisian"], + "filipino": ["tagalog"], + "tagalog": ["filipino"], + "persian": ["farsi"], + "farsi": ["persian"], + "burmese": ["myanmar"], + "myanmar": ["burmese"], + "limburgish": ["dutch"] + }, + titleElementOrder: { + fullDiscRemux: [ + "name", + "aka", + "locale", + "year", + "season", + "cut", + "ratio", + "repack", + "resolution", + "edition", + "region", + "3d", + "source", + "type", + "hdr", + "vcodec", + "dub", + "acodec", + "channels", + "object", + "group" + ], + encodeWeb: [ + "name", + "aka", + "locale", + "year", + "season", + "cut", + "ratio", + "repack", + "resolution", + "edition", + "3d", + "source", + "type", + "dub", + "acodec", + "channels", + "object", + "hdr", + "vcodec", + "group" + ] + }, + cuts: [ + "Theatrical", + "Director's Cut", + "Extended", + "Extended Cut", + "Extended Edition", + "Special Edition", + "Unrated", + "Unrated Director's Cut", + "Uncut", + "Super Duper Cut", + "Ultimate Cut", + "Ultimate Edition", + "Final Cut", + "Producer's Cut", + "Assembly Cut", + "International Cut", + "Redux", + "Rough Cut", + "Bootleg Cut", + "Criterion", + "Criterion Cut", + "Workprint", + "Hybrid Cut" + ], + ratios: [ + "IMAX", + "Open Matte", + "MAR" + ], + editions: [ + "Anniversary Edition", + "Remastered", + "4K Remaster", + "Criterion Collection", + "Limited", + "Collector's Edition", + "Deluxe Edition", + "Restored" + ], + repacks: [ + "REPACK", + "REPACK2", + "REPACK3", + "PROPER", + "RERIP" + ], + dubs: [ + "Multi", + "Dual-Audio", + "Dual Audio", + "Dubbed" + ], + _tieredGroupsRaw: [ + { + name: "Anime BD Tier 01", + source: "radarr", + groups: [ + "DemiHuman", "FLE", "Flugel", "LYS1TH3A", "Moxie", "NAN0", "sam", "smol", "SoM", "ZR" + ] + }, + { + name: "Anime BD Tier 02", + source: "radarr", + groups: [ + "Aergia", "Arg0", "Arid", "FateSucks", "hchcsen", "hydes", "JOHNTiTOR", "JySzE", "koala", + "Kulot", "LostYears", "Lulu", "Meakes", "Orphan", "PMR", "Vodes", "WAP", "YURI", "ZeroBuild" + ] + }, + { + name: "Anime BD Tier 03", + source: "radarr", + groups: [ + "ARC", "BBT-RMX", "cappybara", "ChucksMux", "CRUCiBLE", "CUNNY", "Cunnysseur", "Doc", "fig", + "Headpatter", "Inka-Subs", "LaCroiX", "Legion", "Mehul", "MTBB", "Mysteria", "Netaro", + "Noiy", "npz", "NTRX", "Okay-Subs", "P9", "RaiN", "RMX", "RUDY", "Sekkon", "Serendipity", + "sgt", "SubsMix", "uba" + ] + }, + { + name: "Anime BD Tier 04", + source: "radarr", + groups: [ + "ABdex", "Afro", "aRMX", "BiRJU", "BKC", "CBT", "Chimera", "derp", "DIY", "EXP", "Foxtrot", + "grimf", "IK", "Iznjie Biznjie", "Kaleido-subs", "Kametsu", "Kawatare", "KH", "LazyRemux", + "Metal", "MK", "neko-kBaraka", "OZR", "Pizza", "pog42", "Quetzal", "Reza", "SCY", "Shimatta", + "Smoke", "Spirale", "UDF", "UQW", "Vanilla", "Virtuality", "VULCAN" + ] + }, + { + name: "Anime BD Tier 05", + source: "radarr", + groups: [ + "Animorphs", "AOmundson", "ASC", "B00BA", "Baws", "Beatrice", "Cait-Sidhe", "CsS", "CTR", + "D4C", "deanzel", "Drag", "eldon", "Freehold", "GHS", "Hark0N", "Holomux", "Judgement", "MC", + "mottoj", "NH", "NTRM", "o7", "QM", "Thighs", "TTGA", "UltraRemux", "WBDP", "WSE", "Yuki" + ] + }, + { + name: "Anime BD Tier 06", + source: "radarr", + groups: [ + "ANE", "Bunny-Apocalypse", "CyC", "Datte13", "EJF", "GetItTwisted", "GSK_kun", "iKaos", + "karios", "Pookie", "RASETSU", "Starbez", "Tsundere", "Yoghurt", "YURASUKA" + ] + }, + { + name: "Anime BD Tier 07", + source: "radarr", + groups: [ + "9volt", "AC", "Almighty", "Asakura", "Asenshi", "BlurayDesuYo", "Bolshevik", "Brrrrrrr", + "Chihiro", "Commie", "Crow", "Dae", "Dekinai", "Dragon-Releases", "DragsterPS", + "Exiled-Destiny", "FFF", "Final8", "Geonope", "GJM", "iAHD", "inid4c", "Koten_Gars", + "kuchikirukia", "LCE", "NTW", "orz", "RAI", "REVO", "SCP-2223", "Senjou", "SEV", "THORA", + "Vivid" + ] + }, + { + name: "Anime BD Tier 08", + source: "radarr", + groups: [ + "AkihitoSubs", "Arukoru", "EDGE", "EMBER", "GHOST", "Judas", "naiyas", "Nep_Blanc", "Prof", + "Shirσ" + ] + }, + { + name: "Anime Web Tier 01", + source: "radarr", + groups: [ + "Arg0", "Arid", "Baws", "FLE", "LostYears", "LYS1TH3A", "McBalls", "sam", "SCY", "Setsugen", + "smol", "SoM", "Vodes", "Z4ST1N", "ZeroBuild" + ] + }, + { + name: "Anime Web Tier 02", + source: "radarr", + groups: [ + "0x539", "Asakura", "Cyan", "Cytox", "Dae", "Foxtrot", "Gao", "GSK_kun", "Half-Baked", + "HatSubs", "MALD", "MTBB", "Not-Vodes", "Okay-Subs", "Pizza", "Reza", "Slyfox", "SoLCE", + "Tenshi" + ] + }, + { + name: "Anime Web Tier 03", + source: "radarr", + groups: [ + "AnoZu", "Dooky", "Kitsune", "SubsPlus+", "ZR" + ] + }, + { + name: "Anime Web Tier 04", + source: "radarr", + groups: [ + "Erai-Raws", "ToonsHub", "VARYG" + ] + }, + { + name: "Anime Web Tier 05", + source: "radarr", + groups: [ + "BlueLobster", "GST", "HorribleRips", "HorribleSubs", "KAN3D2M", "KiyoshiStar", "Lia", + "NanDesuKa", "PlayWeb", "SobsPlease", "Some-Stuffs", "SubsPlease", "URANIME", "ZigZag" + ] + }, + { + name: "Anime Web Tier 06", + source: "radarr", + groups: [ + "9volt", "Asenshi", "Chihiro", "Commie", "DameDesuYo", "Doki", "GJM", "Kaleido", "Kantai", + "KawaSubs", "Tsundere" + ] + }, + { + name: "FR Anime Tier 01", + source: "radarr", + groups: [ + "Darki", "Delivroozzi", "Fuceo", "Good Job! Alexis", "Punisher694", "SR-71", "T3KASHi", + "TANOSHii", "Tsundere-Raws" + ] + }, + { + name: "FR Anime Tier 02", + source: "radarr", + groups: [ + "Aoi-Project", "Elecman", "FUJiSAN", "GundamGuy", "IssouCorp", "KAF", "Nagutos", "OECUF", + "XSPITFIRE911" + ] + }, + { + name: "FR Anime Tier 03", + source: "radarr", + groups: [ + "BLV", "D3T3R10R1TY", "Galactic", "HANAMi", "kazuizui", "KHAYA", "KushEnthusiast", + "matheousse", "Monkey-D.Lulu", "NeoSG", "RONiN", "TheFantastics", "TTN" + ] + }, + { + name: "FR HD Bluray Tier 01", + source: "radarr", + groups: [ + "BDHD", "FoX", "FRATERNiTY", "FrIeNdS", "MAX", "Psaro", "YODA" + ] + }, + { + name: "FR HD Bluray Tier 02", + source: "radarr", + groups: [ + "HDForever", "HeavyWeight", "MARBLECAKE", "MYSTERiON", "NoNE", "ONLY", "ONLYMOViE", "TkHD", + "UTT" + ] + }, + { + name: "FR Remux Tier 01", + source: "radarr", + groups: [ + "BlackAngel", "Choco", "HDForever", "MAX", "ONLY", "Psaro", "Sicario", "Tezcat74", + "TyrellCorp", "Zapax" + ] + }, + { + name: "FR Remux Tier 02", + source: "radarr", + groups: [ + "BDHD", "FtLi", "Goldenyann", "HeavyWeight", "KTM", "MARBLECAKE", "MUSTANG", "Obi", "PEPiTE", + "QUEBEC63", "ROMKENT" + ] + }, + { + name: "FR UHD Bluray Tier 01", + source: "radarr", + groups: [ + "FLOP", "FoX", "FRATERNiTY", "Not SDR", "Psaro" + ] + }, + { + name: "FR UHD Bluray Tier 02", + source: "radarr", + groups: [ + "DUSTiN", "FCK", "FrIeNdS", "Not SDR", "QUALiTY" + ] + }, + { + name: "FR WEB Tier 01", + source: "radarr", + groups: [ + "BONBON", "FCK", "FoX", "FRATERNiTY", "FrIeNdS", "FW", "MOONLY", "MTDK", "PATOPESTO", + "Psaro", "RG", "SUPPLY", "TFA", "TiNA" + ] + }, + { + name: "FR WEB Tier 02", + source: "radarr", + groups: [ + "ALLDAYiN", "ARK01", "HeavyWeight", "NEO", "NoNe", "ONLYMOViE", "POTO", "Slay3R", "TkHD", + "WaCkS" + ] + }, + { + name: "German Bluray Tier 01", + source: "radarr", + groups: [ + "CNY", "MAMA", "NIMA4K", "PXL", "TSCC", "TvR", "TVS", "WalterBishop", "WeebPinn", "ZeroTwo", + "ZeroTwo Aliases" + ] + }, + { + name: "German Bluray Tier 02", + source: "radarr", + groups: [ + "ABJ", "MULTiPLEX", "Oergel", "paranoid06", "RocketHD", "SiXTYNiNE", "VECTOR" + ] + }, + { + name: "German Bluray Tier 03", + source: "radarr", + groups: [ + "FX", "HDSource", "iNCEPTION", "LeetHD", "RDR", "RHD", "RobertDeNiro", "UNFIrED" + ] + }, + { + name: "German Remux Tier 01", + source: "radarr", + groups: [ + "MAMA", "NIMA4K", "pmHD", "QfG", "TvR", "WeebPinn" + ] + }, + { + name: "German Remux Tier 02", + source: "radarr", + groups: [ + "FX", "HDSource", "iNCEPTION", "MULTiPLEX", "RHD", "RocketHD" + ] + }, + { + name: "German Web Tier 01", + source: "radarr", + groups: [ + "CNY", "D02KU", "MEDiATHEK", "NIMA4K", "pmHD", "PXL", "QfG", "RiiR", "RiiR Aliases", "TSCC", + "TvR", "TVS", "WalterBishop", "WeebPinn", "ZeroTwo", "ZeroTwo Aliases" + ] + }, + { + name: "German Web Tier 02", + source: "radarr", + groups: [ + "4SF", "ABJ", "MULTiPLEX", "Oergel", "paranoid06", "SiXTYNiNE", "VECTOR" + ] + }, + { + name: "German Web Tier 03", + source: "radarr", + groups: [ + "BALENCiAGA", "FX", "HDSource", "RDR", "RobertDeNiro" + ] + }, + { + name: "HD Bluray Tier 01", + source: "radarr", + groups: [ + "BBQ", "BMF", "c0kE", "Chotab", "CRiSC", "CtrlHD", "D-Z0N3", "Dariush", "decibeL", "DON", + "EbP", "EDPH", "Geek", "LolHD", "NCmt", "PTer", "TayTO", "TDD", "TnP", "VietHD", + "ZoroSenpai", "ZQ" + ] + }, + { + name: "HD Bluray Tier 02", + source: "radarr", + groups: [ + "ATELiER", "EA", "HiDt", "HiSD", "iFT", "NTb", "QOQ", "SA89", "sbR" + ] + }, + { + name: "HD Bluray Tier 03", + source: "radarr", + groups: [ + "BHDStudio", "hallowed", "HiFi", "HONE", "LoRD", "playHD", "SPHD", "W4NK3R" + ] + }, + { + name: "Remux Tier 01", + source: "radarr", + groups: [ + "3L", "BiZKiT", "BLURANiUM", "BMF", "CiNEPHiLES", "FraMeSToR", "PiRAMiDHEAD", "PmP", + "WiLDCAT", "ZQ" + ] + }, + { + name: "Remux Tier 02", + source: "radarr", + groups: [ + "ATELiER", "NCmt", "playBD", "SiCFoI", "SURFINBIRD", "TEPES" + ] + }, + { + name: "Remux Tier 03", + source: "radarr", + groups: [ + "12GaugeShotgun", "decibeL", "EPSiLON", "HiFi", "iFT", "KRaLiMaRKo", "NTb", "PTP", + "SumVision", "TOA", "TRiToN" + ] + }, + { + name: "UHD Bluray Tier 01", + source: "radarr", + groups: [ + "CtrlHD", "DON", "MainFrame", "W4NK3R" + ] + }, + { + name: "UHD Bluray Tier 02", + source: "radarr", + groups: [ + "HQMUX" + ] + }, + { + name: "UHD Bluray Tier 03", + source: "radarr", + groups: [ + "BHDStudio", "hallowed", "HONE", "PTer", "SPHD", "WEBDV" + ] + }, + { + name: "WEB Tier 01", + source: "radarr", + groups: [ + "ABBIE", "AJP69", "APEX", "BLUTONiUM", "BYNDR", "CMRG", "CRFW", "CRUD", "FLUX", "GNOME", + "HONE", "KiNGS", "Kitsune", "NOSiViD", "NTb", "NTG", "RAWR", "SiC", "TEPES", "TheFarm", + "ZoroSenpai" + ] + }, + { + name: "WEB Tier 02", + source: "radarr", + groups: [ + "dB", "Flights", "MiU", "monkee", "MZABI", "PHOENiX", "playWEB", "SbR", "SMURF", "TOMMY", + "XEBEC" + ] + }, + { + name: "WEB Tier 03", + source: "radarr", + groups: [ + "BLOOM", "Dooky", "GNOMiSSiON", "HHWEB", "NINJACENTRAL", "NPMS", "ROCCaT", "SiGMA", + "SLiGNOME", "SwAgLaNdEr" + ] + }, + { + name: "Anime BD Tier 01", + source: "sonarr", + groups: [ + "DemiHuman", "FLE", "Flugel", "LYS1TH3A", "Moxie", "NAN0", "sam", "smol", "SoM", "ZR" + ] + }, + { + name: "Anime BD Tier 02", + source: "sonarr", + groups: [ + "Aergia", "Arg0", "Arid", "FateSucks", "hchcsen", "hydes", "JOHNTiTOR", "JySzE", "koala", + "Kulot", "LostYears", "Lulu", "Meakes", "Orphan", "PMR", "Vodes", "WAP", "YURI", "ZeroBuild" + ] + }, + { + name: "Anime BD Tier 03", + source: "sonarr", + groups: [ + "ARC", "BBT-RMX", "cappybara", "ChucksMux", "CRUCiBLE", "CUNNY", "Cunnysseur", "Doc", "fig", + "Headpatter", "Inka-Subs", "LaCroiX", "Legion", "Mehul", "MTBB", "Mysteria", "Netaro", + "Noiy", "npz", "NTRX", "Okay-Subs", "P9", "RaiN", "RMX", "RUDY", "Sekkon", "Serendipity", + "sgt", "SubsMix", "uba" + ] + }, + { + name: "Anime BD Tier 04", + source: "sonarr", + groups: [ + "ABdex", "Afro", "aRMX", "BiRJU", "BKC", "CBT", "Chimera", "derp", "DIY", "EXP", "Foxtrot", + "grimf", "IK", "Iznjie Biznjie", "Kaleido-subs", "Kametsu", "Kawatare", "KH", "LazyRemux", + "Metal", "MK", "neko-kBaraka", "OZR", "Pizza", "pog42", "Quetzal", "Reza", "SCY", "Shimatta", + "Smoke", "Spirale", "UDF", "UQW", "Vanilla", "Virtuality", "VULCAN" + ] + }, + { + name: "Anime BD Tier 05", + source: "sonarr", + groups: [ + "Animorphs", "AOmundson", "ASC", "B00BA", "Baws", "Beatrice", "Cait-Sidhe", "CsS", "CTR", + "D4C", "deanzel", "Drag", "eldon", "Freehold", "GHS", "Hark0N", "Holomux", "Judgement", "MC", + "mottoj", "NH", "NTRM", "o7", "QM", "Thighs", "TTGA", "UltraRemux", "WBDP", "WSE", "Yuki" + ] + }, + { + name: "Anime BD Tier 06", + source: "sonarr", + groups: [ + "ANE", "Bunny-Apocalypse", "CyC", "Datte13", "EJF", "GetItTwisted", "GSK_kun", "iKaos", + "karios", "Pookie", "RASETSU", "Starbez", "Tsundere", "Yoghurt", "YURASUKA" + ] + }, + { + name: "Anime BD Tier 07", + source: "sonarr", + groups: [ + "9volt", "AC", "Almighty", "Asakura", "Asenshi", "BlurayDesuYo", "Bolshevik", "Brrrrrrr", + "Chihiro", "Commie", "Crow", "Dae", "Dekinai", "Dragon-Releases", "DragsterPS", + "Exiled-Destiny", "FFF", "Final8", "Geonope", "GJM", "iAHD", "inid4c", "Koten_Gars", + "kuchikirukia", "LCE", "NTW", "orz", "RAI", "REVO", "SCP-2223", "Senjou", "SEV", "THORA", + "Vivid" + ] + }, + { + name: "Anime BD Tier 08", + source: "sonarr", + groups: [ + "AkihitoSubs", "Arukoru", "EDGE", "EMBER", "GHOST", "Judas", "naiyas", "Nep_Blanc", "Prof", + "Shirσ" + ] + }, + { + name: "Anime Web Tier 01", + source: "sonarr", + groups: [ + "Arg0", "Arid", "Baws", "FLE", "LostYears", "LYS1TH3A", "McBalls", "sam", "SCY", "Setsugen", + "smol", "SoM", "Vodes", "Z4ST1N", "ZeroBuild" + ] + }, + { + name: "Anime Web Tier 02", + source: "sonarr", + groups: [ + "0x539", "Asakura", "Cyan", "Cytox", "Dae", "Foxtrot", "Gao", "GSK_kun", "Half-Baked", + "HatSubs", "MALD", "MTBB", "Not-Vodes", "Okay-Subs", "Pizza", "Reza", "Slyfox", "SoLCE", + "Tenshi" + ] + }, + { + name: "Anime Web Tier 03", + source: "sonarr", + groups: [ + "AnoZu", "Dooky", "Kitsune", "SubsPlus+", "ZR" + ] + }, + { + name: "Anime Web Tier 04", + source: "sonarr", + groups: [ + "Erai-Raws", "ToonsHub", "VARYG" + ] + }, + { + name: "Anime Web Tier 05", + source: "sonarr", + groups: [ + "BlueLobster", "GST", "HorribleRips", "HorribleSubs", "KAN3D2M", "KiyoshiStar", "Lia", + "NanDesuKa", "PlayWeb", "SobsPlease", "Some-Stuffs", "SubsPlease", "URANIME", "ZigZag" + ] + }, + { + name: "Anime Web Tier 06", + source: "sonarr", + groups: [ + "9volt", "Asenshi", "Chihiro", "Commie", "DameDesuYo", "Doki", "GJM", "Kaleido", "Kantai", + "KawaSubs", "Tsundere" + ] + }, + { + name: "FR Anime Tier 01", + source: "sonarr", + groups: [ + "Darki", "Delivroozzi", "Fuceo", "Good Job! Alexis", "Punisher694", "SR-71", "T3KASHi", + "TANOSHii", "Tsundere-Raws" + ] + }, + { + name: "FR Anime Tier 02", + source: "sonarr", + groups: [ + "Aoi-Project", "Elecman", "FUJiSAN", "GundamGuy", "IssouCorp", "KAF", "Nagutos", "OECUF", + "XSPITFIRE911" + ] + }, + { + name: "FR Anime Tier 03", + source: "sonarr", + groups: [ + "BLV", "D3T3R10R1TY", "Galactic", "HANAMi", "kazuizui", "KHAYA", "KushEnthusiast", + "matheousse", "Monkey-D.Lulu", "NeoSG", "RONiN", "TheFantastics", "TTN" + ] + }, + { + name: "FR HD Bluray Tier 01", + source: "sonarr", + groups: [ + "ARK01", "BONBON", "FRATERNiTY", "FTMVHD", "HeavyWeight", "Psaro" + ] + }, + { + name: "FR Remux Tier 01", + source: "sonarr", + groups: [ + "FtLi", "Goldenyann", "HDForever", "HeavyWeight", "ONLY", "Psaro", "TyrellCorp" + ] + }, + { + name: "FR WEB Tier 01", + source: "sonarr", + groups: [ + "BONBON", "FCK", "FRATERNiTY", "FW", "MTDK", "NoLo", "PATOPESTO", "Psaro", "SUPPLY", "TFA", + "TiNA" + ] + }, + { + name: "FR WEB Tier 02", + source: "sonarr", + groups: [ + "COLL3CTiF", "FiND", "FrIeNdS", "HeavyWeight", "NoNe", "pERsO", "POTO", "RG", "RiPiT", "TAT" + ] + }, + { + name: "FR WEB Tier 03", + source: "sonarr", + groups: [ + "ARK01", "BraD", "dRuIdE", "FTMVHD", "LAZARUS", "MYSTERiON", "Scaph", "WaCkS", "WQM" + ] + }, + { + name: "German Bluray Tier 01", + source: "sonarr", + groups: [ + "CNY", "NIMA4K", "PXL", "TSCC", "TvR", "TVS", "WalterBishop", "WeebPinn", "ZeroTwo", + "ZeroTwo Aliases" + ] + }, + { + name: "German Bluray Tier 02", + source: "sonarr", + groups: [ + "ABJ", "MULTiPLEX", "Oergel", "SiXTYNiNE", "VECTOR" + ] + }, + { + name: "German Bluray Tier 03", + source: "sonarr", + groups: [ + "HDSource", "HQC", "RDR", "RobertDeNiro" + ] + }, + { + name: "German Remux Tier 01", + source: "sonarr", + groups: [ + "NIMA4K", "pmHD", "QfG", "TSCC", "TvR" + ] + }, + { + name: "German Remux Tier 02", + source: "sonarr", + groups: [ + "HDSource", "HQC", "MULTiPLEX" + ] + }, + { + name: "German Web Tier 01", + source: "sonarr", + groups: [ + "CNY", "MEDiATHEK", "NIMA4K", "PXL", "QfG", "RiiR", "RiiR Aliases", "TSCC", "TvR", "TVS", + "WalterBishop", "WeebPinn", "ZeroTwo", "ZeroTwo Aliases" + ] + }, + { + name: "German Web Tier 02", + source: "sonarr", + groups: [ + "4SF", "4SF Aliases", "ABJ", "MULTiPLEX", "Oergel", "SiXTYNiNE", "VECTOR" + ] + }, + { + name: "German Web Tier 03", + source: "sonarr", + groups: [ + "BALENCiAGA", "HDSource", "HQC", "iNCEPTION", "RDR", "RobertDeNiro" + ] + }, + { + name: "HD Bluray Tier 01", + source: "sonarr", + groups: [ + "Chotab", "CtrlHD", "DON", "EbP", "NTb", "PTer" + ] + }, + { + name: "HD Bluray Tier 02", + source: "sonarr", + groups: [ + "SA89", "sbR" + ] + }, + { + name: "Remux Tier 01", + source: "sonarr", + groups: [ + "BLURANiUM", "BMF", "FraMeSToR", "PmP" + ] + }, + { + name: "Remux Tier 02", + source: "sonarr", + groups: [ + "12GaugeShotgun", "decibeL", "EPSiLON", "HiFi", "KRaLiMaRKo", "playBD", "PTer", "SiCFoI", + "TRiToN" + ] + }, + { + name: "WEB Tier 01", + source: "sonarr", + groups: [ + "ABBiE", "AJP69", "APEX", "CasStudio", "CRFW", "CtrlHD", "FLUX", "HONE", "KiNGS", "Kitsune", + "monkee", "NOSiViD", "NTb", "NTG", "QOQ", "RAWR", "RTN", "SiC", "T6D", "TOMMY", "ViSUM" + ] + }, + { + name: "WEB Tier 02", + source: "sonarr", + groups: [ + "3cTWeB", "BLUTONiUM", "BTW", "BYNDR", "Chotab", "Cinefeel", "CiT", "CMRG", "Coo7", "dB", + "DEEP", "END", "ETHiCS", "FC", "Flights", "GNOME", "iJP", "iKA", "iT00NZ", "JETIX", "KHN", + "KiMCHI", "LAZY", "MiU", "MZABI", "NPMS", "NYH", "orbitron", "PHOENiX", "playWEB", "PSiG", + "ROCCaT", "RTFM", "SA89", "SbR", "SDCC", "SIGMA", "SMURF", "SPiRiT", "TEPES", "TVSmash", + "WELP", "XEBEC" + ] + }, + { + name: "WEB Tier 03", + source: "sonarr", + groups: [ + "BLOOM", "Dooky", "DRACULA", "HHWEB", "NINJACENTRAL", "SLiGNOME", "SwAgLaNdEr", "T4H", + "ViSiON" + ] + } + ] +}; + + +const DEFAULT_SELECTORS = { + torrentName: "h1.torrent__name", + tmdbTitle: "h1.meta__title", + category: "li.torrent__category a", + type: "li.torrent__type a", + resolution: "li.torrent__resolution a", + originalLanguage: ".work__language-link", + torrentTags: "ul.torrent__tags", + panels: "section.panelV2, div.panelV2", + panelHeading: ".panel__heading", + descriptionBody: ".panel__body.bbcode-rendered", + descriptionHeading: "Description", + mediaInfoDump: '.torrent-mediainfo-dump code, code[x-ref="mediainfo"]', + mediaInfoHeading: "MediaInfo", + bdInfoHeading: "BDInfo", + moderationHeading: "Moderation", + mediaInfoFilename: "section.mediainfo__filename span, .mediainfo__filename span", + mediaInfoAudioFlags: ".mediainfo__audio dl dd img", + mediaInfoSubFlags: ".mediainfo__subtitles ul li img", + mediaInfoVideoDt: ".mediainfo__video dt", + fileHierarchy: '.dialog__form[data-tab="hierarchy"]', + fileList: '.dialog__form[data-tab="list"] table.data-table tbody', + moderationForms: 'form[action*="moderation"]', + moderationStatus: 'input[name="status"]', + moderationMessage: 'textarea[name="message"]', +}; + + +const INSTANCE_CONFIGS = { + "znth.cx": { + name: "Zenith", + rulesUrl: "https://znth.cx/wikis/7", // Movie/TV Naming Guide + uploadRulesUrl: "https://znth.cx/pages/8", // Uploading Rules + // Zenith theme (torrent-detail--znth): TMDB title lives in the hero, not h1.meta__title + selectors: { + tmdbTitle: "h2.torrent-detail__meta-title, h1.meta__title", + }, + features: { + dpChecks: true, + dpTitleValidation: true, + zenithRules: true, + // Integrations are compiled in but stay off until enabled in Settings + prowlarr: true, + srrdb: true, + }, + }, +}; + +const DEFAULT_MOD_STATUSES = { postpone: "3", reject: "2" }; + +/* Resolved at runtime by main() — accessible by E and U */ +let _resolvedSelectors = DEFAULT_SELECTORS; +let _resolvedModStatuses = DEFAULT_MOD_STATUSES; + + +/* ======================================================================== + * SETTINGS — Persistent user configuration via GM storage + * ======================================================================== */ + +const Settings = { + _KEY: "modq_settings", + _defaults: { + prowlarr: { url: "", apiKey: "", enabled: false, preferredIndexers: [], ignoredIndexers: [], timeout: 15000, autoSearch: true }, + srrdb: { enabled: false }, + checks: { + tmdbMatch: true, seasonEpisode: true, namingGuide: true, + elementOrder: true, folderStructure: true, mediaInfo: true, + audioTags: true, subtitleRequirement: true, screenshots: true, + bannedGroup: true, encodeCompliance: true, upscaleDetection: true, + containerFormat: true, packUniformity: true, resolutionTypeMatch: true, + /* DarkPeers checks */ + nogroup: true, unknownLanguage: true, extraneousFiles: true, + categoryTypeMismatch: true, suspicion: true, bannedFilename: true, + singleFileFolder: true, missingEpisodes: true, dpTitle: true, + /* Zenith upload rules (znth.cx/pages/8) */ + znArchives: true, znBoxset: true, znSeasonPacks: true, znSingleEpisode: true, + znTrueHdCompat: true, znFullDisc: true, znAdvertising: true, + }, + ui: { autoExpand: true, debugMode: false, showAdvisory: true }, + minScreenshots: 3, + customBannedGroups: [], + }, + + load() { + try { + const raw = typeof GM_getValue === "function" ? GM_getValue(this._KEY, null) : null; + if (!raw) return JSON.parse(JSON.stringify(this._defaults)); + const saved = typeof raw === "string" ? JSON.parse(raw) : raw; + return this._merge(this._defaults, saved); + } catch (e) { + console.warn("[ModQ Settings] Failed to load, using defaults:", e); + return JSON.parse(JSON.stringify(this._defaults)); + } + }, + + save(settings) { + try { + if (typeof GM_setValue === "function") { + GM_setValue(this._KEY, JSON.stringify(settings)); + } + } catch (e) { + console.error("[ModQ Settings] Failed to save:", e); + } + }, + + get(key) { + const s = this.load(); + return key.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), s); + }, + + set(key, val) { + const s = this.load(); + const parts = key.split("."); + let target = s; + for (let i = 0; i < parts.length - 1; i++) { + if (!target[parts[i]]) target[parts[i]] = {}; + target = target[parts[i]]; + } + target[parts[parts.length - 1]] = val; + this.save(s); + return s; + }, + + _merge(defaults, saved) { + const result = JSON.parse(JSON.stringify(defaults)); + for (const key of Object.keys(saved)) { + if (key in result) { + if (typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) + && typeof saved[key] === "object" && saved[key] !== null && !Array.isArray(saved[key])) { + result[key] = this._merge(result[key], saved[key]); + } else { + result[key] = saved[key]; + } + } + } + return result; + }, + + render() { + const s = this.load(); + const modal = document.createElement("div"); + modal.id = "modq-settings-modal"; + modal.innerHTML = ` +
+
+

ModQ Helper Settings

+ +
+ General + + + + +
+ +
+ Integrations + + + + + + + + + When off, Prowlarr/SRRDB only search when you click the Search button. +
+ +
+ Check Toggles +
+ ${Object.keys(s.checks).map(c => ` + + `).join("")} +
+
+ +
+ + +
+
+ `; + + const style = document.createElement("style"); + style.textContent = ` + .modq-settings-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 99998; + } + .modq-settings-dialog { + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); + background: #0f172a; border: 1px solid #334155; border-radius: 8px; + padding: 24px; z-index: 99999; width: 480px; max-height: 80vh; + overflow-y: auto; box-shadow: 0 25px 50px rgba(0,0,0,0.5); + } + `; + modal.appendChild(style); + document.body.appendChild(modal); + this.attach(modal); + }, + + attach(modal) { + const self = this; + + modal.querySelector("#modq-settings-cancel").addEventListener("click", () => { + modal.remove(); + }); + modal.querySelector(".modq-settings-overlay").addEventListener("click", () => { + modal.remove(); + }); + + modal.querySelector("#modq-settings-save").addEventListener("click", () => { + const s = self.load(); + modal.querySelectorAll("[data-setting]").forEach(el => { + const key = el.getAttribute("data-setting"); + let val = el.type === "checkbox" ? el.checked + : el.type === "number" ? parseInt(el.value, 10) + : el.value; + // Parse comma-separated list settings into arrays + if (key === "prowlarr.preferredIndexers" || key === "prowlarr.ignoredIndexers") { + val = typeof val === "string" ? val.split(",").map(s => s.trim()).filter(Boolean) : []; + } + const parts = key.split("."); + let target = s; + for (let i = 0; i < parts.length - 1; i++) { + if (!target[parts[i]]) target[parts[i]] = {}; + target = target[parts[i]]; + } + target[parts[parts.length - 1]] = val; + }); + self.save(s); + modal.remove(); + console.log("[ModQ Settings] Saved:", s); + }); + }, + + init() { + if (typeof GM_registerMenuCommand === "function") { + GM_registerMenuCommand("ModQ Helper Settings", () => this.render()); + } + }, +}; + +Settings.init(); + + +/* ======================================================================== + * HELPERS — Pure parsing & utility functions (no DOM access) + * 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},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=` +
+ @@ filename comparison — ${n}% match @@ +
`;if(!t)return`
+ ${s} +
+ + ${this.renderLine(e,"ctx")} +
+
`;const a=e.filter(c=>c.type!=="extra"),o=e.filter(c=>c.type!=="missing");return`
+ ${s} +
+ - + ${this.renderLine(a,"del")} +
+
+ + + ${this.renderLine(o,"add")} +
+
`}}; + +/* ======================================================================== + * SITE ADAPTER — DOM abstraction layer (configurable selectors) + * Replaces the original E object. All DOM reads go through selectors. + * ======================================================================== */ + +/* E is now created by createExtractors() which receives resolved selectors. + All DOM access is driven by the instance configuration. */ +let E; // Initialized in main() with resolved selectors + +function createExtractors(sel) { + return { + getTorrentName() { + const e = document.querySelector(sel.torrentName); + return e ? e.textContent.trim() : null; + }, + getTmdbTitle() { + const e = document.querySelector(sel.tmdbTitle); + if (!e) return null; + const n = e.textContent.trim(), + t = n.match(/^(.+?)\s*\(\d{4}\)\s*$/); + return t ? t[1].trim() : n; + }, + getTmdbYear() { + const e = document.querySelector(sel.tmdbTitle); + if (!e) return null; + const t = e.textContent.trim().match(/\((\d{4})\)\s*$/); + return t ? t[1] : null; + }, + getCategory() { + const e = document.querySelector(sel.category); + return e ? e.textContent.trim() : null; + }, + getType() { + const e = document.querySelector(sel.type); + return e ? e.textContent.trim() : null; + }, + getResolution() { + const e = document.querySelector(sel.resolution); + return e ? e.textContent.trim() : null; + }, + getDescription() { + const panels = document.querySelectorAll(sel.panels); + for (const p of panels) { + const h = p.querySelector(sel.panelHeading); + if (h && h.textContent.includes(sel.descriptionHeading || "Description")) { + const b = p.querySelector(sel.descriptionBody); + return b ? b.innerHTML : ""; + } + } + return ""; + }, + getFileStructure() { + const hierForms = document.querySelectorAll(sel.fileHierarchy); + for (const t of hierForms) { + const s = t.querySelector("i.fas.fa-folder"); + if (s) { + const o = s.parentElement; + if (o) { + const c = o.querySelector('span[style*="word-break"]'), + r = c ? c.textContent.trim() : null, + d = o.querySelector('span[style*="grid-area: count"]'), + f = d ? d.textContent.match(/\((\d+)\)/) : null, + A = f ? parseInt(f[1], 10) : 0, + T = []; + t.querySelectorAll("details i.fas.fa-file").forEach((u) => { + const x = u.parentElement?.querySelector('span[style*="word-break"]'); + x && T.push(x.textContent.trim()); + }); + return { hasFolder: true, folderName: r, fileCount: A, files: T }; + } + } + const a = t.querySelector(":scope > details > summary i.fas.fa-file"); + if (a) { + const c = a.parentElement?.querySelector('span[style*="word-break"]'); + return { hasFolder: false, folderName: null, fileCount: 1, files: c ? [c.textContent.trim()] : [] }; + } + } + const listTbody = document.querySelector(sel.fileList); + if (listTbody) { + const rows = listTbody.querySelectorAll("tr"), + files = []; + rows.forEach((a) => { + const o = a.querySelector("td:nth-child(2)"); + o && files.push(o.textContent.trim()); + }); + if (files.length > 0 && files[0].includes("/")) + return { hasFolder: true, folderName: files[0].split("/")[0], fileCount: files.length, files }; + return { hasFolder: false, folderName: null, fileCount: files.length, files }; + } + return null; + }, + hasMediaInfo() { + const panels = document.querySelectorAll(sel.panels); + for (const p of panels) { + const h = p.querySelector(sel.panelHeading); + if (h && h.textContent.includes(sel.mediaInfoHeading || "MediaInfo")) return true; + } + // Zenith renders MediaInfo in a heading-less tab panel, so the dump itself is the evidence + return this.getMediaInfoText().trim().length > 0; + }, + hasBdInfo() { + const panels = document.querySelectorAll(sel.panels); + for (const p of panels) { + const h = p.querySelector(sel.panelHeading); + if (h && h.textContent.includes(sel.bdInfoHeading || "BDInfo")) return true; + } + // Zenith tab bar: (untested, no full-disc sample yet) + for (const b of document.querySelectorAll("section.torrent-detail__tabs button")) { + if (/^\s*BDInfo\b/i.test(b.textContent)) return true; + } + return false; + }, + isTV() { + const e = this.getCategory(); + return e ? e.toLowerCase().includes("tv") || e.toLowerCase().includes("series") || e.toLowerCase().includes("episode") : false; + }, + getOriginalLanguage() { + const e = document.querySelector(sel.originalLanguage); + return e ? e.textContent.trim().toLowerCase() : null; + }, + getMediaInfoLanguages() { + const langs = new Set(); + const miText = this.getMediaInfoText(); + if (miText) { + const sections = miText.split(/\n(?=Audio(?: #\d+)?[\r\n])/); + for (const sec of sections) { + if (!/^Audio(?:\s|$)/m.test(sec)) continue; + const lines = sec.split(`\n`); + let lang = null, isCommentary = false; + for (const line of lines) { + if (/^(Video|Text|Menu|General|Chapter)/.test(line.trim())) break; + const lm = line.match(/^Language\s*:\s*(.+)$/); + if (lm) lang = lm[1].trim(); + const tm = line.match(/^Title\s*:\s*(.+)$/); + if (tm && /commentary/i.test(tm[1])) isCommentary = true; + } + if (lang && !isCommentary) langs.add(lang); + } + } + if (langs.size === 0) { + document.querySelectorAll(sel.mediaInfoAudioFlags).forEach((img) => { + if (img.alt) langs.add(img.alt.trim()); + }); + } + return Array.from(langs); + }, + getMediaInfoText() { + const e = document.querySelector(sel.mediaInfoDump); + return e ? e.textContent : ""; + }, + getMediaInfoFilename() { + const el = document.querySelector(sel.mediaInfoFilename); + if (el) return (el.querySelector('span[x-ref="filename"], span') || el).textContent.trim(); + const mi = this.getMediaInfoText(); + if (mi) { + const m = mi.match(/^Complete name\s*:\s*(.+)$/m); + if (m) { + const parts = m[1].trim().split(/[/\\]/); + return parts[parts.length - 1]; + } + } + return null; + }, + getMediaInfoSubtitles() { + const subs = new Set(); + document.querySelectorAll(sel.mediaInfoSubFlags).forEach((img) => { + if (img.alt) subs.add(img.alt.trim()); + }); + if (subs.size === 0) { + const mi = this.getMediaInfoText(); + if (mi) { + const sections = mi.split(/\n(?=Text(?: #\d+)?[\r\n])/); + for (const sec of sections) { + if (!/^Text(?:\s|$)/m.test(sec)) continue; + const lines = sec.split(`\n`); + for (const line of lines) { + if (/^(Video|Audio|Menu|General|Chapter)/.test(line.trim())) break; + const lm = line.match(/^Language\s*:\s*(.+)$/); + if (lm) { subs.add(lm[1].trim()); break; } + } + } + } + } + return Array.from(subs); + }, + getAudioTracksFromMediaInfo() { + const tracks = [], mi = this.getMediaInfoText(); + if (!mi) return tracks; + const sections = mi.split(/\n(?=Audio(?: #\d+)?[\r\n])/); + for (const sec of sections) { + if (!/^Audio(?:\s|$)/m.test(sec)) continue; + const track = { codec: null, channels: null, language: null, title: null, isDefault: false }; + const lines = sec.split(`\n`); + for (const c of lines) { + if (/^(Video|Text|Menu|General|Chapter)/.test(c.trim())) break; + const r = c.match(/^Format\s*:\s*(.+)$/); + r && !track.codec && (track.codec = r[1].trim()); + const d = c.match(/^Commercial name\s*:\s*(.+)$/); + d && (track.commercialName = d[1].trim()); + const f = c.match(/^Channel\(s\)\s*:\s*(\d+)/); + if (f) { + const u = parseInt(f[1], 10); + track.channels = u === 1 ? "1.0" : u === 2 ? "2.0" : u === 6 ? "5.1" : u === 7 ? "6.1" : u === 8 ? "7.1" : `${u}ch`; + } + const A = c.match(/^Language\s*:\s*(.+)$/); + A && (track.language = A[1].trim()); + const T = c.match(/^Title\s*:\s*(.+)$/); + T && (track.title = T[1].trim()); + const b = c.match(/^Default\s*:\s*(.+)$/); + b && (track.isDefault = b[1].trim().toLowerCase() === "yes"); + } + track.codec && tracks.push(track); + } + return tracks; + }, + getHdrFromMediaInfo() { + const videoDts = document.querySelectorAll(sel.mediaInfoVideoDt); + for (const dt of videoDts) { + if (dt.textContent.trim() === "HDR") { + const dd = dt.nextElementSibling; + if (dd && dd.tagName === "DD") { + const val = dd.textContent.trim(); + if (val && val !== "Unknown") return this.parseHdrFormats(val); + } + } + } + const mi = this.getMediaInfoText(); + if (!mi) return []; + const m = mi.match(/HDR format\s*:\s*(.+?)(?:\n|$)/i); + return m ? this.parseHdrFormats(m[1]) : []; + }, + parseHdrFormats(e) { + const n = [], t = e.toLowerCase(); + (t.includes("dolby vision") || t.includes("dvhe")) && + (t.includes("profile 5") || t.includes("dvhe.05") + ? n.push("DV5") + : t.includes("profile 7") || t.includes("dvhe.07") + ? n.push("DV7") + : t.includes("profile 8") || t.includes("dvhe.08") + ? n.push("DV8") + : n.push("DV")); + t.includes("hdr10+") || t.includes("hdr10 plus") || t.includes("hdr10plus") + ? n.push("HDR10+") + : (t.includes("hdr10") || t.includes("hdr")) && n.push("HDR"); + t.includes("hlg") && n.push("HLG"); + t.includes("pq10") && n.push("PQ10"); + return n; + }, + getModerationPanel() { + const panels = document.querySelectorAll(sel.panels); + for (const p of panels) { + const h = p.querySelector(sel.panelHeading); + if (h && h.textContent.includes(sel.moderationHeading || "Moderation")) return p; + } + return null; + }, + }; +} + + +/* ======================================================================== + * CHECKS — Quality-gate rule engine + * Ported from the original k object. Pure functions operating on data. + * NOTE: Where the original called E methods directly, we now read from + * the siteData parameter passed through from the bootstrap. + * ======================================================================== */ + +const k = { + tmdbNameMatch(e, n) { + if (!n) return { + status: "warn", + message: "TMDB title not found on page", + details: null + }; + if (!e) return { + status: "fail", + message: "Torrent name not found", + details: null + }; + const t = H.normalizeForComparison(e), + s = H.normalizeForComparison(n), + a = H.normalizeForComparisonPreserveCase(e), + o = H.normalizeForComparisonPreserveCase(n); + if (t.startsWith(s)) return a.startsWith(o) ? { + status: "pass", + message: `"${n}" found at start of title`, + details: null + } : { + status: "warn", + message: `"${n}" found but capitalization differs`, + details: { + expected: n, + found: e.substring(0, n.length) + } + }; + if (s.startsWith("the ") && t.startsWith(s.substring(4))) return { + status: "warn", + message: `"${n}" found (without "The" prefix)`, + details: null + }; + const c = t.match(/^(.+?)\s+aka\s+/i); + if (c) { + const r = c[1].trim(); + if (r === s || r === "the " + s || s.startsWith("the ") && r === s.substring(4)) { + const d = a.match(/^(.+?)\s+AKA\s+/i), + f = d ? d[1].trim() : ""; + return f !== o && f !== "The " + o && !(o.startsWith("The ") && f === o.substring(4)) ? { + status: "warn", + message: `"${n}" found (AKA format) but capitalization differs`, + details: { + expected: n, + found: f + } + } : { + status: "pass", + message: `"${n}" found (AKA format)`, + details: null + } + } + } + return { + status: "fail", + message: `Title should start with "${n}"`, + details: { + expected: n, + found: e.substring(0, Math.min(50, e.length)) + (e.length > 50 ? "..." : "") + } + } + }, + + movieFolderStructure(e, n, t, s) { + return g.fullDiscTypes.some(c => s?.includes(c)) ? { + status: "na", + message: "N/A - Full Disc (folder structure expected)", + details: null + } : t ? { + status: "na", + message: "N/A - Folder structure check not applicable for TV", + details: null + } : n?.toLowerCase().includes("movie") ? e ? e.hasFolder ? e.fileCount === 1 ? { + status: "fail", + message: "Movie should not have a top-level folder", + details: { + found: `${e.folderName}/${e.files[0]||"..."}`, + expected: e.files[0] || "Single file without folder wrapper" + } + } : { + status: "warn", + message: `Movie has folder with ${e.fileCount} files`, + details: { + folder: e.folderName, + fileCount: e.fileCount + } + } : { + status: "pass", + message: "File structure correct (no folder wrapper)", + details: null + } : { + status: "warn", + message: "Could not determine file structure", + details: null + } : { + status: "na", + message: "N/A - Not a movie", + details: null + } + }, + + seasonEpisodeFormat(e, n) { + if (!n) return { + status: "na", + message: "N/A - Not TV content", + details: null + }; + H.parseSeasonEpisode(e); + const t = e.match(/S(\d{2,})E(\d{2,})/i), + s = e.match(/\bS(\d{2,})\b(?!E)/i); + if (t) return { + status: "pass", + message: `Episode format correct: S${t[1]}E${t[2]}`, + details: null + }; + if (s) return { + status: "pass", + message: `Season pack format correct: S${s[1]}`, + details: null + }; + const a = e.match(/S(\d)E(\d)(?!\d)/i), + o = e.match(/\bS(\d)\b(?!E|\d)/i); + return a ? { + status: "fail", + message: `Season/Episode must be zero-padded: found S${a[1]}E${a[2]}, expected S0${a[1]}E0${a[2]}`, + details: null + } : o ? { + status: "fail", + message: `Season must be zero-padded: found S${o[1]}, expected S0${o[1]}`, + details: null + } : { + status: "fail", + message: "No S##E## or S## format found in TV content title", + details: null + } + }, + + namingGuideCompliance(e, n, t, s) { + const a = { + status: "pass", + checks: [] + }, + o = e || "", + c = (typeof E !== "undefined" && E?.isTV) ? E.isTV() : !1, + r = H.extractYear(o); + let d = "fail", + f = "No year found"; + r ? o.includes(`(${r})`) ? (d = "warn", f = `Found: (${r}) - Remove parentheses`) : (d = "pass", f = `Found: ${r}`) : c ? (d = "pass", f = "No year found (Optional for TV)") : (d = "fail", f = "No year found (Required for Movies)"), a.checks.push({ + name: "Year", + status: d, + message: f, + required: !c + }); + const A = g.validResolutions.find(R => o.includes(R)), + T = /\b(NTSC|PAL)\b/i.test(o), + b = g.fullDiscTypes.some(R => n?.includes(R)), + u = A || T, + D = A || (T ? o.match(/\b(NTSC|PAL)\b/i)[1] : null), + x = s || ""; + let p = u ? "pass" : "fail", + y = u ? `Found: ${D}` : "No valid resolution found"; + !u && x === "Other" && (p = "warn", y = "Non-standard resolution (tagged as Other)"), a.checks.push({ + name: "Resolution", + status: p, + message: y, + required: !0 + }); + const C = [...g.validAudioCodecs].sort((R, P) => P.length - R.length); + let h = null; + for (const R of C) { + const P = R.replace(/[+]/g, "\\+").replace(/[-.]/g, "[-.]?"); + if (new RegExp("(? 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", + B = "No object audio detected"; + S === "Atmos" ? N ? (M = "pass", B = "Atmos detected & in title") : (M = "warn", B = "Atmos detected in MediaInfo but missing from Title") : S === "Auro3D" ? $ ? (M = "pass", B = "Auro3D detected & in title") : (M = "warn", B = "Auro3D detected in MediaInfo but missing from Title") : (N || $) && (m ? (M = "pass", B = `${N?"Atmos":"Auro3D"} in title (Full Disc - MediaInfo not validated)`) : (M = "warn", B = "Object tag in title but not confirmed in MediaInfo")), (S || N || $) && a.checks.push({ + name: "Audio Object", + status: M, + message: B, + required: !!S + }); + const I = this.checkSourceForType(o, n); + a.checks.push(I); + const J = [...g.validVideoCodecs].sort((R, P) => P.length - R.length); + let V = null; + for (const R of J) + if (new RegExp(R.replace(/[.]/g, "\\.?"), "i").test(o)) { + V = R; + break + } const K = g.fullDiscTypes.some(R => n?.includes(R)) || g.remuxTypes.some(R => n?.toUpperCase().includes(R.toUpperCase())); + if (a.checks.push({ + name: "Video Codec", + 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 + }), (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) + if (new RegExp("(?:^|\\s)" + L.replace(/[+]/g, "\\+") + "(?:\\s|$)", "i").test(o)) { + v = L.toUpperCase(); + break + } let _ = "pass", + F = ""; + const q = /\bHDR10\b/i.test(o) && !/\bHDR10\+/i.test(o); + if (m) q && (!v || v === "HDR10") ? (_ = "fail", F = '"HDR10" in title should be renamed to "HDR"') : v ? (_ = "pass", F = `HDR in title: ${v} (Full Disc - MediaInfo not validated)`) : F = "SDR (no HDR in title)"; + else if (R.length === 0) q && (!v || v === "HDR10") ? (_ = "fail", F = '"HDR10" in title should be renamed to "HDR"') : v ? (_ = "warn", F = `Title has ${v} but MediaInfo shows no HDR`) : F = "SDR (no HDR in title or MediaInfo)"; + else { + const L = R.join(", "), + O = R.some(ae => ae.startsWith("DV")), + j = R.includes("HDR10+"), + Y = R.includes("HDR10"), + X = R.includes("HDR"), + te = R.includes("HLG"), + se = R.includes("PQ10"); + let w = null; + O && j ? w = "DV HDR10+" : O && (Y || X) ? w = "DV HDR" : O ? w = "DV" : j ? w = "HDR10+" : Y || X ? w = "HDR" : te ? w = "HLG" : se && (w = "PQ10"), v && w && v === w.toUpperCase() ? (_ = "pass", F = `Correct: ${w} (MediaInfo: ${L})`) : !v && !w ? F = "SDR (no HDR in title or MediaInfo)" : v ? w ? (_ = "fail", F = `Wrong HDR tag - MediaInfo shows ${L}, title has ${v} but should be: ${w}`) : (_ = "warn", F = `Title has ${v} but could not determine expected tag from MediaInfo (${L})`) : (_ = "fail", F = `Missing HDR tag - MediaInfo shows ${L}, title should include: ${w}`) + } + a.checks.push({ + name: "HDR Format", + status: _, + message: F, + required: R.length > 0 + }) + } + const Q = a.checks.some(R => R.required && R.status === "fail"), + ee = a.checks.some(R => R.status === "warn") || a.checks.some(R => !R.required && R.status === "fail"); + return a.status = Q ? "fail" : ee ? "warn" : "pass", a + }, + + checkSourceForType(e, n) { + e.toUpperCase(); + const t = n?.toUpperCase() || ""; + let s = [], + a = "Unknown"; + g.fullDiscTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.fullDisc, a = "Full Disc") : g.remuxTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.remux, a = "REMUX") : g.encodeTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.encode, a = "Encode") : g.webTypes.some(c => t.includes(c.toUpperCase())) ? (s = [...g.sources.web, ...g.streamingServices], a = "WEB") : g.hdtvTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.hdtv, a = "HDTV") : (s = [...g.sources.fullDisc, ...g.sources.remux, ...g.sources.encode, ...g.sources.web, ...g.sources.hdtv, ...g.streamingServices], s = [...new Set(s)]); + let o = null; + for (const c of s) + if (new RegExp(c.replace(/[-.]/g, "[-. ]?"), "i").test(e)) { + o = c; + break + } return !o && a === "Encode" && /blu-?ray/i.test(e) && (o = "BluRay"), { + name: "Source", + status: o ? "pass" : "fail", + message: o ? `Found: ${o}${a!=="Unknown"?` (valid for ${a})`:""}` : `No valid source for ${a} type`, + required: !0 + } + }, + + titleElementOrder(e, n) { + const { + elements: t, + positions: s + } = H.extractTitleElements(e, n); + if (t.length < 3) return { + status: "warn", + message: "Too few elements detected to validate order", + details: null, + violations: [] + }; + const a = g.fullDiscTypes.some(T => n?.includes(T)) || g.remuxTypes.some(T => n?.toUpperCase().includes(T.toUpperCase())), + o = a ? g.titleElementOrder.fullDiscRemux : g.titleElementOrder.encodeWeb, + c = a ? "Full Disc/REMUX" : "Encode/WEB", + r = [], + d = t.map(T => T.type); + for (let T = 0; T < d.length; T++) + for (let b = T + 1; b < d.length; b++) { + const u = d[T], + D = d[b], + x = o.indexOf(u), + p = o.indexOf(D); + x === -1 || p === -1 || x > p && r.push({ + first: { + type: u, + value: t[T].value + }, + second: { + type: D, + value: t[b].value + }, + message: `"${t[T].value}" (${u}) should come after "${t[b].value}" (${D})` + }) + } + if (r.length === 0) return { + status: "pass", + message: `Element order correct for ${c}`, + details: null, + violations: [] + }; + const f = r.find(T => T.first.type === "hdr" && T.second.type === "vcodec" || T.first.type === "vcodec" && T.second.type === "hdr"); + let A = `${r.length} ordering issue(s) found`; + return f && (a ? A = "HDR should come BEFORE video codec for Full Disc/REMUX" : A = "HDR should come AFTER video codec for Encode/WEB"), { + status: "fail", + message: A, + details: { + orderType: c, + violations: r.map(T => T.message) + }, + violations: r + } + }, + + audioTagCompliance(e, n, t, s, a) { + const o = g.fullDiscTypes.some(b => s?.includes(b)), + c = o && /\b(NTSC|PAL|DVD5|DVD9)\b/i.test(e || ""); + if (o && !c) return { + status: "na", + message: "N/A - Full Disc (no MediaInfo)", + details: null, + checks: [] + }; + const r = []; + if (t && t.length > 0) { + const b = (e || "").toLowerCase(), + u = b.includes("dual-audio") || b.includes("dual audio"), + D = b.includes("multi"), + x = t.length, + p = g.languageMap[n] || n, + y = n === "en", + C = i => i.toLowerCase().startsWith("english"), + h = i => { + if (!p) return !1; + const l = i.toLowerCase(), + m = p.toLowerCase(); + 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 (x > 2) r.push({ + name: "Language Tags", + status: "fail", + 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` + }) : r.push({ + name: "Language Tags", + status: "pass", + message: `Multi-Audio correct (${x} languages)` + }); + else if (x > 2) o || r.push({ + name: "Language Tags", + status: "warn", + message: `Found ${x} languages but no "Multi" tag` + }); + else if (x === 2) { + const i = t.some(C), + l = t.some(h); + !o && i && l && !y ? r.push({ + name: "Language Tags", + status: "warn", + message: `Found English + Original (${p}), consider "Dual-Audio" tag` + }) : r.push({ + name: "Language Tags", + status: "pass", + message: `Audio languages OK (${x})` + }) + } else r.push({ + name: "Language Tags", + status: "pass", + message: `Audio languages OK (${x})` + }) + } + 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, + D = (i, l) => { + const m = (i || "").toLowerCase(), + S = (l || "").toLowerCase(); + return m.includes("dts") && (S.includes("dts-hd") || S.includes("dts:x") || S.includes("master audio") || S.includes("dts-hd ma")) ? "DTS-HD" : m.includes("dts") ? "DTS" : m === "e-ac-3" || m.includes("e-ac-3") ? "E-AC-3" : m === "ac-3" || m.includes("ac-3") ? "AC-3" : m.includes("mlp fba") || S.includes("truehd") ? "TrueHD" : m === "flac" || m.includes("flac") ? "FLAC" : m === "opus" || m.includes("opus") ? "Opus" : m === "pcm" || m.includes("pcm") || m.includes("lpcm") ? "LPCM" : m === "aac" || m.includes("aac") ? "AAC" : m === "mpeg audio" && S.includes("mp2") ? "MP2" : m === "mpeg audio" && S.includes("mp3") || m.includes("mp3") || m === "mpeg audio" && !S ? "MP3" : m.includes("mp2") ? "MP2" : m.includes("vorbis") ? "Vorbis" : m.includes("alac") ? "ALAC" : i + }, + x = i => i ? i === "1.0" || i === "2.0" || i === "1ch" || i === "2ch" : !1, + p = i => { + const l = (i.title || "").toLowerCase(); + return l.includes("commentary") || l.includes("comment") + }, + y = { + "DTS-HD MA": "DTS-HD", + "DTS-HD HRA": "DTS-HD", + "DTS:X": "DTS-HD", + "DTS-ES": "DTS", + DTS: "DTS", + TrueHD: "TrueHD", + "DD+": "E-AC-3", + DDP: "E-AC-3", + "DD EX": "AC-3", + DD: "AC-3", + "E-AC-3": "E-AC-3", + "AC-3": "AC-3", + LPCM: "LPCM", + PCM: "LPCM", + FLAC: "FLAC", + ALAC: "ALAC", + AAC: "AAC", + MP3: "MP3", + MP2: "MP2", + Opus: "Opus", + Vorbis: "Vorbis" + }, + C = [...g.validAudioCodecs].sort((i, l) => l.length - i.length); + let h = null; + for (const i of C) { + const l = i.replace(/[+]/g, "\\+").replace(/[-.]/g, "[-.]?"); + if (new RegExp("(? S.isDefault) || d[0], + l = D(i.codec, i.commercialName), + m = y[h]; + m && l && m !== l && r.push({ + name: "Audio Codec Mismatch", + status: "fail", + message: `Title claims ${h} but primary audio track is ${l}` + }) + } + for (let i = 0; i < d.length; i++) { + 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 === "LPCM") { + const N = b; // LPCM multichannel allowed for untouched sources + !x(l.channels) && !N ? r.push({ + name: S, + status: "fail", + message: `${m} only allowed as mono/stereo. Found: ${l.channels||"unknown"}` + }) : r.push({ + name: S, + 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", + message: "MP2 OK (untouched source)" + }) : r.push({ + name: S, + status: "fail", + message: "MP2 only allowed if untouched (HDTV/DVD)" + }) : m === "MP3" ? p(l) ? r.push({ + name: S, + status: "pass", + message: "MP3 OK (commentary track)" + }) : r.push({ + name: S, + status: "warn", + message: "MP3 only allowed for supplementary tracks (e.g. commentary)" + }) : m === "Vorbis" || m === "ALAC" ? r.push({ + name: S, + status: "fail", + message: `${m} is not an allowed audio codec` + }) : ["DTS", "DTS-HD", "AC-3", "E-AC-3", "TrueHD", "AAC"].includes(m) ? r.push({ + name: S, + status: "pass", + message: `${m} OK` + }) : r.push({ + name: S, + status: "warn", + message: `Unrecognized codec: ${l.codec}${l.commercialName?" / "+l.commercialName:""}` + }) + } + } + if (r.length === 0) return { + status: "na", + message: "No audio data detected in MediaInfo", + details: null, + checks: [] + }; + const f = r.some(b => b.status === "fail"), + A = r.some(b => b.status === "warn"), + T = f ? "fail" : A ? "warn" : "pass"; + return { + status: T, + message: T === "pass" ? `Audio OK (${d.length} track${d.length!==1?"s":""})` : "Audio issues found", + details: null, + checks: r + } + }, + + mediaInfoPresent(e, n, t, s) { + const a = g.fullDiscTypes.some(c => t?.includes(c)), + o = a && /\b(NTSC|PAL|DVD5|DVD9)\b/i.test(s || ""); + return a && !o ? n ? { + status: "pass", + message: "BDInfo present (Full Disc)", + details: null + } : e ? { + status: "warn", + message: "BDInfo expected for Full Disc", + details: null + } : { + status: "fail", + message: "BDInfo required for Full Disc uploads", + details: null + } : o ? { + status: "na", + message: "N/A - DVD Full Disc (BDInfo not applicable)", + details: null + } : n ? { + status: "fail", + message: "Release is not Full Disc, BDInfo should be empty", + details: null + } : e ? { + status: "pass", + message: "MediaInfo Present", + details: null + } : { + status: "fail", + message: "MediaInfo Required", + details: null + } + }, + + subtitleRequirement(e, n, t, s) { + if (g.fullDiscTypes.some(d => s?.includes(d))) return { + status: "na", + message: "N/A - Full Disc (no MediaInfo)", + details: null + }; + if (!e || e.length === 0) return { + status: "na", + message: "No audio languages detected", + details: null + }; + const o = d => { + const f = d.toLowerCase(); + return f === "english" || f.startsWith("english") + }; + return e.some(o) ? { + status: "pass", + message: "English audio present - subtitles optional", + details: null + } : !n || n.length === 0 ? { + status: "fail", + message: "No English audio & no subtitles detected", + details: { + audio: e.join(", "), + expected: "English subtitles required for non-English audio" + } + } : n.some(o) ? { + status: "pass", + message: "Non-English audio with English subtitles", + details: null + } : { + status: "fail", + message: "Non-English audio requires English subtitles", + details: { + audio: e.join(", "), + subtitles: n.join(", ") || "None detected", + expected: "English subtitles" + } + } + }, + + screenshotCount(e) { + const { + count: n, + urls: t + } = H.countScreenshots(e); + return n >= g.minScreenshots ? { + status: "pass", + count: n, + message: `${n} screenshots found`, + details: null + } : n > 0 ? { + status: "warn", + count: n, + message: `Only ${n} screenshot(s) found (${g.minScreenshots}+ required)`, + details: null + } : { + status: "fail", + count: 0, + message: "No screenshots found in description", + details: null + } + }, + + containerFormat(e, n) { + if (g.fullDiscTypes.some(r => n?.includes(r))) return { + status: "na", + message: "N/A - Full Disc uploads use native folder structure", + details: null + }; + if (!e || !e.files || e.files.length === 0) return { + status: "warn", + message: "Could not determine file structure to verify container", + details: null + }; + const s = [".mkv", ".mp4", ".avi", ".wmv", ".m4v", ".ts", ".m2ts", ".vob", ".mpg", ".mpeg", ".mov", ".flv", ".webm"], + a = e.files.filter(r => { + const d = r.toLowerCase(); + return s.some(f => d.endsWith(f)) + }); + if (a.length === 0) return { + status: "warn", + message: "No video files detected in file list", + details: null + }; + // Zenith 3.5: MKV, MP4, AVI; untouched HDTV/UHDTV may be .ts, untouched SDTV .ps/.mpg + const allowedExts = [".mkv", ".mp4", ".avi"]; + if (/\b(UHD)?HDTV\b/i.test(n || "")) allowedExts.push(".ts"); + if (/\bSDTV\b/i.test(n || "")) allowedExts.push(".ps", ".mpg"); + const o = a.filter(r => !allowedExts.some(ext => r.toLowerCase().endsWith(ext))); + return o.length === 0 ? { + status: "pass", + message: `Container verified (${a.length} video file${a.length>1?"s":""})`, + details: null + } : { + status: "fail", + message: `Unsupported container detected: ${[...new Set(o.map(r=>r.split(".").pop().toUpperCase()))].join(", ")}`, + details: { + expected: "MKV, MP4 or AVI (Zenith 3.5; .ts for untouched HDTV, .ps/.mpg for untouched SDTV)", + found: o.join(", ") + } + } + }, + + packUniformity(e, n) { + if (g.fullDiscTypes.some(b => n?.includes(b))) return { + status: "na", + message: "N/A - Full Disc", + details: null, + checks: [] + }; + if (!e || !e.files || e.files.length === 0) return { + status: "na", + message: "N/A - No files detected", + details: null, + checks: [] + }; + const s = [".mkv", ".mp4", ".avi", ".wmv", ".m4v", ".ts", ".m2ts", ".vob", ".mpg", ".mpeg", ".mov", ".flv", ".webm"], + a = e.files.filter(b => { + const u = b.toLowerCase(); + return s.some(D => u.endsWith(D)) + }); + if (a.length < 2) return { + status: "na", + message: "N/A - Single file upload", + details: null, + checks: [] + }; + const o = b => { + const u = {}, + D = g.validResolutions.find(i => b.includes(i)); + u.resolution = D || null; + const x = [{ + pattern: /\bWEB-DL\b/i, + name: "WEB-DL" + }, { + pattern: /\bWEBRip\b/i, + name: "WEBRip" + }, { + pattern: /\bWEB\b/i, + name: "WEB" + }, { + pattern: /\bBlu-?Ray\b/i, + name: "BluRay" + }, { + pattern: /\bREMUX\b/i, + name: "REMUX" + }, { + pattern: /\bHDTV\b/i, + name: "HDTV" + }, { + pattern: /\bSDTV\b/i, + name: "SDTV" + }, { + pattern: /\bDVDRip\b/i, + name: "DVDRip" + }, { + pattern: /\bBDRip\b/i, + name: "BDRip" + }, { + pattern: /\bBRRip\b/i, + name: "BRRip" + }, { + pattern: /\bHDDVD\b/i, + name: "HDDVD" + }, { + pattern: /\bWEBDL\b/i, + name: "WEB-DL" + }]; + u.source = null; + for (const i of x) + if (i.pattern.test(b)) { + u.source = i.name; + break + } const p = [...g.validAudioCodecs].sort((i, l) => l.length - i.length); + u.audioCodec = null; + for (const i of p) { + const l = i.replace(/[+]/g, "\\+").replace(/[-.]/g, "[-.]?"); + if (new RegExp("(? l.length - i.length); + u.videoCodec = null; + for (const i of C) + if (new RegExp(i.replace(/[.]/g, "\\.?"), "i").test(b)) { + u.videoCodec = i; + break + } const h = b.match(/-([A-Za-z0-9$!_&+\$™]+)(?:\.[a-z0-9]+)?$/i); + if (h) u.group = h[1]; + else { + const i = b.match(/-\s+([A-Za-z0-9$!_&+\$™]+)(?:\.[a-z0-9]+)?$/i); + if (i) u.group = i[1]; + else { + const l = b.match(/-\s*([A-Za-z0-9$!_&+\$™]+)\s*\)\s*\[([A-Za-z0-9$!_&+\$™]+)\](?:\.[a-z0-9]+)?$/i); + if (l) u.group = `${l[1]} [${l[2]}]`; + else { + const m = b.match(/\(\s*[^()]*-\s*([A-Za-z0-9$!._&+\$™]+)\s*\)(?:\.[a-z0-9]+)?$/i); + u.group = m ? m[1] : null + } + } + } + return u + }, + c = a.map(b => ({ + file: b, + attrs: o(b) + })), + r = [{ + key: "resolution", + label: "Resolution" + }, { + key: "source", + label: "Source/Format" + }, { + key: "audioCodec", + label: "Audio Codec" + }, { + key: "videoCodec", + label: "Video Codec" + }, { + key: "group", + label: "Release Group" + }], + d = []; + let f = !1; + for (const { + key: b, + label: u + } + of r) { + const D = c.map(p => p.attrs[b]).filter(p => p !== null), + x = [...new Set(D.map(p => p.toUpperCase()))]; + if (D.length === 0) d.push({ + name: u, + status: "warn", + message: `Could not detect ${u.toLowerCase()} in filenames` + }); + else if (x.length === 1) d.push({ + name: u, + status: "pass", + message: `Uniform: ${D[0]}` + }); + else { + f = !0; + const p = {}; + D.forEach(C => { + const h = C.toUpperCase(); + p[h] = (p[h] || 0) + 1 + }); + const y = Object.entries(p).map(([C, h]) => `${C} (${h})`).join(", "); + d.push({ + name: u, + status: "fail", + message: `Mixed: ${y}` + }) + } + } + const A = d.some(b => b.status === "warn"); + return { + status: f ? "fail" : A ? "warn" : "pass", + message: f ? `Mixed pack detected across ${a.length} files` : `Uniform across ${a.length} files`, + details: null, + checks: d + } + }, + + encodeCompliance(e, n, t) { + if (!g.encodeTypes.some(i => n?.toUpperCase().includes(i.toUpperCase()))) return { + status: "na", + message: "N/A - Not an Encode", + details: null, + checks: [] + }; + const a = [], + o = e || "", + c = /\bx264\b/i.test(o), + r = /\bx265\b/i.test(o), + d = /\bSVT[-.]?AV1\b/i.test(o), + f = d || /\bAV1\b/i.test(o), + A = c || r || d || f, + T = ["AVC", "HEVC", "H.264", "H.265", "MPEG-2", "VC-1", "VP9", "XviD", "DivX"]; + let b = null; + if (!b) { + for (const i of T) + if (new RegExp("\\b" + i.replace(/[.]/g, "\\.?") + "\\b", "i").test(o)) { + b = i; + break + } + } + if (A) { + const i = c ? "x264" : r ? "x265" : d ? "SVT-AV1" : "AV1"; + a.push({ + name: "Encoder", + status: "pass", + message: `Found: ${i}` + }) + } else b ? a.push({ + name: "Encoder", + status: "fail", + message: `Found ${b} — encodes must use x264, x265, or SVT-AV1` + }) : a.push({ + name: "Encoder", + status: "fail", + message: "No x264, x265, or SVT-AV1 detected in title" + }); + if (t) + if (f) a.push({ + name: "Encoder Metadata", + status: "pass", + message: "AV1 detected" + }); + else { + const i = t.match(/Writing library\s*:\s*(.+?)(?:\n|$)/im), + l = i && /x264/i.test(i[1]), + m = i && /x265/i.test(i[1]), + S = t.match(/Encoding settings\s*:\s*(.+?)(?:\n|$)/im), + N = t.match(/Encoder_settings\s*:\s*(.+?)(?:\n|$)/im); + if (l || m || S || N) { + let M = ""; + l ? M = "x264" : m ? M = "x265" : M = "encoding settings present", a.push({ + name: "Encoder Metadata", + status: "pass", + message: `Encoder metadata found (${M})` + }) + } else a.push({ + name: "Encoder Metadata", + status: "fail", + message: "No encoder metadata found in MediaInfo — x264/x265 info required" + }) + } + else a.push({ + name: "Encoder Metadata", + status: "warn", + message: "No MediaInfo available — cannot verify encoder metadata" + }); + const u = /\bH\.?264\b/i.test(o), + D = /\bH\.?265\b/i.test(o); + if (u || D) { + const i = t ? t.match(/Writing library\s*:\s*(.+?)(?:\n|$)/im) : null, + l = i && /x264/i.test(i[1]), + m = i && /x265/i.test(i[1]); + u && l ? a.push({ + name: "Codec vs Encoder", + status: "fail", + message: "Title has H.264 but MediaInfo shows x264 — use encoder name (x264)" + }) : D && m ? a.push({ + name: "Codec vs Encoder", + status: "fail", + message: "Title has H.265 but MediaInfo shows x265 — use encoder name (x265)" + }) : u && !l ? a.push({ + name: "Codec vs Encoder", + status: "warn", + message: "Title has H.264 — encodes typically use encoder name (x264) instead" + }) : D && !m && a.push({ + name: "Codec vs Encoder", + status: "warn", + message: "Title has H.265 — encodes typically use encoder name (x265) instead" + }) + } + 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: "warn", + message: "Could not detect resolution to verify encode requirement" + }), f) a.push({ + name: "Rate Control", + status: "pass", + message: "AV1 detected — rate control cannot be verified from AV1 bitstream metadata" + }); + else if (t) { + const i = t.match(/Encoding settings\s*:\s*(.+?)(?:\n|$)/im), + l = t.match(/Encoder_settings\s*:\s*(.+?)(?:\n|$)/im), + m = i ? i[1] : l ? l[1] : null; + if (m) { + const S = m.match(/rc=(\w+)/), + N = /--crf\b/.test(m), + $ = m.match(/--passes?\s+(\d+)/); + if (S) { + const M = S[1].toLowerCase(); + if (M === "crf") a.push({ + name: "Rate Control", + status: "pass", + message: "CRF encoding detected" + }); + else if (M === "abr") { + const B = m.match(/stats-read=(\d+)/), + I = m.match(/(?:^|[\s/])pass=?(\d+)/); + B && parseInt(B[1], 10) >= 2 || I && parseInt(I[1], 10) >= 2 ? a.push({ + name: "Rate Control", + status: "pass", + message: "Multi-pass ABR encoding detected" + }) : a.push({ + name: "Rate Control", + status: "fail", + message: "Single-pass ABR detected — must use CRF or multi-pass ABR" + }) + } else M === "2pass" ? a.push({ + name: "Rate Control", + status: "pass", + message: "2-pass encoding detected" + }) : M === "cbr" ? a.push({ + name: "Rate Control", + status: "fail", + message: "CBR encoding detected — must use CRF or multi-pass ABR" + }) : a.push({ + name: "Rate Control", + status: "warn", + message: `Unrecognized rate control: rc=${M}` + }) + } else N ? a.push({ + name: "Rate Control", + status: "pass", + message: "CRF encoding detected (SVT-AV1)" + }) : $ && parseInt($[1], 10) >= 2 ? a.push({ + name: "Rate Control", + status: "pass", + message: `Multi-pass encoding detected (SVT-AV1, ${$[1]} passes)` + }) : /--tbr\b/.test(m) ? a.push({ + name: "Rate Control", + status: "fail", + message: "Target bitrate (ABR) detected without multi-pass — must use CRF or multi-pass" + }) : a.push({ + name: "Rate Control", + status: "warn", + message: "Encoding settings found but could not determine rate control method" + }) + } else a.push({ + name: "Rate Control", + status: "warn", + message: "No encoding settings in MediaInfo — cannot verify rate control" + }) + } else a.push({ + name: "Rate Control", + status: "warn", + message: "No MediaInfo available — cannot verify rate control" + }); + const y = a.some(i => i.status === "fail"), + C = a.some(i => i.status === "warn"), + h = y ? "fail" : C ? "warn" : "pass"; + return { + status: h, + message: h === "pass" ? "Encode requirements met" : y ? "Encode compliance issues found" : "Encode checks need review", + details: null, + checks: a + } + }, + + upscaleDetection(e) { + if (!e) return { + status: "na", + message: "No torrent name to check", + alert: !1 + }; + const t = [{ + name: "AI Upscales", + regex: new RegExp("(?<=\\b[12]\\d{3}\\b)(?=.*\\b(HEVC)\\b)(?=.*\\b(AI)\\b)", "i") + }, { + name: "AIUS", + regex: /\b(AIUS)\b/i + }, { + name: "Regrade", + regex: /\b((Upscale)?Re-?graded?)\b/i + }, { + name: "RW", + regex: /\b(RW)\b/ + }, { + name: "TheUpscaler", + regex: /\b(The[ ._-]?Upscaler)\b/i + }, { + name: "Upscaled", + regex: new RegExp("(?<=\\b[12]\\d{3}\\b).*\\b(AI[ ._-]?Enhanced?|UPS(UHD)?|Upscaled?([ ._-]?UHD)?|UpRez)\\b", "i") + }, { + name: "Upscale", + regex: /\b(UPSCALE)\b/i + }].filter(s => s.regex.test(e)); + return t.length > 0 ? { + status: "fail", + message: `UPSCALE DETECTED: ${t.map(a=>a.name).join(", ")}`, + alert: !0 + } : { + status: "pass", + message: "No upscale indicators found", + alert: !1 + } + }, + + bannedReleaseGroup(e, n, t) { + const s = g.fullDiscTypes.some(r => t?.includes(r)), + a = H.extractReleaseGroup(e); + if (!a) return { + status: s ? "na" : "warn", + group: null, + message: s ? "N/A for Full Disc" : "Could not extract release group from title", + alert: !1, + tieredInfo: null + }; + const o = H.findTieredGroup(a, n); + 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) { + const t = e || ""; + const s = n || ""; + const a = { NTSC: ["480i", "480p"], PAL: ["576i", "576p"] }; + const o = g.validResolutions.find(f => t.includes(f)); + const c = t.match(/\b(NTSC|PAL)\b/i); + const r = !!c; + const d = g.validResolutions.includes(s); + + if (!o && r) { + const f = c[1].toUpperCase(); + const A = a[f] || []; + if (A.includes(s)) + return { status: "pass", message: `${f} source correctly tagged as ${s}` }; + if (s === "Other") + return { status: "pass", message: `${f} source tagged as Other` }; + return { status: "warn", message: `${f} source expected ${A.join(" or ")} (or Other), found: ${s}` }; + } + + if (o) { + return o === s + ? { status: "pass", message: `Resolution tag matches title: ${s}` } + : { status: "fail", message: `Resolution tag mismatch — title contains "${o}" but tagged as "${s}"`, details: { expected: o, found: s } }; + } + + if (s === "Other") + return { status: "pass", message: "Non-standard resolution correctly tagged as Other" }; + if (!d && s) + return { status: "warn", message: `Non-standard resolution "${s}" should use "Other" resolution type` }; + return { status: "warn", message: "Could not detect resolution in title to validate tag" }; +}; + + +/* ======================================================================== + * DARKPEERS CHECKS — Additional quality-gate checks for DarkPeers + * Feature-gated: only executed when instanceConfig.features.dpChecks + * ======================================================================== */ + +const dpk = { + + /** + * nogroupCheck — flag NOGROUP in title and cross-reference filename groups. + */ + nogroupCheck(torrentName, mediaInfoFilename, fileStructure) { + const titleGroup = H.extractReleaseGroup(torrentName); + if (!titleGroup || titleGroup.toUpperCase() !== "NOGROUP") { + return { status: "pass", message: "Release group is not NOGROUP", details: null }; + } + + // Title says NOGROUP — check if filename also says NOGROUP + const filenameGroupMatch = (mediaInfoFilename || "").match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i); + const filenameGroup = filenameGroupMatch ? filenameGroupMatch[1] : null; + + // Also check files in the file structure + const fileGroups = []; + if (fileStructure && fileStructure.files) { + for (const f of fileStructure.files) { + const m = f.match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i); + if (m) fileGroups.push(m[1]); + } + } + + const allFilenameGroups = [filenameGroup, ...fileGroups].filter(Boolean); + const nogroupInFiles = allFilenameGroups.some(fg => fg.toUpperCase() === "NOGROUP"); + const otherGroups = allFilenameGroups.filter(fg => fg.toUpperCase() !== "NOGROUP"); + + if (otherGroups.length > 0) { + // Title says NOGROUP but filename has a different group + const foundGroup = otherGroups[0]; + const isBanned = g.bannedGroups.some(bg => bg.toLowerCase() === foundGroup.toLowerCase()); + if (isBanned) { + return { + status: "fail", + message: `Banned group detected in filename: ${foundGroup} (title disguised as NOGROUP)`, + details: { titleGroup: "NOGROUP", filenameGroup: foundGroup }, + }; + } + return { + status: "warn", + message: `Title says NOGROUP but filename shows -${foundGroup}`, + details: { titleGroup: "NOGROUP", filenameGroup: foundGroup }, + }; + } + + if (nogroupInFiles) { + return { + status: "warn", + message: "NOGROUP in title and filename — clarify if personal release or found as-is", + details: null, + }; + } + + return { status: "warn", message: "NOGROUP in title — could not verify filename group", details: null }; + }, + + /** + * unknownLanguageCheck — flag audio/text tracks with unknown or missing language. + */ + unknownLanguageCheck(mediaInfoText) { + if (!mediaInfoText) { + return { status: "na", message: "No MediaInfo available", details: null, checks: [] }; + } + + const checks = []; + // Split MediaInfo into sections + const sections = mediaInfoText.split(/\n\n+/); + let audioIdx = 0; + let textIdx = 0; + + for (const section of sections) { + const isAudio = /^Audio/m.test(section); + const isText = /^Text/m.test(section); + if (!isAudio && !isText) continue; + + const trackType = isAudio ? "Audio" : "Subtitle"; + const idx = isAudio ? ++audioIdx : ++textIdx; + + const langMatch = section.match(/^Language\s*:\s*(.+)$/m); + const lang = langMatch ? langMatch[1].trim() : ""; + + const isUnknown = !lang || lang.toLowerCase() === "unknown" || lang.toLowerCase() === "und" || lang === ""; + + checks.push({ + name: `${trackType} Track ${idx}`, + status: isUnknown ? "warn" : "pass", + message: isUnknown + ? `${trackType} track ${idx}: language is ${lang || "missing"}` + : `${trackType} track ${idx}: ${lang}`, + }); + } + + if (checks.length === 0) { + return { status: "na", message: "No audio/text tracks found in MediaInfo", details: null, checks: [] }; + } + + const hasUnknown = checks.some(c => c.status === "warn"); + return { + status: hasUnknown ? "warn" : "pass", + message: hasUnknown + ? "Unknown or missing language tags detected — verify and correct" + : "All tracks have language tags", + details: null, + checks, + }; + }, + + /** + * extraneousFiles — detect files that do not belong in the upload category. + */ + extraneousFiles(fileStructure, category, type) { + if (g.fullDiscTypes.some(t => type?.includes(t))) { + return { status: "na", message: "N/A - Full Disc", details: null }; + } + if (!fileStructure || !fileStructure.files || fileStructure.files.length === 0) { + return { status: "warn", message: "Could not determine file structure", details: null }; + } + + // Zenith 3.5: AVI allowed; untouched SDTV may be .ps/.mpg + const videoExts = [".mkv", ".mp4", ".avi", ".ts", ".m2ts", ".mpg", ".ps", ".vob", ".iso", ".srt", ".sub", ".idx", ".ass", ".ssa", ".sup"]; + const musicExts = [".flac", ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".cue", ".log", ".nfo", ".jpg", ".jpeg", ".png"]; + const bookExts = [".epub", ".pdf", ".mobi", ".azw3", ".cbr", ".cbz", ".djvu", ".nfo", ".jpg", ".jpeg", ".png"]; + + const cat = (category || "").toLowerCase(); + if (/\b(game|app|software)/i.test(cat)) { + return { status: "na", message: "N/A - Games/Apps (rule 8)", details: null }; + } + let whitelist; + let catLabel; + if (/music|audiobook/i.test(cat)) { + whitelist = musicExts; + catLabel = "Music/Audiobook"; + } else if (/ebook|book/i.test(cat)) { + whitelist = bookExts; + catLabel = "eBook"; + } else { + whitelist = videoExts; + catLabel = "Video"; + } + + const extraneous = fileStructure.files.filter(f => { + const ext = f.substring(f.lastIndexOf(".")).toLowerCase(); + return !whitelist.includes(ext); + }); + + if (extraneous.length === 0) { + return { status: "pass", message: `All files valid for ${catLabel} category`, details: null }; + } + + return { + status: "fail", + message: `Extraneous files found for ${catLabel}: ${extraneous.join(", ")}`, + details: { extraneous, category: catLabel }, + }; + }, + + /** + * categoryTypeMismatch — detect mismatches between category, type, and title content. + */ + categoryTypeMismatch(category, type, torrentName) { + const cat = (category || "").toLowerCase(); + const typ = (type || "").toUpperCase(); + const name = torrentName || ""; + const issues = []; + + // Movie category + HDTV type is unusual + if (cat.includes("movie") && /HDTV|PDTV|SDTV/i.test(typ)) { + issues.push("Movie category with HDTV type — verify this is correct (TV recording of a movie?)"); + } + + // TV category should have S##E## or S## in title + if (cat.includes("tv") && !/S\d{1,2}(?:E\d{1,2})?/i.test(name) && !/\b(?:Season|Series)\b/i.test(name)) { + issues.push("TV category but no S##E## or season indicator in title"); + } + + // Title says WEB-DL but type says Encode + if (/WEB-DL/i.test(name) && /ENCODE/i.test(typ)) { + issues.push("Title contains WEB-DL but type is set to Encode"); + } + + // Title says REMUX but type is not REMUX + if (/\bREMUX\b/i.test(name) && !/REMUX/i.test(typ)) { + issues.push("Title contains REMUX but type is not set to REMUX"); + } + + // Title says Encode indicators but type is REMUX + if (/\b(x264|x265|SVT-AV1)\b/i.test(name) && /REMUX/i.test(typ)) { + issues.push("Title contains encode indicators (x264/x265/SVT-AV1) but type is REMUX"); + } + + if (issues.length === 0) { + return { status: "pass", message: "Category and type are consistent", details: null }; + } + + return { + status: "warn", + message: issues.join("; "), + details: { category: cat, type: typ, issues }, + }; + }, + + /** + * suspicionHeuristics — advisory-level signals that do not count toward verdict. + */ + suspicionHeuristics(torrentName, type, mediaInfoText, fileStructure, mediaInfoFilename) { + const checks = []; + const name = torrentName || ""; + + // Check .ts container in non-HDTV + if (fileStructure && fileStructure.files) { + const hasTsFiles = fileStructure.files.some(f => f.toLowerCase().endsWith(".ts")); + const isHdtv = /HDTV|PDTV|SDTV/i.test(type || ""); + if (hasTsFiles && !isHdtv) { + checks.push({ + name: "TS Container", + status: "advisory", + message: ".ts container detected in non-HDTV release — may indicate raw capture", + }); + } + } + + // Title group != filename group + const titleGroup = H.extractReleaseGroup(name); + const fnMatch = (mediaInfoFilename || "").match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i); + const fnGroup = fnMatch ? fnMatch[1] : null; + if (titleGroup && fnGroup && titleGroup.toLowerCase() !== fnGroup.toLowerCase()) { + checks.push({ + name: "Group Mismatch", + status: "advisory", + message: `Title group (-${titleGroup}) differs from filename group (-${fnGroup})`, + }); + } + + // Unknown/new group with 4K REMUX + if (titleGroup && /2160p/i.test(name) && /REMUX/i.test(type || "")) { + const isKnown = H.findTieredGroup(titleGroup, false) || H.findTieredGroup(titleGroup, true); + if (!isKnown) { + checks.push({ + name: "Unknown 4K REMUX Group", + status: "advisory", + message: `Unknown group "${titleGroup}" uploading 4K REMUX — extra scrutiny recommended`, + }); + } + } + + return { + status: "advisory", + message: checks.length > 0 + ? `${checks.length} advisory signal(s) detected` + : "No suspicious patterns detected", + details: null, + checks, + }; + }, + + /** + * bannedGroupInFilename — catch uploaders hiding banned groups behind a different title group. + */ + bannedGroupInFilename(torrentName, mediaInfoFilename, fileStructure, isTV, type) { + const titleGroup = H.extractReleaseGroup(torrentName); + + // Extract groups from filenames + const extractFnGroup = (filename) => { + const m = (filename || "").match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i); + return m ? m[1] : null; + }; + + const filenameGroups = new Set(); + const miFnGroup = extractFnGroup(mediaInfoFilename); + if (miFnGroup) filenameGroups.add(miFnGroup); + + if (fileStructure && fileStructure.files) { + for (const f of fileStructure.files) { + const fg = extractFnGroup(f); + if (fg) filenameGroups.add(fg); + } + } + + if (filenameGroups.size === 0) { + return { status: "na", message: "No filename groups detected", details: null }; + } + + const bannedLower = g.bannedGroups.map(b => b.toLowerCase()); + const bannedFound = []; + + for (const fg of filenameGroups) { + const idx = bannedLower.indexOf(fg.toLowerCase()); + if (idx !== -1) { + // Only flag if title group is different (i.e., they tried to hide it) + if (!titleGroup || titleGroup.toLowerCase() !== fg.toLowerCase()) { + bannedFound.push(g.bannedGroups[idx]); + } + } + } + + if (bannedFound.length > 0) { + return { + status: "fail", + message: `Banned group hidden in filename: ${bannedFound.join(", ")} (title group: ${titleGroup || "none"})`, + alert: true, + details: { titleGroup, bannedInFilename: bannedFound }, + }; + } + + return { status: "pass", message: "No banned groups detected in filenames", details: null }; + }, + + /** + * singleFileInFolder — generalized folder structure check for all categories. + */ + singleFileInFolder(fileStructure, category, type) { + if (g.fullDiscTypes.some(t => type?.includes(t))) { + return { status: "na", message: "N/A - Full Disc (folder structure expected)", details: null }; + } + if (!fileStructure) { + return { status: "warn", message: "Could not determine file structure", details: null }; + } + if (fileStructure.hasFolder && fileStructure.fileCount === 1) { + return { + status: "fail", + message: "Single file in a folder — upload without the unnecessary folder", + details: { + folder: fileStructure.folderName, + file: fileStructure.files ? fileStructure.files[0] : "unknown", + }, + }; + } + return { status: "pass", message: "File structure OK", details: null }; + }, + + /** + * missingEpisodes — detect gaps in season packs by parsing S##E## from filenames. + */ + missingEpisodes(fileStructure, torrentName, isTV) { + if (!isTV) { + return { status: "na", message: "N/A - Not TV content", details: null }; + } + + // Only for season packs: S## without E## + const seasonMatch = (torrentName || "").match(/\bS(\d{2,})\b(?!E)/i); + if (!seasonMatch) { + return { status: "na", message: "N/A - Not a season pack", details: null }; + } + + if (!fileStructure || !fileStructure.files || fileStructure.files.length === 0) { + return { status: "warn", message: "Season pack detected but no files to verify", details: null }; + } + + const season = parseInt(seasonMatch[1], 10); + const episodeNumbers = []; + + for (const f of fileStructure.files) { + const m = f.match(/S(\d{2,})E(\d{2,})/i); + if (m && parseInt(m[1], 10) === season) { + episodeNumbers.push(parseInt(m[2], 10)); + } + } + + if (episodeNumbers.length === 0) { + return { status: "warn", message: "Season pack but no S##E## patterns found in filenames", details: null }; + } + + episodeNumbers.sort((a, b) => a - b); + const gaps = []; + for (let i = 0; i < episodeNumbers.length - 1; i++) { + const curr = episodeNumbers[i]; + const next = episodeNumbers[i + 1]; + if (next - curr > 1) { + for (let ep = curr + 1; ep < next; ep++) { + gaps.push(ep); + } + } + } + + // Also check if pack starts at E01 + if (episodeNumbers[0] > 1) { + for (let ep = 1; ep < episodeNumbers[0]; ep++) { + gaps.unshift(ep); + } + } + + if (gaps.length === 0) { + return { + status: "pass", + message: `Season ${season} pack: ${episodeNumbers.length} episodes (E${String(episodeNumbers[0]).padStart(2, "0")}-E${String(episodeNumbers[episodeNumbers.length - 1]).padStart(2, "0")}), no gaps`, + details: null, + }; + } + + const gapStr = gaps.map(ep => `E${String(ep).padStart(2, "0")}`).join(", "); + return { + status: "warn", + message: `Season ${season} pack has gaps: missing ${gapStr} (Note: total episode count unknown without external data)`, + details: { season, found: episodeNumbers, missing: gaps }, + }; + }, +}; + + +/* ======================================================================== + * ZENITH RULES: Checks from https://znth.cx/pages/8 (Uploading Rules, + * updated 2026-08-14) plus Zenith severity policy for inherited checks. + * Feature-gated: only executed when instanceConfig.features.zenithRules + * ======================================================================== */ + +const zk = { + + _videoExts: [".mkv", ".mp4", ".avi", ".ts", ".m2ts", ".mpg", ".mpeg", ".vob", ".wmv", ".m4v", ".mov", ".webm"], + + _isFullDisc(type) { + return g.fullDiscTypes.some(t => (type || "").includes(t)); + }, + + _files(fs) { + return fs && Array.isArray(fs.files) ? fs.files : null; + }, + + /** 2.1: No archive or multi-archive uploads (games/apps exempt). */ + archives(fileStructure, category) { + if (/\b(game|app|software)/i.test(category || "")) { + return { status: "na", message: "N/A - Games/Apps may contain archives", details: null }; + } + const files = this._files(fileStructure); + if (!files) return { status: "warn", message: "Could not determine file list to check for archives", details: null }; + const arch = files.filter(f => /\.(rar|r\d{2,3}|zip|7z)$/i.test(f)); + return arch.length === 0 + ? { status: "pass", message: "No archives", details: null } + : { status: "fail", message: `Archive files are not allowed: ${arch.slice(0, 3).join(", ")}${arch.length > 3 ? ` (+${arch.length - 3})` : ""}`, details: { found: arch.join(", ") } }; + }, + + /** 2.2: No movie boxsets; each movie is its own upload. */ + boxset(torrentName, category, type, fileStructure, isTV) { + if (isTV || !/movie/i.test(category || "")) return { status: "na", message: "N/A - Not a movie", details: null }; + const byName = /\b(Collection|Trilogy|Duology|Quadrilogy|Anthology|Box ?set)\b/i.test(torrentName || ""); + const files = this._files(fileStructure) || []; + const years = new Set(); + files.filter(f => this._videoExts.some(x => f.toLowerCase().endsWith(x))) + .forEach(f => { const m = f.match(/\b(19|20)\d{2}\b/); m && years.add(m[0]); }); + if (!byName && years.size < 2) return { status: "pass", message: "Single movie", details: null }; + if (this._isFullDisc(type)) { + return { status: "advisory", message: "Multiple titles on one disc: allowed by the naming guide's Multiple Titles template; verify it is a single retail disc", details: null }; + } + return { status: "fail", message: byName + ? "Movie boxsets are not allowed: upload each movie individually" + : `Video files from ${years.size} different years: looks like a boxset; upload each movie individually`, details: null }; + }, + + /** 2.3 / 2.3.1: No multi-season COMPLETE packs, except full discs. */ + seasonPacks(torrentName, type, fileStructure, isTV) { + if (!isTV) return { status: "na", message: "N/A - Not TV", details: null }; + const name = torrentName || ""; + const seasons = new Set(); + const range = name.match(/\bS(\d{2})-S(\d{2})\b/i); + if (range) { seasons.add(range[1]); seasons.add(range[2]); } + (this._files(fileStructure) || []).forEach(f => { const m = f.match(/\bS(\d{2})E\d{2}/i); m && seasons.add(m[1]); }); + const multi = seasons.size > 1 || /\bCOMPLETE\b/i.test(name); + if (!multi) return { status: "pass", message: "Single season", details: null }; + if (this._isFullDisc(type)) { + return { status: "advisory", message: "Multi-season full disc: allowed (2.3.1) when seasons share a disc", details: null }; + } + return { status: "fail", message: `Multi-season pack (${seasons.size > 1 ? [...seasons].sort().map(s => "S" + s).join(", ") : "COMPLETE"}): package each season individually`, details: null }; + }, + + /** 2.4: Single episodes only while the season is still airing. */ + singleEpisode(torrentName, isTV) { + if (!isTV) return { status: "na", message: "N/A - Not TV", details: null }; + if (!/\bS\d{2}E\d{2}/i.test(torrentName || "")) return { status: "na", message: "N/A - Season pack", details: null }; + return { status: "advisory", message: "Single episode: only allowed if the season is currently airing; otherwise a season pack is required", details: null }; + }, + + _isTrueHd(t) { + return /MLP FBA|TrueHD/i.test(`${t.codec || ""} ${t.commercialName || ""}`); + }, + + /** 3.8: REMUX with TrueHD main audio needs a retail AC-3/E-AC-3 compatibility track. */ + trueHdCompat(type, tracks) { + if (!/REMUX/i.test(type || "")) return { status: "na", message: "N/A - Not a REMUX", details: null }; + if (!tracks || tracks.length === 0) return { status: "warn", message: "No audio tracks parsed from MediaInfo", details: null }; + const main = tracks.find(t => t.isDefault) || tracks[0]; + if (!this._isTrueHd(main)) return { status: "na", message: "N/A - Main audio is not TrueHD", details: null }; + const compat = tracks.filter(t => t !== main && /^E?-?AC-3/i.test(t.codec || "")); + return compat.length > 0 + ? { status: "pass", message: `TrueHD main audio with ${compat[0].codec} compatibility track`, details: null } + : { status: "fail", message: "TrueHD main audio without an AC-3/E-AC-3 compatibility track", details: null }; + }, + + /** 3.3 / 3.9 / 3.9.1: Full-disc BDInfo and packaging. */ + fullDisc(torrentName, type, fileStructure, hasBdInfo) { + if (!this._isFullDisc(type)) return { status: "na", message: "N/A - Not a full disc", details: null }; + const name = torrentName || ""; + const files = (this._files(fileStructure) || []).map(f => f.toLowerCase()); + const isBluray = /Blu-?ray/i.test(name); + const mustBeIso = isBluray && /\b(3D|MGVC)\b/i.test(name); + const hasIso = files.some(f => f.endsWith(".iso")); + const unpacked = files.some(f => /\.(m2ts|bdmv|clpi|mpls|vob|ifo|bup)$/.test(f)); + const problems = []; + if (isBluray && !hasBdInfo) problems.push("BDInfo is required for Blu-ray full discs (3.3)"); + if (mustBeIso && !hasIso) problems.push("3D/MGVC Blu-ray must be uploaded as ISO (3.9.1)"); + if (!mustBeIso && hasIso) problems.push("Full discs must be unpacked: BDMV/VIDEO_TS, not ISO (3.9)"); + if (!mustBeIso && !hasIso && files.length > 0 && !unpacked) problems.push("No BDMV/VIDEO_TS structure found in file list (3.9)"); + return problems.length === 0 + ? { status: "pass", message: mustBeIso ? "ISO as required for 3D/MGVC" : "Unpacked disc structure", details: null } + : { status: "fail", message: problems.join("; "), details: null }; + }, + + _trackerNames: [ + "aither", "blutopia", "beyond-hd", "beyondhd", "passthepopcorn", "broadcasthe.net", "hdbits", + "darkpeers", "lst.gg", "onlyencodes", "fearnopeer", "reelflix", "upload.cx", "hawke.uno", + "oldtoons", "seedpool", "torrentleech", "iptorrents", "rarbg", "1337x", "yts.mx", "torrentgalaxy", + "thepiratebay", "nyaa.si", "animebytes", "morethantv", "hd-torrents", "cinemaz", + ], + + /** 1.3: Do not advertise other websites or trackers (heuristic). */ + advertising(description) { + const text = (description || "").toLowerCase(); + if (!text) return { status: "na", message: "N/A - No description", details: null }; + const hits = this._trackerNames.filter(t => text.includes(t)); + if (/downloaded from|visit (us|our)|join (us|our)/i.test(text)) hits.push("promotional phrasing"); + return hits.length === 0 + ? { status: "pass", message: "No other trackers mentioned", details: null } + : { status: "advisory", message: `Description mentions: ${hits.join(", ")}: check for advertising`, details: null }; + }, + + /** Zenith severity: inherited checks without a backing Zenith rule are shown, never failed. */ + toAdvisory(r) { + if (!r || typeof r !== "object") return r; + const out = { ...r }; + if (out.status === "fail" || out.status === "warn") out.status = "advisory"; + if (Array.isArray(out.checks)) out.checks = out.checks.map(c => (c.status === "fail" || c.status === "warn") ? { ...c, status: "advisory" } : c); + return out; + }, + + applySeverityPolicy(results) { + // Zenith 1.5: English subtitles are not required: reword the inherited DP requirement + const sub = results.subtitleRequirement; + if (sub && (sub.status === "fail" || sub.status === "warn")) { + results.subtitleRequirement = { ...sub, message: "No English subtitles: optional on Zenith (1.5); include retail English subs if they exist" }; + } + // No Zenith rule: English subs are optional (1.5); no encode spec; no upscale / unknown-language / folder rule + for (const key of ["subtitleRequirement", "encodeCompliance", "upscaleDetection", "unknownLanguage", "singleFileFolder"]) { + if (results[key]) results[key] = this.toAdvisory(results[key]); + } + // 1.4 / naming guide: an untagged release is allowed: only a banned group hidden in the files stays a fail + if (results.nogroup && results.nogroup.status === "warn") results.nogroup = this.toAdvisory(results.nogroup); + // Naming guide Tag: "If none exists, leave empty" + if (results.dpTitle && Array.isArray(results.dpTitle.checks)) { + const checks = results.dpTitle.checks.map(c => (c.name === "group" && c.status === "fail") ? { ...c, status: "advisory", message: "No release tag: allowed if none exists" } : c); + const st = checks.map(c => c.status); + results.dpTitle = { ...results.dpTitle, checks, status: st.includes("fail") ? "fail" : st.includes("warn") ? "warn" : results.dpTitle.status === "fail" || results.dpTitle.status === "warn" ? "pass" : results.dpTitle.status }; + } + return results; + }, + + run(data, tracks) { + return { + znArchives: this.archives(data.fileStructure, data.category), + znBoxset: this.boxset(data.torrentName, data.category, data.type, data.fileStructure, data.isTV), + znSeasonPacks: this.seasonPacks(data.torrentName, data.type, data.fileStructure, data.isTV), + znSingleEpisode: this.singleEpisode(data.torrentName, data.isTV), + znTrueHdCompat: this.trueHdCompat(data.type, tracks), + znFullDisc: this.fullDisc(data.torrentName, data.type, data.fileStructure, data.hasBdInfo), + znAdvertising: this.advertising(data.description), + }; + }, + + /** Panel rows: [result key, label, Zenith rule ref]: rendered in this order. */ + SECTION: [ + ["znArchives", "Archives", "2.1"], + ["znBoxset", "Movie Boxset", "2.2"], + ["znSeasonPacks", "Season Packaging", "2.3"], + ["znSingleEpisode", "Single Episode", "2.4"], + ["znFullDisc", "Full Disc", "3.9"], + ["znTrueHdCompat", "TrueHD Compatibility Track", "3.8"], + ["extraneousFiles", "Extraneous Files", "3.6"], + ["bannedFilename", "Banned Group in Files", null], + ["nogroup", "Release Tag", "1.4"], + ["categoryTypeMismatch", "Category / Type", null], + ["missingEpisodes", "Missing Episodes", "2.4"], + ["unknownLanguage", "Unknown Language", null], + ["singleFileFolder", "Single File in Folder", null], + ["znAdvertising", "Advertising", "1.3"], + ["suspicion", "Suspicion Heuristics", null], + ["dpTitle", "Title Template (Naming Guide)", "3.1"], + ], + + /** Standard corrective-message ids → Zenith upload-rule refs (3.1 is covered by the Naming Guide link). */ + STANDARD_RULE_REFS: { screenshots: "3.4", mediainfo: "3.2", container: "3.5", folder: "3.6" }, + + enabledSection(results, settingsChecks) { + const on = k => !settingsChecks || settingsChecks[k] !== false; + return this.SECTION.filter(([k]) => results[k] && results[k].status !== "na" && on(k)); + }, +}; + +/* ======================================================================== + * TITLE VALIDATOR — Template-based title validation for DarkPeers + * Feature-gated: only executed when instanceConfig.features.dpTitleValidation + * ======================================================================== */ + +const TitleValidator = { + + isMusic(cat) { + return /\bmusic\b/i.test(cat || ""); + }, + + isAudioBook(cat) { + return /\baudiobook\b/i.test(cat || ""); + }, + + isEbook(cat) { + return /\b(ebook|e-book|book)\b/i.test(cat || ""); + }, + + /** + * validate — entry point; delegates to the right category validator. + */ + validate(title, category, type, mediaInfoText) { + if (!title) { + return { status: "fail", message: "No title to validate", details: null, checks: [] }; + } + + if (this.isMusic(category)) { + return this.music(title); + } + if (this.isAudioBook(category)) { + return this.audioBook(title, mediaInfoText); + } + if (this.isEbook(category)) { + return this.ebook(title); + } + + // Video content + const isFullDiscOrRemux = g.fullDiscTypes.some(t => type?.includes(t)) + || g.remuxTypes.some(t => type?.toUpperCase().includes(t.toUpperCase())); + + if (isFullDiscOrRemux) { + return this.videoFullDiscRemux(title, type); + } + return this.videoEncodeWeb(title, type); + }, + + /** + * videoFullDiscRemux — validate title against Full Disc/REMUX template. + * Template order: name year season cut ratio repack resolution edition region 3d source type hdr vcodec dub acodec channels object -group + */ + videoFullDiscRemux(title, type) { + const checks = []; + const { elements, positions } = H.extractTitleElements(title, type); + const order = g.titleElementOrder.fullDiscRemux; + const isTV = /S\d{2}/i.test(title); + + // Check required elements — year optional for TV per DP rules + const required = ["resolution", "source", "group"]; + for (const req of required) { + const found = elements.find(e => e.type === req); + if (found) { + checks.push({ name: req, status: "pass", message: `Found: ${found.value}` }); + } else if (req === "group" && g.fullDiscTypes.some(t => type?.includes(t))) { + checks.push({ name: req, status: "na", message: "Group not required for Full Disc" }); + } else { + checks.push({ name: req, status: "fail", message: `Missing required element: ${req}` }); + } + } + // Year: required for movies, optional for TV + const yearEl = elements.find(e => e.type === "year"); + if (yearEl) { + checks.push({ name: "year", status: "pass", message: `Found: ${yearEl.value}` }); + } else if (isTV) { + checks.push({ name: "year", status: "pass", message: "Year optional for TV" }); + } else { + checks.push({ name: "year", status: "fail", message: "Missing required element: year" }); + } + + // Check element order + const presentOrder = elements.map(e => e.type); + const orderViolations = []; + for (let i = 0; i < presentOrder.length; i++) { + for (let j = i + 1; j < presentOrder.length; j++) { + const idxA = order.indexOf(presentOrder[i]); + const idxB = order.indexOf(presentOrder[j]); + if (idxA !== -1 && idxB !== -1 && idxA > idxB) { + orderViolations.push(`"${elements[i].value}" (${presentOrder[i]}) should come after "${elements[j].value}" (${presentOrder[j]})`); + } + } + } + + if (orderViolations.length > 0) { + checks.push({ + name: "Element Order", + status: "fail", + message: `${orderViolations.length} ordering issue(s): ${orderViolations[0]}${orderViolations.length > 1 ? " (+" + (orderViolations.length - 1) + " more)" : ""}`, + }); + } else if (elements.length >= 3) { + checks.push({ name: "Element Order", status: "pass", message: "Elements in correct order" }); + } + + // Check for DS4K if 2160p + if (/2160p/i.test(title)) { + const hdrEl = elements.find(e => e.type === "hdr"); + if (hdrEl) { + checks.push({ name: "HDR", status: "pass", message: `HDR tag present: ${hdrEl.value}` }); + } else { + checks.push({ name: "HDR", status: "warn", message: "4K content — consider adding HDR tag if applicable" }); + } + } + + const hasFail = checks.some(c => c.status === "fail"); + const hasWarn = checks.some(c => c.status === "warn"); + return { + status: hasFail ? "fail" : hasWarn ? "warn" : "pass", + message: hasFail ? "Title validation issues found" : hasWarn ? "Title may need attention" : "Title structure valid", + details: null, + checks, + }; + }, + + /** + * videoEncodeWeb — validate title against Encode/WEB template. + * Template order: name year season cut ratio repack resolution edition 3d source type dub acodec channels object hdr vcodec -group + */ + videoEncodeWeb(title, type) { + const checks = []; + const { elements, positions } = H.extractTitleElements(title, type); + const order = g.titleElementOrder.encodeWeb; + const isTV = /S\d{2}/i.test(title); + + // Check required elements — year is optional for TV per DP rules + const required = ["resolution", "source", "vcodec", "group"]; + for (const req of required) { + const found = elements.find(e => e.type === req); + if (found) { + checks.push({ name: req, status: "pass", message: `Found: ${found.value}` }); + } else { + checks.push({ name: req, status: "fail", message: `Missing required element: ${req}` }); + } + } + // Year: required for movies, optional for TV + const yearEl = elements.find(e => e.type === "year"); + if (yearEl) { + checks.push({ name: "year", status: "pass", message: `Found: ${yearEl.value}` }); + } else if (isTV) { + checks.push({ name: "year", status: "pass", message: "Year optional for TV" }); + } else { + checks.push({ name: "year", status: "fail", message: "Missing required element: year" }); + } + + // Check element order + const presentOrder = elements.map(e => e.type); + const orderViolations = []; + for (let i = 0; i < presentOrder.length; i++) { + for (let j = i + 1; j < presentOrder.length; j++) { + const idxA = order.indexOf(presentOrder[i]); + const idxB = order.indexOf(presentOrder[j]); + if (idxA !== -1 && idxB !== -1 && idxA > idxB) { + orderViolations.push(`"${elements[i].value}" (${presentOrder[i]}) should come after "${elements[j].value}" (${presentOrder[j]})`); + } + } + } + + if (orderViolations.length > 0) { + checks.push({ + name: "Element Order", + status: "fail", + message: `${orderViolations.length} ordering issue(s): ${orderViolations[0]}${orderViolations.length > 1 ? " (+" + (orderViolations.length - 1) + " more)" : ""}`, + }); + } else if (elements.length >= 3) { + checks.push({ name: "Element Order", status: "pass", message: "Elements in correct order" }); + } + + // DS4K check: 2160p encode from non-4K source + if (/2160p/i.test(title)) { + const sourceEl = elements.find(e => e.type === "source"); + const sourceVal = sourceEl ? sourceEl.value : ""; + // If source is not UHD, it might be an upscale + if (sourceVal && !/UHD/i.test(sourceVal) && !/2160p/i.test(sourceVal)) { + checks.push({ + name: "DS4K", + status: "warn", + message: `2160p encode from ${sourceVal} source — verify this is not an upscale (DS4K)`, + }); + } + } + + const hasFail = checks.some(c => c.status === "fail"); + const hasWarn = checks.some(c => c.status === "warn"); + return { + status: hasFail ? "fail" : hasWarn ? "warn" : "pass", + message: hasFail ? "Title validation issues found" : hasWarn ? "Title may need attention" : "Title structure valid", + details: null, + checks, + }; + }, + + /** + * music — validate Artist - Album (Year) - Format pattern. + */ + music(title) { + const checks = []; + + // Expected: Artist - Album (Year) - Format + // or: Artist - Album (Year) [Format] + const pattern = /^(.+?)\s+-\s+(.+?)\s+\((\d{4})\)\s+(?:-\s+|[\[(])(.+?)[\])]?\s*$/; + const match = title.match(pattern); + + if (!match) { + // Try a more relaxed pattern + const hasArtist = /^.+?\s+-\s+/.test(title); + const hasYear = /\(\d{4}\)/.test(title); + + if (!hasArtist) checks.push({ name: "Artist", status: "fail", message: "Could not parse 'Artist - ' prefix" }); + else checks.push({ name: "Artist", status: "pass", message: "Artist separator found" }); + + if (!hasYear) checks.push({ name: "Year", status: "fail", message: "No (Year) found — expected format: (2024)" }); + else checks.push({ name: "Year", status: "pass", message: "Year found" }); + + const hasFormat = /\b(FLAC|MP3|AAC|ALAC|OGG|OPUS|WAV|WEB|CD|Vinyl|24bit|16bit|320|V0|V2)\b/i.test(title); + if (!hasFormat) checks.push({ name: "Format", status: "warn", message: "No audio format detected" }); + else checks.push({ name: "Format", status: "pass", message: "Format indicator found" }); + } else { + checks.push({ name: "Artist", status: "pass", message: `Artist: ${match[1]}` }); + checks.push({ name: "Album", status: "pass", message: `Album: ${match[2]}` }); + checks.push({ name: "Year", status: "pass", message: `Year: ${match[3]}` }); + checks.push({ name: "Format", status: "pass", message: `Format: ${match[4]}` }); + } + + const hasFail = checks.some(c => c.status === "fail"); + const hasWarn = checks.some(c => c.status === "warn"); + return { + status: hasFail ? "fail" : hasWarn ? "warn" : "pass", + message: hasFail ? "Music title format issues" : hasWarn ? "Music title may need attention" : "Music title format valid", + details: null, + checks, + }; + }, + + /** + * audioBook — validate Author - Name Year Format ISBN-Tag pattern. + */ + audioBook(title, mediaInfoText) { + const checks = []; + + const hasAuthor = /^.+?\s+-\s+/.test(title); + checks.push({ + name: "Author", + status: hasAuthor ? "pass" : "fail", + message: hasAuthor ? "Author separator found" : "Could not parse 'Author - ' prefix", + }); + + const yearMatch = title.match(/\b(19|20)\d{2}\b/); + checks.push({ + name: "Year", + status: yearMatch ? "pass" : "warn", + message: yearMatch ? `Year: ${yearMatch[0]}` : "No year found in title", + }); + + const formatMatch = /\b(FLAC|MP3|AAC|M4B|M4A|OGG|OPUS|Audiobook)\b/i.test(title); + checks.push({ + name: "Format", + status: formatMatch ? "pass" : "warn", + message: formatMatch ? "Audio format found" : "No audio format detected", + }); + + const isbnMatch = title.match(/\b(ISBN[-:]?\s*[\dX-]{10,17})\b/i); + checks.push({ + name: "ISBN", + status: isbnMatch ? "pass" : "warn", + message: isbnMatch ? `ISBN found: ${isbnMatch[1]}` : "No ISBN — recommended if available", + }); + + // Note: narrator/publisher ideally in description, but we flag if mediaInfoText hints at it + if (mediaInfoText && /narrator/i.test(mediaInfoText)) { + checks.push({ name: "Narrator", status: "pass", message: "Narrator mentioned in info" }); + } + + const hasFail = checks.some(c => c.status === "fail"); + const hasWarn = checks.some(c => c.status === "warn"); + return { + status: hasFail ? "fail" : hasWarn ? "warn" : "pass", + message: hasFail ? "Audiobook title format issues" : hasWarn ? "Audiobook title may need attention" : "Audiobook title format valid", + details: null, + checks, + }; + }, + + /** + * ebook — validate Author - Name Year Format ISBN pattern. + */ + ebook(title) { + const checks = []; + + const hasAuthor = /^.+?\s+-\s+/.test(title); + checks.push({ + name: "Author", + status: hasAuthor ? "pass" : "fail", + message: hasAuthor ? "Author separator found" : "Could not parse 'Author - ' prefix", + }); + + const yearMatch = title.match(/\b(19|20)\d{2}\b/); + checks.push({ + name: "Year", + status: yearMatch ? "pass" : "warn", + message: yearMatch ? `Year: ${yearMatch[0]}` : "No year found in title", + }); + + const formatMatch = /\b(EPUB|PDF|MOBI|AZW3?|CBR|CBZ|DJVU)\b/i.test(title); + checks.push({ + name: "Format", + status: formatMatch ? "pass" : "fail", + message: formatMatch ? "eBook format found" : "No eBook format detected (EPUB, PDF, MOBI, etc.)", + }); + + const isbnMatch = title.match(/\b(ISBN[-:]?\s*[\dX-]{10,17})\b/i); + checks.push({ + name: "ISBN", + status: isbnMatch ? "pass" : "warn", + message: isbnMatch ? `ISBN found: ${isbnMatch[1]}` : "No ISBN — recommended if available", + }); + + const hasFail = checks.some(c => c.status === "fail"); + const hasWarn = checks.some(c => c.status === "warn"); + return { + status: hasFail ? "fail" : hasWarn ? "warn" : "pass", + message: hasFail ? "eBook title format issues" : hasWarn ? "eBook title may need attention" : "eBook title format valid", + details: null, + checks, + }; + }, + + /** + * collection — check for Collection/Trilogy suffix. + */ + collection(title) { + const hasCollectionSuffix = /\b(Collection|Trilogy|Duology|Quadrilogy|Anthology|Complete\s+Series)\b/i.test(title); + if (hasCollectionSuffix) { + return { status: "pass", message: "Collection suffix detected", details: null }; + } + return { status: "warn", message: "No collection suffix found — add if this is a multi-title set", details: null }; + }, +}; + + +/* ======================================================================== + * INTEGRATIONS — External API wrappers (SRRDB, Prowlarr) + * Feature-gated: only executed when instanceConfig.features.srrdb/prowlarr + * ======================================================================== */ + +const Integrations = { + + /** + * _request — Promise wrapper around GM_xmlhttpRequest. + */ + _request(options) { + return new Promise((resolve, reject) => { + if (typeof GM_xmlhttpRequest !== "function") { + return reject(new Error("GM_xmlhttpRequest not available")); + } + + const timeout = options.timeout || 10000; + + GM_xmlhttpRequest({ + method: options.method || "GET", + url: options.url, + headers: options.headers || {}, + timeout, + onload(response) { + try { + if (response.status >= 200 && response.status < 300) { + const data = options.json !== false ? JSON.parse(response.responseText) : response.responseText; + resolve({ status: response.status, data }); + } else { + reject(new Error(`HTTP ${response.status}: ${response.statusText}`)); + } + } catch (e) { + reject(new Error(`Parse error: ${e.message}`)); + } + }, + onerror(err) { + reject(new Error(`Network error: ${err.error || "unknown"}`)); + }, + ontimeout() { + reject(new Error(`Timeout after ${timeout}ms`)); + }, + }); + }); + }, + + 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 { + // 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, + }); + + const data = result.data; + if (data && data.results && data.results.length > 0) { + return { + 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 }; + } + }, + + /** + * test — simple health check for SRRDB. + */ + async test() { + try { + await Integrations._request({ + url: "https://www.srrdb.com/api/search/r:test", + timeout: 5000, + }); + return { ok: true, error: null }; + } catch (e) { + return { ok: false, error: e.message }; + } + }, + + /** + * getFiles — fetch file list for a release from SRRDB. + */ + async getFiles(releaseName) { + if (!releaseName) return { files: [], error: "No release name" }; + try { + const encoded = encodeURIComponent(releaseName); + const result = await Integrations._request({ + url: `https://www.srrdb.com/api/files/${encoded}`, + timeout: 8000, + }); + // SRRDB files API returns an array of file objects + const files = Array.isArray(result.data) ? result.data : []; + return { files, error: null }; + } catch (e) { + return { files: [], error: e.message }; + } + }, + }, + + prowlarr: { + /** + * search — search Prowlarr for a torrent name. + */ + async search(config, torrentName) { + if (!config || !config.url || !config.apiKey) { + return { found: false, results: [], error: "Prowlarr not configured" }; + } + + try { + const url = `${config.url.replace(/\/+$/, "")}/api/v1/search?query=${encodeURIComponent(torrentName)}&type=search`; + const result = await Integrations._request({ + url, + headers: { "X-Api-Key": config.apiKey }, + timeout: config.timeout || 15000, + }); + + const data = result.data; + if (Array.isArray(data) && data.length > 0) { + return { found: true, results: data, error: null }; + } + return { found: false, results: [], error: null }; + } catch (e) { + return { found: false, results: [], error: e.message }; + } + }, + + /** + * test — health check for Prowlarr instance. + */ + async test(config) { + if (!config || !config.url || !config.apiKey) { + return { ok: false, error: "Prowlarr not configured" }; + } + + try { + await Integrations._request({ + url: `${config.url.replace(/\/+$/, "")}/api/v1/health`, + headers: { "X-Api-Key": config.apiKey }, + timeout: 5000, + }); + return { ok: true, error: null }; + } catch (e) { + return { ok: false, error: e.message }; + } + }, + }, +}; + + +/* ======================================================================== + * 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 = { + + /** + * _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; } + }, + + /** + * _stripAka — Remove "AKA ..." portion from a title name. + * DP naming convention includes "Name AKA Original" but folders/filenames + * never include the AKA part, causing Jaccard mismatch. Strip it before + * comparing titleNames. + */ + _stripAka(name) { + if (!name) return name; + return name.replace(/\s+AKA\s+.*/i, "").trim(); + }, + + /** + * _stripDiacritics — Normalize accented characters to ASCII equivalents. + * É→E, â→a, û→u, ñ→n, ö→o, etc. Uses Unicode NFD decomposition + * followed by combining-mark removal. + */ + _stripDiacritics(str) { + if (!str) return str; + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + }, + + /** 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(); + + // Extract AKA title from the structural portion (after first token, e.g. after year). + // Only matches AKA that appears AFTER titleEnd — AKA in the title portion is + // handled by _stripAka on titleName instead. + const postTitle = normalized.slice(titleEnd); + const akaMatch = postTitle.match(/\bAKA\s+(.+?)(?=[\s.]+\d{3,4}p\b|[\s.]+(?:Blu-?ray|BluRay|WEB[-.]?DL|WEB[-.]?Rip|HDTV|Remux|REMUX|BDRip|BRRip|DVDRip)\b)/i); + const akaTitle = akaMatch ? akaMatch[1].replace(/[.\-_]/g, " ").replace(/\s+/g, " ").trim() : null; + + return { + raw: name, + titleName, + akaTitle, + 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 norm = s => this._stripDiacritics(s).toLowerCase().replace(/['\u2019]/g, ""); + const wordsA = new Set(norm(a).split(/\s+/).filter(Boolean)); + const wordsB = new Set(norm(b).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 (strip AKA for fair comparison) + let titleScore = this._jaccardWords(this._stripAka(uploadTokens.titleName), this._stripAka(resultTokens.titleName)); + // AKA fallback: try mid-name AKA titles if primary comparison is weak + if (titleScore < 0.5) { + const akaTitleScores = [ + resultTokens.akaTitle ? this._jaccardWords(this._stripAka(uploadTokens.titleName), resultTokens.akaTitle) : 0, + uploadTokens.akaTitle ? this._jaccardWords(uploadTokens.akaTitle, this._stripAka(resultTokens.titleName)) : 0, + ]; + titleScore = Math.max(titleScore, ...akaTitleScores); + } + 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 }; + }, + + /** + * _indexerRank — Return preference rank for an indexer name. + * Lower = more preferred. Infinity = not in preference list. + */ + _indexerRank(indexerName, preferredList) { + if (!preferredList || preferredList.length === 0) return Infinity; + const idx = preferredList.findIndex( + p => p.toLowerCase() === (indexerName || "").toLowerCase() + ); + return idx === -1 ? Infinity : idx; + }, + + /** + * findBestMatch — Select the best Prowlarr result using candidate ranking. + * + * Sort order: + * 1. relevanceScore (descending) — scores within 0.01 epsilon are ties + * 2. preferredIndexer rank (ascending — lower = more preferred) + * 3. seeders (descending — liveness tiebreaker) + * + * Also tracks up to 3 alternative candidates (score > 0.50) for display. + */ + 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, + }); + } + + if (candidates.length === 0) return null; + + const prefs = Array.isArray(preferredIndexers) ? preferredIndexers : []; + + candidates.sort((a, b) => { + // Primary: relevanceScore descending (ties within epsilon) + const scoreDiff = b.relevanceScore - a.relevanceScore; + if (Math.abs(scoreDiff) > EPSILON) return scoreDiff; + // Secondary: preferred indexer rank ascending + const rankA = this._indexerRank(a.indexer, prefs); + const rankB = this._indexerRank(b.indexer, prefs); + if (rankA !== rankB) return rankA - rankB; + // Tertiary: seeders descending + return (b.seeders || 0) - (a.seeders || 0); + }); + + const best = candidates[0]; + // 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; 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, infoUrl: c.infoUrl || null, publishDate: c.publishDate || null }); + } + } + best.alternatives = alternatives; + + 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 — strip AKA portion (folders never include it) + const titleA = this._stripAka(tokensA.titleName); + const titleB = this._stripAka(tokensB.titleName); + let titleSim = this._jaccardWords(titleA, titleB); + // AKA fallback: one side may use the foreign title while the other has the + // English title in a mid-name AKA portion (e.g. "La.voie.du.serpent.2024.AKA.Serpents.Path...") + if (titleSim < 0.5) { + const akaSims = [ + tokensB.akaTitle ? this._jaccardWords(titleA, tokensB.akaTitle) : 0, + tokensA.akaTitle ? this._jaccardWords(tokensA.akaTitle, titleB) : 0, + ]; + titleSim = Math.max(titleSim, ...akaSims); + } + 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) + // 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, { ignoreGroup: true })) { + 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, { ignoreGroup: true })) { + 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. + * ======================================================================== */ + +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&&this.RULES_URL){b+=` + +Please review the [url=${this.RULES_URL}]Naming Guide[/url].`}return b}}; + +/* Zenith: add Zenith-rule issues to the corrective message and cite upload rules by § */ +G.UPLOAD_RULES_URL = null; +(function() { + const _collect = G.collectIssues, _beautify = G.beautify, _build = G.buildMessage; + const NAMING = /^(tmdb|season_episode|element_order|resolution_type|naming_|audio_|zn_dpTitle$)/; + G.collectIssues = function(e) { + const n = _collect.call(this, e); + for (const [key, label, rule] of (e._zenithRows || [])) { + const v = e[key]; + if (v && (v.status === "fail" || v.status === "warn")) n.push({ id: "zn_" + key, status: v.status, raw: v.message, label, zenithRule: rule }); + } + return n; + }; + G.beautify = function(e) { + if (e.id && e.id.startsWith("zn_")) return { text: e.raw, rules: e.zenithRule ? [e.zenithRule] : [] }; + return _beautify.call(this, e); + }; + G.buildMessage = function(e) { + if (!this.UPLOAD_RULES_URL) return _build.call(this, e); + const namingUrl = this.RULES_URL; + this.RULES_URL = null; // suppress the generic trailer; cite precisely below + let b; + try { b = _build.call(this, e); } finally { this.RULES_URL = namingUrl; } + if (!b) return b; + const active = e.filter(u => u.status === "fail" || u.status === "warn"); + const refs = new Set(); + for (const u of active) { + const r = u.zenithRule || zk.STANDARD_RULE_REFS[u.id] || null; + if (u.id === "mediainfo") refs.add(/BDInfo/i.test(u.raw || "") ? "3.3" : "3.2"); + else if (r && r !== "3.1") refs.add(r); + } + const lines = []; + if (namingUrl && active.some(u => NAMING.test(u.id))) lines.push(`Please review the [url=${namingUrl}]Naming Guide[/url].`); + if (refs.size > 0) { + const sorted = [...refs].sort((a, c) => a.localeCompare(c, undefined, { numeric: true })); + lines.push(`See the [url=${this.UPLOAD_RULES_URL}]Uploading Rules[/url]: ${sorted.map(r => "§" + r).join(", ")}.`); + } + return lines.length ? `${b}\n\n${lines.join("\n")}` : b; + }; +})(); + +/* ======================================================================== + * UI — Panel rendering, injection, and event handling + * Ported from the original U object. + * ======================================================================== */ + +const U={getStatusIcon(e){switch(e){case"pass":return'';case"fail":return'';case"warn":return'';case"na":return'';case"advisory":return'';case"integration":return'';default:return''}},getStatusBadge(e){return`${{pass:"Pass",fail:"Fail",warn:"Warning",na:"N/A",advisory:"Review",integration:"..."}[e]||e}`},worstStatus(e){const n=e.filter(t=>t!=="na");return n.includes("fail")?"fail":n.includes("warn")?"warn":n.length?"pass":"na"},accordion(e,n,t,s,{forceOpen:a=null,alert:o=!1}={}){return` +
+ + ${this.getStatusIcon(t)} + ${n} + ${this.getStatusBadge(t)} + + +
${s}
+
`},checkRow(e,n,t,s=null){let a="";if(s)if(s.expected!==void 0&&s.found!==void 0)a=` +
+ Expected: ${s.expected} + Found: ${s.found} +
`;else if(typeof s=="object"&&!Array.isArray(s)){const o=Object.entries(s);o.length&&(a=`
${o.map(([c,r])=>`${c}: ${Array.isArray(r)?r.join(", "):r}`).join("")}
`)}else Array.isArray(s)&&s.length&&(a=`
${s.map(o=>`${o}`).join("")}
`);return` +
+ ${this.getStatusIcon(e)} + ${n} + ${t} + ${a} +
`},buildSimple(e,n,t,{alert:s=!1,extraBadge:a=""}={}){if(t.details&&(t.details.expected!==void 0&&t.details.found!==void 0||Array.isArray(t.details)&&t.details.length>0||typeof t.details=="object"&&!Array.isArray(t.details)&&Object.keys(t.details).length>0)){const c=this.checkRow(t.status,n,t.message,t.details);return this.accordion(e,n,t.status,c,{alert:s})}return this.inlineRow(e,t.status,n,t.message,a)},inlineRow(e,n,t,s,a=""){return` +
+ ${this.getStatusIcon(n)} + ${t} + ${s} + ${a} + ${this.getStatusBadge(n)} +
`},buildNamingGuide(e){const n=e.checks.map(t=>{const s=t.required===!1?' (optional)':"";return this.checkRow(t.status,t.name+s,t.message)}).join("");return this.accordion("naming","Naming Convention",e.status,n)},buildElementOrder(e){const n=[];if(e.violations&&e.violations.length>0&&n.push(...e.violations.map(s=>typeof s=="object"?s.message:s)),e.details&&e.details.violations&&e.details.violations.length>0&&n.push(...e.details.violations),!n.length)return this.inlineRow("order",e.status,"Title Element Order",e.message);let t=this.checkRow(e.status,"Element Order",e.message);return t+=`
+ ${e.details?.orderType?`Order type: ${e.details.orderType}`:""} +
    ${n.map(s=>`
  • ${s}
  • `).join("")}
+
`,this.accordion("order","Title Element Order",e.status,t)},buildMultiCheck(e,n,t){if(t.checks&&t.checks.length>1){const s=t.checks.map(a=>this.checkRow(a.status,a.name,a.message)).join("");return this.accordion(e,n,t.status,s)}return t.checks&&t.checks.length===1?this.inlineRow(e,t.checks[0].status,n,t.checks[0].message):this.inlineRow(e,t.status,n,t.message)},buildBannedGroupAlert(e){return e.alert?` +
+ +
+ Banned Release Group Detected + ${e.message} +
+
`:""},groupHeading(e){return`
${e}
`},sectionGroup(e,n,t){const s=t.map(f=>f.status).filter(f=>f!=="na"&&f!=="advisory"),a=s.filter(f=>f==="pass").length,o=s.length,c=a===o&&o>0,r=this.worstStatus(t.map(f=>f.status));return` +
+ + ${a}/${o} passed + + + +
${n}
+
`},createPanel(e){const n=document.createElement("section");n.className="panelV2 mh-panel",n.id="mod-helper-panel";const s=[e.tmdbMatch,e.seasonEpisode,e.namingGuide,e.elementOrder,e.folderStructure,e.mediaInfo,e.audioTags,e.subtitleRequirement,e.screenshots,e.bannedGroup,e.encodeCompliance,e.upscaleDetection,e.containerFormat,e.packUniformity,e.resolutionTypeMatch,...(e._zenithRows||[]).map(([k])=>e[k])].map(i=>i.status),a=this.worstStatus(s),o=s.filter(i=>i!=="na"&&i!=="advisory"),c=o.filter(i=>i==="pass").length,r=o.filter(i=>i==="warn").length,d=o.filter(i=>i==="fail").length,f=o.length;let A=[];if(d&&A.push(`${d} failed`),r&&A.push(`${r} warning${r>1?"s":""}`),A.push(`${c}/${f} passed`),e.bannedGroup.tieredInfo){const i=e.bannedGroup.tieredInfo.join(", ");A.push(`TRaSH Tiered Group`)}let T="";T+=this.buildBannedGroupAlert(e.bannedGroup);const b=[e.tmdbMatch,e.seasonEpisode,e.namingGuide,e.elementOrder,e.bannedGroup,e.screenshots];let u="";u+=this.buildSimple("tmdb","TMDB Title Match",e.tmdbMatch),e.seasonEpisode.status!=="na"&&(u+=this.buildSimple("season","Season / Episode Format",e.seasonEpisode));const D=e.bannedGroup.tieredInfo?`TRaSH Tiered Group`:"";u+=this.buildSimple("group","Release Group",e.bannedGroup,{alert:e.bannedGroup.alert,extraBadge:D}),u+=this.buildSimple("screenshots","Screenshots",e.screenshots),u+=this.buildElementOrder(e.elementOrder),u+=this.buildNamingGuide(e.namingGuide),T+=this.sectionGroup("Content & Naming",u,b);const x=[e.folderStructure,e.containerFormat,e.mediaInfo,e.audioTags,e.subtitleRequirement,e.encodeCompliance,e.packUniformity,e.upscaleDetection,e.resolutionTypeMatch];let p="";e.folderStructure.status!=="na"&&(p+=this.buildSimple("folder","Folder Structure",e.folderStructure)),e.containerFormat.status!=="na"&&(p+=this.buildSimple("container","Container Format",e.containerFormat)),p+=this.buildSimple("mediainfo","MediaInfo",e.mediaInfo),e.subtitleRequirement.status!=="na"&&(p+=this.buildSimple("subs","Subtitle Requirement",e.subtitleRequirement)),p+=this.buildSimple("upscale","Upscale Detection",e.upscaleDetection),p+=this.buildSimple("restype","Resolution Type",e.resolutionTypeMatch),p+=this.buildMultiCheck("audio","Audio Compliance",e.audioTags),e.encodeCompliance.status!=="na"&&(p+=this.buildMultiCheck("encode","Encode Compliance",e.encodeCompliance)),e.packUniformity.status!=="na"&&(p+=this.buildMultiCheck("pack","Pack Uniformity",e.packUniformity)),T+=this.sectionGroup("Technical",p,x);if(e._zenithRows&&e._zenithRows.length){const zr=e._zenithRows;let zh="";for(const [zk_k,zk_l,zk_r] of zr){const v=e[zk_k],lab=zk_r?`${zk_l} (${zk_r})`:zk_l;zh+=Array.isArray(v.checks)&&v.checks.length>1?this.buildMultiCheck("zn-"+zk_k,lab,v):this.buildSimple("zn-"+zk_k,lab,v)}T+=this.sectionGroup("Zenith Rules",zh,zr.map(([k])=>e[k]))}const y=G.collectIssues(e),C=G.buildMessage(y);let h="";if(C){const i=C.replace(/&/g,"&").replace(//g,">");h=` +
+
+ + Corrective Message +
+ + + +
+
+
${i}
+ +
+ + Click the message to edit before copying or filling. +
+
`}return n.innerHTML=` +
+

+ ${this.getStatusIcon(a)} + ModQ Helper +

+
${A.join("")}
+
+ + +
+
+ ${h} + +
${T}${this._buildIntegrationPlaceholders(e, e._autoSearch)}
`,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, autoSearch){if(!results.nogroup&&!results.dpTitle)return"";const _idle=autoSearch===false;const _srrdbContent=_idle?`
SRRDBIdle — use the Search button above to check
`:`
SRRDBChecking scene database...
`;const _prowlarrContent=_idle?`
ProwlarrIdle — use the Search button above to check
`:`
ProwlarrSearching indexers...
`;const _btnLabel=_idle?"Search":"Re-search";const _btnIcon=_idle?"fa-search":"fa-rotate";return`
External Integrations
${_srrdbContent}
${_prowlarrContent}
`},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 — 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+=`
    ${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+=``} +// Group note +if(conf.note&&confLevel!=="likely_renamed"&&confLevel!=="renamed"){matchHtml+=`
    ${esc(conf.note)}
    `} +// Collapsible field comparison detail +const fieldLabels={titleName:"Title",year:"Year",resolution:"Resolution",source:"Source",vcodec:"Video codec",acodec:"Audio codec"};const fieldEntries=Object.entries(fs).filter(([k])=>fieldLabels[k]);if(fieldEntries.length>0){let detailRows=fieldEntries.map(([k,v])=>{const label=fieldLabels[k];const icon=v>=1.0?'':v>0?'':'';return`
    ${icon} ${label}
    `}).join(""); +// 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 _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 +const headerMsg=count===1?`Found on ${esc(bestMatch?.indexer||"1 indexer")}`:`Best match from ${esc(bestMatch?.indexer||"unknown")} (${count} indexers total)`; +return`
    Prowlarr${headerMsg}${matchHtml}
    `}return`
    ${esc(name)}${result.found?"Found":"Not found"}
    `}}; + +/* ======================================================================== + * CSS — Panel styles (injected via GM_addStyle) + * All var() references have fallback values for cross-theme compatibility. + * ======================================================================== */ + +const Z=` + .mh-panel { + margin-bottom: 16px; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--panel-border, #383838); + background: var(--surface-01, #1a1a2e); + } + + .mh-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px 12px 0; + flex-wrap: nowrap; + min-width: 0; + } + .mh-heading { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 18px; + color: var(--panel-head-fg, #d8d7dc); + white-space: nowrap; + flex-shrink: 0; + } + .mh-summary { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex-shrink: 1; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + } + .mh-summary::-webkit-scrollbar { display: none; } + .mh-chip { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 4px; + letter-spacing: 0.3px; + white-space: nowrap; + flex-shrink: 0; + } + .mh-chip--pass { background: rgba(40, 168, 70, 0.15); color: rgba(45, 219, 109, 0.80); } + .mh-chip--warn { background: rgba(255, 192, 5, 0.12); color: rgba(255, 201, 40, 0.80); } + .mh-chip--fail { background: rgba(220, 40, 40, 0.12); color: rgba(226, 79, 79, 0.80); } + + .mh-actions { display: flex; gap: 2px; flex-shrink: 0; } + .mh-btn { + background: none; + border: none; + color: var(--text-color, #8c8c8c); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; + transition: background 0.15s, color 0.15s; + } + .mh-btn:hover { + background: #2d2d2d; + color: var(--text-color, #e5e5e5); + } + .mh-btn--sm { font-size: 11px; padding: 2px 6px; margin-left: auto; } + .mh-btn--search { font-size: 12px; padding: 4px 12px; background: rgba(59,130,246,0.15); color: rgb(96,165,250); border: 1px solid rgba(59,130,246,0.3); border-radius: 4px; } + .mh-btn--search:hover { background: rgba(59,130,246,0.25); color: rgb(147,197,253); } + + .mh-body { + padding: 0 !important; + display: flex; + flex-direction: column; + } + + .mh-group { + padding: 8px 16px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-color, #737373); + background: var(--surface-01, #1a1a2e); + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-group:first-child { + padding-top: 10px; + } + + .mh-section { + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-section__summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-color, #737373); + background: var(--surface-01, #1a1a2e); + cursor: pointer; + list-style: none; + user-select: none; + } + .mh-section__summary::-webkit-details-marker, + .mh-section__summary::marker { + display: none; + } + .mh-section__summary:hover { + filter: brightness(1.06); + } + .mh-section__chevron { + margin-left: auto; + font-size: 10px; + transition: transform .2s ease; + color: var(--text-color, #737373); + } + .mh-section[open] > .mh-section__summary .mh-section__chevron { + transform: rotate(180deg); + } + .mh-alert { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + font-size: 13px; + } + .mh-alert--fail { + background: rgba(220, 40, 40, 0.10); + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + color: rgba(232, 114, 114, 0.80); + } + .mh-alert__icon { + font-size: 16px; + flex-shrink: 0; + color: rgba(226, 79, 79, 0.80); + } + .mh-alert__content { + display: flex; + flex-direction: column; + gap: 2px; + } + .mh-alert__content strong { + font-size: 13px; + color: rgba(232, 114, 114, 0.80); + } + .mh-alert__content span { + font-size: 12px; + color: rgba(226, 79, 79, 0.80); + } + + .mh-accordion { + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-accordion:last-child { border-bottom: none; } + + .mh-accordion__summary { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + cursor: pointer; + user-select: none; + list-style: none; + transition: background 0.15s; + border-left: 3px solid transparent; + } + .mh-accordion__summary::-webkit-details-marker, + .mh-accordion__summary::marker { + display: none; + content: ''; + } + .mh-accordion__summary:hover { + background: var(--surface-01, #1a1a2e); + filter: brightness(1.06); + } + .mh-accordion__summary:focus-visible { + outline: 2px solid rgba(0, 127, 255, 0.50); + outline-offset: -2px; + } + + .mh-accordion__summary--pass { border-left-color: rgba(33, 196, 93, 0.80); } + .mh-accordion__summary--fail { border-left-color: rgba(226, 79, 79, 0.80); } + .mh-accordion__summary--warn { border-left-color: rgba(255, 192, 5, 0.80); } + .mh-accordion__summary--na { border-left-color: rgba(89, 89, 89, 0.80); } + + .mh-accordion__icon { font-size: 14px; flex-shrink: 0; } + .mh-accordion__title { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--panel-head-fg, #ccc); + } + + .mh-accordion__chevron { + font-size: 10px; + color: var(--text-color, #737373); + transition: transform 0.2s ease; + flex-shrink: 0; + } + .mh-accordion[open] > .mh-accordion__summary .mh-accordion__chevron { + transform: rotate(180deg); + } + + .mh-accordion__body { + padding: 8px 16px 12px 36px; + background: var(--surface-01, #1a1a2e); + } + + .mh-inline { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + border-left: 3px solid transparent; + } + .mh-inline--pass { border-left-color: rgba(33, 196, 93, 0.80); } + .mh-inline--fail { border-left-color: rgba(226, 79, 79, 0.80); } + .mh-inline--warn { border-left-color: rgba(255, 192, 5, 0.80); } + .mh-inline--na { border-left-color: rgba(89, 89, 89, 0.80); } + .mh-inline__icon { flex-shrink: 0; font-size: 12px; } + .mh-inline__title { + font-weight: 600; + font-size: 13px; + color: var(--panel-head-fg, #d8d7dc); + } + .mh-inline__msg { + flex: 1; + font-size: 12px; + color: var(--text-color, #8c8c8c); + } + + .mh-accordion--alert > .mh-accordion__summary--fail { + border-left-width: 4px; + animation: mh-pulse 1.8s ease-in-out infinite; + } + @keyframes mh-pulse { + 0%, 100% { border-left-color: rgba(226, 79, 79, 0.80); } + 50% { border-left-color: rgba(246, 85, 85, 0.80); } + } + + .mh-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + line-height: 1.4; + } + .mh-badge--pass { background: rgba(40, 168, 70, 0.15); color: rgba(45, 219, 109, 0.80); } + .mh-badge--fail { background: rgba(220, 40, 40, 0.12); color: rgba(226, 79, 79, 0.80); } + .mh-badge--warn { background: rgba(255, 192, 5, 0.12); color: rgba(255, 201, 40, 0.80); } + .mh-badge--na { background: rgba(128, 128, 128, 0.12); color: rgba(140, 140, 140, 0.80); } + .mh-badge--info { background: rgba(40, 168, 70, 0.15); color: rgba(45, 219, 109, 0.80); } + + .mh-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 5px 0; + font-size: 13px; + color: var(--text-color, #b8b8b8); + flex-wrap: wrap; + } + .mh-row + .mh-row { + border-top: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-row__icon { flex-shrink: 0; font-size: 12px; padding-top: 2px; } + .mh-row__label { + font-weight: 600; + color: var(--text-color, #c7c7c7); + min-width: 120px; + flex-shrink: 0; + font-size: 12.5px; + } + .mh-row__msg { flex: 1; font-size: 12.5px; } + + .mh-optional { + font-weight: 400; + font-size: 11px; + color: var(--text-color, #808080); + } + + .mh-detail { + width: 100%; + padding: 4px 0 2px 20px; + display: flex; + flex-direction: column; + gap: 2px; + } + .mh-detail__item { + font-size: 12px; + color: var(--text-color, #949494); + line-height: 1.5; + } + .mh-detail__item strong { + color: var(--text-color, #adadad); + } + + .mh-violations { + width: 100%; + padding: 6px 0 2px 20px; + } + .mh-violations__type { + display: block; + font-size: 11px; + color: var(--text-color, #8c8c8c); + margin-bottom: 4px; + } + .mh-violations__list { + margin: 0; + padding: 0 0 0 16px; + list-style: disc; + } + .mh-violations__list li { + font-size: 12px; + color: #e87272; + line-height: 1.6; + } + + .mh-icon--pass { color: rgba(33, 196, 93, 0.80); } + .mh-icon--fail { color: rgba(226, 79, 79, 0.80); } + .mh-icon--warn { color: rgba(255, 192, 5, 0.80); } + .mh-icon--na { color: rgba(107, 107, 107, 0.80); } + + .mh-message-block { + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-message-block__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px 0; + } + .mh-message-block__icon { + font-size: 13px; + color: rgba(255, 201, 40, 0.80); + flex-shrink: 0; + } + .mh-message-block__title { + font-size: 12px; + font-weight: 600; + color: var(--text-color, #bfbfbf); + text-transform: uppercase; + letter-spacing: 0.5px; + } + .mh-message-block__actions { + display: flex; + gap: 4px; + margin-left: auto; + flex-shrink: 0; + } + .mh-message-block__content { + margin: 8px 16px; + padding: 10px 14px; + font-family: inherit; + font-size: 13px; + line-height: 1.6; + color: var(--text-color, #ccc); + background: rgba(20, 20, 20, 0.40); + border: 1px solid rgba(59, 61, 62, 0.70); + border-radius: 6px; + white-space: pre-wrap; + word-wrap: break-word; + cursor: text; + transition: border-color 0.15s; + } + .mh-message-block__content:hover { + border-color: rgba(95, 97, 98, 0.70); + } + .mh-message-block__editor { + display: none; + margin: 8px 16px; + padding: 10px 14px; + width: calc(100% - 32px); + min-height: 80px; + font-family: inherit; + font-size: 13px; + line-height: 1.6; + color: var(--text-color, #ccc); + background: rgba(20, 20, 20, 0.40); + border: 1px solid rgba(63, 127, 191, 0.50); + border-radius: 6px; + resize: vertical; + outline: none; + box-sizing: border-box; + } + .mh-message-block__hint { + padding: 0 16px 10px; + font-size: 11px; + color: var(--text-color, #737373); + display: flex; + align-items: center; + gap: 6px; + } + .mh-message-block__hint i { + font-size: 11px; + color: var(--text-color, #666); + } + .mh-btn--success { + color: rgba(45, 219, 109, 0.80) !important; + } + .mh-btn--fail { + color: rgba(226, 79, 79, 0.80) !important; + } + + .mh-filename-block { + padding: 10px 16px 12px; + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + display: flex; + flex-direction: column; + gap: 8px; + } + .mh-filename-block__header { + display: flex; + align-items: center; + gap: 8px; + } + .mh-filename-block__icon { + font-size: 12px; + color: rgba(99, 163, 255, 0.80); + } + .mh-filename-block__title { + font-size: 12px; + font-weight: 600; + color: var(--text-color, #bfbfbf); + text-transform: uppercase; + letter-spacing: 0.5px; + } + .mh-filename-uploaded-row, + .mh-filename-input-row { + display: flex; + align-items: center; + gap: 8px; + } + .mh-filename-label { + font-size: 11px; + font-weight: 600; + color: var(--text-color, #737373); + white-space: nowrap; + min-width: 68px; + text-transform: uppercase; + letter-spacing: 0.3px; + } + .mh-filename-code { + font-family: monospace; + font-size: 12px; + color: var(--text-color, #aaa); + word-break: break-all; + } + .mh-filename-input { + flex: 1; + background: rgba(20, 20, 20, 0.40); + border: 1px solid rgba(59, 61, 62, 0.70); + border-radius: 4px; + padding: 5px 8px; + color: var(--text-color, #ccc); + font-size: 12px; + font-family: monospace; + outline: none; + transition: border-color 0.15s; + } + .mh-filename-input:focus { + border-color: rgba(63, 127, 191, 0.60); + } + .mh-filename-result { + min-height: 0; + } + + .mh-diff-hunk { + font-family: monospace; + font-size: 12px; + border-radius: 4px; + overflow: hidden; + border: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-diff-hunk-header { + background: rgba(20, 20, 20, 0.40); + color: var(--text-color, #ccc); + padding: 4px 10px; + font-size: 11px; + border-bottom: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-diff-hunk-at { color: var(--text-color, #ccc); } + .mh-diff-line { + display: flex; + align-items: baseline; + } + .mh-diff-line--del { background: rgba(239, 68, 68, 0.08); } + .mh-diff-line--add { background: rgba(34, 197, 94, 0.08); } + .mh-diff-line--ctx { background: transparent; } + .mh-diff-gutter { + width: 26px; + min-width: 26px; + text-align: center; + font-weight: 700; + font-size: 14px; + padding: 4px 0; + user-select: none; + border-right: 1px solid rgba(59, 61, 62, 0.70); + } + .mh-diff-line--del .mh-diff-gutter { color: rgba(239, 68, 68, 0.65); } + .mh-diff-line--add .mh-diff-gutter { color: rgba(34, 197, 94, 0.65); } + .mh-diff-line--ctx .mh-diff-gutter { color: rgba(140, 140, 140, 0.40); } + .mh-diff-content { + flex: 1; + font-family: monospace; + font-size: 12px; + word-break: break-all; + padding: 4px 10px; + line-height: 1.8; + color: var(--text-color, #ccc); + background: none; + border: none; + } + .mh-diff-token--ctx { color: var(--text-color, #ccc); } + .mh-diff-token--del { + background: rgba(239, 68, 68, 0.28); + color: rgba(254, 202, 202, 1.0); + padding: 0 2px; + } + .mh-diff-token--add { + background: rgba(34, 197, 94, 0.28); + color: rgba(187, 247, 208, 1.0); + padding: 0 2px; + } + .mh-diff-dot { opacity: 0.35; } + .mh-diff-error { + font-size: 12px; + color: rgba(226, 79, 79, 0.80); + } + + /* Advisory status */ + .mh-icon--advisory { color: rgba(96, 165, 250, 0.80); } + .mh-badge--advisory { background: rgba(59, 130, 246, 0.12); color: rgba(96, 165, 250, 0.80); } + .mh-chip--advisory { background: rgba(59, 130, 246, 0.12); color: rgba(96, 165, 250, 0.80); } + .mh-accordion__summary--advisory { border-left-color: rgba(96, 165, 250, 0.80); } + .mh-inline--advisory { border-left-color: rgba(96, 165, 250, 0.80); } + + /* Integration results */ + .mh-integration { + display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; + padding: 8px 12px; border-left: 3px solid transparent; + font-size: 13px; color: var(--text-color, #8c8c8c); + } + .mh-integration + .mh-integration { border-top: 1px solid rgba(255,255,255,0.04); } + .mh-integration--pass { border-left-color: rgba(33, 196, 93, 0.80); } + .mh-integration--fail { border-left-color: rgba(226, 79, 79, 0.80); } + .mh-integration--warn { border-left-color: rgba(255, 192, 5, 0.80); } + .mh-integration--na { border-left-color: rgba(89, 89, 89, 0.80); } + .mh-integration--loading { border-left-color: rgba(96, 165, 250, 0.50); opacity: 0.7; } + .mh-integration__icon { flex-shrink: 0; font-size: 14px; margin-top: 1px; } + .mh-integration__label { font-weight: 600; color: var(--panel-head-fg, #d8d7dc); min-width: 70px; } + .mh-integration__msg { flex: 1; } + .mh-integration__indexer { font-size: 11px; opacity: 0.6; } + .mh-integration__detail { + width: 100%; padding: 6px 10px; margin-top: 4px; + background: rgba(255,255,255,0.02); border-radius: 4px; font-size: 12px; + } + .mh-integration__detail--pass { color: rgba(45, 219, 109, 0.80); } + .mh-integration__detail--fail { color: rgba(226, 79, 79, 0.80); } + .mh-integration__detail--warn { color: rgba(255, 201, 40, 0.80); } + .mh-integration__diffs { margin: 4px 0 0 16px; padding: 0; list-style: disc; font-size: 11px; } + .mh-integration__compare { margin-top: 4px; font-size: 11px; opacity: 0.85; } + .mh-integration__compare div { padding: 2px 0; } + .mh-integration__detail--advisory { color: rgba(96, 165, 250, 0.80); } + .mh-integration__detail--note { color: var(--text-color, #8c8c8c); } + .mh-integration__link { color: rgba(96, 165, 250, 0.70); text-decoration: none; margin-left: 4px; font-size: 11px; } + .mh-integration__link:hover { color: rgba(96, 165, 250, 1); } + .mh-integration__expand { margin-top: 6px; } + .mh-integration__expand-trigger { cursor: pointer; list-style: none; color: rgba(96, 165, 250, 0.70); user-select: none; } + .mh-integration__expand-trigger::-webkit-details-marker, + .mh-integration__expand-trigger::marker { display: none; } + .mh-integration__expand-trigger::before { content: "▸ "; } + .mh-integration__expand[open] > .mh-integration__expand-trigger::before { content: "▾ "; } + .mh-integration__fields { padding: 4px 0 0 8px; } + .mh-field-row { font-size: 11px; padding: 1px 0; color: var(--text-color, #cbd5e1); display: flex; align-items: center; gap: 4px; } + .mh-field-row i { font-size: 10px; width: 12px; text-align: center; } + .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; } + `;; + +/* ======================================================================== + * BOOTSTRAP — Entry point with instance detection and page guard + * ======================================================================== */ + +function main() { + // Page guard: only run on torrent detail pages + if (!/\/torrents\/\d+/.test(window.location.pathname)) return; + + // Instance detection + const hostname = window.location.hostname; + const instanceConfig = INSTANCE_CONFIGS[hostname] || {}; + const instanceName = instanceConfig.name || hostname; + + // Resolve selectors: merge instance overrides onto defaults + _resolvedSelectors = Object.assign({}, DEFAULT_SELECTORS, instanceConfig.selectors || {}); + _resolvedModStatuses = instanceConfig.moderationStatuses || DEFAULT_MOD_STATUSES; + + // Initialize extractors with resolved selectors + E = createExtractors(_resolvedSelectors); + + // Set rules URL from instance config + G.RULES_URL = instanceConfig.rulesUrl || null; + G.UPLOAD_RULES_URL = instanceConfig.uploadRulesUrl || null; + + // DOM fingerprint: verify this looks like a UNIT3D torrent page + const torrentNameEl = document.querySelector(_resolvedSelectors.torrentName); + if (!torrentNameEl) { + console.log("[ModQ Helper] No torrent name element found on", instanceName, "- skipping"); + return; + } + + console.log("[ModQ Helper] Running on", instanceName, "| Config:", Object.keys(instanceConfig.selectors || {}).length, "selector overrides,", "rulesUrl:", instanceConfig.rulesUrl || "(none)"); + + try { + // Extract data from page + const data = { + torrentName: E.getTorrentName(), + tmdbTitle: E.getTmdbTitle(), + tmdbYear: E.getTmdbYear(), + category: E.getCategory(), + type: E.getType(), + resolution: E.getResolution(), + description: E.getDescription(), + hasMediaInfo: E.hasMediaInfo(), + mediaInfoText: E.getMediaInfoText(), + mediaInfoFilename: E.getMediaInfoFilename(), + hasBdInfo: E.hasBdInfo(), + isTV: E.isTV(), + originalLanguage: E.getOriginalLanguage(), + mediaInfoLanguages: E.getMediaInfoLanguages(), + mediaInfoSubtitles: E.getMediaInfoSubtitles(), + fileStructure: E.getFileStructure(), + }; + + console.log("[ModQ Helper] Extracted data:", data); + + // Run all checks + const results = { + tmdbMatch: k.tmdbNameMatch(data.torrentName, data.tmdbTitle), + seasonEpisode: k.seasonEpisodeFormat(data.torrentName, data.isTV), + namingGuide: k.namingGuideCompliance(data.torrentName, data.type, data.mediaInfoText, data.resolution), + elementOrder: k.titleElementOrder(data.torrentName, data.type), + folderStructure: k.movieFolderStructure(data.fileStructure, data.category, data.isTV, data.type), + mediaInfo: k.mediaInfoPresent(data.hasMediaInfo, data.hasBdInfo, data.type, data.torrentName), + audioTags: k.audioTagCompliance(data.torrentName, data.originalLanguage, data.mediaInfoLanguages, data.type, data.mediaInfoText), + subtitleRequirement: k.subtitleRequirement(data.mediaInfoLanguages, data.mediaInfoSubtitles, data.originalLanguage, data.type), + screenshots: k.screenshotCount(data.description), + bannedGroup: k.bannedReleaseGroup(data.torrentName, data.isTV, data.type), + encodeCompliance: k.encodeCompliance(data.torrentName, data.type, data.mediaInfoText), + upscaleDetection: k.upscaleDetection(data.mediaInfoFilename || data.torrentName), + containerFormat: k.containerFormat(data.fileStructure, data.type), + packUniformity: k.packUniformity(data.fileStructure, data.type), + resolutionTypeMatch: k.resolutionTypeMatch(data.torrentName, data.resolution), + }; + + console.log("[ModQ Helper] Check results:", results); + + // DarkPeers-derived checks (gated by features) + const dpFeatures = instanceConfig.features || {}; + if (dpFeatures.dpChecks) { + results.nogroup = dpk.nogroupCheck(data.torrentName, data.mediaInfoFilename, data.fileStructure); + results.unknownLanguage = dpk.unknownLanguageCheck(data.mediaInfoText); + results.extraneousFiles = dpk.extraneousFiles(data.fileStructure, data.category, data.type); + results.categoryTypeMismatch = dpk.categoryTypeMismatch(data.category, data.type, data.torrentName); + results.suspicion = dpk.suspicionHeuristics(data.torrentName, data.type, data.mediaInfoText, data.fileStructure, data.mediaInfoFilename); + results.bannedFilename = dpk.bannedGroupInFilename(data.torrentName, data.mediaInfoFilename, data.fileStructure, data.isTV, data.type); + results.singleFileFolder = dpk.singleFileInFolder(data.fileStructure, data.category, data.type); + results.missingEpisodes = dpk.missingEpisodes(data.fileStructure, data.torrentName, data.isTV); + } + + if (dpFeatures.dpTitleValidation) { + results.dpTitle = TitleValidator.validate(data.torrentName, data.category, data.type, data.mediaInfoText); + } + + // Zenith upload rules + severity policy (gated by features) + if (dpFeatures.zenithRules) { + Object.assign(results, zk.run(data, E.getAudioTracksFromMediaInfo())); + zk.applySeverityPolicy(results); + results._zenithRows = zk.enabledSection(results, Settings.load().checks); + } + + // Inject CSS via GM_addStyle + if (typeof GM_addStyle === "function") { + GM_addStyle(Z); + } else { + const s = document.createElement("style"); + s.textContent = Z; + document.head.appendChild(s); + } + + // Create and inject panel + const _settings = Settings.load(); + const _autoSearch = _settings.prowlarr?.autoSearch !== false; + results._autoSearch = _autoSearch; + const panel = U.createPanel(results); + U.injectPanel(panel); + + console.log("[ModQ Helper] Panel injected successfully"); + + // Async integration pipeline (SRRDB / Prowlarr, opt-in via Settings) + // Extracted into a function so the re-search button can call it again. + const _setIntegration = (id, html) => { + const c = document.querySelector(`#mh-${id}-container`); + if (c) c.innerHTML = html; + }; + const _loadingHtml = (label) => `
    ${label}Checking...
    `; + const runIntegrationSearches = async () => { + if (!dpFeatures.prowlarr && !dpFeatures.srrdb) return; + const settings = Settings.load(); + + // Show loading state + _setIntegration("srrdb", _loadingHtml("SRRDB")); + _setIntegration("prowlarr", _loadingHtml("Prowlarr")); + + // SRRDB: search + file comparison + if (dpFeatures.srrdb && settings.srrdb?.enabled !== false) { + (async () => { + try { + const searchResult = await Integrations.srrdb.search(data.torrentName); + + // If found, fetch files and compare against local file structure + if (searchResult.found && searchResult.release) { + const filesResult = await Integrations.srrdb.getFiles(searchResult.release.release); + if (filesResult.files.length > 0 && data.fileStructure?.files?.length > 0) { + const srrdbNames = new Set(filesResult.files.map(f => (f.name || f).toLowerCase().trim())); + const localNames = data.fileStructure.files.map(f => { + const parts = f.split("/"); + return parts[parts.length - 1].toLowerCase().trim(); + }); + const discrepancies = []; + for (const local of localNames) { + if (!srrdbNames.has(local)) { + const close = [...srrdbNames].find(s => s.replace(/[.\-_ ]/g, "") === local.replace(/[.\-_ ]/g, "")); + if (close) { + discrepancies.push(`"${local}" differs from SRRDB "${close}"`); + } else { + discrepancies.push(`"${local}" not in SRRDB file list`); + } + } + } + searchResult.fileCheck = discrepancies.length === 0 + ? { match: true } + : { match: false, discrepancies }; + } else if (filesResult.error) { + searchResult.fileCheck = { match: false, error: filesResult.error }; + } else { + searchResult.fileCheck = { match: true }; + } + } + + _setIntegration("srrdb", U.renderIntegrationResult("SRRDB", searchResult)); + } catch (err) { + _setIntegration("srrdb", U.renderIntegrationResult("SRRDB", { error: err.message })); + } + })(); + } + + // Prowlarr: confidence-based rename detection via RenameDetector + if (dpFeatures.prowlarr && settings.prowlarr?.enabled && settings.prowlarr?.url) { + (async () => { + try { + 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) { + const preferredIndexers = settings.prowlarr?.preferredIndexers || []; + const ignoredIndexers = settings.prowlarr?.ignoredIndexers || []; + const bestMatch = RenameDetector.findBestMatch(uploadTokens, searchResult.results, preferredIndexers, ignoredIndexers); + + if (bestMatch) { + const assessment = RenameDetector.assessRename(data, bestMatch, tvScope); + searchResult.bestMatch = { + title: bestMatch.title, + indexer: bestMatch.indexer, + size: bestMatch.size, + infoUrl: bestMatch.infoUrl, + uploadedTitle: data.torrentName, + relevanceScore: bestMatch.relevanceScore, + confidence: assessment, + alternatives: bestMatch.alternatives || [], + }; + } + } + + _setIntegration("prowlarr", U.renderIntegrationResult("Prowlarr", searchResult)); + } catch (err) { + _setIntegration("prowlarr", U.renderIntegrationResult("Prowlarr", { error: err.message })); + } + })(); + } else if (dpFeatures.prowlarr) { + _setIntegration("prowlarr", U.renderIntegrationResult("Prowlarr", { notConfigured: true })); + } + }; + + // Wire up search/re-search button + const refreshBtn = document.querySelector("#mh-integration-refresh"); + if (refreshBtn) { + refreshBtn.addEventListener("click", () => { + runIntegrationSearches(); + }); + } + + // Run initial search (only if autoSearch is enabled) + if ((dpFeatures.prowlarr || dpFeatures.srrdb) && _autoSearch) { + runIntegrationSearches(); + } + + } catch (err) { + console.error("[ModQ Helper] Error:", err); + } +} + +main(); +})();