// ==UserScript== // @name UNIT3D Mod Queue Helper — DarkPeers // @namespace https://gitea.computerliebe.org/Procuria/dp-modq-helper // @version 0.3.0 // @description Quality-gate checks for DarkPeers — extended moderation rules, title validation, SRRDB & Prowlarr integrations // @author TQG Contributors // @updateURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js // @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" ] }, bannedGroups: [ "1000", "24xHD", "41RGB", "4K4U", "AG", "AOC", "AROMA", "aXXo", "AZAZE", "BARC0DE", "BAUCKLEY", "BdC", "beAst", "BRiNK", "BTM", "C1NEM4", "C4K", "CDDHD", "CHAOS", "CHD", "CHX", "CiNE", "COLLECTiVE", "CREATiVE24", "CrEwSaDe", "CTFOH", "d3g", "DDR", "DepraveD", "DNL", "DRX", "EPiC", "EuReKA", "EVO", "FaNGDiNG0", "Feranki1980", "FGT", "flower", "FMD", "FRDS", "FZHD", "GalaxyRG", "GHD", "GHOSTS", "GPTHD", "HDHUB4U", "HDS", "HDT", "HDTime", "HDWinG", "HiQVE", "iNTENSO", "iPlanet", "iVy", "jennaortegaUHD", "JFF", "KC", "KiNGDOM", "KIRA", "L0SERNIGHT", "LAMA", "Leffe", "Liber8", "LiGaS", "LT", "LUCY", "MarkII", "MeGusta", "Mesc", "mHD", "mSD", "MT", "MTeam", "MySiLU", "NhaNc3", "nhanc3", "nHD", "nikt0", "nSD", "OFT", "Paheph", "PATOMiEL", "PRODJi", "PSA", "PTNK", "RARBG", "RDN", "Rifftrax", "RU4HD", "SANTi", "SasukeducK", "Scene", "SHD", "ShieldBearer", "STUTTERSHIT", "SUNSCREEN", "TBS", "TEKNO3D", "TG", "Tigole", "TIKO", "VIDEOHOLE", "VISIONPLUSHDR", "WAF", "WiKi", "worldmkv", "x0r", "XLF", "YIFY", "YTSMX", "Zero00", "Zeus" ], 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", "img.luminarr.me", "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 }, 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
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"); const val = el.type === "checkbox" ? el.checked : el.type === "number" ? parseInt(el.value, 10) : el.value; 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},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 = E.isTV(), 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)), S = m ? null : 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 }), o.includes("2160p") || o.includes("4320p")) { const R = 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 (y) { const i = t.filter(m => !C(m)); let l = ""; x >= 2 && i.length > 0 ? i.length > 1 ? l = `. Found ${x} audio tracks (${t.join(", ")}). Use "Multi" instead` : /^[a-z]{2,3}$/i.test(i[0]) ? l = `. Found ${x} audio tracks (${t.join(", ")}), use "{Language_Name} Multi" instead` : l = `. Found ${x} audio tracks (${t.join(", ")}). Use "${i[0]} Multi" instead` : x >= 2 ? l = `. Found ${x} audio tracks but non-English languages not recognized by MediaInfo parser. Use "{Language} Multi" instead (or "Multi" if 3+ languages)` : l = '. Only 1 recognized language found — non-English track may not be recognized by MediaInfo parser. Use "{Language} Multi" if a second language is present (or "Multi" if 3+ languages)', r.push({ name: "Language Tags", status: "fail", message: `Dual-Audio is reserved for non-English original content with an English dub${l}` }) } else if (x > 2) r.push({ name: "Language Tags", status: "fail", message: `Tagged Dual-Audio but found ${x} languages. Should be "Multi"` }); else if (x < 2) r.push({ name: "Language Tags", status: "fail", message: `Tagged Dual-Audio but found only ${x} language` }); else { const i = t.some(C), l = t.some(h); i ? l ? r.push({ name: "Language Tags", status: "pass", message: `Dual-Audio correct (English + ${p})` }) : r.push({ name: "Language Tags", status: "warn", message: `Dual-Audio implies Original Language (${p}) present` }) : r.push({ name: "Language Tags", status: "fail", message: "Dual-Audio requires English track" }) } 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 = 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 === "FLAC" || m === "Opus" || m === "LPCM") { const N = m === "LPCM" && b; !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 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 o = a.filter(r => !r.toLowerCase().endsWith(".mkv")); return o.length === 0 ? { status: "pass", message: `MKV container verified (${a.length} video file${a.length>1?"s":""})`, details: null } : { status: "fail", message: `Non-MKV container detected: ${[...new Set(o.map(r=>r.split(".").pop().toUpperCase()))].join(", ")}`, details: { expected: "MKV 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 x = ["720p", "1080i", "1080p", "2160p", "4320p"], p = g.validResolutions.find(i => o.includes(i)); if (p ? x.includes(p) ? a.push({ name: "Resolution", status: "pass", message: `Found: ${p}` }) : a.push({ name: "Resolution", status: "fail", message: `Found ${p} — encodes must be 720p or greater` }) : a.push({ name: "Resolution", status: "warn", 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); return g.bannedGroups.some(r => r.toLowerCase() === a.toLowerCase()) ? { status: "fail", group: a, message: `BANNED GROUP: ${a}`, alert: !0, tieredInfo: o } : { status: "pass", group: a, message: `Release Group: ${a}`, alert: !1, tieredInfo: o } } }; 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: { /** * search — look up a release name on SRRDB. */ async search(releaseName) { if (!releaseName) return { found: false, release: null, error: "No release name provided" }; try { const encoded = encodeURIComponent(releaseName.replace(/\s+/g, ".")); 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, error: null, }; } 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: 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 }; } }, }, }; /* ======================================================================== * 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){const D=[...s].sort((x,p)=>{const y=x.split(".").map(Number),C=p.split(".").map(Number);for(let h=0;h`§${x}`).join(", ");b+=` Please review the following [url=${this.RULES_URL}]Upload Rules[/url]: ${D}.`}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}`:""}
    ${n.map(s=>`
  • ${s}
  • `).join("")}
`,this.accordion("order","Title Element Order",e.status,t)},buildMultiCheck(e,n,t){if(t.checks&&t.checks.length>1){const s=t.checks.map(a=>this.checkRow(a.status,a.name,a.message)).join("");return this.accordion(e,n,t.status,s)}return t.checks&&t.checks.length===1?this.inlineRow(e,t.checks[0].status,n,t.checks[0].message):this.inlineRow(e,t.status,n,t.message)},buildBannedGroupAlert(e){return e.alert?`
Banned Release Group Detected ${e.message}
`:""},groupHeading(e){return`
${e}
`},sectionGroup(e,n,t){const s=t.map(f=>f.status).filter(f=>f!=="na"),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)}
`,n},injectPanel(e){const n=E.getModerationPanel();if(n)n.parentNode.insertBefore(e,n);else{const t=document.querySelector(_resolvedSelectors.torrentTags);t&&t.parentNode.insertBefore(e,t.nextSibling)}this.attachEvents(e)},attachEvents(e){const n=e.querySelector("#mh-toggle-all");if(n){let D=!1;n.addEventListener("click",()=>{D=!D,e.querySelectorAll(".mh-accordion, .mh-section").forEach(x=>x.open=D),n.querySelector("i").className=D?"fas fa-angles-up":"fas fa-angles-down",n.title=D?"Collapse all sections":"Expand all sections"})}const t=e.querySelector("#mh-corrective-text"),s=e.querySelector("#mh-corrective-editor");t&&s&&(t.addEventListener("click",()=>{t.style.display="none",s.style.display="block",s.focus()}),s.addEventListener("blur",()=>{t.textContent=s.value,t.style.display="",s.style.display=""}));const a=()=>s?s.value:"",o=e.querySelector("#mh-copy-message");o&&o.addEventListener("click",()=>{const D=a();navigator.clipboard.writeText(D).then(()=>{const x=o.querySelector("i");x.className="fas fa-check",o.classList.add("mh-btn--success"),setTimeout(()=>{x.className="fas fa-copy",o.classList.remove("mh-btn--success")},2e3)})});const c=D=>{const x=document.querySelectorAll(_resolvedSelectors.moderationForms);for(const p of x){const y=p.querySelector(_resolvedSelectors.moderationStatus);if(y&&y.value===D)return p.querySelector(_resolvedSelectors.moderationMessage)}return null},r=(D,x)=>{const p=e.querySelector(D);p&&p.addEventListener("click",()=>{const y=a(),C=c(x);if(C){C.value=y,C.dispatchEvent(new Event("input",{bubbles:!0}));const h=p.querySelector("i");h.className="fas fa-check",p.classList.add("mh-btn--success"),setTimeout(()=>{h.className="fas fa-paste",p.classList.remove("mh-btn--success")},2e3)}else{const h=p.querySelector("i");h.className="fas fa-xmark",p.classList.add("mh-btn--fail"),setTimeout(()=>{h.className="fas fa-paste",p.classList.remove("mh-btn--fail")},2e3)}})};r("#mh-fill-postpone",_resolvedModStatuses.postpone),r("#mh-fill-reject",_resolvedModStatuses.reject);const d=e.querySelector("#mh-filename-compare-btn"),f=e.querySelector("#mh-filename-block"),A=e.querySelector("#mh-filename-uploaded"),T=e.querySelector("#mh-filename-input"),b=e.querySelector("#mh-filename-run"),u=e.querySelector("#mh-filename-result");if(d&&f&&d.addEventListener("click",()=>{const D=f.style.display!=="none";if(f.style.display=D?"none":"",d.classList.toggle("mh-btn--active",!D),!D&&A){const x=E.getMediaInfoFilename()||"(not found)";A.textContent=x}}),b&&T&&u){const D=()=>{const x=T.value.trim();if(!x){u.innerHTML='Please enter a reference filename.';return}const p=E.getMediaInfoFilename();if(!p||p==="(not found)"){u.innerHTML='Could not read MediaInfo filename from page.';return}const y=z.diff(x,p);u.innerHTML=z.renderDiff(y)};b.addEventListener("click",D),T.addEventListener("keydown",x=>{x.key==="Enter"&&D()})}},_buildIntegrationPlaceholders(results){if(!results.nogroup&&!results.dpTitle)return"";return`
External Integrations
SRRDBChecking scene database...
ProwlarrSearching indexers...
`},renderIntegrationResult(name,result){const esc=(s)=>String(s||"").replace(/&/g,"&").replace(//g,">");if(result.error){return`
${esc(name)}${esc(result.error)}
`}if(result.notConfigured){return`
${esc(name)}Not configured — open ModQ Helper Settings
`}if(name==="SRRDB"){if(!result.found){return`
SRRDBNot found — may not be a scene release
`}const relName=esc(result.release?.release||"Unknown");let fileHtml="";if(result.fileCheck){if(result.fileCheck.match){fileHtml=`
File/folder names match SRRDB record
`}else if(result.fileCheck.error){fileHtml=`
Could not verify files: ${esc(result.fileCheck.error)}
`}else{const diffs=(result.fileCheck.discrepancies||[]).map(d=>`
  • ${esc(d)}
  • `).join("");fileHtml=`
    File names differ from SRRDB record — possible rename (REJECT per A1.1)${diffs?`
      ${diffs}
    `:""}
    `}}return`
    SRRDBScene release found: ${relName}${fileHtml}
    `}if(name==="Prowlarr"){if(!result.found){return`
    ProwlarrRelease not indexed — may be new or not tracked
    `}const count=result.results?.length||0;const bestMatch=result.bestMatch;let matchHtml="";if(bestMatch){matchHtml+=`
    Best match: ${esc(bestMatch.title)} [${esc(bestMatch.indexer)}]
    `;if(bestMatch.renameWarning){matchHtml+=`
    Title differs from indexed release — possible rename (REJECT per A1.1)
    Uploaded: ${esc(bestMatch.uploadedTitle)}
    Indexed: ${esc(bestMatch.title)}
    `}else{matchHtml+=`
    Title consistent with indexed release
    `}if(bestMatch.crossSeed){const cs=bestMatch.crossSeed;if(cs.issues&&cs.issues.length>0){const csItems=cs.issues.map(i=>{if(i.type==="folder")return`
  • Folder renamed: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `;if(i.type==="filename")return`
  • File renamed: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `;return`
  • File mismatch: expected ${esc(i.expected)}, found ${esc(i.found)}
  • `}).join("");matchHtml+=`
    Cross-seed broken: files/folders have been renamed from the original release. A new torrent file is needed for cross-seed to work. (REJECT per A1.1)
      ${csItems}
    `}else{matchHtml+=`
    Cross-seed compatible — file/folder names match indexed release
    `}}}const hasCrossSeedIssue=bestMatch?.crossSeed?.issues?.length>0;const prowlStatus=hasCrossSeedIssue?"fail":bestMatch?.renameWarning?"warn":"pass";const prowlIcon=hasCrossSeedIssue?"times-circle mh-icon--fail":bestMatch?.renameWarning?"exclamation-triangle mh-icon--warn":"check-circle mh-icon--pass";return`
    ProwlarrFound on ${count} indexer(s)${matchHtml}
    `}return`
    ${esc(name)}${result.found?"Found":"Not found"}
    `}}; /* ======================================================================== * 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-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; } `;; /* ======================================================================== * 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 panel = U.createPanel(results); U.injectPanel(panel); console.log("[ModQ Helper] Panel injected successfully"); // Async integration pipeline (DarkPeers) if (dpFeatures.prowlarr || dpFeatures.srrdb) { const settings = Settings.load(); // 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) { // Compare: normalize filenames for comparison const srrdbNames = new Set(filesResult.files.map(f => (f.name || f).toLowerCase().trim())); const localNames = data.fileStructure.files.map(f => { const parts = f.split("/"); return parts[parts.length - 1].toLowerCase().trim(); }); const discrepancies = []; for (const local of localNames) { if (!srrdbNames.has(local)) { // Check if it's close but renamed const close = [...srrdbNames].find(s => s.replace(/[.\-_ ]/g, "") === local.replace(/[.\-_ ]/g, "")); if (close) { discrepancies.push(`"${local}" differs from SRRDB "${close}"`); } 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 }; // No files to compare = no mismatch } } const el = document.querySelector('[data-integration="srrdb"]'); if (el) el.outerHTML = U.renderIntegrationResult("SRRDB", searchResult); } catch (err) { const el = document.querySelector('[data-integration="srrdb"]'); if (el) el.outerHTML = U.renderIntegrationResult("SRRDB", { error: err.message }); } })(); } // Prowlarr: search + rename / cross-seed detection if (dpFeatures.prowlarr && settings.prowlarr?.enabled && settings.prowlarr?.url) { (async () => { try { const searchResult = await Integrations.prowlarr.search(settings.prowlarr, data.torrentName); if (searchResult.found && searchResult.results.length > 0) { // --- Best match by word-overlap similarity --- const normalize = (t) => t.toLowerCase().replace(/[.\-_]/g, " ").replace(/\s+/g, " ").trim(); const uploadedNorm = normalize(data.torrentName); let bestScore = -1; let bestMatch = null; for (const r of searchResult.results) { const rNorm = normalize(r.title || ""); const uploadedWords = new Set(uploadedNorm.split(" ")); const rWords = rNorm.split(" "); const matchCount = rWords.filter(w => uploadedWords.has(w)).length; const score = matchCount / Math.max(uploadedWords.size, rWords.length); if (score > bestScore) { bestScore = score; bestMatch = { title: r.title, indexer: r.indexer, size: r.size, score }; } } if (bestMatch) { bestMatch.uploadedTitle = data.torrentName; bestMatch.renameWarning = bestMatch.score < 0.6; // --- Cross-seed / file rename detection --- // Compare local filenames and folder name against the Prowlarr // best-match title to detect renames that break cross-seed. // Cross-seed tools match on exact folder/file names; if the // uploader renamed files, cross-seeding is impossible. const crossSeed = { issues: [] }; // Expected base: the Prowlarr title IS the expected folder/file stem const expectedStem = (bestMatch.title || "").replace(/\.[a-z0-9]{2,4}$/i, ""); const expectedNorm = normalize(expectedStem); // Check folder name if present if (data.fileStructure?.folderName) { const folderNorm = normalize(data.fileStructure.folderName); if (expectedNorm && folderNorm !== expectedNorm) { // Tolerate minor separator differences (. vs space) const stripped = (s) => s.replace(/[\s.\-_]/g, "").toLowerCase(); if (stripped(data.fileStructure.folderName) !== stripped(expectedStem)) { crossSeed.issues.push({ type: "folder", expected: expectedStem, found: data.fileStructure.folderName, }); } } } // Check MediaInfo filename (most reliable single-file indicator) const miFilename = data.mediaInfoFilename; if (miFilename && expectedStem) { const miStem = miFilename.replace(/\.[a-z0-9]{2,4}$/i, ""); const stripped = (s) => s.replace(/[\s.\-_]/g, "").toLowerCase(); if (stripped(miStem) !== stripped(expectedStem)) { crossSeed.issues.push({ type: "filename", expected: expectedStem, found: miStem, }); } } // Check individual files in the file list if (data.fileStructure?.files?.length > 0 && data.fileStructure.files.length <= 5) { // For small packs, check each file starts with the expected stem const stripped = (s) => s.replace(/[\s.\-_]/g, "").toLowerCase(); const expStripped = stripped(expectedStem); for (const f of data.fileStructure.files) { const fname = f.split("/").pop().replace(/\.[a-z0-9]{2,4}$/i, ""); if (!stripped(fname).startsWith(expStripped.substring(0, Math.min(20, expStripped.length)))) { // Only flag if the file doesn't even share the first ~20 chars crossSeed.issues.push({ type: "file", expected: expectedStem + ".*", found: fname, }); break; // One example is enough } } } bestMatch.crossSeed = crossSeed; searchResult.bestMatch = bestMatch; } } const el = document.querySelector('[data-integration="prowlarr"]'); if (el) el.outerHTML = U.renderIntegrationResult("Prowlarr", searchResult); } catch (err) { const el = document.querySelector('[data-integration="prowlarr"]'); if (el) el.outerHTML = U.renderIntegrationResult("Prowlarr", { error: err.message }); } })(); } else if (dpFeatures.prowlarr) { const el = document.querySelector('[data-integration="prowlarr"]'); if (el) el.outerHTML = U.renderIntegrationResult("Prowlarr", { notConfigured: true }); } } } catch (err) { console.error("[ModQ Helper] Error:", err); } } main(); })();