From f1597a78d85093f4f9bd153195ebcf8a1c4cb67e Mon Sep 17 00:00:00 2001 From: Procuria Date: Sun, 5 Apr 2026 22:46:36 +0200 Subject: [PATCH] Initial release v0.1.0 --- CHANGELOG.md | 20 + modq-helper-darkpeers.user.js | 2459 +++++++++++++++++++++++++++++++++ 2 files changed, 2479 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 modq-helper-darkpeers.user.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6351d75 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to the DarkPeers Mod Queue Helper will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-04-05 + +### Added + +- **Quality-gate rule engine** — automated checks for resolution, audio/video codecs, HDR formats, sources, screenshots, media info, and subtitles +- **DarkPeers-specific checks** — extended moderation rules tailored to DarkPeers upload standards (banned groups, tiered release groups, naming conventions) +- **Title validator** — template-based title validation enforcing correct element order for Full Disc/Remux, Encode, and WEB releases +- **SRRDB integration** — scene release verification via SRRDB API lookup +- **Prowlarr integration** — cross-seed and duplicate detection through Prowlarr search +- **Cross-seed rename detection** — flags potential cross-seed renames by comparing against Prowlarr results +- **Corrective message builder** — generates pre-formatted moderation messages listing detected issues +- **Settings panel** — persistent user configuration via Tampermonkey storage (Prowlarr URL/API key, rules URL, feature toggles) +- **Configurable selectors** — DOM abstraction layer supporting custom CSS selectors per instance +- **Multi-instance support** — instance configuration system for site-specific feature flags and settings diff --git a/modq-helper-darkpeers.user.js b/modq-helper-darkpeers.user.js new file mode 100644 index 0000000..5cafb82 --- /dev/null +++ b/modq-helper-darkpeers.user.js @@ -0,0 +1,2459 @@ +// ==UserScript== +// @name UNIT3D Mod Queue Helper — DarkPeers +// @namespace https://gitea.computerliebe.org/Procuria/dp-modq-helper +// @version 0.1.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;Tp&&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;ib.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||"",s=n||"",a={NTSC:["480i","480p"],PAL:["576i","576p"]},o=g.validResolutions.find(f=>t.includes(f)),c=t.match(/\b(NTSC|PAL)\b/i),r=!!c,d=g.validResolutions.includes(s);if(!o&&r){const f=c[1].toUpperCase(),A=a[f]||[];return A.includes(s)?{status:"pass",message:`${f} source correctly tagged as ${s}`}:s==="Other"?{status:"pass",message:`${f} source tagged as Other`}:{status:"warn",message:`${f} source expected ${A.join(" or ")} (or Other), found: ${s}`}}return o?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}}:s==="Other"?{status:"pass",message:"Non-standard resolution correctly tagged as Other"}:!d&&s?{status:"warn",message:`Non-standard resolution "${s}" should use "Other" resolution type`}:{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(); +})();