// ==UserScript== // @name UNIT3D Mod Queue Helper — DarkPeers // @namespace https://gitea.computerliebe.org/Procuria/dp-modq-helper // @version 0.6.6 // @description Quality-gate checks for DarkPeers — extended moderation rules, title validation, SRRDB & Prowlarr integrations // @author TQG Contributors // @updateURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js // @downloadURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js // @match https://darkpeers.org/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-HD MA", "DTS-HD HRA", "DTS:X", "DTS-ES", "DTS", "TrueHD", "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: [ "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: [ "Blu-ray", "UHD Blu-ray", "HD DVD", "DVD5", "DVD9", "NTSC DVD", "PAL DVD" ], remux: [ "BluRay", "UHD BluRay", "HDDVD", "NTSC DVD", "PAL DVD" ], encode: [ "BluRay", "UHD BluRay", "DVDRip", "HDDVD", "BDRip", "BRRip", "WEB-DL", "WEBRip", "WEB" ], web: [ "WEB-DL", "WEBRip", "WEB" ], hdtv: [ "HDTV", "SDTV", "UHDTV", "PDTV", "DSR" ] }, // DarkPeers banned groups — sourced from https://darkpeers.org/pages/9 (2026-04-08) bannedGroups: [ "ARCADE", "aXXo", "BANDOLEROS", "BONE", "BRrip", "CM8", "CrEwSaDe", "CTFOH", "dAV1nci", "DNL", "eranger2", "FGT", "FiSTER", "flower", "GalaxyTV", "HD2DVD", "HDTime", "HorribleSubs", "iHYTECH", "ION10", "iPlanet", "KiNGDOM", "LAMA", "MeGusta", "mHD", "mSD", "NaNi", "NhaNc3", "nHD", "nikt0", "nSD", "OFT", "PiTBULL", "PRODJi", "PSA", "RARBG", "Rifftrax", "ROCKETRACCOON", "SANTi", "SasukeducK", "SEEDSTER", "ShAaNiG", "Sicario", "STUTTERSHIT", "Subsplease", "TAoE", "TGALAXY", "TGx", "TORRENTGALAXY", "ToVaR", "Trix", "TSP", "TSPxL", "ViSION", "VXT", "WAF", "WKS", "X0r", "YIFY", "YTS" ], // Groups with conditional exceptions (not in bannedGroups — checked separately) bannedGroupExceptions: { "EVO": { allowedTypes: ["WEB-DL"] }, "HDT": { allowedTypes: ["REMUX"] }, }, exceptionGroupNames: [ "DiscoD HONE", "DarQ HONE", "Eml HDTeam", "BEN THE MEN", "D-Z0N3", "ZØNEHD", "Anime Time", "Project Angel", "Hakata Ramen", "-ZR-" ], 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 = { "darkpeers.org": { name: "DarkPeers", rulesUrl: "https://darkpeers.org/wikis/13", features: { dpChecks: true, dpTitleValidation: true, 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: true }, 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, }, 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; } return false; }, 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; } 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 }; const allowedExts = [".mkv", ".mp4"]; 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 or MP4 container for all non-Full Disc releases", 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 }; } const videoExts = [".mkv", ".mp4", ".ts", ".m2ts", ".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(); 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 }, }; }, }; /* ======================================================================== * 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(); }, /** Codec aliases for fuzzy matching */ _codecAliases: { "x264": "h.264", "h.264": "h.264", "avc": "h.264", "x265": "h.265", "h.265": "h.265", "hevc": "h.265", "dd": "ac-3", "ac-3": "ac-3", "ddp": "e-ac-3", "dd+": "e-ac-3", "e-ac-3": "e-ac-3", "truehd": "truehd", "atmos": "truehd", }, /** * tokenize — Parse a release name into structured fields. * Wraps H.extractTitleElements and adds titleName + container. */ tokenize(name) { if (!name) return { raw: "", titleName: "", elements: [], positions: {}, group: null, year: null, resolution: null, source: null, vcodec: null, acodec: null, hdr: null, container: null }; let raw = name; // Strip file extension let container = null; const extMatch = raw.match(/\.(mkv|mp4|avi|wmv|m4v|ts|m2ts|mov|flv|webm)$/i); if (extMatch) { container = extMatch[1].toLowerCase(); raw = raw.slice(0, -extMatch[0].length); } // Normalize separators before parsing — ensures dot-separated filenames // (e.g. DTS-HD.MA.5.1) parse identically to space-separated titles // (e.g. DTS-HD MA 5.1). Without this, multi-word codecs like DTS-HD MA // only match when space-separated. // Preserve dots in codec patterns like H.264, H.265 where the dot is // semantically meaningful (letter.digits pattern). const normalized = raw .replace(/[_]/g, " ") .replace(/(?<=[A-Za-z])\.(?=\d{3})/g, "\x00") // protect codec dots (H.264, H.265) .replace(/(?<=\d)\.(?=\d)/g, "\x01") // protect channel dots (5.1, 7.1, 2.0) .replace(/\./g, " ") .replace(/\x00/g, ".") .replace(/\x01/g, "."); const { elements, positions } = H.extractTitleElements(normalized); // Extract individual fields from elements array const fieldOf = (type) => { const el = elements.find(e => e.type === type); return el ? el.value : null; }; // Derive titleName: text before the first structural token let titleEnd = raw.length; for (const el of elements) { if (el.position < titleEnd) titleEnd = el.position; } const titleName = raw.slice(0, titleEnd).replace(/[.\-_]/g, " ").replace(/\s+/g, " ").trim(); return { raw: name, titleName, elements, positions, group: fieldOf("group"), year: fieldOf("year"), resolution: fieldOf("resolution"), source: fieldOf("source") || fieldOf("type"), vcodec: fieldOf("vcodec"), acodec: fieldOf("acodec"), hdr: fieldOf("hdr"), container, }; }, /** * _jaccardWords — Jaccard similarity on normalized word sets. */ _jaccardWords(a, b) { if (!a || !b) return 0; const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean)); const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean)); if (wordsA.size === 0 && wordsB.size === 0) return 1; if (wordsA.size === 0 || wordsB.size === 0) return 0; let intersection = 0; for (const w of wordsA) if (wordsB.has(w)) intersection++; const union = new Set([...wordsA, ...wordsB]).size; return union === 0 ? 0 : intersection / union; }, /** * _normalizeCodec — Normalize a codec string via alias table. */ _normalizeCodec(codec) { if (!codec) return null; const key = codec.toLowerCase().replace(/[.\s-]/g, "").replace("dts", "dts"); // Direct alias lookup for (const [alias, canonical] of Object.entries(this._codecAliases)) { if (key === alias.replace(/[.\s-]/g, "")) return canonical; } return codec.toLowerCase(); }, /** * scoreMatch — Weighted field comparison between upload and result tokens. * Group is excluded from relevance score (tracked separately). */ scoreMatch(uploadTokens, resultTokens) { const weights = { titleName: 3.0, year: 2.0, resolution: 1.5, source: 1.0, vcodec: 1.0, acodec: 0.5 }; let totalWeight = 0; let weightedSum = 0; const fieldScores = {}; // titleName — Jaccard similarity (strip AKA for fair comparison) const titleScore = this._jaccardWords(this._stripAka(uploadTokens.titleName), this._stripAka(resultTokens.titleName)); fieldScores.titleName = titleScore; weightedSum += titleScore * weights.titleName; totalWeight += weights.titleName; // Exact-match fields const exactFields = ["year", "resolution"]; for (const f of exactFields) { if (uploadTokens[f] || resultTokens[f]) { const score = uploadTokens[f] && resultTokens[f] && uploadTokens[f].toLowerCase() === resultTokens[f].toLowerCase() ? 1.0 : 0.0; fieldScores[f] = score; weightedSum += score * weights[f]; totalWeight += weights[f]; } } // Source — exact match if (uploadTokens.source || resultTokens.source) { const score = uploadTokens.source && resultTokens.source && uploadTokens.source.toLowerCase().replace(/[.\s-]/g, "") === resultTokens.source.toLowerCase().replace(/[.\s-]/g, "") ? 1.0 : 0.0; fieldScores.source = score; weightedSum += score * weights.source; totalWeight += weights.source; } // Codec fields — alias-aware for (const f of ["vcodec", "acodec"]) { if (uploadTokens[f] || resultTokens[f]) { const a = this._normalizeCodec(uploadTokens[f]); const b = this._normalizeCodec(resultTokens[f]); const score = a && b && a === b ? 1.0 : 0.0; fieldScores[f] = score; weightedSum += score * weights[f]; totalWeight += weights[f]; } } const relevanceScore = totalWeight > 0 ? weightedSum / totalWeight : 0; const groupMatch = !!(uploadTokens.group && resultTokens.group && uploadTokens.group.toLowerCase() === resultTokens.group.toLowerCase()); return { relevanceScore, fieldScores, groupMatch }; }, /** * _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); const titleSim = this._jaccardWords(titleA, titleB); 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}}; /* ======================================================================== * 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}`:""}
`,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"),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].map(i=>i.status),a=this.worstStatus(s),o=s.filter(i=>i!=="na"),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);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?``:""}
    `}}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; // 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-specific 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); } // 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 (DarkPeers) // 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(); })();