2460 lines
170 KiB
JavaScript
2460 lines
170 KiB
JavaScript
// ==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 = `
|
||
<div class="modq-settings-overlay"></div>
|
||
<div class="modq-settings-dialog">
|
||
<h2 style="margin:0 0 16px;font-size:18px;color:#e2e8f0;">ModQ Helper Settings</h2>
|
||
|
||
<fieldset style="border:1px solid #334155;border-radius:6px;padding:12px;margin-bottom:12px;">
|
||
<legend style="color:#94a3b8;font-size:13px;padding:0 6px;">General</legend>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
<input type="checkbox" data-setting="ui.autoExpand" ${s.ui.autoExpand ? "checked" : ""}>
|
||
Auto-expand panel on load
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
<input type="checkbox" data-setting="ui.debugMode" ${s.ui.debugMode ? "checked" : ""}>
|
||
Debug mode (extra console output)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
<input type="checkbox" data-setting="ui.showAdvisory" ${s.ui.showAdvisory ? "checked" : ""}>
|
||
Show advisory-level results
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
Min screenshots:
|
||
<input type="number" data-setting="minScreenshots" value="${s.minScreenshots}" min="0" max="20"
|
||
style="width:50px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:2px 6px;">
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset style="border:1px solid #334155;border-radius:6px;padding:12px;margin-bottom:12px;">
|
||
<legend style="color:#94a3b8;font-size:13px;padding:0 6px;">Integrations</legend>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
<input type="checkbox" data-setting="srrdb.enabled" ${s.srrdb.enabled ? "checked" : ""}>
|
||
SRRDB lookup
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
<input type="checkbox" data-setting="prowlarr.enabled" ${s.prowlarr.enabled ? "checked" : ""}>
|
||
Prowlarr search
|
||
</label>
|
||
<label style="display:flex;flex-direction:column;gap:4px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
Prowlarr URL:
|
||
<input type="text" data-setting="prowlarr.url" value="${s.prowlarr.url}" placeholder="http://localhost:9696"
|
||
style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:4px 8px;font-size:12px;">
|
||
</label>
|
||
<label style="display:flex;flex-direction:column;gap:4px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
Prowlarr API Key:
|
||
<input type="password" data-setting="prowlarr.apiKey" value="${s.prowlarr.apiKey}" placeholder="API key"
|
||
style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:4px 8px;font-size:12px;">
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset style="border:1px solid #334155;border-radius:6px;padding:12px;margin-bottom:12px;">
|
||
<legend style="color:#94a3b8;font-size:13px;padding:0 6px;">Check Toggles</legend>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;">
|
||
${Object.keys(s.checks).map(c => `
|
||
<label style="display:flex;align-items:center;gap:6px;color:#cbd5e1;font-size:12px;">
|
||
<input type="checkbox" data-setting="checks.${c}" ${s.checks[c] ? "checked" : ""}>
|
||
${c}
|
||
</label>
|
||
`).join("")}
|
||
</div>
|
||
</fieldset>
|
||
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px;">
|
||
<button id="modq-settings-cancel" style="padding:6px 16px;background:#334155;color:#e2e8f0;border:none;border-radius:4px;cursor:pointer;font-size:13px;">Cancel</button>
|
||
<button id="modq-settings-save" style="padding:6px 16px;background:#3b82f6;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">Save</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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=/<img[^>]+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("(?<![a-zA-Z])"+i+"(?![a-zA-Z])","i"),m=a.match(l);if(m){o("acodec",m[0],m.index);break}}const T=a.match(/\b(\d{1,2}\.\d)\b/);T&&o("channels",T[0],T.index);const b=a.match(/\bAtmos\b/i),u=a.match(/\bAuro(?:3D)?\b/i);b?o("object",b[0],b.index):u&&o("object",u[0],u.index);const D=[...g.sources.fullDisc,...g.sources.remux,...g.sources.encode,...g.sources.web,...g.sources.hdtv],x=[...new Set(D)].sort((h,i)=>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,"<").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",`<span class="${s}">${this.escapeHtml(t.text)}</span>`}).join("")},renderDiff(e){const n=this.matchPercent(e),t=e.some(c=>c.type!=="match"),s=`
|
||
<div class="mh-diff-hunk-header">
|
||
<span class="mh-diff-hunk-at">@@</span> filename comparison — ${n}% match <span class="mh-diff-hunk-at">@@</span>
|
||
</div>`;if(!t)return`<div class="mh-diff-hunk">
|
||
${s}
|
||
<div class="mh-diff-line mh-diff-line--ctx">
|
||
<span class="mh-diff-gutter"> </span>
|
||
<code class="mh-diff-content">${this.renderLine(e,"ctx")}</code>
|
||
</div>
|
||
</div>`;const a=e.filter(c=>c.type!=="extra"),o=e.filter(c=>c.type!=="missing");return`<div class="mh-diff-hunk">
|
||
${s}
|
||
<div class="mh-diff-line mh-diff-line--del" title="Reference filename">
|
||
<span class="mh-diff-gutter">-</span>
|
||
<code class="mh-diff-content">${this.renderLine(a,"del")}</code>
|
||
</div>
|
||
<div class="mh-diff-line mh-diff-line--add" title="Uploaded filename">
|
||
<span class="mh-diff-gutter">+</span>
|
||
<code class="mh-diff-content">${this.renderLine(o,"add")}</code>
|
||
</div>
|
||
</div>`}};
|
||
|
||
/* ========================================================================
|
||
* 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("(?<![a-zA-Z])"+P+"(?![a-zA-Z])","i").test(o)){h=R;break}}a.checks.push({name:"Audio Codec",status:h?"pass":"fail",message:h?`Found: ${h}`:"No audio codec found",required:!0});const i=o.match(/\b(\d{1,2}\.\d)\b/);a.checks.push({name:"Channels",status:i?"pass":"fail",message:i?`Found: ${i[1]}`:"No channel config found (e.g., 5.1)",required:!0});const l=H.extractReleaseGroup(o);a.checks.push({name:"Release Group",status:l?"pass":b?"na":"fail",message:l?`Found: ${l}`:b?"N/A for Full Disc":"No release group tag found (should end with -GROUP)",required:!0});const m=g.fullDiscTypes.some(R=>n?.includes(R)),S=m?null:H.detectAudioObject(t),N=/Atmos/i.test(o),$=/Auro/i.test(o);let M="pass",B="No object audio detected";S==="Atmos"?N?(M="pass",B="Atmos detected & in title"):(M="warn",B="Atmos detected in MediaInfo but missing from Title"):S==="Auro3D"?$?(M="pass",B="Auro3D detected & in title"):(M="warn",B="Auro3D detected in MediaInfo but missing from Title"):(N||$)&&(m?(M="pass",B=`${N?"Atmos":"Auro3D"} in title (Full Disc - MediaInfo not validated)`):(M="warn",B="Object tag in title but not confirmed in MediaInfo")),(S||N||$)&&a.checks.push({name:"Audio Object",status:M,message:B,required:!!S});const I=this.checkSourceForType(o,n);a.checks.push(I);const J=[...g.validVideoCodecs].sort((R,P)=>P.length-R.length);let V=null;for(const R of J)if(new RegExp(R.replace(/[.]/g,"\\.?"),"i").test(o)){V=R;break}const K=g.fullDiscTypes.some(R=>n?.includes(R))||g.remuxTypes.some(R=>n?.toUpperCase().includes(R.toUpperCase()));if(a.checks.push({name:"Video Codec",status:V?"pass":K?"na":"warn",message:V?`Found: ${V}`:K?"N/A for Full Disc/REMUX":"No video codec found (may be implied)",required:!0}),o.includes("2160p")||o.includes("4320p")){const R=E.getHdrFromMediaInfo(),P=[...g.hdrFormats].sort((L,O)=>O.length-L.length);let v=null;for(const L of P)if(new RegExp("(?:^|\\s)"+L.replace(/[+]/g,"\\+")+"(?:\\s|$)","i").test(o)){v=L.toUpperCase();break}let _="pass",F="";const q=/\bHDR10\b/i.test(o)&&!/\bHDR10\+/i.test(o);if(m)q&&(!v||v==="HDR10")?(_="fail",F='"HDR10" in title should be renamed to "HDR"'):v?(_="pass",F=`HDR in title: ${v} (Full Disc - MediaInfo not validated)`):F="SDR (no HDR in title)";else if(R.length===0)q&&(!v||v==="HDR10")?(_="fail",F='"HDR10" in title should be renamed to "HDR"'):v?(_="warn",F=`Title has ${v} but MediaInfo shows no HDR`):F="SDR (no HDR in title or MediaInfo)";else{const L=R.join(", "),O=R.some(ae=>ae.startsWith("DV")),j=R.includes("HDR10+"),Y=R.includes("HDR10"),X=R.includes("HDR"),te=R.includes("HLG"),se=R.includes("PQ10");let w=null;O&&j?w="DV HDR10+":O&&(Y||X)?w="DV HDR":O?w="DV":j?w="HDR10+":Y||X?w="HDR":te?w="HLG":se&&(w="PQ10"),v&&w&&v===w.toUpperCase()?(_="pass",F=`Correct: ${w} (MediaInfo: ${L})`):!v&&!w?F="SDR (no HDR in title or MediaInfo)":v?w?(_="fail",F=`Wrong HDR tag - MediaInfo shows ${L}, title has ${v} but should be: ${w}`):(_="warn",F=`Title has ${v} but could not determine expected tag from MediaInfo (${L})`):(_="fail",F=`Missing HDR tag - MediaInfo shows ${L}, title should include: ${w}`)}a.checks.push({name:"HDR Format",status:_,message:F,required:R.length>0})}const Q=a.checks.some(R=>R.required&&R.status==="fail"),ee=a.checks.some(R=>R.status==="warn")||a.checks.some(R=>!R.required&&R.status==="fail");return a.status=Q?"fail":ee?"warn":"pass",a},checkSourceForType(e,n){e.toUpperCase();const t=n?.toUpperCase()||"";let s=[],a="Unknown";g.fullDiscTypes.some(c=>t.includes(c.toUpperCase()))?(s=g.sources.fullDisc,a="Full Disc"):g.remuxTypes.some(c=>t.includes(c.toUpperCase()))?(s=g.sources.remux,a="REMUX"):g.encodeTypes.some(c=>t.includes(c.toUpperCase()))?(s=g.sources.encode,a="Encode"):g.webTypes.some(c=>t.includes(c.toUpperCase()))?(s=[...g.sources.web,...g.streamingServices],a="WEB"):g.hdtvTypes.some(c=>t.includes(c.toUpperCase()))?(s=g.sources.hdtv,a="HDTV"):(s=[...g.sources.fullDisc,...g.sources.remux,...g.sources.encode,...g.sources.web,...g.sources.hdtv,...g.streamingServices],s=[...new Set(s)]);let o=null;for(const c of s)if(new RegExp(c.replace(/[-.]/g,"[-. ]?"),"i").test(e)){o=c;break}return!o&&a==="Encode"&&/blu-?ray/i.test(e)&&(o="BluRay"),{name:"Source",status:o?"pass":"fail",message:o?`Found: ${o}${a!=="Unknown"?` (valid for ${a})`:""}`:`No valid source for ${a} type`,required:!0}},titleElementOrder(e,n){const{elements:t,positions:s}=H.extractTitleElements(e,n);if(t.length<3)return{status:"warn",message:"Too few elements detected to validate order",details:null,violations:[]};const a=g.fullDiscTypes.some(T=>n?.includes(T))||g.remuxTypes.some(T=>n?.toUpperCase().includes(T.toUpperCase())),o=a?g.titleElementOrder.fullDiscRemux:g.titleElementOrder.encodeWeb,c=a?"Full Disc/REMUX":"Encode/WEB",r=[],d=t.map(T=>T.type);for(let T=0;T<d.length;T++)for(let b=T+1;b<d.length;b++){const u=d[T],D=d[b],x=o.indexOf(u),p=o.indexOf(D);x===-1||p===-1||x>p&&r.push({first:{type:u,value:t[T].value},second:{type:D,value:t[b].value},message:`"${t[T].value}" (${u}) should come after "${t[b].value}" (${D})`})}if(r.length===0)return{status:"pass",message:`Element order correct for ${c}`,details:null,violations:[]};const f=r.find(T=>T.first.type==="hdr"&&T.second.type==="vcodec"||T.first.type==="vcodec"&&T.second.type==="hdr");let A=`${r.length} ordering issue(s) found`;return f&&(a?A="HDR should come BEFORE video codec for Full Disc/REMUX":A="HDR should come AFTER video codec for Encode/WEB"),{status:"fail",message:A,details:{orderType:c,violations:r.map(T=>T.message)},violations:r}},audioTagCompliance(e,n,t,s,a){const o=g.fullDiscTypes.some(b=>s?.includes(b)),c=o&&/\b(NTSC|PAL|DVD5|DVD9)\b/i.test(e||"");if(o&&!c)return{status:"na",message:"N/A - Full Disc (no MediaInfo)",details:null,checks:[]};const r=[];if(t&&t.length>0){const b=(e||"").toLowerCase(),u=b.includes("dual-audio")||b.includes("dual audio"),D=b.includes("multi"),x=t.length,p=g.languageMap[n]||n,y=n==="en",C=i=>i.toLowerCase().startsWith("english"),h=i=>{if(!p)return!1;const l=i.toLowerCase(),m=p.toLowerCase();return l===m||l.startsWith(m)||l.includes(m)||m.includes(l)?!0:(g.languageAliases[m]||[]).some(N=>l.includes(N)||N.includes(l))};if(u)if(y){const i=t.filter(m=>!C(m));let l="";x>=2&&i.length>0?i.length>1?l=`. Found ${x} audio tracks (${t.join(", ")}). Use "Multi" instead`:/^[a-z]{2,3}$/i.test(i[0])?l=`. Found ${x} audio tracks (${t.join(", ")}), use "{Language_Name} Multi" instead`:l=`. Found ${x} audio tracks (${t.join(", ")}). Use "${i[0]} Multi" instead`:x>=2?l=`. Found ${x} audio tracks but non-English languages not recognized by MediaInfo parser. Use "{Language} Multi" instead (or "Multi" if 3+ languages)`:l='. Only 1 recognized language found — non-English track may not be recognized by MediaInfo parser. Use "{Language} Multi" if a second language is present (or "Multi" if 3+ languages)',r.push({name:"Language Tags",status:"fail",message:`Dual-Audio is reserved for non-English original content with an English dub${l}`})}else if(x>2)r.push({name:"Language Tags",status:"fail",message:`Tagged Dual-Audio but found ${x} languages. Should be "Multi"`});else if(x<2)r.push({name:"Language Tags",status:"fail",message:`Tagged Dual-Audio but found only ${x} language`});else{const i=t.some(C),l=t.some(h);i?l?r.push({name:"Language Tags",status:"pass",message:`Dual-Audio correct (English + ${p})`}):r.push({name:"Language Tags",status:"warn",message:`Dual-Audio implies Original Language (${p}) present`}):r.push({name:"Language Tags",status:"fail",message:"Dual-Audio requires English track"})}else if(D)x<2?r.push({name:"Language Tags",status:"fail",message:`"Multi" used but found only ${x} language`}):r.push({name:"Language Tags",status:"pass",message:`Multi-Audio correct (${x} languages)`});else if(x>2)o||r.push({name:"Language Tags",status:"warn",message:`Found ${x} languages but no "Multi" tag`});else if(x===2){const i=t.some(C),l=t.some(h);!o&&i&&l&&!y?r.push({name:"Language Tags",status:"warn",message:`Found English + Original (${p}), consider "Dual-Audio" tag`}):r.push({name:"Language Tags",status:"pass",message:`Audio languages OK (${x})`})}else r.push({name:"Language Tags",status:"pass",message:`Audio languages OK (${x})`})}const d=E.getAudioTracksFromMediaInfo();if(d.length>0){const b=g.remuxTypes.some(i=>s?.toUpperCase().includes(i.toUpperCase()))||/\b(HDTV|PDTV|SDTV)\b/i.test(s||"")||/\bDVD\b/i.test(s||""),u=/\b(HDTV|PDTV|SDTV|DVD)\b/i.test(s||"")||b,D=(i,l)=>{const m=(i||"").toLowerCase(),S=(l||"").toLowerCase();return m.includes("dts")&&(S.includes("dts-hd")||S.includes("dts:x")||S.includes("master audio")||S.includes("dts-hd ma"))?"DTS-HD":m.includes("dts")?"DTS":m==="e-ac-3"||m.includes("e-ac-3")?"E-AC-3":m==="ac-3"||m.includes("ac-3")?"AC-3":m.includes("mlp fba")||S.includes("truehd")?"TrueHD":m==="flac"||m.includes("flac")?"FLAC":m==="opus"||m.includes("opus")?"Opus":m==="pcm"||m.includes("pcm")||m.includes("lpcm")?"LPCM":m==="aac"||m.includes("aac")?"AAC":m==="mpeg audio"&&S.includes("mp2")?"MP2":m==="mpeg audio"&&S.includes("mp3")||m.includes("mp3")||m==="mpeg audio"&&!S?"MP3":m.includes("mp2")?"MP2":m.includes("vorbis")?"Vorbis":m.includes("alac")?"ALAC":i},x=i=>i?i==="1.0"||i==="2.0"||i==="1ch"||i==="2ch":!1,p=i=>{const l=(i.title||"").toLowerCase();return l.includes("commentary")||l.includes("comment")},y={"DTS-HD MA":"DTS-HD","DTS-HD HRA":"DTS-HD","DTS:X":"DTS-HD","DTS-ES":"DTS",DTS:"DTS",TrueHD:"TrueHD","DD+":"E-AC-3",DDP:"E-AC-3","DD EX":"AC-3",DD:"AC-3","E-AC-3":"E-AC-3","AC-3":"AC-3",LPCM:"LPCM",PCM:"LPCM",FLAC:"FLAC",ALAC:"ALAC",AAC:"AAC",MP3:"MP3",MP2:"MP2",Opus:"Opus",Vorbis:"Vorbis"},C=[...g.validAudioCodecs].sort((i,l)=>l.length-i.length);let h=null;for(const i of C){const l=i.replace(/[+]/g,"\\+").replace(/[-.]/g,"[-.]?");if(new RegExp("(?<![a-zA-Z])"+l+"(?![a-zA-Z])","i").test(e||"")){h=i;break}}if(h){const i=d.find(S=>S.isDefault)||d[0],l=D(i.codec,i.commercialName),m=y[h];m&&l&&m!==l&&r.push({name:"Audio Codec Mismatch",status:"fail",message:`Title claims ${h} but primary audio track is ${l}`})}for(let i=0;i<d.length;i++){const l=d[i],m=D(l.codec,l.commercialName),S=`Track ${i+1}: ${m}${l.channels?" "+l.channels:""}${l.language?" ("+l.language+")":""}`;if(m==="FLAC"||m==="Opus"||m==="LPCM"){const N=m==="LPCM"&&b;!x(l.channels)&&!N?r.push({name:S,status:"fail",message:`${m} only allowed as mono/stereo. Found: ${l.channels||"unknown"}`}):r.push({name:S,status:"pass",message:x(l.channels)?`${m} mono/stereo OK`:`${m} multichannel (untouched OK)`})}else m==="MP2"?u?r.push({name:S,status:"pass",message:"MP2 OK (untouched source)"}):r.push({name:S,status:"fail",message:"MP2 only allowed if untouched (HDTV/DVD)"}):m==="MP3"?p(l)?r.push({name:S,status:"pass",message:"MP3 OK (commentary track)"}):r.push({name:S,status:"warn",message:"MP3 only allowed for supplementary tracks (e.g. commentary)"}):m==="Vorbis"||m==="ALAC"?r.push({name:S,status:"fail",message:`${m} is not an allowed audio codec`}):["DTS","DTS-HD","AC-3","E-AC-3","TrueHD","AAC"].includes(m)?r.push({name:S,status:"pass",message:`${m} OK`}):r.push({name:S,status:"warn",message:`Unrecognized codec: ${l.codec}${l.commercialName?" / "+l.commercialName:""}`})}}if(r.length===0)return{status:"na",message:"No audio data detected in MediaInfo",details:null,checks:[]};const f=r.some(b=>b.status==="fail"),A=r.some(b=>b.status==="warn"),T=f?"fail":A?"warn":"pass";return{status:T,message:T==="pass"?`Audio OK (${d.length} track${d.length!==1?"s":""})`:"Audio issues found",details:null,checks:r}},mediaInfoPresent(e,n,t,s){const a=g.fullDiscTypes.some(c=>t?.includes(c)),o=a&&/\b(NTSC|PAL|DVD5|DVD9)\b/i.test(s||"");return a&&!o?n?{status:"pass",message:"BDInfo present (Full Disc)",details:null}:e?{status:"warn",message:"BDInfo expected for Full Disc",details:null}:{status:"fail",message:"BDInfo required for Full Disc uploads",details:null}:o?{status:"na",message:"N/A - DVD Full Disc (BDInfo not applicable)",details:null}:n?{status:"fail",message:"Release is not Full Disc, BDInfo should be empty",details:null}:e?{status:"pass",message:"MediaInfo Present",details:null}:{status:"fail",message:"MediaInfo Required",details:null}},subtitleRequirement(e,n,t,s){if(g.fullDiscTypes.some(d=>s?.includes(d)))return{status:"na",message:"N/A - Full Disc (no MediaInfo)",details:null};if(!e||e.length===0)return{status:"na",message:"No audio languages detected",details:null};const o=d=>{const f=d.toLowerCase();return f==="english"||f.startsWith("english")};return e.some(o)?{status:"pass",message:"English audio present - subtitles optional",details:null}:!n||n.length===0?{status:"fail",message:"No English audio & no subtitles detected",details:{audio:e.join(", "),expected:"English subtitles required for non-English audio"}}:n.some(o)?{status:"pass",message:"Non-English audio with English subtitles",details:null}:{status:"fail",message:"Non-English audio requires English subtitles",details:{audio:e.join(", "),subtitles:n.join(", ")||"None detected",expected:"English subtitles"}}},screenshotCount(e){const{count:n,urls:t}=H.countScreenshots(e);return n>=g.minScreenshots?{status:"pass",count:n,message:`${n} screenshots found`,details:null}:n>0?{status:"warn",count:n,message:`Only ${n} screenshot(s) found (${g.minScreenshots}+ required)`,details:null}:{status:"fail",count:0,message:"No screenshots found in description",details:null}},containerFormat(e,n){if(g.fullDiscTypes.some(r=>n?.includes(r)))return{status:"na",message:"N/A - Full Disc uploads use native folder structure",details:null};if(!e||!e.files||e.files.length===0)return{status:"warn",message:"Could not determine file structure to verify container",details:null};const s=[".mkv",".mp4",".avi",".wmv",".m4v",".ts",".m2ts",".vob",".mpg",".mpeg",".mov",".flv",".webm"],a=e.files.filter(r=>{const d=r.toLowerCase();return s.some(f=>d.endsWith(f))});if(a.length===0)return{status:"warn",message:"No video files detected in file list",details:null};const o=a.filter(r=>!r.toLowerCase().endsWith(".mkv"));return o.length===0?{status:"pass",message:`MKV container verified (${a.length} video file${a.length>1?"s":""})`,details:null}:{status:"fail",message:`Non-MKV container detected: ${[...new Set(o.map(r=>r.split(".").pop().toUpperCase()))].join(", ")}`,details:{expected:"MKV container for all non-Full Disc releases",found:o.join(", ")}}},packUniformity(e,n){if(g.fullDiscTypes.some(b=>n?.includes(b)))return{status:"na",message:"N/A - Full Disc",details:null,checks:[]};if(!e||!e.files||e.files.length===0)return{status:"na",message:"N/A - No files detected",details:null,checks:[]};const s=[".mkv",".mp4",".avi",".wmv",".m4v",".ts",".m2ts",".vob",".mpg",".mpeg",".mov",".flv",".webm"],a=e.files.filter(b=>{const u=b.toLowerCase();return s.some(D=>u.endsWith(D))});if(a.length<2)return{status:"na",message:"N/A - Single file upload",details:null,checks:[]};const o=b=>{const u={},D=g.validResolutions.find(i=>b.includes(i));u.resolution=D||null;const x=[{pattern:/\bWEB-DL\b/i,name:"WEB-DL"},{pattern:/\bWEBRip\b/i,name:"WEBRip"},{pattern:/\bWEB\b/i,name:"WEB"},{pattern:/\bBlu-?Ray\b/i,name:"BluRay"},{pattern:/\bREMUX\b/i,name:"REMUX"},{pattern:/\bHDTV\b/i,name:"HDTV"},{pattern:/\bSDTV\b/i,name:"SDTV"},{pattern:/\bDVDRip\b/i,name:"DVDRip"},{pattern:/\bBDRip\b/i,name:"BDRip"},{pattern:/\bBRRip\b/i,name:"BRRip"},{pattern:/\bHDDVD\b/i,name:"HDDVD"},{pattern:/\bWEBDL\b/i,name:"WEB-DL"}];u.source=null;for(const i of x)if(i.pattern.test(b)){u.source=i.name;break}const p=[...g.validAudioCodecs].sort((i,l)=>l.length-i.length);u.audioCodec=null;for(const i of p){const l=i.replace(/[+]/g,"\\+").replace(/[-.]/g,"[-.]?");if(new RegExp("(?<![a-zA-Z])"+l+"(?![a-zA-Z])","i").test(b)){u.audioCodec=i;break}}const y=b.match(/(\d{1,2}\.\d)(?!\d)/);u.channels=y?y[1]:null;const C=[...g.validVideoCodecs].sort((i,l)=>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<Math.max(y.length,C.length);h++){const i=(y[h]||0)-(C[h]||0);if(i!==0)return i}return 0}).map(x=>`§${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'<i class="fas fa-check-circle mh-icon--pass"></i>';case"fail":return'<i class="fas fa-times-circle mh-icon--fail"></i>';case"warn":return'<i class="fas fa-exclamation-triangle mh-icon--warn"></i>';case"na":return'<i class="fas fa-minus-circle mh-icon--na"></i>';case"advisory":return'<i class="fas fa-info-circle mh-icon--advisory"></i>';case"integration":return'<i class="fas fa-spinner fa-spin"></i>';default:return'<i class="fas fa-question-circle"></i>'}},getStatusBadge(e){return`<span class="mh-badge mh-badge--${e}">${{pass:"Pass",fail:"Fail",warn:"Warning",na:"N/A",advisory:"Review",integration:"..."}[e]||e}</span>`},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`
|
||
<details class="mh-accordion${o?" mh-accordion--alert":""}" data-section="${e}" data-status="${t}" ${(a!==null?a:t!=="pass"&&t!=="na")?"open":""}>
|
||
<summary class="mh-accordion__summary mh-accordion__summary--${t}">
|
||
<span class="mh-accordion__icon">${this.getStatusIcon(t)}</span>
|
||
<span class="mh-accordion__title">${n}</span>
|
||
${this.getStatusBadge(t)}
|
||
<i class="fas fa-chevron-down mh-accordion__chevron"></i>
|
||
</summary>
|
||
<div class="mh-accordion__body">${s}</div>
|
||
</details>`},checkRow(e,n,t,s=null){let a="";if(s)if(s.expected!==void 0&&s.found!==void 0)a=`
|
||
<div class="mh-detail">
|
||
<span class="mh-detail__item"><strong>Expected:</strong> ${s.expected}</span>
|
||
<span class="mh-detail__item"><strong>Found:</strong> ${s.found}</span>
|
||
</div>`;else if(typeof s=="object"&&!Array.isArray(s)){const o=Object.entries(s);o.length&&(a=`<div class="mh-detail">${o.map(([c,r])=>`<span class="mh-detail__item"><strong>${c}:</strong> ${Array.isArray(r)?r.join(", "):r}</span>`).join("")}</div>`)}else Array.isArray(s)&&s.length&&(a=`<div class="mh-detail">${s.map(o=>`<span class="mh-detail__item">${o}</span>`).join("")}</div>`);return`
|
||
<div class="mh-row mh-row--${e}">
|
||
<span class="mh-row__icon">${this.getStatusIcon(e)}</span>
|
||
<span class="mh-row__label">${n}</span>
|
||
<span class="mh-row__msg">${t}</span>
|
||
${a}
|
||
</div>`},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`
|
||
<div class="mh-inline mh-inline--${n}" data-section="${e}" data-status="${n}">
|
||
<span class="mh-inline__icon">${this.getStatusIcon(n)}</span>
|
||
<span class="mh-inline__title">${t}</span>
|
||
<span class="mh-inline__msg">${s}</span>
|
||
${a}
|
||
${this.getStatusBadge(n)}
|
||
</div>`},buildNamingGuide(e){const n=e.checks.map(t=>{const s=t.required===!1?' <span class="mh-optional">(optional)</span>':"";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+=`<div class="mh-violations">
|
||
${e.details?.orderType?`<span class="mh-violations__type">Order type: ${e.details.orderType}</span>`:""}
|
||
<ul class="mh-violations__list">${n.map(s=>`<li>${s}</li>`).join("")}</ul>
|
||
</div>`,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?`
|
||
<div class="mh-alert mh-alert--fail">
|
||
<i class="fas fa-ban mh-alert__icon"></i>
|
||
<div class="mh-alert__content">
|
||
<strong>Banned Release Group Detected</strong>
|
||
<span>${e.message}</span>
|
||
</div>
|
||
</div>`:""},groupHeading(e){return`<div class="mh-group">${e}</div>`},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`
|
||
<details class="mh-section" ${c?"":"open"}>
|
||
<summary class="mh-section__summary">
|
||
<span class="mh-chip mh-chip--${r==="na"?"pass":r}">${a}/${o} passed</span>
|
||
<span class="mh-section__label">${e}</span>
|
||
<i class="fas fa-chevron-down mh-section__chevron"></i>
|
||
</summary>
|
||
<div class="mh-section__body">${n}</div>
|
||
</details>`},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(`<span class="mh-chip mh-chip--fail">${d} failed</span>`),r&&A.push(`<span class="mh-chip mh-chip--warn">${r} warning${r>1?"s":""}</span>`),A.push(`<span class="mh-chip mh-chip--pass">${c}/${f} passed</span>`),e.bannedGroup.tieredInfo){const i=e.bannedGroup.tieredInfo.join(", ");A.push(`<span class="mh-chip mh-chip--pass" title="${i}">TRaSH Tiered Group</span>`)}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?`<span class="mh-badge mh-badge--info" title="${e.bannedGroup.tieredInfo.join(", ")}">TRaSH Tiered Group</span>`:"";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,"<").replace(/>/g,">");h=`
|
||
<div class="mh-message-block">
|
||
<div class="mh-message-block__header">
|
||
<i class="fas fa-clipboard-list mh-message-block__icon"></i>
|
||
<span class="mh-message-block__title">Corrective Message</span>
|
||
<div class="mh-message-block__actions">
|
||
<button class="form__button form__button--text mh-btn" id="mh-fill-postpone" title="Pre-fill postpone message">
|
||
<i class="fas fa-paste"></i> Postpone
|
||
</button>
|
||
<button class="form__button form__button--text mh-btn" id="mh-fill-reject" title="Pre-fill reject message">
|
||
<i class="fas fa-paste"></i> Reject
|
||
</button>
|
||
<button class="form__button form__button--text mh-btn" id="mh-copy-message" title="Copy to clipboard">
|
||
<i class="fas fa-copy"></i> Copy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<pre class="mh-message-block__content" id="mh-corrective-text">${i}</pre>
|
||
<textarea class="mh-message-block__editor" id="mh-corrective-editor" spellcheck="true">${i}</textarea>
|
||
<div class="mh-message-block__hint">
|
||
<i class="fas fa-info-circle"></i>
|
||
Click the message to edit before copying or filling.
|
||
</div>
|
||
</div>`}return n.innerHTML=`
|
||
<header class="panel__header mh-header">
|
||
<h2 class="panel__heading mh-heading">
|
||
${this.getStatusIcon(a)}
|
||
<span>ModQ Helper</span>
|
||
</h2>
|
||
<div class="mh-summary">${A.join("")}</div>
|
||
<div class="mh-actions">
|
||
<button class="form__button form__button--text mh-btn" id="mh-filename-compare-btn" title="Compare filename">
|
||
<i class="fas fa-file-magnifying-glass"></i> Compare
|
||
</button>
|
||
<button class="form__button form__button--text mh-btn" id="mh-toggle-all" title="Expand all sections">
|
||
<i class="fas fa-angles-down"></i>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
${h}
|
||
<div class="mh-filename-block" id="mh-filename-block" style="display:none">
|
||
<div class="mh-filename-block__header">
|
||
<i class="fas fa-file-magnifying-glass mh-filename-block__icon"></i>
|
||
<span class="mh-filename-block__title">Filename Comparison</span>
|
||
</div>
|
||
<div class="mh-filename-uploaded-row">
|
||
<span class="mh-filename-label">Uploaded:</span>
|
||
<code id="mh-filename-uploaded" class="mh-filename-code">—</code>
|
||
</div>
|
||
<div class="mh-filename-input-row">
|
||
<span class="mh-filename-label">Reference:</span>
|
||
<input type="text" id="mh-filename-input" class="mh-filename-input" placeholder="Paste expected filename here…" />
|
||
<button class="form__button form__button--text mh-btn" id="mh-filename-run">
|
||
<i class="fas fa-magnifying-glass"></i> Compare
|
||
</button>
|
||
</div>
|
||
<div id="mh-filename-result" class="mh-filename-result"></div>
|
||
</div>
|
||
<div class="panel__body mh-body">${T}${this._buildIntegrationPlaceholders(e)}</div>`,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='<span class="mh-diff-error">Please enter a reference filename.</span>';return}const p=E.getMediaInfoFilename();if(!p||p==="(not found)"){u.innerHTML='<span class="mh-diff-error">Could not read MediaInfo filename from page.</span>';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`<div class="mh-section-group"><details class="mh-section" open><summary class="mh-section__header"><i class="fas fa-plug mh-section__icon"></i><span class="mh-section__title">External Integrations</span></summary><div class="mh-section__body"><div data-integration="srrdb" class="mh-integration mh-integration--loading"><span class="mh-integration__icon"><i class="fas fa-spinner fa-spin"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Checking scene database...</span></div><div data-integration="prowlarr" class="mh-integration mh-integration--loading"><span class="mh-integration__icon"><i class="fas fa-spinner fa-spin"></i></span><span class="mh-integration__label">Prowlarr</span><span class="mh-integration__msg">Searching indexers...</span></div></div></details></div>`},renderIntegrationResult(name,result){const esc=(s)=>String(s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");if(result.error){return`<div class="mh-integration mh-integration--warn"><span class="mh-integration__icon"><i class="fas fa-exclamation-triangle mh-icon--warn"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">${esc(result.error)}</span></div>`}if(result.notConfigured){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">Not configured — open ModQ Helper Settings</span></div>`}if(name==="SRRDB"){if(!result.found){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Not found — may not be a scene release</span></div>`}const relName=esc(result.release?.release||"Unknown");let fileHtml="";if(result.fileCheck){if(result.fileCheck.match){fileHtml=`<div class="mh-integration__detail mh-integration__detail--pass"><i class="fas fa-check-circle mh-icon--pass"></i> File/folder names match SRRDB record</div>`}else if(result.fileCheck.error){fileHtml=`<div class="mh-integration__detail mh-integration__detail--warn"><i class="fas fa-exclamation-triangle mh-icon--warn"></i> Could not verify files: ${esc(result.fileCheck.error)}</div>`}else{const diffs=(result.fileCheck.discrepancies||[]).map(d=>`<li>${esc(d)}</li>`).join("");fileHtml=`<div class="mh-integration__detail mh-integration__detail--fail"><i class="fas fa-times-circle mh-icon--fail"></i> File names differ from SRRDB record — possible rename (REJECT per A1.1)${diffs?`<ul class="mh-integration__diffs">${diffs}</ul>`:""}</div>`}}return`<div class="mh-integration mh-integration--${result.fileCheck?.match?"pass":result.fileCheck?.discrepancies?.length?"fail":"pass"}"><span class="mh-integration__icon"><i class="fas fa-${result.fileCheck?.match?"check-circle mh-icon--pass":result.fileCheck?.discrepancies?.length?"times-circle mh-icon--fail":"check-circle mh-icon--pass"}"></i></span><span class="mh-integration__label">SRRDB</span><span class="mh-integration__msg">Scene release found: ${relName}</span>${fileHtml}</div>`}if(name==="Prowlarr"){if(!result.found){return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-minus-circle mh-icon--na"></i></span><span class="mh-integration__label">Prowlarr</span><span class="mh-integration__msg">Release not indexed — may be new or not tracked</span></div>`}const count=result.results?.length||0;const bestMatch=result.bestMatch;let matchHtml="";if(bestMatch){matchHtml+=`<div class="mh-integration__detail"><strong>Best match:</strong> ${esc(bestMatch.title)} <span class="mh-integration__indexer">[${esc(bestMatch.indexer)}]</span></div>`;if(bestMatch.renameWarning){matchHtml+=`<div class="mh-integration__detail mh-integration__detail--fail"><i class="fas fa-times-circle mh-icon--fail"></i> Title differs from indexed release — possible rename (REJECT per A1.1)<div class="mh-integration__compare"><div><strong>Uploaded:</strong> ${esc(bestMatch.uploadedTitle)}</div><div><strong>Indexed:</strong> ${esc(bestMatch.title)}</div></div></div>`}else{matchHtml+=`<div class="mh-integration__detail mh-integration__detail--pass"><i class="fas fa-check-circle mh-icon--pass"></i> Title consistent with indexed release</div>`}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`<li>Folder renamed: expected <code>${esc(i.expected)}</code>, found <code>${esc(i.found)}</code></li>`;if(i.type==="filename")return`<li>File renamed: expected <code>${esc(i.expected)}</code>, found <code>${esc(i.found)}</code></li>`;return`<li>File mismatch: expected <code>${esc(i.expected)}</code>, found <code>${esc(i.found)}</code></li>`}).join("");matchHtml+=`<div class="mh-integration__detail mh-integration__detail--fail"><i class="fas fa-times-circle mh-icon--fail"></i> <strong>Cross-seed broken:</strong> files/folders have been renamed from the original release. A new torrent file is needed for cross-seed to work. (REJECT per A1.1)<ul class="mh-integration__diffs">${csItems}</ul></div>`}else{matchHtml+=`<div class="mh-integration__detail mh-integration__detail--pass"><i class="fas fa-check-circle mh-icon--pass"></i> Cross-seed compatible — file/folder names match indexed release</div>`}}}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`<div class="mh-integration mh-integration--${prowlStatus}"><span class="mh-integration__icon"><i class="fas fa-${prowlIcon}"></i></span><span class="mh-integration__label">Prowlarr</span><span class="mh-integration__msg">Found on ${count} indexer(s)</span>${matchHtml}</div>`}return`<div class="mh-integration mh-integration--na"><span class="mh-integration__icon"><i class="fas fa-info-circle mh-icon--na"></i></span><span class="mh-integration__label">${esc(name)}</span><span class="mh-integration__msg">${result.found?"Found":"Not found"}</span></div>`}};
|
||
|
||
/* ========================================================================
|
||
* 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();
|
||
})();
|