5165 lines
212 KiB
JavaScript
5165 lines
212 KiB
JavaScript
// ==UserScript==
|
||
// @name UNIT3D Mod Queue Helper — DarkPeers
|
||
// @namespace https://gitea.computerliebe.org/Procuria/dp-modq-helper
|
||
// @version 0.6.4
|
||
// @description Quality-gate checks for DarkPeers — extended moderation rules, title validation, SRRDB & Prowlarr integrations
|
||
// @author TQG Contributors
|
||
// @updateURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js
|
||
// @downloadURL https://gitea.computerliebe.org/Procuria/dp-modq-helper/raw/branch/main/modq-helper-darkpeers.user.js
|
||
// @match https://darkpeers.org/torrents/*
|
||
// @grant GM_addStyle
|
||
// @grant GM_getValue
|
||
// @grant GM_setValue
|
||
// @grant GM_registerMenuCommand
|
||
// @grant GM_xmlhttpRequest
|
||
// @connect www.srrdb.com
|
||
// @connect srrdb.com
|
||
// @connect *
|
||
// @run-at document-idle
|
||
// ==/UserScript==
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
/* ========================================================================
|
||
* CONFIG — Quality-gate configuration data
|
||
* Ported from the original modq-helper g object. Pure data, no DOM.
|
||
* ======================================================================== */
|
||
|
||
const g = {
|
||
minScreenshots: 3,
|
||
validResolutions: [
|
||
"480i",
|
||
"480p",
|
||
"576i",
|
||
"576p",
|
||
"720p",
|
||
"1080i",
|
||
"1080p",
|
||
"2160p",
|
||
"4320p"
|
||
],
|
||
validAudioCodecs: [
|
||
"DTS-HD MA",
|
||
"DTS-HD HRA",
|
||
"DTS:X",
|
||
"DTS-ES",
|
||
"DTS",
|
||
"TrueHD",
|
||
"DD+",
|
||
"DDP",
|
||
"DD EX",
|
||
"DD",
|
||
"E-AC-3",
|
||
"AC-3",
|
||
"LPCM",
|
||
"PCM",
|
||
"FLAC",
|
||
"ALAC",
|
||
"AAC",
|
||
"MP3",
|
||
"MP2",
|
||
"Opus",
|
||
"Vorbis"
|
||
],
|
||
validVideoCodecs: [
|
||
"AVC",
|
||
"HEVC",
|
||
"H.264",
|
||
"H.265",
|
||
"x264",
|
||
"x265",
|
||
"MPEG-2",
|
||
"VC-1",
|
||
"VP9",
|
||
"AV1",
|
||
"XviD",
|
||
"DivX"
|
||
],
|
||
hdrFormats: [
|
||
"DV HDR10+",
|
||
"DV HDR",
|
||
"HDR10+",
|
||
"HDR10",
|
||
"HDR",
|
||
"DV",
|
||
"HLG",
|
||
"PQ10"
|
||
],
|
||
fullDiscTypes: [
|
||
"Full Disc",
|
||
"BD50",
|
||
"BD25",
|
||
"BD66",
|
||
"BD100"
|
||
],
|
||
remuxTypes: [
|
||
"REMUX"
|
||
],
|
||
encodeTypes: [
|
||
"Encode"
|
||
],
|
||
webTypes: [
|
||
"WEB-DL",
|
||
"WEBRip"
|
||
],
|
||
hdtvTypes: [
|
||
"HDTV",
|
||
"SDTV",
|
||
"UHDTV",
|
||
"PDTV",
|
||
"DSR"
|
||
],
|
||
streamingServices: [
|
||
"AMZN",
|
||
"NF",
|
||
"DSNP",
|
||
"HMAX",
|
||
"ATVP",
|
||
"PCOK",
|
||
"PMTP",
|
||
"HBO",
|
||
"HULU",
|
||
"iT",
|
||
"MA",
|
||
"STAN",
|
||
"RED",
|
||
"CRAV",
|
||
"CRITERION",
|
||
"SHO",
|
||
"STARZ",
|
||
"VUDU",
|
||
"MUBI",
|
||
"BCORE",
|
||
"PLAY",
|
||
"APTV"
|
||
],
|
||
sources: {
|
||
fullDisc: [
|
||
"Blu-ray",
|
||
"UHD Blu-ray",
|
||
"HD DVD",
|
||
"DVD5",
|
||
"DVD9",
|
||
"NTSC DVD",
|
||
"PAL DVD"
|
||
],
|
||
remux: [
|
||
"BluRay",
|
||
"UHD BluRay",
|
||
"HDDVD",
|
||
"NTSC DVD",
|
||
"PAL DVD"
|
||
],
|
||
encode: [
|
||
"BluRay",
|
||
"UHD BluRay",
|
||
"DVDRip",
|
||
"HDDVD",
|
||
"BDRip",
|
||
"BRRip",
|
||
"WEB-DL",
|
||
"WEBRip",
|
||
"WEB"
|
||
],
|
||
web: [
|
||
"WEB-DL",
|
||
"WEBRip",
|
||
"WEB"
|
||
],
|
||
hdtv: [
|
||
"HDTV",
|
||
"SDTV",
|
||
"UHDTV",
|
||
"PDTV",
|
||
"DSR"
|
||
]
|
||
},
|
||
// DarkPeers banned groups — sourced from https://darkpeers.org/pages/9 (2026-04-08)
|
||
bannedGroups: [
|
||
"ARCADE", "aXXo", "BANDOLEROS", "BONE", "BRrip", "CM8", "CrEwSaDe", "CTFOH", "dAV1nci", "DNL",
|
||
"eranger2", "FGT", "FiSTER", "flower", "GalaxyTV", "HD2DVD", "HDTime", "HorribleSubs",
|
||
"iHYTECH", "ION10", "iPlanet", "KiNGDOM", "LAMA", "MeGusta", "mHD", "mSD", "NaNi", "NhaNc3",
|
||
"nHD", "nikt0", "nSD", "OFT", "PiTBULL", "PRODJi", "PSA", "RARBG", "Rifftrax",
|
||
"ROCKETRACCOON", "SANTi", "SasukeducK", "SEEDSTER", "ShAaNiG", "Sicario", "STUTTERSHIT",
|
||
"Subsplease", "TAoE", "TGALAXY", "TGx", "TORRENTGALAXY", "ToVaR", "Trix", "TSP", "TSPxL",
|
||
"ViSION", "VXT", "WAF", "WKS", "X0r", "YIFY", "YTS"
|
||
],
|
||
// Groups with conditional exceptions (not in bannedGroups — checked separately)
|
||
bannedGroupExceptions: {
|
||
"EVO": { allowedTypes: ["WEB-DL"] },
|
||
"HDT": { allowedTypes: ["REMUX"] },
|
||
},
|
||
exceptionGroupNames: [
|
||
"DiscoD HONE", "DarQ HONE", "Eml HDTeam", "BEN THE MEN", "D-Z0N3", "ZØNEHD", "Anime Time",
|
||
"Project Angel", "Hakata Ramen", "-ZR-"
|
||
],
|
||
bracketGroupNames: [
|
||
"Silence", "afm72", "Panda", "Ghost", "MONOLITH", "Tigole", "Joy", "ImE", "UTR", "t3nzin",
|
||
"Anime Time", "Project Angel", "Hakata Ramen", "HONE", "GiLG", "Vyndros", "SEV", "Garshasp",
|
||
"Kappa", "Natty", "RCVR", "SAMPA", "YOGI", "r00t", "EDGE2020", "RZeroX", "FreetheFish", "Anna",
|
||
"Bandi", "Qman", "theincognito", "HDO", "DusIctv", "DHD", "CtrlHD", "-ZR-", "ADC", "XZVN", "RH",
|
||
"Kametsu"
|
||
],
|
||
releaseGroupSuffixes: /(?:-(RP|1|NZBGeek|Obfuscated|Obfuscation|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen|RePACKPOST))+$/i,
|
||
imageHosts: [
|
||
"imgbb.com", "imgur.com", "ptpimg.me", "imgbox.com", "beyondhd.co",
|
||
"slowpic.", "pixhost.", "ibb.co", "postimg.", "funkyimg.", "image.tmdb.org"
|
||
],
|
||
imageExtensions: [
|
||
".jpg",
|
||
".jpeg",
|
||
".png",
|
||
".gif",
|
||
".webp"
|
||
],
|
||
languageMap: {
|
||
aa: "Afar",
|
||
ab: "Abkhazian",
|
||
ae: "Avestan",
|
||
af: "Afrikaans",
|
||
ak: "Akan",
|
||
am: "Amharic",
|
||
an: "Aragonese",
|
||
ar: "Arabic",
|
||
as: "Assamese",
|
||
av: "Avaric",
|
||
ay: "Aymara",
|
||
az: "Azerbaijani",
|
||
ba: "Bashkir",
|
||
be: "Belarusian",
|
||
bg: "Bulgarian",
|
||
bi: "Bislama",
|
||
bm: "Bambara",
|
||
bn: "Bengali",
|
||
bo: "Tibetan",
|
||
br: "Breton",
|
||
bs: "Bosnian",
|
||
ca: "Catalan",
|
||
ce: "Chechen",
|
||
ch: "Chamorro",
|
||
cn: "Cantonese",
|
||
co: "Corsican",
|
||
cr: "Cree",
|
||
cs: "Czech",
|
||
cu: "Slavic",
|
||
cv: "Chuvash",
|
||
cy: "Welsh",
|
||
da: "Danish",
|
||
de: "German",
|
||
dv: "Divehi",
|
||
dz: "Dzongkha",
|
||
ee: "Ewe",
|
||
el: "Greek",
|
||
en: "English",
|
||
eo: "Esperanto",
|
||
es: "Spanish",
|
||
et: "Estonian",
|
||
eu: "Basque",
|
||
fa: "Persian",
|
||
ff: "Fulah",
|
||
fi: "Finnish",
|
||
fj: "Fijian",
|
||
fo: "Faroese",
|
||
fr: "French",
|
||
fy: "Frisian",
|
||
ga: "Irish",
|
||
gd: "Gaelic",
|
||
gl: "Galician",
|
||
gn: "Guarani",
|
||
gu: "Gujarati",
|
||
gv: "Manx",
|
||
ha: "Hausa",
|
||
he: "Hebrew",
|
||
hi: "Hindi",
|
||
ho: "Hiri Motu",
|
||
hr: "Croatian",
|
||
ht: "Haitian",
|
||
hu: "Hungarian",
|
||
hy: "Armenian",
|
||
hz: "Herero",
|
||
ia: "Interlingua",
|
||
id: "Indonesian",
|
||
ie: "Interlingue",
|
||
ig: "Igbo",
|
||
ii: "Yi",
|
||
ik: "Inupiaq",
|
||
io: "Ido",
|
||
is: "Icelandic",
|
||
it: "Italian",
|
||
iu: "Inuktitut",
|
||
ja: "Japanese",
|
||
jv: "Javanese",
|
||
ka: "Georgian",
|
||
kg: "Kongo",
|
||
ki: "Kikuyu",
|
||
kj: "Kuanyama",
|
||
kk: "Kazakh",
|
||
kl: "Kalaallisut",
|
||
km: "Khmer",
|
||
kn: "Kannada",
|
||
ko: "Korean",
|
||
kr: "Kanuri",
|
||
ks: "Kashmiri",
|
||
ku: "Kurdish",
|
||
kv: "Komi",
|
||
kw: "Cornish",
|
||
ky: "Kirghiz",
|
||
la: "Latin",
|
||
lb: "Letzeburgesch",
|
||
lg: "Ganda",
|
||
li: "Limburgish",
|
||
ln: "Lingala",
|
||
lo: "Lao",
|
||
lt: "Lithuanian",
|
||
lu: "Luba-Katanga",
|
||
lv: "Latvian",
|
||
mg: "Malagasy",
|
||
mh: "Marshall",
|
||
mi: "Maori",
|
||
mk: "Macedonian",
|
||
ml: "Malayalam",
|
||
mn: "Mongolian",
|
||
mo: "Moldavian",
|
||
mr: "Marathi",
|
||
ms: "Malay",
|
||
mt: "Maltese",
|
||
my: "Burmese",
|
||
na: "Nauru",
|
||
nb: "Norwegian Bokmål",
|
||
nd: "Ndebele",
|
||
ne: "Nepali",
|
||
ng: "Ndonga",
|
||
nl: "Dutch",
|
||
nn: "Norwegian Nynorsk",
|
||
no: "Norwegian",
|
||
nr: "Ndebele",
|
||
nv: "Navajo",
|
||
ny: "Chichewa",
|
||
oc: "Occitan",
|
||
oj: "Ojibwa",
|
||
om: "Oromo",
|
||
or: "Oriya",
|
||
os: "Ossetian",
|
||
pa: "Punjabi",
|
||
pi: "Pali",
|
||
pl: "Polish",
|
||
ps: "Pushto",
|
||
pt: "Portuguese",
|
||
qu: "Quechua",
|
||
rm: "Raeto-Romance",
|
||
rn: "Rundi",
|
||
ro: "Romanian",
|
||
ru: "Russian",
|
||
rw: "Kinyarwanda",
|
||
sa: "Sanskrit",
|
||
sc: "Sardinian",
|
||
sd: "Sindhi",
|
||
se: "Northern Sami",
|
||
sg: "Sango",
|
||
sh: "Serbo-Croatian",
|
||
si: "Sinhalese",
|
||
sk: "Slovak",
|
||
sl: "Slovenian",
|
||
sm: "Samoan",
|
||
sn: "Shona",
|
||
so: "Somali",
|
||
sq: "Albanian",
|
||
sr: "Serbian",
|
||
ss: "Swati",
|
||
st: "Sotho",
|
||
su: "Sundanese",
|
||
sv: "Swedish",
|
||
sw: "Swahili",
|
||
ta: "Tamil",
|
||
te: "Telugu",
|
||
tg: "Tajik",
|
||
th: "Thai",
|
||
ti: "Tigrinya",
|
||
tk: "Turkmen",
|
||
tl: "Tagalog",
|
||
tn: "Tswana",
|
||
to: "Tonga",
|
||
tr: "Turkish",
|
||
ts: "Tsonga",
|
||
tt: "Tatar",
|
||
tw: "Twi",
|
||
ty: "Tahitian",
|
||
ug: "Uighur",
|
||
uk: "Ukrainian",
|
||
ur: "Urdu",
|
||
uz: "Uzbek",
|
||
ve: "Venda",
|
||
vi: "Vietnamese",
|
||
vo: "Volapük",
|
||
wa: "Walloon",
|
||
wo: "Wolof",
|
||
xh: "Xhosa",
|
||
xx: "No Language",
|
||
yi: "Yiddish",
|
||
yo: "Yoruba",
|
||
za: "Zhuang",
|
||
zh: "Mandarin",
|
||
zu: "Zulu"
|
||
},
|
||
languageAliases: {
|
||
"mandarin": ["chinese"],
|
||
"cantonese": ["chinese"],
|
||
"norwegian bokmål": ["norwegian"],
|
||
"norwegian nynorsk": ["norwegian"],
|
||
"moldavian": ["romanian"],
|
||
"letzeburgesch": ["luxembourgish"],
|
||
"sinhalese": ["sinhala"],
|
||
"pushto": ["pashto"],
|
||
"raeto-romance": ["romansh"],
|
||
"slavic": ["church slavic"],
|
||
"frisian": ["western frisian"],
|
||
"filipino": ["tagalog"],
|
||
"tagalog": ["filipino"],
|
||
"persian": ["farsi"],
|
||
"farsi": ["persian"],
|
||
"burmese": ["myanmar"],
|
||
"myanmar": ["burmese"],
|
||
"limburgish": ["dutch"]
|
||
},
|
||
titleElementOrder: {
|
||
fullDiscRemux: [
|
||
"name",
|
||
"aka",
|
||
"locale",
|
||
"year",
|
||
"season",
|
||
"cut",
|
||
"ratio",
|
||
"repack",
|
||
"resolution",
|
||
"edition",
|
||
"region",
|
||
"3d",
|
||
"source",
|
||
"type",
|
||
"hdr",
|
||
"vcodec",
|
||
"dub",
|
||
"acodec",
|
||
"channels",
|
||
"object",
|
||
"group"
|
||
],
|
||
encodeWeb: [
|
||
"name",
|
||
"aka",
|
||
"locale",
|
||
"year",
|
||
"season",
|
||
"cut",
|
||
"ratio",
|
||
"repack",
|
||
"resolution",
|
||
"edition",
|
||
"3d",
|
||
"source",
|
||
"type",
|
||
"dub",
|
||
"acodec",
|
||
"channels",
|
||
"object",
|
||
"hdr",
|
||
"vcodec",
|
||
"group"
|
||
]
|
||
},
|
||
cuts: [
|
||
"Theatrical",
|
||
"Director's Cut",
|
||
"Extended",
|
||
"Extended Cut",
|
||
"Extended Edition",
|
||
"Special Edition",
|
||
"Unrated",
|
||
"Unrated Director's Cut",
|
||
"Uncut",
|
||
"Super Duper Cut",
|
||
"Ultimate Cut",
|
||
"Ultimate Edition",
|
||
"Final Cut",
|
||
"Producer's Cut",
|
||
"Assembly Cut",
|
||
"International Cut",
|
||
"Redux",
|
||
"Rough Cut",
|
||
"Bootleg Cut",
|
||
"Criterion",
|
||
"Criterion Cut",
|
||
"Workprint",
|
||
"Hybrid Cut"
|
||
],
|
||
ratios: [
|
||
"IMAX",
|
||
"Open Matte",
|
||
"MAR"
|
||
],
|
||
editions: [
|
||
"Anniversary Edition",
|
||
"Remastered",
|
||
"4K Remaster",
|
||
"Criterion Collection",
|
||
"Limited",
|
||
"Collector's Edition",
|
||
"Deluxe Edition",
|
||
"Restored"
|
||
],
|
||
repacks: [
|
||
"REPACK",
|
||
"REPACK2",
|
||
"REPACK3",
|
||
"PROPER",
|
||
"RERIP"
|
||
],
|
||
dubs: [
|
||
"Multi",
|
||
"Dual-Audio",
|
||
"Dual Audio",
|
||
"Dubbed"
|
||
],
|
||
_tieredGroupsRaw: [
|
||
{
|
||
name: "Anime BD Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"DemiHuman", "FLE", "Flugel", "LYS1TH3A", "Moxie", "NAN0", "sam", "smol", "SoM", "ZR"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"Aergia", "Arg0", "Arid", "FateSucks", "hchcsen", "hydes", "JOHNTiTOR", "JySzE", "koala",
|
||
"Kulot", "LostYears", "Lulu", "Meakes", "Orphan", "PMR", "Vodes", "WAP", "YURI", "ZeroBuild"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"ARC", "BBT-RMX", "cappybara", "ChucksMux", "CRUCiBLE", "CUNNY", "Cunnysseur", "Doc", "fig",
|
||
"Headpatter", "Inka-Subs", "LaCroiX", "Legion", "Mehul", "MTBB", "Mysteria", "Netaro",
|
||
"Noiy", "npz", "NTRX", "Okay-Subs", "P9", "RaiN", "RMX", "RUDY", "Sekkon", "Serendipity",
|
||
"sgt", "SubsMix", "uba"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 04",
|
||
source: "radarr",
|
||
groups: [
|
||
"ABdex", "Afro", "aRMX", "BiRJU", "BKC", "CBT", "Chimera", "derp", "DIY", "EXP", "Foxtrot",
|
||
"grimf", "IK", "Iznjie Biznjie", "Kaleido-subs", "Kametsu", "Kawatare", "KH", "LazyRemux",
|
||
"Metal", "MK", "neko-kBaraka", "OZR", "Pizza", "pog42", "Quetzal", "Reza", "SCY", "Shimatta",
|
||
"Smoke", "Spirale", "UDF", "UQW", "Vanilla", "Virtuality", "VULCAN"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 05",
|
||
source: "radarr",
|
||
groups: [
|
||
"Animorphs", "AOmundson", "ASC", "B00BA", "Baws", "Beatrice", "Cait-Sidhe", "CsS", "CTR",
|
||
"D4C", "deanzel", "Drag", "eldon", "Freehold", "GHS", "Hark0N", "Holomux", "Judgement", "MC",
|
||
"mottoj", "NH", "NTRM", "o7", "QM", "Thighs", "TTGA", "UltraRemux", "WBDP", "WSE", "Yuki"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 06",
|
||
source: "radarr",
|
||
groups: [
|
||
"ANE", "Bunny-Apocalypse", "CyC", "Datte13", "EJF", "GetItTwisted", "GSK_kun", "iKaos",
|
||
"karios", "Pookie", "RASETSU", "Starbez", "Tsundere", "Yoghurt", "YURASUKA"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 07",
|
||
source: "radarr",
|
||
groups: [
|
||
"9volt", "AC", "Almighty", "Asakura", "Asenshi", "BlurayDesuYo", "Bolshevik", "Brrrrrrr",
|
||
"Chihiro", "Commie", "Crow", "Dae", "Dekinai", "Dragon-Releases", "DragsterPS",
|
||
"Exiled-Destiny", "FFF", "Final8", "Geonope", "GJM", "iAHD", "inid4c", "Koten_Gars",
|
||
"kuchikirukia", "LCE", "NTW", "orz", "RAI", "REVO", "SCP-2223", "Senjou", "SEV", "THORA",
|
||
"Vivid"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 08",
|
||
source: "radarr",
|
||
groups: [
|
||
"AkihitoSubs", "Arukoru", "EDGE", "EMBER", "GHOST", "Judas", "naiyas", "Nep_Blanc", "Prof",
|
||
"Shirσ"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"Arg0", "Arid", "Baws", "FLE", "LostYears", "LYS1TH3A", "McBalls", "sam", "SCY", "Setsugen",
|
||
"smol", "SoM", "Vodes", "Z4ST1N", "ZeroBuild"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"0x539", "Asakura", "Cyan", "Cytox", "Dae", "Foxtrot", "Gao", "GSK_kun", "Half-Baked",
|
||
"HatSubs", "MALD", "MTBB", "Not-Vodes", "Okay-Subs", "Pizza", "Reza", "Slyfox", "SoLCE",
|
||
"Tenshi"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"AnoZu", "Dooky", "Kitsune", "SubsPlus+", "ZR"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 04",
|
||
source: "radarr",
|
||
groups: [
|
||
"Erai-Raws", "ToonsHub", "VARYG"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 05",
|
||
source: "radarr",
|
||
groups: [
|
||
"BlueLobster", "GST", "HorribleRips", "HorribleSubs", "KAN3D2M", "KiyoshiStar", "Lia",
|
||
"NanDesuKa", "PlayWeb", "SobsPlease", "Some-Stuffs", "SubsPlease", "URANIME", "ZigZag"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 06",
|
||
source: "radarr",
|
||
groups: [
|
||
"9volt", "Asenshi", "Chihiro", "Commie", "DameDesuYo", "Doki", "GJM", "Kaleido", "Kantai",
|
||
"KawaSubs", "Tsundere"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Anime Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"Darki", "Delivroozzi", "Fuceo", "Good Job! Alexis", "Punisher694", "SR-71", "T3KASHi",
|
||
"TANOSHii", "Tsundere-Raws"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Anime Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"Aoi-Project", "Elecman", "FUJiSAN", "GundamGuy", "IssouCorp", "KAF", "Nagutos", "OECUF",
|
||
"XSPITFIRE911"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Anime Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"BLV", "D3T3R10R1TY", "Galactic", "HANAMi", "kazuizui", "KHAYA", "KushEnthusiast",
|
||
"matheousse", "Monkey-D.Lulu", "NeoSG", "RONiN", "TheFantastics", "TTN"
|
||
]
|
||
},
|
||
{
|
||
name: "FR HD Bluray Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"BDHD", "FoX", "FRATERNiTY", "FrIeNdS", "MAX", "Psaro", "YODA"
|
||
]
|
||
},
|
||
{
|
||
name: "FR HD Bluray Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"HDForever", "HeavyWeight", "MARBLECAKE", "MYSTERiON", "NoNE", "ONLY", "ONLYMOViE", "TkHD",
|
||
"UTT"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Remux Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"BlackAngel", "Choco", "HDForever", "MAX", "ONLY", "Psaro", "Sicario", "Tezcat74",
|
||
"TyrellCorp", "Zapax"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Remux Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"BDHD", "FtLi", "Goldenyann", "HeavyWeight", "KTM", "MARBLECAKE", "MUSTANG", "Obi", "PEPiTE",
|
||
"QUEBEC63", "ROMKENT"
|
||
]
|
||
},
|
||
{
|
||
name: "FR UHD Bluray Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"FLOP", "FoX", "FRATERNiTY", "Not SDR", "Psaro"
|
||
]
|
||
},
|
||
{
|
||
name: "FR UHD Bluray Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"DUSTiN", "FCK", "FrIeNdS", "Not SDR", "QUALiTY"
|
||
]
|
||
},
|
||
{
|
||
name: "FR WEB Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"BONBON", "FCK", "FoX", "FRATERNiTY", "FrIeNdS", "FW", "MOONLY", "MTDK", "PATOPESTO",
|
||
"Psaro", "RG", "SUPPLY", "TFA", "TiNA"
|
||
]
|
||
},
|
||
{
|
||
name: "FR WEB Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"ALLDAYiN", "ARK01", "HeavyWeight", "NEO", "NoNe", "ONLYMOViE", "POTO", "Slay3R", "TkHD",
|
||
"WaCkS"
|
||
]
|
||
},
|
||
{
|
||
name: "German Bluray Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"CNY", "MAMA", "NIMA4K", "PXL", "TSCC", "TvR", "TVS", "WalterBishop", "WeebPinn", "ZeroTwo",
|
||
"ZeroTwo Aliases"
|
||
]
|
||
},
|
||
{
|
||
name: "German Bluray Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"ABJ", "MULTiPLEX", "Oergel", "paranoid06", "RocketHD", "SiXTYNiNE", "VECTOR"
|
||
]
|
||
},
|
||
{
|
||
name: "German Bluray Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"FX", "HDSource", "iNCEPTION", "LeetHD", "RDR", "RHD", "RobertDeNiro", "UNFIrED"
|
||
]
|
||
},
|
||
{
|
||
name: "German Remux Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"MAMA", "NIMA4K", "pmHD", "QfG", "TvR", "WeebPinn"
|
||
]
|
||
},
|
||
{
|
||
name: "German Remux Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"FX", "HDSource", "iNCEPTION", "MULTiPLEX", "RHD", "RocketHD"
|
||
]
|
||
},
|
||
{
|
||
name: "German Web Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"CNY", "D02KU", "MEDiATHEK", "NIMA4K", "pmHD", "PXL", "QfG", "RiiR", "RiiR Aliases", "TSCC",
|
||
"TvR", "TVS", "WalterBishop", "WeebPinn", "ZeroTwo", "ZeroTwo Aliases"
|
||
]
|
||
},
|
||
{
|
||
name: "German Web Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"4SF", "ABJ", "MULTiPLEX", "Oergel", "paranoid06", "SiXTYNiNE", "VECTOR"
|
||
]
|
||
},
|
||
{
|
||
name: "German Web Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"BALENCiAGA", "FX", "HDSource", "RDR", "RobertDeNiro"
|
||
]
|
||
},
|
||
{
|
||
name: "HD Bluray Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"BBQ", "BMF", "c0kE", "Chotab", "CRiSC", "CtrlHD", "D-Z0N3", "Dariush", "decibeL", "DON",
|
||
"EbP", "EDPH", "Geek", "LolHD", "NCmt", "PTer", "TayTO", "TDD", "TnP", "VietHD",
|
||
"ZoroSenpai", "ZQ"
|
||
]
|
||
},
|
||
{
|
||
name: "HD Bluray Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"ATELiER", "EA", "HiDt", "HiSD", "iFT", "NTb", "QOQ", "SA89", "sbR"
|
||
]
|
||
},
|
||
{
|
||
name: "HD Bluray Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"BHDStudio", "hallowed", "HiFi", "HONE", "LoRD", "playHD", "SPHD", "W4NK3R"
|
||
]
|
||
},
|
||
{
|
||
name: "Remux Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"3L", "BiZKiT", "BLURANiUM", "BMF", "CiNEPHiLES", "FraMeSToR", "PiRAMiDHEAD", "PmP",
|
||
"WiLDCAT", "ZQ"
|
||
]
|
||
},
|
||
{
|
||
name: "Remux Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"ATELiER", "NCmt", "playBD", "SiCFoI", "SURFINBIRD", "TEPES"
|
||
]
|
||
},
|
||
{
|
||
name: "Remux Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"12GaugeShotgun", "decibeL", "EPSiLON", "HiFi", "iFT", "KRaLiMaRKo", "NTb", "PTP",
|
||
"SumVision", "TOA", "TRiToN"
|
||
]
|
||
},
|
||
{
|
||
name: "UHD Bluray Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"CtrlHD", "DON", "MainFrame", "W4NK3R"
|
||
]
|
||
},
|
||
{
|
||
name: "UHD Bluray Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"HQMUX"
|
||
]
|
||
},
|
||
{
|
||
name: "UHD Bluray Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"BHDStudio", "hallowed", "HONE", "PTer", "SPHD", "WEBDV"
|
||
]
|
||
},
|
||
{
|
||
name: "WEB Tier 01",
|
||
source: "radarr",
|
||
groups: [
|
||
"ABBIE", "AJP69", "APEX", "BLUTONiUM", "BYNDR", "CMRG", "CRFW", "CRUD", "FLUX", "GNOME",
|
||
"HONE", "KiNGS", "Kitsune", "NOSiViD", "NTb", "NTG", "RAWR", "SiC", "TEPES", "TheFarm",
|
||
"ZoroSenpai"
|
||
]
|
||
},
|
||
{
|
||
name: "WEB Tier 02",
|
||
source: "radarr",
|
||
groups: [
|
||
"dB", "Flights", "MiU", "monkee", "MZABI", "PHOENiX", "playWEB", "SbR", "SMURF", "TOMMY",
|
||
"XEBEC"
|
||
]
|
||
},
|
||
{
|
||
name: "WEB Tier 03",
|
||
source: "radarr",
|
||
groups: [
|
||
"BLOOM", "Dooky", "GNOMiSSiON", "HHWEB", "NINJACENTRAL", "NPMS", "ROCCaT", "SiGMA",
|
||
"SLiGNOME", "SwAgLaNdEr"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"DemiHuman", "FLE", "Flugel", "LYS1TH3A", "Moxie", "NAN0", "sam", "smol", "SoM", "ZR"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Aergia", "Arg0", "Arid", "FateSucks", "hchcsen", "hydes", "JOHNTiTOR", "JySzE", "koala",
|
||
"Kulot", "LostYears", "Lulu", "Meakes", "Orphan", "PMR", "Vodes", "WAP", "YURI", "ZeroBuild"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ARC", "BBT-RMX", "cappybara", "ChucksMux", "CRUCiBLE", "CUNNY", "Cunnysseur", "Doc", "fig",
|
||
"Headpatter", "Inka-Subs", "LaCroiX", "Legion", "Mehul", "MTBB", "Mysteria", "Netaro",
|
||
"Noiy", "npz", "NTRX", "Okay-Subs", "P9", "RaiN", "RMX", "RUDY", "Sekkon", "Serendipity",
|
||
"sgt", "SubsMix", "uba"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 04",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ABdex", "Afro", "aRMX", "BiRJU", "BKC", "CBT", "Chimera", "derp", "DIY", "EXP", "Foxtrot",
|
||
"grimf", "IK", "Iznjie Biznjie", "Kaleido-subs", "Kametsu", "Kawatare", "KH", "LazyRemux",
|
||
"Metal", "MK", "neko-kBaraka", "OZR", "Pizza", "pog42", "Quetzal", "Reza", "SCY", "Shimatta",
|
||
"Smoke", "Spirale", "UDF", "UQW", "Vanilla", "Virtuality", "VULCAN"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 05",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Animorphs", "AOmundson", "ASC", "B00BA", "Baws", "Beatrice", "Cait-Sidhe", "CsS", "CTR",
|
||
"D4C", "deanzel", "Drag", "eldon", "Freehold", "GHS", "Hark0N", "Holomux", "Judgement", "MC",
|
||
"mottoj", "NH", "NTRM", "o7", "QM", "Thighs", "TTGA", "UltraRemux", "WBDP", "WSE", "Yuki"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 06",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ANE", "Bunny-Apocalypse", "CyC", "Datte13", "EJF", "GetItTwisted", "GSK_kun", "iKaos",
|
||
"karios", "Pookie", "RASETSU", "Starbez", "Tsundere", "Yoghurt", "YURASUKA"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 07",
|
||
source: "sonarr",
|
||
groups: [
|
||
"9volt", "AC", "Almighty", "Asakura", "Asenshi", "BlurayDesuYo", "Bolshevik", "Brrrrrrr",
|
||
"Chihiro", "Commie", "Crow", "Dae", "Dekinai", "Dragon-Releases", "DragsterPS",
|
||
"Exiled-Destiny", "FFF", "Final8", "Geonope", "GJM", "iAHD", "inid4c", "Koten_Gars",
|
||
"kuchikirukia", "LCE", "NTW", "orz", "RAI", "REVO", "SCP-2223", "Senjou", "SEV", "THORA",
|
||
"Vivid"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime BD Tier 08",
|
||
source: "sonarr",
|
||
groups: [
|
||
"AkihitoSubs", "Arukoru", "EDGE", "EMBER", "GHOST", "Judas", "naiyas", "Nep_Blanc", "Prof",
|
||
"Shirσ"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Arg0", "Arid", "Baws", "FLE", "LostYears", "LYS1TH3A", "McBalls", "sam", "SCY", "Setsugen",
|
||
"smol", "SoM", "Vodes", "Z4ST1N", "ZeroBuild"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"0x539", "Asakura", "Cyan", "Cytox", "Dae", "Foxtrot", "Gao", "GSK_kun", "Half-Baked",
|
||
"HatSubs", "MALD", "MTBB", "Not-Vodes", "Okay-Subs", "Pizza", "Reza", "Slyfox", "SoLCE",
|
||
"Tenshi"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"AnoZu", "Dooky", "Kitsune", "SubsPlus+", "ZR"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 04",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Erai-Raws", "ToonsHub", "VARYG"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 05",
|
||
source: "sonarr",
|
||
groups: [
|
||
"BlueLobster", "GST", "HorribleRips", "HorribleSubs", "KAN3D2M", "KiyoshiStar", "Lia",
|
||
"NanDesuKa", "PlayWeb", "SobsPlease", "Some-Stuffs", "SubsPlease", "URANIME", "ZigZag"
|
||
]
|
||
},
|
||
{
|
||
name: "Anime Web Tier 06",
|
||
source: "sonarr",
|
||
groups: [
|
||
"9volt", "Asenshi", "Chihiro", "Commie", "DameDesuYo", "Doki", "GJM", "Kaleido", "Kantai",
|
||
"KawaSubs", "Tsundere"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Anime Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Darki", "Delivroozzi", "Fuceo", "Good Job! Alexis", "Punisher694", "SR-71", "T3KASHi",
|
||
"TANOSHii", "Tsundere-Raws"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Anime Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Aoi-Project", "Elecman", "FUJiSAN", "GundamGuy", "IssouCorp", "KAF", "Nagutos", "OECUF",
|
||
"XSPITFIRE911"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Anime Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"BLV", "D3T3R10R1TY", "Galactic", "HANAMi", "kazuizui", "KHAYA", "KushEnthusiast",
|
||
"matheousse", "Monkey-D.Lulu", "NeoSG", "RONiN", "TheFantastics", "TTN"
|
||
]
|
||
},
|
||
{
|
||
name: "FR HD Bluray Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ARK01", "BONBON", "FRATERNiTY", "FTMVHD", "HeavyWeight", "Psaro"
|
||
]
|
||
},
|
||
{
|
||
name: "FR Remux Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"FtLi", "Goldenyann", "HDForever", "HeavyWeight", "ONLY", "Psaro", "TyrellCorp"
|
||
]
|
||
},
|
||
{
|
||
name: "FR WEB Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"BONBON", "FCK", "FRATERNiTY", "FW", "MTDK", "NoLo", "PATOPESTO", "Psaro", "SUPPLY", "TFA",
|
||
"TiNA"
|
||
]
|
||
},
|
||
{
|
||
name: "FR WEB Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"COLL3CTiF", "FiND", "FrIeNdS", "HeavyWeight", "NoNe", "pERsO", "POTO", "RG", "RiPiT", "TAT"
|
||
]
|
||
},
|
||
{
|
||
name: "FR WEB Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ARK01", "BraD", "dRuIdE", "FTMVHD", "LAZARUS", "MYSTERiON", "Scaph", "WaCkS", "WQM"
|
||
]
|
||
},
|
||
{
|
||
name: "German Bluray Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"CNY", "NIMA4K", "PXL", "TSCC", "TvR", "TVS", "WalterBishop", "WeebPinn", "ZeroTwo",
|
||
"ZeroTwo Aliases"
|
||
]
|
||
},
|
||
{
|
||
name: "German Bluray Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ABJ", "MULTiPLEX", "Oergel", "SiXTYNiNE", "VECTOR"
|
||
]
|
||
},
|
||
{
|
||
name: "German Bluray Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"HDSource", "HQC", "RDR", "RobertDeNiro"
|
||
]
|
||
},
|
||
{
|
||
name: "German Remux Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"NIMA4K", "pmHD", "QfG", "TSCC", "TvR"
|
||
]
|
||
},
|
||
{
|
||
name: "German Remux Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"HDSource", "HQC", "MULTiPLEX"
|
||
]
|
||
},
|
||
{
|
||
name: "German Web Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"CNY", "MEDiATHEK", "NIMA4K", "PXL", "QfG", "RiiR", "RiiR Aliases", "TSCC", "TvR", "TVS",
|
||
"WalterBishop", "WeebPinn", "ZeroTwo", "ZeroTwo Aliases"
|
||
]
|
||
},
|
||
{
|
||
name: "German Web Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"4SF", "4SF Aliases", "ABJ", "MULTiPLEX", "Oergel", "SiXTYNiNE", "VECTOR"
|
||
]
|
||
},
|
||
{
|
||
name: "German Web Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"BALENCiAGA", "HDSource", "HQC", "iNCEPTION", "RDR", "RobertDeNiro"
|
||
]
|
||
},
|
||
{
|
||
name: "HD Bluray Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"Chotab", "CtrlHD", "DON", "EbP", "NTb", "PTer"
|
||
]
|
||
},
|
||
{
|
||
name: "HD Bluray Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"SA89", "sbR"
|
||
]
|
||
},
|
||
{
|
||
name: "Remux Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"BLURANiUM", "BMF", "FraMeSToR", "PmP"
|
||
]
|
||
},
|
||
{
|
||
name: "Remux Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"12GaugeShotgun", "decibeL", "EPSiLON", "HiFi", "KRaLiMaRKo", "playBD", "PTer", "SiCFoI",
|
||
"TRiToN"
|
||
]
|
||
},
|
||
{
|
||
name: "WEB Tier 01",
|
||
source: "sonarr",
|
||
groups: [
|
||
"ABBiE", "AJP69", "APEX", "CasStudio", "CRFW", "CtrlHD", "FLUX", "HONE", "KiNGS", "Kitsune",
|
||
"monkee", "NOSiViD", "NTb", "NTG", "QOQ", "RAWR", "RTN", "SiC", "T6D", "TOMMY", "ViSUM"
|
||
]
|
||
},
|
||
{
|
||
name: "WEB Tier 02",
|
||
source: "sonarr",
|
||
groups: [
|
||
"3cTWeB", "BLUTONiUM", "BTW", "BYNDR", "Chotab", "Cinefeel", "CiT", "CMRG", "Coo7", "dB",
|
||
"DEEP", "END", "ETHiCS", "FC", "Flights", "GNOME", "iJP", "iKA", "iT00NZ", "JETIX", "KHN",
|
||
"KiMCHI", "LAZY", "MiU", "MZABI", "NPMS", "NYH", "orbitron", "PHOENiX", "playWEB", "PSiG",
|
||
"ROCCaT", "RTFM", "SA89", "SbR", "SDCC", "SIGMA", "SMURF", "SPiRiT", "TEPES", "TVSmash",
|
||
"WELP", "XEBEC"
|
||
]
|
||
},
|
||
{
|
||
name: "WEB Tier 03",
|
||
source: "sonarr",
|
||
groups: [
|
||
"BLOOM", "Dooky", "DRACULA", "HHWEB", "NINJACENTRAL", "SLiGNOME", "SwAgLaNdEr", "T4H",
|
||
"ViSiON"
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
|
||
const DEFAULT_SELECTORS = {
|
||
torrentName: "h1.torrent__name",
|
||
tmdbTitle: "h1.meta__title",
|
||
category: "li.torrent__category a",
|
||
type: "li.torrent__type a",
|
||
resolution: "li.torrent__resolution a",
|
||
originalLanguage: ".work__language-link",
|
||
torrentTags: "ul.torrent__tags",
|
||
panels: "section.panelV2, div.panelV2",
|
||
panelHeading: ".panel__heading",
|
||
descriptionBody: ".panel__body.bbcode-rendered",
|
||
descriptionHeading: "Description",
|
||
mediaInfoDump: '.torrent-mediainfo-dump code, code[x-ref="mediainfo"]',
|
||
mediaInfoHeading: "MediaInfo",
|
||
bdInfoHeading: "BDInfo",
|
||
moderationHeading: "Moderation",
|
||
mediaInfoFilename: "section.mediainfo__filename span, .mediainfo__filename span",
|
||
mediaInfoAudioFlags: ".mediainfo__audio dl dd img",
|
||
mediaInfoSubFlags: ".mediainfo__subtitles ul li img",
|
||
mediaInfoVideoDt: ".mediainfo__video dt",
|
||
fileHierarchy: '.dialog__form[data-tab="hierarchy"]',
|
||
fileList: '.dialog__form[data-tab="list"] table.data-table tbody',
|
||
moderationForms: 'form[action*="moderation"]',
|
||
moderationStatus: 'input[name="status"]',
|
||
moderationMessage: 'textarea[name="message"]',
|
||
};
|
||
|
||
|
||
const INSTANCE_CONFIGS = {
|
||
"darkpeers.org": {
|
||
name: "DarkPeers",
|
||
rulesUrl: "https://darkpeers.org/wikis/13",
|
||
features: {
|
||
dpChecks: true,
|
||
dpTitleValidation: true,
|
||
prowlarr: true,
|
||
srrdb: true,
|
||
},
|
||
},
|
||
};
|
||
|
||
const DEFAULT_MOD_STATUSES = { postpone: "3", reject: "2" };
|
||
|
||
/* Resolved at runtime by main() — accessible by E and U */
|
||
let _resolvedSelectors = DEFAULT_SELECTORS;
|
||
let _resolvedModStatuses = DEFAULT_MOD_STATUSES;
|
||
|
||
|
||
/* ========================================================================
|
||
* SETTINGS — Persistent user configuration via GM storage
|
||
* ======================================================================== */
|
||
|
||
const Settings = {
|
||
_KEY: "modq_settings",
|
||
_defaults: {
|
||
prowlarr: { url: "", apiKey: "", enabled: false, preferredIndexers: [], ignoredIndexers: [], timeout: 15000 },
|
||
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>
|
||
<label style="display:flex;flex-direction:column;gap:4px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
Preferred indexers:
|
||
<input type="text" data-setting="prowlarr.preferredIndexers" value="${(s.prowlarr.preferredIndexers || []).join(", ")}" placeholder="e.g. Aither, BLU (most preferred first)"
|
||
style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:4px 8px;font-size:12px;">
|
||
<small style="color:#64748b;font-size:11px;">Comma-separated. When tied matches exist on multiple indexers, prefer these.</small>
|
||
</label>
|
||
<label style="display:flex;flex-direction:column;gap:4px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
Ignored indexers:
|
||
<input type="text" data-setting="prowlarr.ignoredIndexers" value="${(s.prowlarr.ignoredIndexers || []).join(", ")}" placeholder="e.g. TorrentLeech, RARBG"
|
||
style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:4px 8px;font-size:12px;">
|
||
<small style="color:#64748b;font-size:11px;">Comma-separated. Results from these indexers are excluded from Prowlarr matches.</small>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#cbd5e1;font-size:13px;">
|
||
Prowlarr timeout (ms):
|
||
<input type="number" data-setting="prowlarr.timeout" value="${s.prowlarr.timeout || 15000}" min="1000" max="60000" step="1000"
|
||
style="width:80px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:4px;padding:2px 6px;">
|
||
<small style="color:#64748b;font-size:11px;">Default: 15000</small>
|
||
</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");
|
||
let val = el.type === "checkbox" ? el.checked
|
||
: el.type === "number" ? parseInt(el.value, 10)
|
||
: el.value;
|
||
// Parse comma-separated list settings into arrays
|
||
if (key === "prowlarr.preferredIndexers" || key === "prowlarr.ignoredIndexers") {
|
||
val = typeof val === "string" ? val.split(",").map(s => s.trim()).filter(Boolean) : [];
|
||
}
|
||
const parts = key.split(".");
|
||
let target = s;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
if (!target[parts[i]]) target[parts[i]] = {};
|
||
target = target[parts[i]];
|
||
}
|
||
target[parts[parts.length - 1]] = val;
|
||
});
|
||
self.save(s);
|
||
modal.remove();
|
||
console.log("[ModQ Settings] Saved:", s);
|
||
});
|
||
},
|
||
|
||
init() {
|
||
if (typeof GM_registerMenuCommand === "function") {
|
||
GM_registerMenuCommand("ModQ Helper Settings", () => this.render());
|
||
}
|
||
},
|
||
};
|
||
|
||
Settings.init();
|
||
|
||
|
||
/* ========================================================================
|
||
* HELPERS — Pure parsing & utility functions (no DOM access)
|
||
* Ported from the original H object + z (diff tool)
|
||
* ======================================================================== */
|
||
|
||
const H={extractReleaseGroup(e){if(!e)return null;let n=e.replace(g.releaseGroupSuffixes,"");const t=g.exceptionGroupNames.find(o=>n.endsWith("-"+o)||n.endsWith("- "+o));if(t)return t;const s=n.match(/[(\[]([^\]()]+)[)\]]$/);if(s){const o=s[1];if(g.bracketGroupNames.some(r=>r.toLowerCase()===o.toLowerCase()))return o}const a=n.match(/-([A-Za-z0-9$!._&+\$™]+)$/i);return a?a[1]:null},findTieredGroup(e,n){if(!e)return null;const t=n?"sonarr":"radarr",s=e.toLowerCase(),a=[];for(const o of g._tieredGroupsRaw)o.source===t&&o.groups.some(c=>c.toLowerCase()===s)&&a.push(o.name);return a.length>0?a:null},extractYear(e){if(!e)return null;const n=e.match(/\b(19|20)\d{2}\b/);return n?n[0]:null},countScreenshots(e){if(!e)return{count:0,urls:[]};const n=[],t=/\[img\](.*?)\[\/img\]/gi;let s;for(;(s=t.exec(e))!==null;)n.push(s[1]);const a=/<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},detectAudioObjectFromTracks(tracks){if(!tracks||tracks.length===0)return null;const def=tracks.find(t=>t.isDefault)||tracks[0];if(!def)return null;const codec=(def.codec||"").toLowerCase();const comm=(def.commercialName||"").toLowerCase();const title=(def.title||"").toLowerCase();if(comm.includes("atmos")||title.includes("atmos")||codec.includes("joc")||(codec.includes("e-ac-3")&&title.includes("joc")))return"Atmos";if(comm.includes("auro")||title.includes("auro 3d")||title.includes("auro3d"))return"Auro3D";return null},extractTitleElements(e,n){if(!e)return{elements:[],positions:{}};const t=[],s={},a=e,o=(h,i,l)=>{i!==null&&l!==-1&&(t.push({type:h,value:i,position:l}),s[h]=l)},c=a.match(/\b(19|20)\d{2}\b/);c&&o("year",c[0],c.index);const r=a.match(/\bS(\d{1,2})(?:E(\d{1,2}))?\b/i);r&&o("season",r[0],r.index);for(const h of g.validResolutions){const i=a.indexOf(h);if(i!==-1){o("resolution",h,i);break}}const d=[...g.hdrFormats].sort((h,i)=>i.length-h.length);for(const h of d){const i=new RegExp("\\b"+h.replace(/[+]/g,"\\+")+"\\b","i"),l=a.match(i);if(l){o("hdr",l[0],l.index);break}}const f=[...g.validVideoCodecs].sort((h,i)=>i.length-h.length);for(const h of f){const i=new RegExp(h.replace(/[.]/g,"\\.?"),"i"),l=a.match(i);if(l){o("vcodec",l[0],l.index);break}}const A=[...g.validAudioCodecs].sort((h,i)=>i.length-h.length);for(const h of A){const i=h.replace(/[+]/g,"\\+").replace(/[-.]/g,"[-.]?"),l=new RegExp("(?<![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 = (typeof E !== "undefined" && E?.isTV) ? E.isTV() : !1,
|
||
r = H.extractYear(o);
|
||
let d = "fail",
|
||
f = "No year found";
|
||
r ? o.includes(`(${r})`) ? (d = "warn", f = `Found: (${r}) - Remove parentheses`) : (d = "pass", f = `Found: ${r}`) : c ? (d = "pass", f = "No year found (Optional for TV)") : (d = "fail", f = "No year found (Required for Movies)"), a.checks.push({
|
||
name: "Year",
|
||
status: d,
|
||
message: f,
|
||
required: !c
|
||
});
|
||
const A = g.validResolutions.find(R => o.includes(R)),
|
||
T = /\b(NTSC|PAL)\b/i.test(o),
|
||
b = g.fullDiscTypes.some(R => n?.includes(R)),
|
||
u = A || T,
|
||
D = A || (T ? o.match(/\b(NTSC|PAL)\b/i)[1] : null),
|
||
x = s || "";
|
||
let p = u ? "pass" : "fail",
|
||
y = u ? `Found: ${D}` : "No valid resolution found";
|
||
!u && x === "Other" && (p = "warn", y = "Non-standard resolution (tagged as Other)"), a.checks.push({
|
||
name: "Resolution",
|
||
status: p,
|
||
message: y,
|
||
required: !0
|
||
});
|
||
const C = [...g.validAudioCodecs].sort((R, P) => P.length - R.length);
|
||
let h = null;
|
||
for (const R of C) {
|
||
const P = R.replace(/[+]/g, "\\+").replace(/[-.]/g, "[-.]?");
|
||
if (new RegExp("(?<![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" : "warn",
|
||
message: l ? `Found: ${l}` : b ? "N/A for Full Disc" : "No release group tag detected (allowed on DarkPeers if none exists)",
|
||
required: !1
|
||
});
|
||
const m = g.fullDiscTypes.some(R => n?.includes(R));
|
||
// Prefer track-based detection (default track only) over raw-text detection (all tracks)
|
||
const _audioTracks = (typeof E !== "undefined" && E?.getAudioTracksFromMediaInfo) ? E.getAudioTracksFromMediaInfo() : [];
|
||
const S = m ? null : (_audioTracks.length > 0 ? H.detectAudioObjectFromTracks(_audioTracks) : H.detectAudioObject(t)),
|
||
N = /Atmos/i.test(o),
|
||
$ = /Auro/i.test(o);
|
||
let M = "pass",
|
||
B = "No object audio detected";
|
||
S === "Atmos" ? N ? (M = "pass", B = "Atmos detected & in title") : (M = "warn", B = "Atmos detected in MediaInfo but missing from Title") : S === "Auro3D" ? $ ? (M = "pass", B = "Auro3D detected & in title") : (M = "warn", B = "Auro3D detected in MediaInfo but missing from Title") : (N || $) && (m ? (M = "pass", B = `${N?"Atmos":"Auro3D"} in title (Full Disc - MediaInfo not validated)`) : (M = "warn", B = "Object tag in title but not confirmed in MediaInfo")), (S || N || $) && a.checks.push({
|
||
name: "Audio Object",
|
||
status: M,
|
||
message: B,
|
||
required: !!S
|
||
});
|
||
const I = this.checkSourceForType(o, n);
|
||
a.checks.push(I);
|
||
const J = [...g.validVideoCodecs].sort((R, P) => P.length - R.length);
|
||
let V = null;
|
||
for (const R of J)
|
||
if (new RegExp(R.replace(/[.]/g, "\\.?"), "i").test(o)) {
|
||
V = R;
|
||
break
|
||
} const K = g.fullDiscTypes.some(R => n?.includes(R)) || g.remuxTypes.some(R => n?.toUpperCase().includes(R.toUpperCase()));
|
||
if (a.checks.push({
|
||
name: "Video Codec",
|
||
status: V ? "pass" : K ? "na" : "warn",
|
||
message: V ? `Found: ${V}` : K ? "N/A for Full Disc/REMUX" : "No video codec found (may be implied)",
|
||
required: !0
|
||
}), (function(){ const _hdrInTitle = g.hdrFormats.some(fmt => new RegExp("\\b" + fmt.replace(/[+]/g, "\\+") + "\\b", "i").test(o)); const _hdrInMI = (typeof E !== "undefined" && E?.getHdrFromMediaInfo) ? E.getHdrFromMediaInfo() : []; return o.includes("2160p") || o.includes("4320p") || _hdrInTitle || _hdrInMI.length > 0; })()) {
|
||
const R = (typeof E !== "undefined" && E?.getHdrFromMediaInfo) ? E.getHdrFromMediaInfo() : [],
|
||
P = [...g.hdrFormats].sort((L, O) => O.length - L.length);
|
||
let v = null;
|
||
for (const L of P)
|
||
if (new RegExp("(?:^|\\s)" + L.replace(/[+]/g, "\\+") + "(?:\\s|$)", "i").test(o)) {
|
||
v = L.toUpperCase();
|
||
break
|
||
} let _ = "pass",
|
||
F = "";
|
||
const q = /\bHDR10\b/i.test(o) && !/\bHDR10\+/i.test(o);
|
||
if (m) q && (!v || v === "HDR10") ? (_ = "fail", F = '"HDR10" in title should be renamed to "HDR"') : v ? (_ = "pass", F = `HDR in title: ${v} (Full Disc - MediaInfo not validated)`) : F = "SDR (no HDR in title)";
|
||
else if (R.length === 0) q && (!v || v === "HDR10") ? (_ = "fail", F = '"HDR10" in title should be renamed to "HDR"') : v ? (_ = "warn", F = `Title has ${v} but MediaInfo shows no HDR`) : F = "SDR (no HDR in title or MediaInfo)";
|
||
else {
|
||
const L = R.join(", "),
|
||
O = R.some(ae => ae.startsWith("DV")),
|
||
j = R.includes("HDR10+"),
|
||
Y = R.includes("HDR10"),
|
||
X = R.includes("HDR"),
|
||
te = R.includes("HLG"),
|
||
se = R.includes("PQ10");
|
||
let w = null;
|
||
O && j ? w = "DV HDR10+" : O && (Y || X) ? w = "DV HDR" : O ? w = "DV" : j ? w = "HDR10+" : Y || X ? w = "HDR" : te ? w = "HLG" : se && (w = "PQ10"), v && w && v === w.toUpperCase() ? (_ = "pass", F = `Correct: ${w} (MediaInfo: ${L})`) : !v && !w ? F = "SDR (no HDR in title or MediaInfo)" : v ? w ? (_ = "fail", F = `Wrong HDR tag - MediaInfo shows ${L}, title has ${v} but should be: ${w}`) : (_ = "warn", F = `Title has ${v} but could not determine expected tag from MediaInfo (${L})`) : (_ = "fail", F = `Missing HDR tag - MediaInfo shows ${L}, title should include: ${w}`)
|
||
}
|
||
a.checks.push({
|
||
name: "HDR Format",
|
||
status: _,
|
||
message: F,
|
||
required: R.length > 0
|
||
})
|
||
}
|
||
const Q = a.checks.some(R => R.required && R.status === "fail"),
|
||
ee = a.checks.some(R => R.status === "warn") || a.checks.some(R => !R.required && R.status === "fail");
|
||
return a.status = Q ? "fail" : ee ? "warn" : "pass", a
|
||
},
|
||
|
||
checkSourceForType(e, n) {
|
||
e.toUpperCase();
|
||
const t = n?.toUpperCase() || "";
|
||
let s = [],
|
||
a = "Unknown";
|
||
g.fullDiscTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.fullDisc, a = "Full Disc") : g.remuxTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.remux, a = "REMUX") : g.encodeTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.encode, a = "Encode") : g.webTypes.some(c => t.includes(c.toUpperCase())) ? (s = [...g.sources.web, ...g.streamingServices], a = "WEB") : g.hdtvTypes.some(c => t.includes(c.toUpperCase())) ? (s = g.sources.hdtv, a = "HDTV") : (s = [...g.sources.fullDisc, ...g.sources.remux, ...g.sources.encode, ...g.sources.web, ...g.sources.hdtv, ...g.streamingServices], s = [...new Set(s)]);
|
||
let o = null;
|
||
for (const c of s)
|
||
if (new RegExp(c.replace(/[-.]/g, "[-. ]?"), "i").test(e)) {
|
||
o = c;
|
||
break
|
||
} return !o && a === "Encode" && /blu-?ray/i.test(e) && (o = "BluRay"), {
|
||
name: "Source",
|
||
status: o ? "pass" : "fail",
|
||
message: o ? `Found: ${o}${a!=="Unknown"?` (valid for ${a})`:""}` : `No valid source for ${a} type`,
|
||
required: !0
|
||
}
|
||
},
|
||
|
||
titleElementOrder(e, n) {
|
||
const {
|
||
elements: t,
|
||
positions: s
|
||
} = H.extractTitleElements(e, n);
|
||
if (t.length < 3) return {
|
||
status: "warn",
|
||
message: "Too few elements detected to validate order",
|
||
details: null,
|
||
violations: []
|
||
};
|
||
const a = g.fullDiscTypes.some(T => n?.includes(T)) || g.remuxTypes.some(T => n?.toUpperCase().includes(T.toUpperCase())),
|
||
o = a ? g.titleElementOrder.fullDiscRemux : g.titleElementOrder.encodeWeb,
|
||
c = a ? "Full Disc/REMUX" : "Encode/WEB",
|
||
r = [],
|
||
d = t.map(T => T.type);
|
||
for (let T = 0; T < d.length; T++)
|
||
for (let b = T + 1; b < d.length; b++) {
|
||
const u = d[T],
|
||
D = d[b],
|
||
x = o.indexOf(u),
|
||
p = o.indexOf(D);
|
||
x === -1 || p === -1 || x > p && r.push({
|
||
first: {
|
||
type: u,
|
||
value: t[T].value
|
||
},
|
||
second: {
|
||
type: D,
|
||
value: t[b].value
|
||
},
|
||
message: `"${t[T].value}" (${u}) should come after "${t[b].value}" (${D})`
|
||
})
|
||
}
|
||
if (r.length === 0) return {
|
||
status: "pass",
|
||
message: `Element order correct for ${c}`,
|
||
details: null,
|
||
violations: []
|
||
};
|
||
const f = r.find(T => T.first.type === "hdr" && T.second.type === "vcodec" || T.first.type === "vcodec" && T.second.type === "hdr");
|
||
let A = `${r.length} ordering issue(s) found`;
|
||
return f && (a ? A = "HDR should come BEFORE video codec for Full Disc/REMUX" : A = "HDR should come AFTER video codec for Encode/WEB"), {
|
||
status: "fail",
|
||
message: A,
|
||
details: {
|
||
orderType: c,
|
||
violations: r.map(T => T.message)
|
||
},
|
||
violations: r
|
||
}
|
||
},
|
||
|
||
audioTagCompliance(e, n, t, s, a) {
|
||
const o = g.fullDiscTypes.some(b => s?.includes(b)),
|
||
c = o && /\b(NTSC|PAL|DVD5|DVD9)\b/i.test(e || "");
|
||
if (o && !c) return {
|
||
status: "na",
|
||
message: "N/A - Full Disc (no MediaInfo)",
|
||
details: null,
|
||
checks: []
|
||
};
|
||
const r = [];
|
||
if (t && t.length > 0) {
|
||
const b = (e || "").toLowerCase(),
|
||
u = b.includes("dual-audio") || b.includes("dual audio"),
|
||
D = b.includes("multi"),
|
||
x = t.length,
|
||
p = g.languageMap[n] || n,
|
||
y = n === "en",
|
||
C = i => i.toLowerCase().startsWith("english"),
|
||
h = i => {
|
||
if (!p) return !1;
|
||
const l = i.toLowerCase(),
|
||
m = p.toLowerCase();
|
||
return l === m || l.startsWith(m) || l.includes(m) || m.includes(l) ? !0 : (g.languageAliases[m] || []).some(N => l.includes(N) || N.includes(l))
|
||
};
|
||
if (u)
|
||
if (x > 2) r.push({
|
||
name: "Language Tags",
|
||
status: "fail",
|
||
message: `Tagged Dual-Audio but found ${x} languages (${t.join(", ")}). Use "MULTi" for 3+ languages`
|
||
});
|
||
else if (x < 2) r.push({
|
||
name: "Language Tags",
|
||
status: "fail",
|
||
message: `Tagged Dual-Audio but found only ${x} language`
|
||
});
|
||
else r.push({
|
||
name: "Language Tags",
|
||
status: "pass",
|
||
message: `Dual-Audio correct (${t.join(" + ")})`
|
||
});
|
||
else if (D) x < 2 ? r.push({
|
||
name: "Language Tags",
|
||
status: "fail",
|
||
message: `"Multi" used but found only ${x} language`
|
||
}) : r.push({
|
||
name: "Language Tags",
|
||
status: "pass",
|
||
message: `Multi-Audio correct (${x} languages)`
|
||
});
|
||
else if (x > 2) o || r.push({
|
||
name: "Language Tags",
|
||
status: "warn",
|
||
message: `Found ${x} languages but no "Multi" tag`
|
||
});
|
||
else if (x === 2) {
|
||
const i = t.some(C),
|
||
l = t.some(h);
|
||
!o && i && l && !y ? r.push({
|
||
name: "Language Tags",
|
||
status: "warn",
|
||
message: `Found English + Original (${p}), consider "Dual-Audio" tag`
|
||
}) : r.push({
|
||
name: "Language Tags",
|
||
status: "pass",
|
||
message: `Audio languages OK (${x})`
|
||
})
|
||
} else r.push({
|
||
name: "Language Tags",
|
||
status: "pass",
|
||
message: `Audio languages OK (${x})`
|
||
})
|
||
}
|
||
const d = (typeof E !== "undefined" && E?.getAudioTracksFromMediaInfo) ? E.getAudioTracksFromMediaInfo() : [];
|
||
if (d.length > 0) {
|
||
const b = g.remuxTypes.some(i => s?.toUpperCase().includes(i.toUpperCase())) || /\b(HDTV|PDTV|SDTV)\b/i.test(s || "") || /\bDVD\b/i.test(s || ""),
|
||
u = /\b(HDTV|PDTV|SDTV|DVD)\b/i.test(s || "") || b,
|
||
D = (i, l) => {
|
||
const m = (i || "").toLowerCase(),
|
||
S = (l || "").toLowerCase();
|
||
return m.includes("dts") && (S.includes("dts-hd") || S.includes("dts:x") || S.includes("master audio") || S.includes("dts-hd ma")) ? "DTS-HD" : m.includes("dts") ? "DTS" : m === "e-ac-3" || m.includes("e-ac-3") ? "E-AC-3" : m === "ac-3" || m.includes("ac-3") ? "AC-3" : m.includes("mlp fba") || S.includes("truehd") ? "TrueHD" : m === "flac" || m.includes("flac") ? "FLAC" : m === "opus" || m.includes("opus") ? "Opus" : m === "pcm" || m.includes("pcm") || m.includes("lpcm") ? "LPCM" : m === "aac" || m.includes("aac") ? "AAC" : m === "mpeg audio" && S.includes("mp2") ? "MP2" : m === "mpeg audio" && S.includes("mp3") || m.includes("mp3") || m === "mpeg audio" && !S ? "MP3" : m.includes("mp2") ? "MP2" : m.includes("vorbis") ? "Vorbis" : m.includes("alac") ? "ALAC" : i
|
||
},
|
||
x = i => i ? i === "1.0" || i === "2.0" || i === "1ch" || i === "2ch" : !1,
|
||
p = i => {
|
||
const l = (i.title || "").toLowerCase();
|
||
return l.includes("commentary") || l.includes("comment")
|
||
},
|
||
y = {
|
||
"DTS-HD MA": "DTS-HD",
|
||
"DTS-HD HRA": "DTS-HD",
|
||
"DTS:X": "DTS-HD",
|
||
"DTS-ES": "DTS",
|
||
DTS: "DTS",
|
||
TrueHD: "TrueHD",
|
||
"DD+": "E-AC-3",
|
||
DDP: "E-AC-3",
|
||
"DD EX": "AC-3",
|
||
DD: "AC-3",
|
||
"E-AC-3": "E-AC-3",
|
||
"AC-3": "AC-3",
|
||
LPCM: "LPCM",
|
||
PCM: "LPCM",
|
||
FLAC: "FLAC",
|
||
ALAC: "ALAC",
|
||
AAC: "AAC",
|
||
MP3: "MP3",
|
||
MP2: "MP2",
|
||
Opus: "Opus",
|
||
Vorbis: "Vorbis"
|
||
},
|
||
C = [...g.validAudioCodecs].sort((i, l) => l.length - i.length);
|
||
let h = null;
|
||
for (const i of C) {
|
||
const l = i.replace(/[+]/g, "\\+").replace(/[-.]/g, "[-.]?");
|
||
if (new RegExp("(?<![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 === "LPCM") {
|
||
const N = b; // LPCM multichannel allowed for untouched sources
|
||
!x(l.channels) && !N ? r.push({
|
||
name: S,
|
||
status: "fail",
|
||
message: `${m} only allowed as mono/stereo. Found: ${l.channels||"unknown"}`
|
||
}) : r.push({
|
||
name: S,
|
||
status: "pass",
|
||
message: x(l.channels) ? `${m} mono/stereo OK` : `${m} multichannel (untouched OK)`
|
||
})
|
||
} else if (m === "FLAC" || m === "Opus") {
|
||
r.push({
|
||
name: S,
|
||
status: "pass",
|
||
message: `${m} ${l.channels || "unknown"} OK`
|
||
})
|
||
} else m === "MP2" ? u ? r.push({
|
||
name: S,
|
||
status: "pass",
|
||
message: "MP2 OK (untouched source)"
|
||
}) : r.push({
|
||
name: S,
|
||
status: "fail",
|
||
message: "MP2 only allowed if untouched (HDTV/DVD)"
|
||
}) : m === "MP3" ? p(l) ? r.push({
|
||
name: S,
|
||
status: "pass",
|
||
message: "MP3 OK (commentary track)"
|
||
}) : r.push({
|
||
name: S,
|
||
status: "warn",
|
||
message: "MP3 only allowed for supplementary tracks (e.g. commentary)"
|
||
}) : m === "Vorbis" || m === "ALAC" ? r.push({
|
||
name: S,
|
||
status: "fail",
|
||
message: `${m} is not an allowed audio codec`
|
||
}) : ["DTS", "DTS-HD", "AC-3", "E-AC-3", "TrueHD", "AAC"].includes(m) ? r.push({
|
||
name: S,
|
||
status: "pass",
|
||
message: `${m} OK`
|
||
}) : r.push({
|
||
name: S,
|
||
status: "warn",
|
||
message: `Unrecognized codec: ${l.codec}${l.commercialName?" / "+l.commercialName:""}`
|
||
})
|
||
}
|
||
}
|
||
if (r.length === 0) return {
|
||
status: "na",
|
||
message: "No audio data detected in MediaInfo",
|
||
details: null,
|
||
checks: []
|
||
};
|
||
const f = r.some(b => b.status === "fail"),
|
||
A = r.some(b => b.status === "warn"),
|
||
T = f ? "fail" : A ? "warn" : "pass";
|
||
return {
|
||
status: T,
|
||
message: T === "pass" ? `Audio OK (${d.length} track${d.length!==1?"s":""})` : "Audio issues found",
|
||
details: null,
|
||
checks: r
|
||
}
|
||
},
|
||
|
||
mediaInfoPresent(e, n, t, s) {
|
||
const a = g.fullDiscTypes.some(c => t?.includes(c)),
|
||
o = a && /\b(NTSC|PAL|DVD5|DVD9)\b/i.test(s || "");
|
||
return a && !o ? n ? {
|
||
status: "pass",
|
||
message: "BDInfo present (Full Disc)",
|
||
details: null
|
||
} : e ? {
|
||
status: "warn",
|
||
message: "BDInfo expected for Full Disc",
|
||
details: null
|
||
} : {
|
||
status: "fail",
|
||
message: "BDInfo required for Full Disc uploads",
|
||
details: null
|
||
} : o ? {
|
||
status: "na",
|
||
message: "N/A - DVD Full Disc (BDInfo not applicable)",
|
||
details: null
|
||
} : n ? {
|
||
status: "fail",
|
||
message: "Release is not Full Disc, BDInfo should be empty",
|
||
details: null
|
||
} : e ? {
|
||
status: "pass",
|
||
message: "MediaInfo Present",
|
||
details: null
|
||
} : {
|
||
status: "fail",
|
||
message: "MediaInfo Required",
|
||
details: null
|
||
}
|
||
},
|
||
|
||
subtitleRequirement(e, n, t, s) {
|
||
if (g.fullDiscTypes.some(d => s?.includes(d))) return {
|
||
status: "na",
|
||
message: "N/A - Full Disc (no MediaInfo)",
|
||
details: null
|
||
};
|
||
if (!e || e.length === 0) return {
|
||
status: "na",
|
||
message: "No audio languages detected",
|
||
details: null
|
||
};
|
||
const o = d => {
|
||
const f = d.toLowerCase();
|
||
return f === "english" || f.startsWith("english")
|
||
};
|
||
return e.some(o) ? {
|
||
status: "pass",
|
||
message: "English audio present - subtitles optional",
|
||
details: null
|
||
} : !n || n.length === 0 ? {
|
||
status: "fail",
|
||
message: "No English audio & no subtitles detected",
|
||
details: {
|
||
audio: e.join(", "),
|
||
expected: "English subtitles required for non-English audio"
|
||
}
|
||
} : n.some(o) ? {
|
||
status: "pass",
|
||
message: "Non-English audio with English subtitles",
|
||
details: null
|
||
} : {
|
||
status: "fail",
|
||
message: "Non-English audio requires English subtitles",
|
||
details: {
|
||
audio: e.join(", "),
|
||
subtitles: n.join(", ") || "None detected",
|
||
expected: "English subtitles"
|
||
}
|
||
}
|
||
},
|
||
|
||
screenshotCount(e) {
|
||
const {
|
||
count: n,
|
||
urls: t
|
||
} = H.countScreenshots(e);
|
||
return n >= g.minScreenshots ? {
|
||
status: "pass",
|
||
count: n,
|
||
message: `${n} screenshots found`,
|
||
details: null
|
||
} : n > 0 ? {
|
||
status: "warn",
|
||
count: n,
|
||
message: `Only ${n} screenshot(s) found (${g.minScreenshots}+ required)`,
|
||
details: null
|
||
} : {
|
||
status: "fail",
|
||
count: 0,
|
||
message: "No screenshots found in description",
|
||
details: null
|
||
}
|
||
},
|
||
|
||
containerFormat(e, n) {
|
||
if (g.fullDiscTypes.some(r => n?.includes(r))) return {
|
||
status: "na",
|
||
message: "N/A - Full Disc uploads use native folder structure",
|
||
details: null
|
||
};
|
||
if (!e || !e.files || e.files.length === 0) return {
|
||
status: "warn",
|
||
message: "Could not determine file structure to verify container",
|
||
details: null
|
||
};
|
||
const s = [".mkv", ".mp4", ".avi", ".wmv", ".m4v", ".ts", ".m2ts", ".vob", ".mpg", ".mpeg", ".mov", ".flv", ".webm"],
|
||
a = e.files.filter(r => {
|
||
const d = r.toLowerCase();
|
||
return s.some(f => d.endsWith(f))
|
||
});
|
||
if (a.length === 0) return {
|
||
status: "warn",
|
||
message: "No video files detected in file list",
|
||
details: null
|
||
};
|
||
const allowedExts = [".mkv", ".mp4"];
|
||
const o = a.filter(r => !allowedExts.some(ext => r.toLowerCase().endsWith(ext)));
|
||
return o.length === 0 ? {
|
||
status: "pass",
|
||
message: `Container verified (${a.length} video file${a.length>1?"s":""})`,
|
||
details: null
|
||
} : {
|
||
status: "fail",
|
||
message: `Unsupported container detected: ${[...new Set(o.map(r=>r.split(".").pop().toUpperCase()))].join(", ")}`,
|
||
details: {
|
||
expected: "MKV or MP4 container for all non-Full Disc releases",
|
||
found: o.join(", ")
|
||
}
|
||
}
|
||
},
|
||
|
||
packUniformity(e, n) {
|
||
if (g.fullDiscTypes.some(b => n?.includes(b))) return {
|
||
status: "na",
|
||
message: "N/A - Full Disc",
|
||
details: null,
|
||
checks: []
|
||
};
|
||
if (!e || !e.files || e.files.length === 0) return {
|
||
status: "na",
|
||
message: "N/A - No files detected",
|
||
details: null,
|
||
checks: []
|
||
};
|
||
const s = [".mkv", ".mp4", ".avi", ".wmv", ".m4v", ".ts", ".m2ts", ".vob", ".mpg", ".mpeg", ".mov", ".flv", ".webm"],
|
||
a = e.files.filter(b => {
|
||
const u = b.toLowerCase();
|
||
return s.some(D => u.endsWith(D))
|
||
});
|
||
if (a.length < 2) return {
|
||
status: "na",
|
||
message: "N/A - Single file upload",
|
||
details: null,
|
||
checks: []
|
||
};
|
||
const o = b => {
|
||
const u = {},
|
||
D = g.validResolutions.find(i => b.includes(i));
|
||
u.resolution = D || null;
|
||
const x = [{
|
||
pattern: /\bWEB-DL\b/i,
|
||
name: "WEB-DL"
|
||
}, {
|
||
pattern: /\bWEBRip\b/i,
|
||
name: "WEBRip"
|
||
}, {
|
||
pattern: /\bWEB\b/i,
|
||
name: "WEB"
|
||
}, {
|
||
pattern: /\bBlu-?Ray\b/i,
|
||
name: "BluRay"
|
||
}, {
|
||
pattern: /\bREMUX\b/i,
|
||
name: "REMUX"
|
||
}, {
|
||
pattern: /\bHDTV\b/i,
|
||
name: "HDTV"
|
||
}, {
|
||
pattern: /\bSDTV\b/i,
|
||
name: "SDTV"
|
||
}, {
|
||
pattern: /\bDVDRip\b/i,
|
||
name: "DVDRip"
|
||
}, {
|
||
pattern: /\bBDRip\b/i,
|
||
name: "BDRip"
|
||
}, {
|
||
pattern: /\bBRRip\b/i,
|
||
name: "BRRip"
|
||
}, {
|
||
pattern: /\bHDDVD\b/i,
|
||
name: "HDDVD"
|
||
}, {
|
||
pattern: /\bWEBDL\b/i,
|
||
name: "WEB-DL"
|
||
}];
|
||
u.source = null;
|
||
for (const i of x)
|
||
if (i.pattern.test(b)) {
|
||
u.source = i.name;
|
||
break
|
||
} const p = [...g.validAudioCodecs].sort((i, l) => l.length - i.length);
|
||
u.audioCodec = null;
|
||
for (const i of p) {
|
||
const l = i.replace(/[+]/g, "\\+").replace(/[-.]/g, "[-.]?");
|
||
if (new RegExp("(?<![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 p = g.validResolutions.find(i => o.includes(i));
|
||
if (p ? a.push({
|
||
name: "Resolution",
|
||
status: "pass",
|
||
message: `Found: ${p}`
|
||
}) : a.push({
|
||
name: "Resolution",
|
||
status: "warn",
|
||
message: "Could not detect resolution to verify encode requirement"
|
||
}), f) a.push({
|
||
name: "Rate Control",
|
||
status: "pass",
|
||
message: "AV1 detected — rate control cannot be verified from AV1 bitstream metadata"
|
||
});
|
||
else if (t) {
|
||
const i = t.match(/Encoding settings\s*:\s*(.+?)(?:\n|$)/im),
|
||
l = t.match(/Encoder_settings\s*:\s*(.+?)(?:\n|$)/im),
|
||
m = i ? i[1] : l ? l[1] : null;
|
||
if (m) {
|
||
const S = m.match(/rc=(\w+)/),
|
||
N = /--crf\b/.test(m),
|
||
$ = m.match(/--passes?\s+(\d+)/);
|
||
if (S) {
|
||
const M = S[1].toLowerCase();
|
||
if (M === "crf") a.push({
|
||
name: "Rate Control",
|
||
status: "pass",
|
||
message: "CRF encoding detected"
|
||
});
|
||
else if (M === "abr") {
|
||
const B = m.match(/stats-read=(\d+)/),
|
||
I = m.match(/(?:^|[\s/])pass=?(\d+)/);
|
||
B && parseInt(B[1], 10) >= 2 || I && parseInt(I[1], 10) >= 2 ? a.push({
|
||
name: "Rate Control",
|
||
status: "pass",
|
||
message: "Multi-pass ABR encoding detected"
|
||
}) : a.push({
|
||
name: "Rate Control",
|
||
status: "fail",
|
||
message: "Single-pass ABR detected — must use CRF or multi-pass ABR"
|
||
})
|
||
} else M === "2pass" ? a.push({
|
||
name: "Rate Control",
|
||
status: "pass",
|
||
message: "2-pass encoding detected"
|
||
}) : M === "cbr" ? a.push({
|
||
name: "Rate Control",
|
||
status: "fail",
|
||
message: "CBR encoding detected — must use CRF or multi-pass ABR"
|
||
}) : a.push({
|
||
name: "Rate Control",
|
||
status: "warn",
|
||
message: `Unrecognized rate control: rc=${M}`
|
||
})
|
||
} else N ? a.push({
|
||
name: "Rate Control",
|
||
status: "pass",
|
||
message: "CRF encoding detected (SVT-AV1)"
|
||
}) : $ && parseInt($[1], 10) >= 2 ? a.push({
|
||
name: "Rate Control",
|
||
status: "pass",
|
||
message: `Multi-pass encoding detected (SVT-AV1, ${$[1]} passes)`
|
||
}) : /--tbr\b/.test(m) ? a.push({
|
||
name: "Rate Control",
|
||
status: "fail",
|
||
message: "Target bitrate (ABR) detected without multi-pass — must use CRF or multi-pass"
|
||
}) : a.push({
|
||
name: "Rate Control",
|
||
status: "warn",
|
||
message: "Encoding settings found but could not determine rate control method"
|
||
})
|
||
} else a.push({
|
||
name: "Rate Control",
|
||
status: "warn",
|
||
message: "No encoding settings in MediaInfo — cannot verify rate control"
|
||
})
|
||
} else a.push({
|
||
name: "Rate Control",
|
||
status: "warn",
|
||
message: "No MediaInfo available — cannot verify rate control"
|
||
});
|
||
const y = a.some(i => i.status === "fail"),
|
||
C = a.some(i => i.status === "warn"),
|
||
h = y ? "fail" : C ? "warn" : "pass";
|
||
return {
|
||
status: h,
|
||
message: h === "pass" ? "Encode requirements met" : y ? "Encode compliance issues found" : "Encode checks need review",
|
||
details: null,
|
||
checks: a
|
||
}
|
||
},
|
||
|
||
upscaleDetection(e) {
|
||
if (!e) return {
|
||
status: "na",
|
||
message: "No torrent name to check",
|
||
alert: !1
|
||
};
|
||
const t = [{
|
||
name: "AI Upscales",
|
||
regex: new RegExp("(?<=\\b[12]\\d{3}\\b)(?=.*\\b(HEVC)\\b)(?=.*\\b(AI)\\b)", "i")
|
||
}, {
|
||
name: "AIUS",
|
||
regex: /\b(AIUS)\b/i
|
||
}, {
|
||
name: "Regrade",
|
||
regex: /\b((Upscale)?Re-?graded?)\b/i
|
||
}, {
|
||
name: "RW",
|
||
regex: /\b(RW)\b/
|
||
}, {
|
||
name: "TheUpscaler",
|
||
regex: /\b(The[ ._-]?Upscaler)\b/i
|
||
}, {
|
||
name: "Upscaled",
|
||
regex: new RegExp("(?<=\\b[12]\\d{3}\\b).*\\b(AI[ ._-]?Enhanced?|UPS(UHD)?|Upscaled?([ ._-]?UHD)?|UpRez)\\b", "i")
|
||
}, {
|
||
name: "Upscale",
|
||
regex: /\b(UPSCALE)\b/i
|
||
}].filter(s => s.regex.test(e));
|
||
return t.length > 0 ? {
|
||
status: "fail",
|
||
message: `UPSCALE DETECTED: ${t.map(a=>a.name).join(", ")}`,
|
||
alert: !0
|
||
} : {
|
||
status: "pass",
|
||
message: "No upscale indicators found",
|
||
alert: !1
|
||
}
|
||
},
|
||
|
||
bannedReleaseGroup(e, n, t) {
|
||
const s = g.fullDiscTypes.some(r => t?.includes(r)),
|
||
a = H.extractReleaseGroup(e);
|
||
if (!a) return {
|
||
status: s ? "na" : "warn",
|
||
group: null,
|
||
message: s ? "N/A for Full Disc" : "Could not extract release group from title",
|
||
alert: !1,
|
||
tieredInfo: null
|
||
};
|
||
const o = H.findTieredGroup(a, n);
|
||
const aLower = a.toLowerCase();
|
||
// Check unconditional ban list
|
||
if (g.bannedGroups.some(r => r.toLowerCase() === aLower)) {
|
||
return { status: "fail", group: a, message: `BANNED GROUP: ${a}`, alert: !0, tieredInfo: o };
|
||
}
|
||
// Check conditional exceptions (EVO → WEB-DL allowed, HDT → REMUX allowed)
|
||
const exception = Object.entries(g.bannedGroupExceptions).find(([name]) => name.toLowerCase() === aLower);
|
||
if (exception) {
|
||
const [excName, excRule] = exception;
|
||
const typeUpper = (t || "").toUpperCase();
|
||
if (excRule.allowedTypes.some(at => typeUpper.includes(at.toUpperCase()))) {
|
||
return { status: "pass", group: a, message: `Release Group: ${a} (${excName} allowed for ${t})`, alert: !1, tieredInfo: o };
|
||
}
|
||
return { status: "fail", group: a, message: `BANNED GROUP: ${a} (only ${excRule.allowedTypes.join("/")} releases allowed)`, alert: !0, tieredInfo: o };
|
||
}
|
||
return { status: "pass", group: a, message: `Release Group: ${a}`, alert: !1, tieredInfo: o };
|
||
}
|
||
};
|
||
k.resolutionTypeMatch = function (e, n) {
|
||
const t = e || "";
|
||
const s = n || "";
|
||
const a = { NTSC: ["480i", "480p"], PAL: ["576i", "576p"] };
|
||
const o = g.validResolutions.find(f => t.includes(f));
|
||
const c = t.match(/\b(NTSC|PAL)\b/i);
|
||
const r = !!c;
|
||
const d = g.validResolutions.includes(s);
|
||
|
||
if (!o && r) {
|
||
const f = c[1].toUpperCase();
|
||
const A = a[f] || [];
|
||
if (A.includes(s))
|
||
return { status: "pass", message: `${f} source correctly tagged as ${s}` };
|
||
if (s === "Other")
|
||
return { status: "pass", message: `${f} source tagged as Other` };
|
||
return { status: "warn", message: `${f} source expected ${A.join(" or ")} (or Other), found: ${s}` };
|
||
}
|
||
|
||
if (o) {
|
||
return o === s
|
||
? { status: "pass", message: `Resolution tag matches title: ${s}` }
|
||
: { status: "fail", message: `Resolution tag mismatch — title contains "${o}" but tagged as "${s}"`, details: { expected: o, found: s } };
|
||
}
|
||
|
||
if (s === "Other")
|
||
return { status: "pass", message: "Non-standard resolution correctly tagged as Other" };
|
||
if (!d && s)
|
||
return { status: "warn", message: `Non-standard resolution "${s}" should use "Other" resolution type` };
|
||
return { status: "warn", message: "Could not detect resolution in title to validate tag" };
|
||
};
|
||
|
||
|
||
/* ========================================================================
|
||
* DARKPEERS CHECKS — Additional quality-gate checks for DarkPeers
|
||
* Feature-gated: only executed when instanceConfig.features.dpChecks
|
||
* ======================================================================== */
|
||
|
||
const dpk = {
|
||
|
||
/**
|
||
* nogroupCheck — flag NOGROUP in title and cross-reference filename groups.
|
||
*/
|
||
nogroupCheck(torrentName, mediaInfoFilename, fileStructure) {
|
||
const titleGroup = H.extractReleaseGroup(torrentName);
|
||
if (!titleGroup || titleGroup.toUpperCase() !== "NOGROUP") {
|
||
return { status: "pass", message: "Release group is not NOGROUP", details: null };
|
||
}
|
||
|
||
// Title says NOGROUP — check if filename also says NOGROUP
|
||
const filenameGroupMatch = (mediaInfoFilename || "").match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i);
|
||
const filenameGroup = filenameGroupMatch ? filenameGroupMatch[1] : null;
|
||
|
||
// Also check files in the file structure
|
||
const fileGroups = [];
|
||
if (fileStructure && fileStructure.files) {
|
||
for (const f of fileStructure.files) {
|
||
const m = f.match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i);
|
||
if (m) fileGroups.push(m[1]);
|
||
}
|
||
}
|
||
|
||
const allFilenameGroups = [filenameGroup, ...fileGroups].filter(Boolean);
|
||
const nogroupInFiles = allFilenameGroups.some(fg => fg.toUpperCase() === "NOGROUP");
|
||
const otherGroups = allFilenameGroups.filter(fg => fg.toUpperCase() !== "NOGROUP");
|
||
|
||
if (otherGroups.length > 0) {
|
||
// Title says NOGROUP but filename has a different group
|
||
const foundGroup = otherGroups[0];
|
||
const isBanned = g.bannedGroups.some(bg => bg.toLowerCase() === foundGroup.toLowerCase());
|
||
if (isBanned) {
|
||
return {
|
||
status: "fail",
|
||
message: `Banned group detected in filename: ${foundGroup} (title disguised as NOGROUP)`,
|
||
details: { titleGroup: "NOGROUP", filenameGroup: foundGroup },
|
||
};
|
||
}
|
||
return {
|
||
status: "warn",
|
||
message: `Title says NOGROUP but filename shows -${foundGroup}`,
|
||
details: { titleGroup: "NOGROUP", filenameGroup: foundGroup },
|
||
};
|
||
}
|
||
|
||
if (nogroupInFiles) {
|
||
return {
|
||
status: "warn",
|
||
message: "NOGROUP in title and filename — clarify if personal release or found as-is",
|
||
details: null,
|
||
};
|
||
}
|
||
|
||
return { status: "warn", message: "NOGROUP in title — could not verify filename group", details: null };
|
||
},
|
||
|
||
/**
|
||
* unknownLanguageCheck — flag audio/text tracks with unknown or missing language.
|
||
*/
|
||
unknownLanguageCheck(mediaInfoText) {
|
||
if (!mediaInfoText) {
|
||
return { status: "na", message: "No MediaInfo available", details: null, checks: [] };
|
||
}
|
||
|
||
const checks = [];
|
||
// Split MediaInfo into sections
|
||
const sections = mediaInfoText.split(/\n\n+/);
|
||
let audioIdx = 0;
|
||
let textIdx = 0;
|
||
|
||
for (const section of sections) {
|
||
const isAudio = /^Audio/m.test(section);
|
||
const isText = /^Text/m.test(section);
|
||
if (!isAudio && !isText) continue;
|
||
|
||
const trackType = isAudio ? "Audio" : "Subtitle";
|
||
const idx = isAudio ? ++audioIdx : ++textIdx;
|
||
|
||
const langMatch = section.match(/^Language\s*:\s*(.+)$/m);
|
||
const lang = langMatch ? langMatch[1].trim() : "";
|
||
|
||
const isUnknown = !lang || lang.toLowerCase() === "unknown" || lang.toLowerCase() === "und" || lang === "";
|
||
|
||
checks.push({
|
||
name: `${trackType} Track ${idx}`,
|
||
status: isUnknown ? "warn" : "pass",
|
||
message: isUnknown
|
||
? `${trackType} track ${idx}: language is ${lang || "missing"}`
|
||
: `${trackType} track ${idx}: ${lang}`,
|
||
});
|
||
}
|
||
|
||
if (checks.length === 0) {
|
||
return { status: "na", message: "No audio/text tracks found in MediaInfo", details: null, checks: [] };
|
||
}
|
||
|
||
const hasUnknown = checks.some(c => c.status === "warn");
|
||
return {
|
||
status: hasUnknown ? "warn" : "pass",
|
||
message: hasUnknown
|
||
? "Unknown or missing language tags detected — verify and correct"
|
||
: "All tracks have language tags",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* extraneousFiles — detect files that do not belong in the upload category.
|
||
*/
|
||
extraneousFiles(fileStructure, category, type) {
|
||
if (g.fullDiscTypes.some(t => type?.includes(t))) {
|
||
return { status: "na", message: "N/A - Full Disc", details: null };
|
||
}
|
||
if (!fileStructure || !fileStructure.files || fileStructure.files.length === 0) {
|
||
return { status: "warn", message: "Could not determine file structure", details: null };
|
||
}
|
||
|
||
const videoExts = [".mkv", ".mp4", ".ts", ".m2ts", ".vob", ".iso", ".srt", ".sub", ".idx", ".ass", ".ssa", ".sup"];
|
||
const musicExts = [".flac", ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".cue", ".log", ".nfo", ".jpg", ".jpeg", ".png"];
|
||
const bookExts = [".epub", ".pdf", ".mobi", ".azw3", ".cbr", ".cbz", ".djvu", ".nfo", ".jpg", ".jpeg", ".png"];
|
||
|
||
const cat = (category || "").toLowerCase();
|
||
let whitelist;
|
||
let catLabel;
|
||
if (/music|audiobook/i.test(cat)) {
|
||
whitelist = musicExts;
|
||
catLabel = "Music/Audiobook";
|
||
} else if (/ebook|book/i.test(cat)) {
|
||
whitelist = bookExts;
|
||
catLabel = "eBook";
|
||
} else {
|
||
whitelist = videoExts;
|
||
catLabel = "Video";
|
||
}
|
||
|
||
const extraneous = fileStructure.files.filter(f => {
|
||
const ext = f.substring(f.lastIndexOf(".")).toLowerCase();
|
||
return !whitelist.includes(ext);
|
||
});
|
||
|
||
if (extraneous.length === 0) {
|
||
return { status: "pass", message: `All files valid for ${catLabel} category`, details: null };
|
||
}
|
||
|
||
return {
|
||
status: "fail",
|
||
message: `Extraneous files found for ${catLabel}: ${extraneous.join(", ")}`,
|
||
details: { extraneous, category: catLabel },
|
||
};
|
||
},
|
||
|
||
/**
|
||
* categoryTypeMismatch — detect mismatches between category, type, and title content.
|
||
*/
|
||
categoryTypeMismatch(category, type, torrentName) {
|
||
const cat = (category || "").toLowerCase();
|
||
const typ = (type || "").toUpperCase();
|
||
const name = torrentName || "";
|
||
const issues = [];
|
||
|
||
// Movie category + HDTV type is unusual
|
||
if (cat.includes("movie") && /HDTV|PDTV|SDTV/i.test(typ)) {
|
||
issues.push("Movie category with HDTV type — verify this is correct (TV recording of a movie?)");
|
||
}
|
||
|
||
// TV category should have S##E## or S## in title
|
||
if (cat.includes("tv") && !/S\d{1,2}(?:E\d{1,2})?/i.test(name) && !/\b(?:Season|Series)\b/i.test(name)) {
|
||
issues.push("TV category but no S##E## or season indicator in title");
|
||
}
|
||
|
||
// Title says WEB-DL but type says Encode
|
||
if (/WEB-DL/i.test(name) && /ENCODE/i.test(typ)) {
|
||
issues.push("Title contains WEB-DL but type is set to Encode");
|
||
}
|
||
|
||
// Title says REMUX but type is not REMUX
|
||
if (/\bREMUX\b/i.test(name) && !/REMUX/i.test(typ)) {
|
||
issues.push("Title contains REMUX but type is not set to REMUX");
|
||
}
|
||
|
||
// Title says Encode indicators but type is REMUX
|
||
if (/\b(x264|x265|SVT-AV1)\b/i.test(name) && /REMUX/i.test(typ)) {
|
||
issues.push("Title contains encode indicators (x264/x265/SVT-AV1) but type is REMUX");
|
||
}
|
||
|
||
if (issues.length === 0) {
|
||
return { status: "pass", message: "Category and type are consistent", details: null };
|
||
}
|
||
|
||
return {
|
||
status: "warn",
|
||
message: issues.join("; "),
|
||
details: { category: cat, type: typ, issues },
|
||
};
|
||
},
|
||
|
||
/**
|
||
* suspicionHeuristics — advisory-level signals that do not count toward verdict.
|
||
*/
|
||
suspicionHeuristics(torrentName, type, mediaInfoText, fileStructure, mediaInfoFilename) {
|
||
const checks = [];
|
||
const name = torrentName || "";
|
||
|
||
// Check .ts container in non-HDTV
|
||
if (fileStructure && fileStructure.files) {
|
||
const hasTsFiles = fileStructure.files.some(f => f.toLowerCase().endsWith(".ts"));
|
||
const isHdtv = /HDTV|PDTV|SDTV/i.test(type || "");
|
||
if (hasTsFiles && !isHdtv) {
|
||
checks.push({
|
||
name: "TS Container",
|
||
status: "advisory",
|
||
message: ".ts container detected in non-HDTV release — may indicate raw capture",
|
||
});
|
||
}
|
||
}
|
||
|
||
// Title group != filename group
|
||
const titleGroup = H.extractReleaseGroup(name);
|
||
const fnMatch = (mediaInfoFilename || "").match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i);
|
||
const fnGroup = fnMatch ? fnMatch[1] : null;
|
||
if (titleGroup && fnGroup && titleGroup.toLowerCase() !== fnGroup.toLowerCase()) {
|
||
checks.push({
|
||
name: "Group Mismatch",
|
||
status: "advisory",
|
||
message: `Title group (-${titleGroup}) differs from filename group (-${fnGroup})`,
|
||
});
|
||
}
|
||
|
||
// Unknown/new group with 4K REMUX
|
||
if (titleGroup && /2160p/i.test(name) && /REMUX/i.test(type || "")) {
|
||
const isKnown = H.findTieredGroup(titleGroup, false) || H.findTieredGroup(titleGroup, true);
|
||
if (!isKnown) {
|
||
checks.push({
|
||
name: "Unknown 4K REMUX Group",
|
||
status: "advisory",
|
||
message: `Unknown group "${titleGroup}" uploading 4K REMUX — extra scrutiny recommended`,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: "advisory",
|
||
message: checks.length > 0
|
||
? `${checks.length} advisory signal(s) detected`
|
||
: "No suspicious patterns detected",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* bannedGroupInFilename — catch uploaders hiding banned groups behind a different title group.
|
||
*/
|
||
bannedGroupInFilename(torrentName, mediaInfoFilename, fileStructure, isTV, type) {
|
||
const titleGroup = H.extractReleaseGroup(torrentName);
|
||
|
||
// Extract groups from filenames
|
||
const extractFnGroup = (filename) => {
|
||
const m = (filename || "").match(/-([A-Za-z0-9$!._&+\$]+?)(?:\.[a-z0-9]{2,4})?$/i);
|
||
return m ? m[1] : null;
|
||
};
|
||
|
||
const filenameGroups = new Set();
|
||
const miFnGroup = extractFnGroup(mediaInfoFilename);
|
||
if (miFnGroup) filenameGroups.add(miFnGroup);
|
||
|
||
if (fileStructure && fileStructure.files) {
|
||
for (const f of fileStructure.files) {
|
||
const fg = extractFnGroup(f);
|
||
if (fg) filenameGroups.add(fg);
|
||
}
|
||
}
|
||
|
||
if (filenameGroups.size === 0) {
|
||
return { status: "na", message: "No filename groups detected", details: null };
|
||
}
|
||
|
||
const bannedLower = g.bannedGroups.map(b => b.toLowerCase());
|
||
const bannedFound = [];
|
||
|
||
for (const fg of filenameGroups) {
|
||
const idx = bannedLower.indexOf(fg.toLowerCase());
|
||
if (idx !== -1) {
|
||
// Only flag if title group is different (i.e., they tried to hide it)
|
||
if (!titleGroup || titleGroup.toLowerCase() !== fg.toLowerCase()) {
|
||
bannedFound.push(g.bannedGroups[idx]);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bannedFound.length > 0) {
|
||
return {
|
||
status: "fail",
|
||
message: `Banned group hidden in filename: ${bannedFound.join(", ")} (title group: ${titleGroup || "none"})`,
|
||
alert: true,
|
||
details: { titleGroup, bannedInFilename: bannedFound },
|
||
};
|
||
}
|
||
|
||
return { status: "pass", message: "No banned groups detected in filenames", details: null };
|
||
},
|
||
|
||
/**
|
||
* singleFileInFolder — generalized folder structure check for all categories.
|
||
*/
|
||
singleFileInFolder(fileStructure, category, type) {
|
||
if (g.fullDiscTypes.some(t => type?.includes(t))) {
|
||
return { status: "na", message: "N/A - Full Disc (folder structure expected)", details: null };
|
||
}
|
||
if (!fileStructure) {
|
||
return { status: "warn", message: "Could not determine file structure", details: null };
|
||
}
|
||
if (fileStructure.hasFolder && fileStructure.fileCount === 1) {
|
||
return {
|
||
status: "fail",
|
||
message: "Single file in a folder — upload without the unnecessary folder",
|
||
details: {
|
||
folder: fileStructure.folderName,
|
||
file: fileStructure.files ? fileStructure.files[0] : "unknown",
|
||
},
|
||
};
|
||
}
|
||
return { status: "pass", message: "File structure OK", details: null };
|
||
},
|
||
|
||
/**
|
||
* missingEpisodes — detect gaps in season packs by parsing S##E## from filenames.
|
||
*/
|
||
missingEpisodes(fileStructure, torrentName, isTV) {
|
||
if (!isTV) {
|
||
return { status: "na", message: "N/A - Not TV content", details: null };
|
||
}
|
||
|
||
// Only for season packs: S## without E##
|
||
const seasonMatch = (torrentName || "").match(/\bS(\d{2,})\b(?!E)/i);
|
||
if (!seasonMatch) {
|
||
return { status: "na", message: "N/A - Not a season pack", details: null };
|
||
}
|
||
|
||
if (!fileStructure || !fileStructure.files || fileStructure.files.length === 0) {
|
||
return { status: "warn", message: "Season pack detected but no files to verify", details: null };
|
||
}
|
||
|
||
const season = parseInt(seasonMatch[1], 10);
|
||
const episodeNumbers = [];
|
||
|
||
for (const f of fileStructure.files) {
|
||
const m = f.match(/S(\d{2,})E(\d{2,})/i);
|
||
if (m && parseInt(m[1], 10) === season) {
|
||
episodeNumbers.push(parseInt(m[2], 10));
|
||
}
|
||
}
|
||
|
||
if (episodeNumbers.length === 0) {
|
||
return { status: "warn", message: "Season pack but no S##E## patterns found in filenames", details: null };
|
||
}
|
||
|
||
episodeNumbers.sort((a, b) => a - b);
|
||
const gaps = [];
|
||
for (let i = 0; i < episodeNumbers.length - 1; i++) {
|
||
const curr = episodeNumbers[i];
|
||
const next = episodeNumbers[i + 1];
|
||
if (next - curr > 1) {
|
||
for (let ep = curr + 1; ep < next; ep++) {
|
||
gaps.push(ep);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also check if pack starts at E01
|
||
if (episodeNumbers[0] > 1) {
|
||
for (let ep = 1; ep < episodeNumbers[0]; ep++) {
|
||
gaps.unshift(ep);
|
||
}
|
||
}
|
||
|
||
if (gaps.length === 0) {
|
||
return {
|
||
status: "pass",
|
||
message: `Season ${season} pack: ${episodeNumbers.length} episodes (E${String(episodeNumbers[0]).padStart(2, "0")}-E${String(episodeNumbers[episodeNumbers.length - 1]).padStart(2, "0")}), no gaps`,
|
||
details: null,
|
||
};
|
||
}
|
||
|
||
const gapStr = gaps.map(ep => `E${String(ep).padStart(2, "0")}`).join(", ");
|
||
return {
|
||
status: "warn",
|
||
message: `Season ${season} pack has gaps: missing ${gapStr} (Note: total episode count unknown without external data)`,
|
||
details: { season, found: episodeNumbers, missing: gaps },
|
||
};
|
||
},
|
||
};
|
||
|
||
|
||
/* ========================================================================
|
||
* TITLE VALIDATOR — Template-based title validation for DarkPeers
|
||
* Feature-gated: only executed when instanceConfig.features.dpTitleValidation
|
||
* ======================================================================== */
|
||
|
||
const TitleValidator = {
|
||
|
||
isMusic(cat) {
|
||
return /\bmusic\b/i.test(cat || "");
|
||
},
|
||
|
||
isAudioBook(cat) {
|
||
return /\baudiobook\b/i.test(cat || "");
|
||
},
|
||
|
||
isEbook(cat) {
|
||
return /\b(ebook|e-book|book)\b/i.test(cat || "");
|
||
},
|
||
|
||
/**
|
||
* validate — entry point; delegates to the right category validator.
|
||
*/
|
||
validate(title, category, type, mediaInfoText) {
|
||
if (!title) {
|
||
return { status: "fail", message: "No title to validate", details: null, checks: [] };
|
||
}
|
||
|
||
if (this.isMusic(category)) {
|
||
return this.music(title);
|
||
}
|
||
if (this.isAudioBook(category)) {
|
||
return this.audioBook(title, mediaInfoText);
|
||
}
|
||
if (this.isEbook(category)) {
|
||
return this.ebook(title);
|
||
}
|
||
|
||
// Video content
|
||
const isFullDiscOrRemux = g.fullDiscTypes.some(t => type?.includes(t))
|
||
|| g.remuxTypes.some(t => type?.toUpperCase().includes(t.toUpperCase()));
|
||
|
||
if (isFullDiscOrRemux) {
|
||
return this.videoFullDiscRemux(title, type);
|
||
}
|
||
return this.videoEncodeWeb(title, type);
|
||
},
|
||
|
||
/**
|
||
* videoFullDiscRemux — validate title against Full Disc/REMUX template.
|
||
* Template order: name year season cut ratio repack resolution edition region 3d source type hdr vcodec dub acodec channels object -group
|
||
*/
|
||
videoFullDiscRemux(title, type) {
|
||
const checks = [];
|
||
const { elements, positions } = H.extractTitleElements(title, type);
|
||
const order = g.titleElementOrder.fullDiscRemux;
|
||
const isTV = /S\d{2}/i.test(title);
|
||
|
||
// Check required elements — year optional for TV per DP rules
|
||
const required = ["resolution", "source", "group"];
|
||
for (const req of required) {
|
||
const found = elements.find(e => e.type === req);
|
||
if (found) {
|
||
checks.push({ name: req, status: "pass", message: `Found: ${found.value}` });
|
||
} else if (req === "group" && g.fullDiscTypes.some(t => type?.includes(t))) {
|
||
checks.push({ name: req, status: "na", message: "Group not required for Full Disc" });
|
||
} else {
|
||
checks.push({ name: req, status: "fail", message: `Missing required element: ${req}` });
|
||
}
|
||
}
|
||
// Year: required for movies, optional for TV
|
||
const yearEl = elements.find(e => e.type === "year");
|
||
if (yearEl) {
|
||
checks.push({ name: "year", status: "pass", message: `Found: ${yearEl.value}` });
|
||
} else if (isTV) {
|
||
checks.push({ name: "year", status: "pass", message: "Year optional for TV" });
|
||
} else {
|
||
checks.push({ name: "year", status: "fail", message: "Missing required element: year" });
|
||
}
|
||
|
||
// Check element order
|
||
const presentOrder = elements.map(e => e.type);
|
||
const orderViolations = [];
|
||
for (let i = 0; i < presentOrder.length; i++) {
|
||
for (let j = i + 1; j < presentOrder.length; j++) {
|
||
const idxA = order.indexOf(presentOrder[i]);
|
||
const idxB = order.indexOf(presentOrder[j]);
|
||
if (idxA !== -1 && idxB !== -1 && idxA > idxB) {
|
||
orderViolations.push(`"${elements[i].value}" (${presentOrder[i]}) should come after "${elements[j].value}" (${presentOrder[j]})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (orderViolations.length > 0) {
|
||
checks.push({
|
||
name: "Element Order",
|
||
status: "fail",
|
||
message: `${orderViolations.length} ordering issue(s): ${orderViolations[0]}${orderViolations.length > 1 ? " (+" + (orderViolations.length - 1) + " more)" : ""}`,
|
||
});
|
||
} else if (elements.length >= 3) {
|
||
checks.push({ name: "Element Order", status: "pass", message: "Elements in correct order" });
|
||
}
|
||
|
||
// Check for DS4K if 2160p
|
||
if (/2160p/i.test(title)) {
|
||
const hdrEl = elements.find(e => e.type === "hdr");
|
||
if (hdrEl) {
|
||
checks.push({ name: "HDR", status: "pass", message: `HDR tag present: ${hdrEl.value}` });
|
||
} else {
|
||
checks.push({ name: "HDR", status: "warn", message: "4K content — consider adding HDR tag if applicable" });
|
||
}
|
||
}
|
||
|
||
const hasFail = checks.some(c => c.status === "fail");
|
||
const hasWarn = checks.some(c => c.status === "warn");
|
||
return {
|
||
status: hasFail ? "fail" : hasWarn ? "warn" : "pass",
|
||
message: hasFail ? "Title validation issues found" : hasWarn ? "Title may need attention" : "Title structure valid",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* videoEncodeWeb — validate title against Encode/WEB template.
|
||
* Template order: name year season cut ratio repack resolution edition 3d source type dub acodec channels object hdr vcodec -group
|
||
*/
|
||
videoEncodeWeb(title, type) {
|
||
const checks = [];
|
||
const { elements, positions } = H.extractTitleElements(title, type);
|
||
const order = g.titleElementOrder.encodeWeb;
|
||
const isTV = /S\d{2}/i.test(title);
|
||
|
||
// Check required elements — year is optional for TV per DP rules
|
||
const required = ["resolution", "source", "vcodec", "group"];
|
||
for (const req of required) {
|
||
const found = elements.find(e => e.type === req);
|
||
if (found) {
|
||
checks.push({ name: req, status: "pass", message: `Found: ${found.value}` });
|
||
} else {
|
||
checks.push({ name: req, status: "fail", message: `Missing required element: ${req}` });
|
||
}
|
||
}
|
||
// Year: required for movies, optional for TV
|
||
const yearEl = elements.find(e => e.type === "year");
|
||
if (yearEl) {
|
||
checks.push({ name: "year", status: "pass", message: `Found: ${yearEl.value}` });
|
||
} else if (isTV) {
|
||
checks.push({ name: "year", status: "pass", message: "Year optional for TV" });
|
||
} else {
|
||
checks.push({ name: "year", status: "fail", message: "Missing required element: year" });
|
||
}
|
||
|
||
// Check element order
|
||
const presentOrder = elements.map(e => e.type);
|
||
const orderViolations = [];
|
||
for (let i = 0; i < presentOrder.length; i++) {
|
||
for (let j = i + 1; j < presentOrder.length; j++) {
|
||
const idxA = order.indexOf(presentOrder[i]);
|
||
const idxB = order.indexOf(presentOrder[j]);
|
||
if (idxA !== -1 && idxB !== -1 && idxA > idxB) {
|
||
orderViolations.push(`"${elements[i].value}" (${presentOrder[i]}) should come after "${elements[j].value}" (${presentOrder[j]})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (orderViolations.length > 0) {
|
||
checks.push({
|
||
name: "Element Order",
|
||
status: "fail",
|
||
message: `${orderViolations.length} ordering issue(s): ${orderViolations[0]}${orderViolations.length > 1 ? " (+" + (orderViolations.length - 1) + " more)" : ""}`,
|
||
});
|
||
} else if (elements.length >= 3) {
|
||
checks.push({ name: "Element Order", status: "pass", message: "Elements in correct order" });
|
||
}
|
||
|
||
// DS4K check: 2160p encode from non-4K source
|
||
if (/2160p/i.test(title)) {
|
||
const sourceEl = elements.find(e => e.type === "source");
|
||
const sourceVal = sourceEl ? sourceEl.value : "";
|
||
// If source is not UHD, it might be an upscale
|
||
if (sourceVal && !/UHD/i.test(sourceVal) && !/2160p/i.test(sourceVal)) {
|
||
checks.push({
|
||
name: "DS4K",
|
||
status: "warn",
|
||
message: `2160p encode from ${sourceVal} source — verify this is not an upscale (DS4K)`,
|
||
});
|
||
}
|
||
}
|
||
|
||
const hasFail = checks.some(c => c.status === "fail");
|
||
const hasWarn = checks.some(c => c.status === "warn");
|
||
return {
|
||
status: hasFail ? "fail" : hasWarn ? "warn" : "pass",
|
||
message: hasFail ? "Title validation issues found" : hasWarn ? "Title may need attention" : "Title structure valid",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* music — validate Artist - Album (Year) - Format pattern.
|
||
*/
|
||
music(title) {
|
||
const checks = [];
|
||
|
||
// Expected: Artist - Album (Year) - Format
|
||
// or: Artist - Album (Year) [Format]
|
||
const pattern = /^(.+?)\s+-\s+(.+?)\s+\((\d{4})\)\s+(?:-\s+|[\[(])(.+?)[\])]?\s*$/;
|
||
const match = title.match(pattern);
|
||
|
||
if (!match) {
|
||
// Try a more relaxed pattern
|
||
const hasArtist = /^.+?\s+-\s+/.test(title);
|
||
const hasYear = /\(\d{4}\)/.test(title);
|
||
|
||
if (!hasArtist) checks.push({ name: "Artist", status: "fail", message: "Could not parse 'Artist - ' prefix" });
|
||
else checks.push({ name: "Artist", status: "pass", message: "Artist separator found" });
|
||
|
||
if (!hasYear) checks.push({ name: "Year", status: "fail", message: "No (Year) found — expected format: (2024)" });
|
||
else checks.push({ name: "Year", status: "pass", message: "Year found" });
|
||
|
||
const hasFormat = /\b(FLAC|MP3|AAC|ALAC|OGG|OPUS|WAV|WEB|CD|Vinyl|24bit|16bit|320|V0|V2)\b/i.test(title);
|
||
if (!hasFormat) checks.push({ name: "Format", status: "warn", message: "No audio format detected" });
|
||
else checks.push({ name: "Format", status: "pass", message: "Format indicator found" });
|
||
} else {
|
||
checks.push({ name: "Artist", status: "pass", message: `Artist: ${match[1]}` });
|
||
checks.push({ name: "Album", status: "pass", message: `Album: ${match[2]}` });
|
||
checks.push({ name: "Year", status: "pass", message: `Year: ${match[3]}` });
|
||
checks.push({ name: "Format", status: "pass", message: `Format: ${match[4]}` });
|
||
}
|
||
|
||
const hasFail = checks.some(c => c.status === "fail");
|
||
const hasWarn = checks.some(c => c.status === "warn");
|
||
return {
|
||
status: hasFail ? "fail" : hasWarn ? "warn" : "pass",
|
||
message: hasFail ? "Music title format issues" : hasWarn ? "Music title may need attention" : "Music title format valid",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* audioBook — validate Author - Name Year Format ISBN-Tag pattern.
|
||
*/
|
||
audioBook(title, mediaInfoText) {
|
||
const checks = [];
|
||
|
||
const hasAuthor = /^.+?\s+-\s+/.test(title);
|
||
checks.push({
|
||
name: "Author",
|
||
status: hasAuthor ? "pass" : "fail",
|
||
message: hasAuthor ? "Author separator found" : "Could not parse 'Author - ' prefix",
|
||
});
|
||
|
||
const yearMatch = title.match(/\b(19|20)\d{2}\b/);
|
||
checks.push({
|
||
name: "Year",
|
||
status: yearMatch ? "pass" : "warn",
|
||
message: yearMatch ? `Year: ${yearMatch[0]}` : "No year found in title",
|
||
});
|
||
|
||
const formatMatch = /\b(FLAC|MP3|AAC|M4B|M4A|OGG|OPUS|Audiobook)\b/i.test(title);
|
||
checks.push({
|
||
name: "Format",
|
||
status: formatMatch ? "pass" : "warn",
|
||
message: formatMatch ? "Audio format found" : "No audio format detected",
|
||
});
|
||
|
||
const isbnMatch = title.match(/\b(ISBN[-:]?\s*[\dX-]{10,17})\b/i);
|
||
checks.push({
|
||
name: "ISBN",
|
||
status: isbnMatch ? "pass" : "warn",
|
||
message: isbnMatch ? `ISBN found: ${isbnMatch[1]}` : "No ISBN — recommended if available",
|
||
});
|
||
|
||
// Note: narrator/publisher ideally in description, but we flag if mediaInfoText hints at it
|
||
if (mediaInfoText && /narrator/i.test(mediaInfoText)) {
|
||
checks.push({ name: "Narrator", status: "pass", message: "Narrator mentioned in info" });
|
||
}
|
||
|
||
const hasFail = checks.some(c => c.status === "fail");
|
||
const hasWarn = checks.some(c => c.status === "warn");
|
||
return {
|
||
status: hasFail ? "fail" : hasWarn ? "warn" : "pass",
|
||
message: hasFail ? "Audiobook title format issues" : hasWarn ? "Audiobook title may need attention" : "Audiobook title format valid",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* ebook — validate Author - Name Year Format ISBN pattern.
|
||
*/
|
||
ebook(title) {
|
||
const checks = [];
|
||
|
||
const hasAuthor = /^.+?\s+-\s+/.test(title);
|
||
checks.push({
|
||
name: "Author",
|
||
status: hasAuthor ? "pass" : "fail",
|
||
message: hasAuthor ? "Author separator found" : "Could not parse 'Author - ' prefix",
|
||
});
|
||
|
||
const yearMatch = title.match(/\b(19|20)\d{2}\b/);
|
||
checks.push({
|
||
name: "Year",
|
||
status: yearMatch ? "pass" : "warn",
|
||
message: yearMatch ? `Year: ${yearMatch[0]}` : "No year found in title",
|
||
});
|
||
|
||
const formatMatch = /\b(EPUB|PDF|MOBI|AZW3?|CBR|CBZ|DJVU)\b/i.test(title);
|
||
checks.push({
|
||
name: "Format",
|
||
status: formatMatch ? "pass" : "fail",
|
||
message: formatMatch ? "eBook format found" : "No eBook format detected (EPUB, PDF, MOBI, etc.)",
|
||
});
|
||
|
||
const isbnMatch = title.match(/\b(ISBN[-:]?\s*[\dX-]{10,17})\b/i);
|
||
checks.push({
|
||
name: "ISBN",
|
||
status: isbnMatch ? "pass" : "warn",
|
||
message: isbnMatch ? `ISBN found: ${isbnMatch[1]}` : "No ISBN — recommended if available",
|
||
});
|
||
|
||
const hasFail = checks.some(c => c.status === "fail");
|
||
const hasWarn = checks.some(c => c.status === "warn");
|
||
return {
|
||
status: hasFail ? "fail" : hasWarn ? "warn" : "pass",
|
||
message: hasFail ? "eBook title format issues" : hasWarn ? "eBook title may need attention" : "eBook title format valid",
|
||
details: null,
|
||
checks,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* collection — check for Collection/Trilogy suffix.
|
||
*/
|
||
collection(title) {
|
||
const hasCollectionSuffix = /\b(Collection|Trilogy|Duology|Quadrilogy|Anthology|Complete\s+Series)\b/i.test(title);
|
||
if (hasCollectionSuffix) {
|
||
return { status: "pass", message: "Collection suffix detected", details: null };
|
||
}
|
||
return { status: "warn", message: "No collection suffix found — add if this is a multi-title set", details: null };
|
||
},
|
||
};
|
||
|
||
|
||
/* ========================================================================
|
||
* INTEGRATIONS — External API wrappers (SRRDB, Prowlarr)
|
||
* Feature-gated: only executed when instanceConfig.features.srrdb/prowlarr
|
||
* ======================================================================== */
|
||
|
||
const Integrations = {
|
||
|
||
/**
|
||
* _request — Promise wrapper around GM_xmlhttpRequest.
|
||
*/
|
||
_request(options) {
|
||
return new Promise((resolve, reject) => {
|
||
if (typeof GM_xmlhttpRequest !== "function") {
|
||
return reject(new Error("GM_xmlhttpRequest not available"));
|
||
}
|
||
|
||
const timeout = options.timeout || 10000;
|
||
|
||
GM_xmlhttpRequest({
|
||
method: options.method || "GET",
|
||
url: options.url,
|
||
headers: options.headers || {},
|
||
timeout,
|
||
onload(response) {
|
||
try {
|
||
if (response.status >= 200 && response.status < 300) {
|
||
const data = options.json !== false ? JSON.parse(response.responseText) : response.responseText;
|
||
resolve({ status: response.status, data });
|
||
} else {
|
||
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
||
}
|
||
} catch (e) {
|
||
reject(new Error(`Parse error: ${e.message}`));
|
||
}
|
||
},
|
||
onerror(err) {
|
||
reject(new Error(`Network error: ${err.error || "unknown"}`));
|
||
},
|
||
ontimeout() {
|
||
reject(new Error(`Timeout after ${timeout}ms`));
|
||
},
|
||
});
|
||
});
|
||
},
|
||
|
||
srrdb: {
|
||
/**
|
||
* _normalizeForSrrdb — Convert a DP torrent title into scene-style format.
|
||
* SRRDB expects dot-separated scene names with specific formatting.
|
||
*/
|
||
_normalizeForSrrdb(name) {
|
||
if (!name) return "";
|
||
return name
|
||
.replace(/[:']/g, "") // strip colons, apostrophes (scene format omits them)
|
||
.replace(/[,]/g, "") // strip commas
|
||
.replace(/&/g, "and") // scene convention
|
||
.replace(/\s+/g, ".") // spaces → dots
|
||
.replace(/\.{2,}/g, "."); // collapse multiple dots
|
||
},
|
||
|
||
/**
|
||
* search — look up a release name on SRRDB.
|
||
* Strategy: try exact match first (r: prefix), then fall back to general
|
||
* keyword search if exact match fails. This handles the common case where
|
||
* the DP torrent title differs slightly from the scene release name.
|
||
*/
|
||
async search(releaseName) {
|
||
if (!releaseName) return { found: false, release: null, error: "No release name provided" };
|
||
|
||
const sceneName = this._normalizeForSrrdb(releaseName);
|
||
|
||
try {
|
||
// Strategy 1: exact release match
|
||
const encoded = encodeURIComponent(sceneName);
|
||
const result = await Integrations._request({
|
||
url: `https://www.srrdb.com/api/search/r:${encoded}`,
|
||
timeout: 8000,
|
||
});
|
||
|
||
const data = result.data;
|
||
if (data && data.results && data.results.length > 0) {
|
||
return {
|
||
found: true,
|
||
release: data.results[0],
|
||
resultCount: data.results.length,
|
||
searchStrategy: "exact",
|
||
error: null,
|
||
};
|
||
}
|
||
|
||
// Strategy 2: keyword search (without r: prefix) — catches partial matches
|
||
// and handles titles with minor formatting differences
|
||
console.log(`[ModQ Helper] SRRDB exact match failed for "${sceneName}", trying keyword search`);
|
||
const kwResult = await Integrations._request({
|
||
url: `https://www.srrdb.com/api/search/${encoded}`,
|
||
timeout: 8000,
|
||
});
|
||
|
||
const kwData = kwResult.data;
|
||
if (kwData && kwData.results && kwData.results.length > 0) {
|
||
return {
|
||
found: true,
|
||
release: kwData.results[0],
|
||
resultCount: kwData.results.length,
|
||
searchStrategy: "keyword",
|
||
error: null,
|
||
};
|
||
}
|
||
|
||
console.log(`[ModQ Helper] SRRDB keyword search also returned no results for "${sceneName}"`);
|
||
return { found: false, release: null, error: null };
|
||
} catch (e) {
|
||
return { found: false, release: null, error: e.message };
|
||
}
|
||
},
|
||
|
||
/**
|
||
* test — simple health check for SRRDB.
|
||
*/
|
||
async test() {
|
||
try {
|
||
await Integrations._request({
|
||
url: "https://www.srrdb.com/api/search/r:test",
|
||
timeout: 5000,
|
||
});
|
||
return { ok: true, error: null };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
},
|
||
|
||
/**
|
||
* getFiles — fetch file list for a release from SRRDB.
|
||
*/
|
||
async getFiles(releaseName) {
|
||
if (!releaseName) return { files: [], error: "No release name" };
|
||
try {
|
||
const encoded = encodeURIComponent(releaseName);
|
||
const result = await Integrations._request({
|
||
url: `https://www.srrdb.com/api/files/${encoded}`,
|
||
timeout: 8000,
|
||
});
|
||
// SRRDB files API returns an array of file objects
|
||
const files = Array.isArray(result.data) ? result.data : [];
|
||
return { files, error: null };
|
||
} catch (e) {
|
||
return { files: [], error: e.message };
|
||
}
|
||
},
|
||
},
|
||
|
||
prowlarr: {
|
||
/**
|
||
* search — search Prowlarr for a torrent name.
|
||
*/
|
||
async search(config, torrentName) {
|
||
if (!config || !config.url || !config.apiKey) {
|
||
return { found: false, results: [], error: "Prowlarr not configured" };
|
||
}
|
||
|
||
try {
|
||
const url = `${config.url.replace(/\/+$/, "")}/api/v1/search?query=${encodeURIComponent(torrentName)}&type=search`;
|
||
const result = await Integrations._request({
|
||
url,
|
||
headers: { "X-Api-Key": config.apiKey },
|
||
timeout: config.timeout || 15000,
|
||
});
|
||
|
||
const data = result.data;
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
return { found: true, results: data, error: null };
|
||
}
|
||
return { found: false, results: [], error: null };
|
||
} catch (e) {
|
||
return { found: false, results: [], error: e.message };
|
||
}
|
||
},
|
||
|
||
/**
|
||
* test — health check for Prowlarr instance.
|
||
*/
|
||
async test(config) {
|
||
if (!config || !config.url || !config.apiKey) {
|
||
return { ok: false, error: "Prowlarr not configured" };
|
||
}
|
||
|
||
try {
|
||
await Integrations._request({
|
||
url: `${config.url.replace(/\/+$/, "")}/api/v1/health`,
|
||
headers: { "X-Api-Key": config.apiKey },
|
||
timeout: 5000,
|
||
});
|
||
return { ok: true, error: null };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
},
|
||
},
|
||
};
|
||
|
||
|
||
/* ========================================================================
|
||
* RENAME DETECTOR — Confidence-based rename detection for Prowlarr
|
||
* Replaces the brittle word-overlap + stripped-string comparison.
|
||
* Uses H.extractTitleElements for structured field-based scoring.
|
||
* ======================================================================== */
|
||
|
||
const RenameDetector = {
|
||
|
||
/**
|
||
* _relativeAge — Format a date string as a human-readable relative age.
|
||
* D5: Used for torrent age display next to Prowlarr matches.
|
||
*/
|
||
_relativeAge(dateStr) {
|
||
if (!dateStr) return null;
|
||
try {
|
||
const d = new Date(dateStr);
|
||
if (isNaN(d.getTime())) return null;
|
||
const diffMs = Date.now() - d.getTime();
|
||
if (diffMs < 0) return "just now";
|
||
const mins = Math.floor(diffMs / 60000);
|
||
if (mins < 60) return `${mins}m ago`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `${hrs}h ago`;
|
||
const days = Math.floor(hrs / 24);
|
||
if (days < 30) return `${days}d ago`;
|
||
const months = Math.floor(days / 30);
|
||
if (months < 12) return `${months}mo ago`;
|
||
const years = Math.floor(months / 12);
|
||
return `${years}y ago`;
|
||
} catch { return null; }
|
||
},
|
||
|
||
/** Codec aliases for fuzzy matching */
|
||
_codecAliases: {
|
||
"x264": "h.264", "h.264": "h.264", "avc": "h.264",
|
||
"x265": "h.265", "h.265": "h.265", "hevc": "h.265",
|
||
"dd": "ac-3", "ac-3": "ac-3",
|
||
"ddp": "e-ac-3", "dd+": "e-ac-3", "e-ac-3": "e-ac-3",
|
||
"truehd": "truehd", "atmos": "truehd",
|
||
},
|
||
|
||
/**
|
||
* tokenize — Parse a release name into structured fields.
|
||
* Wraps H.extractTitleElements and adds titleName + container.
|
||
*/
|
||
tokenize(name) {
|
||
if (!name) return { raw: "", titleName: "", elements: [], positions: {}, group: null, year: null, resolution: null, source: null, vcodec: null, acodec: null, hdr: null, container: null };
|
||
|
||
let raw = name;
|
||
// Strip file extension
|
||
let container = null;
|
||
const extMatch = raw.match(/\.(mkv|mp4|avi|wmv|m4v|ts|m2ts|mov|flv|webm)$/i);
|
||
if (extMatch) {
|
||
container = extMatch[1].toLowerCase();
|
||
raw = raw.slice(0, -extMatch[0].length);
|
||
}
|
||
|
||
// Normalize separators before parsing — ensures dot-separated filenames
|
||
// (e.g. DTS-HD.MA.5.1) parse identically to space-separated titles
|
||
// (e.g. DTS-HD MA 5.1). Without this, multi-word codecs like DTS-HD MA
|
||
// only match when space-separated.
|
||
// Preserve dots in codec patterns like H.264, H.265 where the dot is
|
||
// semantically meaningful (letter.digits pattern).
|
||
const normalized = raw
|
||
.replace(/[_]/g, " ")
|
||
.replace(/(?<=[A-Za-z])\.(?=\d{3})/g, "\x00") // protect codec dots (H.264, H.265)
|
||
.replace(/(?<=\d)\.(?=\d)/g, "\x01") // protect channel dots (5.1, 7.1, 2.0)
|
||
.replace(/\./g, " ")
|
||
.replace(/\x00/g, ".")
|
||
.replace(/\x01/g, ".");
|
||
|
||
const { elements, positions } = H.extractTitleElements(normalized);
|
||
|
||
// Extract individual fields from elements array
|
||
const fieldOf = (type) => {
|
||
const el = elements.find(e => e.type === type);
|
||
return el ? el.value : null;
|
||
};
|
||
|
||
// Derive titleName: text before the first structural token
|
||
let titleEnd = raw.length;
|
||
for (const el of elements) {
|
||
if (el.position < titleEnd) titleEnd = el.position;
|
||
}
|
||
const titleName = raw.slice(0, titleEnd).replace(/[.\-_]/g, " ").replace(/\s+/g, " ").trim();
|
||
|
||
return {
|
||
raw: name,
|
||
titleName,
|
||
elements,
|
||
positions,
|
||
group: fieldOf("group"),
|
||
year: fieldOf("year"),
|
||
resolution: fieldOf("resolution"),
|
||
source: fieldOf("source") || fieldOf("type"),
|
||
vcodec: fieldOf("vcodec"),
|
||
acodec: fieldOf("acodec"),
|
||
hdr: fieldOf("hdr"),
|
||
container,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* _jaccardWords — Jaccard similarity on normalized word sets.
|
||
*/
|
||
_jaccardWords(a, b) {
|
||
if (!a || !b) return 0;
|
||
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
||
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
||
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||
let intersection = 0;
|
||
for (const w of wordsA) if (wordsB.has(w)) intersection++;
|
||
const union = new Set([...wordsA, ...wordsB]).size;
|
||
return union === 0 ? 0 : intersection / union;
|
||
},
|
||
|
||
/**
|
||
* _normalizeCodec — Normalize a codec string via alias table.
|
||
*/
|
||
_normalizeCodec(codec) {
|
||
if (!codec) return null;
|
||
const key = codec.toLowerCase().replace(/[.\s-]/g, "").replace("dts", "dts");
|
||
// Direct alias lookup
|
||
for (const [alias, canonical] of Object.entries(this._codecAliases)) {
|
||
if (key === alias.replace(/[.\s-]/g, "")) return canonical;
|
||
}
|
||
return codec.toLowerCase();
|
||
},
|
||
|
||
/**
|
||
* scoreMatch — Weighted field comparison between upload and result tokens.
|
||
* Group is excluded from relevance score (tracked separately).
|
||
*/
|
||
scoreMatch(uploadTokens, resultTokens) {
|
||
const weights = { titleName: 3.0, year: 2.0, resolution: 1.5, source: 1.0, vcodec: 1.0, acodec: 0.5 };
|
||
let totalWeight = 0;
|
||
let weightedSum = 0;
|
||
const fieldScores = {};
|
||
|
||
// titleName — Jaccard similarity
|
||
const titleScore = this._jaccardWords(uploadTokens.titleName, resultTokens.titleName);
|
||
fieldScores.titleName = titleScore;
|
||
weightedSum += titleScore * weights.titleName;
|
||
totalWeight += weights.titleName;
|
||
|
||
// Exact-match fields
|
||
const exactFields = ["year", "resolution"];
|
||
for (const f of exactFields) {
|
||
if (uploadTokens[f] || resultTokens[f]) {
|
||
const score = uploadTokens[f] && resultTokens[f] && uploadTokens[f].toLowerCase() === resultTokens[f].toLowerCase() ? 1.0 : 0.0;
|
||
fieldScores[f] = score;
|
||
weightedSum += score * weights[f];
|
||
totalWeight += weights[f];
|
||
}
|
||
}
|
||
|
||
// Source — exact match
|
||
if (uploadTokens.source || resultTokens.source) {
|
||
const score = uploadTokens.source && resultTokens.source && uploadTokens.source.toLowerCase().replace(/[.\s-]/g, "") === resultTokens.source.toLowerCase().replace(/[.\s-]/g, "") ? 1.0 : 0.0;
|
||
fieldScores.source = score;
|
||
weightedSum += score * weights.source;
|
||
totalWeight += weights.source;
|
||
}
|
||
|
||
// Codec fields — alias-aware
|
||
for (const f of ["vcodec", "acodec"]) {
|
||
if (uploadTokens[f] || resultTokens[f]) {
|
||
const a = this._normalizeCodec(uploadTokens[f]);
|
||
const b = this._normalizeCodec(resultTokens[f]);
|
||
const score = a && b && a === b ? 1.0 : 0.0;
|
||
fieldScores[f] = score;
|
||
weightedSum += score * weights[f];
|
||
totalWeight += weights[f];
|
||
}
|
||
}
|
||
|
||
const relevanceScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||
const groupMatch = !!(uploadTokens.group && resultTokens.group &&
|
||
uploadTokens.group.toLowerCase() === resultTokens.group.toLowerCase());
|
||
|
||
return { relevanceScore, fieldScores, groupMatch };
|
||
},
|
||
|
||
/**
|
||
* _indexerRank — Return preference rank for an indexer name.
|
||
* Lower = more preferred. Infinity = not in preference list.
|
||
*/
|
||
_indexerRank(indexerName, preferredList) {
|
||
if (!preferredList || preferredList.length === 0) return Infinity;
|
||
const idx = preferredList.findIndex(
|
||
p => p.toLowerCase() === (indexerName || "").toLowerCase()
|
||
);
|
||
return idx === -1 ? Infinity : idx;
|
||
},
|
||
|
||
/**
|
||
* findBestMatch — Select the best Prowlarr result using candidate ranking.
|
||
*
|
||
* Sort order:
|
||
* 1. relevanceScore (descending) — scores within 0.01 epsilon are ties
|
||
* 2. preferredIndexer rank (ascending — lower = more preferred)
|
||
* 3. seeders (descending — liveness tiebreaker)
|
||
*
|
||
* Also tracks up to 3 alternative candidates (score > 0.50) for display.
|
||
*/
|
||
findBestMatch(uploadTokens, results, preferredIndexers, ignoredIndexers) {
|
||
const EPSILON = 0.01;
|
||
const ignored = Array.isArray(ignoredIndexers) ? new Set(ignoredIndexers.map(n => n.toLowerCase())) : new Set();
|
||
const candidates = [];
|
||
|
||
for (const r of results) {
|
||
// D4: Skip results from ignored indexers
|
||
if (ignored.size > 0 && ignored.has((r.indexer || "").toLowerCase())) continue;
|
||
const tokens = this.tokenize(r.title || "");
|
||
const score = this.scoreMatch(uploadTokens, tokens);
|
||
candidates.push({
|
||
title: r.title, indexer: r.indexer, size: r.size,
|
||
infoUrl: r.infoUrl || null, guid: r.guid || null, seeders: r.seeders || 0,
|
||
publishDate: r.publishDate || null, // D5: torrent age
|
||
tokens, score, relevanceScore: score.relevanceScore,
|
||
});
|
||
}
|
||
|
||
if (candidates.length === 0) return null;
|
||
|
||
const prefs = Array.isArray(preferredIndexers) ? preferredIndexers : [];
|
||
|
||
candidates.sort((a, b) => {
|
||
// Primary: relevanceScore descending (ties within epsilon)
|
||
const scoreDiff = b.relevanceScore - a.relevanceScore;
|
||
if (Math.abs(scoreDiff) > EPSILON) return scoreDiff;
|
||
// Secondary: preferred indexer rank ascending
|
||
const rankA = this._indexerRank(a.indexer, prefs);
|
||
const rankB = this._indexerRank(b.indexer, prefs);
|
||
if (rankA !== rankB) return rankA - rankB;
|
||
// Tertiary: seeders descending
|
||
return (b.seeders || 0) - (a.seeders || 0);
|
||
});
|
||
|
||
const best = candidates[0];
|
||
// D3: Collect ALL alternatives (distinct indexers, score > 0.50) — no cap
|
||
const seen = new Set([best.indexer]);
|
||
const alternatives = [];
|
||
for (let i = 1; i < candidates.length; i++) {
|
||
const c = candidates[i];
|
||
if (c.relevanceScore >= 0.50 && !seen.has(c.indexer)) {
|
||
seen.add(c.indexer);
|
||
alternatives.push({ indexer: c.indexer, title: c.title, relevanceScore: c.relevanceScore, infoUrl: c.infoUrl || null, publishDate: c.publishDate || null });
|
||
}
|
||
}
|
||
best.alternatives = alternatives;
|
||
|
||
return best;
|
||
},
|
||
|
||
/**
|
||
* classifyTVScope — Determine upload scope and adapt search query.
|
||
*/
|
||
classifyTVScope(torrentName, fileStructure) {
|
||
const se = H.parseSeasonEpisode(torrentName);
|
||
if (se.season !== null && se.episode !== null) {
|
||
return { scope: "episode", season: se.season, searchQuery: torrentName, canCompareMediaInfoFile: true };
|
||
}
|
||
if (se.isSeasonPack) {
|
||
// For season packs, build a simpler query: title + year + S##
|
||
const year = H.extractYear(torrentName);
|
||
const titleEnd = torrentName.indexOf(se.raw);
|
||
const titlePart = titleEnd > 0 ? torrentName.slice(0, titleEnd).replace(/[.\-_]/g, " ").trim() : torrentName;
|
||
const query = [titlePart, year, se.raw].filter(Boolean).join(" ");
|
||
return { scope: "season_pack", season: se.season, searchQuery: query, canCompareMediaInfoFile: false };
|
||
}
|
||
// Not TV
|
||
return { scope: "movie", season: null, searchQuery: torrentName, canCompareMediaInfoFile: true };
|
||
},
|
||
|
||
/**
|
||
* structurallyEquivalent — Compare two tokenized names by element sets.
|
||
* Ignores element order, separator style, and container extension.
|
||
* optionally ignores group differences.
|
||
*/
|
||
structurallyEquivalent(tokensA, tokensB, { ignoreGroup = false } = {}) {
|
||
// Compare title name words
|
||
const titleSim = this._jaccardWords(tokensA.titleName, tokensB.titleName);
|
||
if (titleSim < 0.5) return false;
|
||
|
||
// Compare structural fields — must match if both present
|
||
const fields = ["year", "resolution"];
|
||
for (const f of fields) {
|
||
if (tokensA[f] && tokensB[f] && tokensA[f].toLowerCase() !== tokensB[f].toLowerCase()) return false;
|
||
}
|
||
|
||
// Codec comparison with aliases
|
||
for (const f of ["vcodec", "acodec"]) {
|
||
if (tokensA[f] && tokensB[f]) {
|
||
const a = this._normalizeCodec(tokensA[f]);
|
||
const b = this._normalizeCodec(tokensB[f]);
|
||
if (a !== b) return false;
|
||
}
|
||
}
|
||
|
||
// Group comparison
|
||
if (!ignoreGroup && tokensA.group && tokensB.group) {
|
||
if (tokensA.group.toLowerCase() !== tokensB.group.toLowerCase()) return false;
|
||
}
|
||
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* assessRename — Full rename assessment with confidence levels.
|
||
* Returns { level, action, note, issues, fieldScores }
|
||
*/
|
||
assessRename(data, bestMatch, tvScope) {
|
||
const issues = [];
|
||
const uploadTokens = this.tokenize(data.torrentName);
|
||
|
||
// Phase 1: Self-consistency checks (strongest signal)
|
||
// Use ignoreGroup for self-consistency — folder/file names may legitimately
|
||
// use different group formatting than the torrent title. The group comparison
|
||
// is handled separately via Prowlarr match scoring.
|
||
if (data.fileStructure?.folderName) {
|
||
const folderTokens = this.tokenize(data.fileStructure.folderName);
|
||
if (!this.structurallyEquivalent(uploadTokens, folderTokens, { ignoreGroup: true })) {
|
||
issues.push({
|
||
type: "folder", severity: "high",
|
||
expected: data.torrentName,
|
||
found: data.fileStructure.folderName,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (data.mediaInfoFilename && tvScope.canCompareMediaInfoFile) {
|
||
const miTokens = this.tokenize(data.mediaInfoFilename);
|
||
if (!this.structurallyEquivalent(uploadTokens, miTokens, { ignoreGroup: true })) {
|
||
issues.push({
|
||
type: "filename", severity: "high",
|
||
expected: data.torrentName,
|
||
found: data.mediaInfoFilename,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Phase 2: Confidence assignment
|
||
const score = bestMatch.score;
|
||
const selfConsistent = issues.filter(i => i.severity === "high").length === 0;
|
||
|
||
if (selfConsistent && score.relevanceScore >= 0.80) {
|
||
const note = score.groupMatch ? null : "Best Prowlarr match has a different release group";
|
||
return { level: "match", action: "pass", note, issues, fieldScores: score.fieldScores };
|
||
}
|
||
|
||
if (selfConsistent && score.relevanceScore >= 0.60) {
|
||
return { level: "likely_match", action: "pass", note: "Minor field differences with indexed release", issues, fieldScores: score.fieldScores };
|
||
}
|
||
|
||
if (!selfConsistent) {
|
||
const highCount = issues.filter(i => i.severity === "high").length;
|
||
if (highCount >= 2) {
|
||
return { level: "renamed", action: "warn", note: "Folder and filename both differ from torrent name", issues, fieldScores: score.fieldScores };
|
||
}
|
||
return { level: "likely_renamed", action: "warn", note: "File or folder name differs from torrent name", issues, fieldScores: score.fieldScores };
|
||
}
|
||
|
||
if (score.relevanceScore < 0.60) {
|
||
return { level: "uncertain", action: "advisory", note: "No strong Prowlarr match — cannot assess rename status", issues, fieldScores: score.fieldScores };
|
||
}
|
||
|
||
return { level: "uncertain", action: "advisory", note: null, issues, fieldScores: score.fieldScores };
|
||
},
|
||
};
|
||
|
||
|
||
/* ========================================================================
|
||
* MESSAGE BUILDER — Corrective message generation
|
||
* Ported from the original G object. Rules URL is now configurable.
|
||
* ======================================================================== */
|
||
|
||
const G={RULES_URL:null,collectIssues(e){const n=[],t=(s,a,o,c={})=>{(a==="fail"||a==="warn")&&n.push({id:s,status:a,raw:o,...c})};if(t("tmdb",e.tmdbMatch.status,e.tmdbMatch.message,{expected:e.tmdbMatch.details?.expected,found:e.tmdbMatch.details?.found}),e.seasonEpisode.status!=="na"&&t("season_episode",e.seasonEpisode.status,e.seasonEpisode.message),e.bannedGroup.alert&&t("banned_group",e.bannedGroup.status,e.bannedGroup.message,{group:e.bannedGroup.group}),t("screenshots",e.screenshots.status,e.screenshots.message,{count:e.screenshots.count}),t("resolution_type",e.resolutionTypeMatch.status,e.resolutionTypeMatch.message,{expected:e.resolutionTypeMatch.details?.expected,found:e.resolutionTypeMatch.details?.found}),e.elementOrder.status==="fail"||e.elementOrder.status==="warn"){const s=e.elementOrder.violations||[];t("element_order",e.elementOrder.status,e.elementOrder.message,{violations:s.map(a=>typeof a=="object"?a.message:a),orderType:e.elementOrder.details?.orderType})}if(e.namingGuide.checks)for(const s of e.namingGuide.checks){const a=s.name.toLowerCase().replace(/[^a-z0-9]+/g,"_");t("naming_"+a,s.status,s.message,{label:s.name})}if(e.folderStructure.status!=="na"&&t("folder",e.folderStructure.status,e.folderStructure.message),e.containerFormat.status!=="na"&&t("container",e.containerFormat.status,e.containerFormat.message,{found:e.containerFormat.details?.found}),t("mediainfo",e.mediaInfo.status,e.mediaInfo.message),e.subtitleRequirement.status!=="na"&&t("subtitles",e.subtitleRequirement.status,e.subtitleRequirement.message,{audio:e.subtitleRequirement.details?.audio}),t("upscale",e.upscaleDetection.status,e.upscaleDetection.message,{alert:e.upscaleDetection.alert}),e.audioTags.checks)for(const s of e.audioTags.checks)t("audio_"+s.name.replace(/[^a-zA-Z0-9]+/g,"_"),s.status,s.message,{label:s.name});if(e.encodeCompliance.checks)for(const s of e.encodeCompliance.checks)t("encode_"+s.name.toLowerCase().replace(/[^a-z0-9]+/g,"_"),s.status,s.message,{label:s.name});if(e.packUniformity.checks)for(const s of e.packUniformity.checks)t("pack_"+s.name.toLowerCase().replace(/[^a-z0-9]+/g,"_"),s.status,s.message,{label:s.name});return n},beautify(e){const n=e.id,t=e.raw||"",s=[];if(n==="tmdb")return/not found on page/i.test(t)||/torrent name not found/i.test(t)?null:e.expected?(s.push("9"),/capitalization/i.test(t)?{text:`Title capitalization should match TMDB: "${e.expected}".`,rules:s}:/without.*the.*prefix/i.test(t)?{text:`Title is missing the "The" prefix — TMDB title is "${e.expected}".`,rules:s}:{text:`Title does not match TMDB. The correct title is "${e.expected}".`,rules:s}):(s.push("9"),{text:"Title does not match TMDB.",rules:s});if(n==="season_episode"){if(s.push("8.2"),/zero-padded/i.test(t)){const a=t.match(/expected\s+(S\d+E?\d*)/i);return{text:`Season and episode numbers must be zero-padded (e.g. ${a?a[1]:"S01E01"}).`,rules:s}}return/no S##E##/i.test(t)?{text:"TV content must include the season/episode in S##E## or S## format.",rules:s}:{text:t,rules:s}}if(n==="banned_group")return s.push("2.11"),{text:`${e.group||"This release group"} is a banned release group.`,rules:s};if(n==="screenshots")return s.push("10.2"),e.count===0?{text:"Required screenshots are missing from the description.",rules:s}:{text:`Only ${e.count} screenshot${e.count===1?"":"s"} included — a minimum of 3 is required.`,rules:s};if(n==="element_order")return s.push("9"),e.violations&&e.violations.length>0?{text:`Title elements are in the wrong order:
|
||
${e.violations.map(o=>" • "+o).join(`
|
||
`)}`,rules:s}:{text:"Title elements are not in the expected order. Please refer to the Naming Guide.",rules:s};if(n.startsWith("naming_")){if(s.push("9"),n==="naming_resolution"&&/non-standard resolution|tagged as other/i.test(t))return null;if(/remove parentheses/i.test(t))return{text:"Year should not be in parentheses.",rules:s};if(n==="naming_hdr_format"){if(/HDR10.*should be renamed to.*HDR/i.test(t))return{text:'"HDR10" in the title should be "HDR".',rules:s};if(/missing hdr tag/i.test(t)){const a=t.match(/should include:\s*(.+)/i);return{text:`Missing HDR format tag in title${a?" — should be "+a[1]:""}.`,rules:s}}if(/wrong hdr tag/i.test(t)){const a=t.match(/should be:\s*(.+)/i);return{text:`Incorrect HDR format tag in title${a?" — should be "+a[1]:""}.`,rules:s}}return/title has.*but mediainfo shows no hdr/i.test(t)?{text:"Title includes an HDR format tag but MediaInfo does not confirm.",rules:s}:{text:t,rules:s}}if(n==="naming_audio_object")return/missing from title/i.test(t)?{text:`${/atmos/i.test(t)?"Atmos":"Auro3D"} detected in MediaInfo but missing from the title.`,rules:s}:/not confirmed in mediainfo/i.test(t)?{text:"Object audio tag in the title is not confirmed by MediaInfo.",rules:s}:{text:t,rules:s};if(n==="naming_source"){if(/no valid source/i.test(t)){const a=t.match(/for\s+(.+?)\s+type/i);let o="";if(a){const c=a[1];o=` for ${/^[aeiouh]/i.test(c)?"an":"a"} ${c} upload`}return{text:`Missing or invalid source tag in the title${o}.`,rules:s}}return{text:t,rules:s}}if(/^No\s/i.test(t)){let a=(e.label||"").toLowerCase();return a==="channels"&&(a="audio channels"),{text:`Missing ${a} in title.`,rules:s,naming:!0,missingElement:a}}return{text:t,rules:s}}if(n==="folder")return s.push("1.6"),/should not have.*folder/i.test(t)?{text:"Single-file movies should not be inside a folder.",rules:s}:{text:t,rules:s};if(n==="container"){if(s.push("5.2.5"),/non-mkv/i.test(t)){const a=t.match(/detected:\s*(.+)/i);return{text:`All non-disc releases must use the MKV container${a?" (found "+a[1]+")":""}.`,rules:s}}return/bdinfo should be empty/i.test(t)?{text:"BDInfo should only be provided for Full Disc uploads.",rules:["1.8","1.9"]}:{text:t,rules:s}}if(n==="mediainfo")return/mediainfo required/i.test(t)?(s.push("1.8"),{text:"MediaInfo is required for all non-disc uploads.",rules:s}):/bdinfo required/i.test(t)?(s.push("1.9"),{text:"BDInfo is required for Full Disc uploads.",rules:s}):/bdinfo expected/i.test(t)?(s.push("1.9"),{text:"Full Disc uploads should provide BDInfo rather than MediaInfo.",rules:s}):/bdinfo should be empty/i.test(t)?(s.push("1.8"),{text:"BDInfo should only be provided for Full Disc uploads.",rules:s}):{text:t,rules:s};if(n==="subtitles")return s.push("5.2.1"),/no english audio.*no subtitles/i.test(t)?{text:"English subtitles are required when the audio is not in English.",rules:s}:/requires english subtitles/i.test(t)?{text:"English subtitles are required for non-English audio content.",rules:s}:{text:t,rules:s};if(n==="upscale")return s.push("2"),{text:"This release appears to be an upscale, which is not permitted.",rules:s};if(n.startsWith("audio_")){if(/dual-audio/i.test(t)&&/reserved for non-english/i.test(t)){s.push("9");const a=t.match(/use\s+"([^"]+)"/i);let o="Dual-Audio is reserved for non-English original content with an English dub.";return a&&(o+=` Use "${a[1]}" instead.`),{text:o,rules:s}}if(/tagged dual-audio but found \d+ languages.*should be/i.test(t))return s.push("9"),{text:'More than two audio languages detected — use "Multi" instead of "Dual-Audio".',rules:s};if(/tagged dual-audio but found only/i.test(t))return s.push("9"),{text:"Dual-Audio tag used but only one audio language detected.",rules:s};if(/dual-audio requires english/i.test(t))return s.push("9"),{text:"Dual-Audio releases must include an English audio track.",rules:s};if(/multi.*found only/i.test(t))return s.push("9"),{text:'"Multi" tag used but only one audio language detected.',rules:s};if(/found \d+ languages but no.*multi/i.test(t)){s.push("9");const a=t.match(/found (\d+)/);return{text:`${a?a[1]+" audio languages":"Multiple audio languages"} detected — consider adding a "Multi" tag.`,rules:s}}if(/consider.*dual-audio/i.test(t))return s.push("9"),{text:'English and original language audio detected — consider adding the "Dual-Audio" tag.',rules:s};if(/only allowed as mono\/stereo/i.test(t)){s.push("5.2.4");const a=t.match(/^(\w+)/);return{text:`${a?a[1]:"This codec"} is only allowed for mono or stereo audio, not multichannel.`,rules:s}}if(/mp2 only allowed/i.test(t))return s.push("5.2.4"),{text:"MP2 audio is only permitted for untouched HDTV or DVD sources.",rules:s};if(/mp3 only allowed/i.test(t))return s.push("5.2.4"),{text:"MP3 is only permitted for supplementary tracks such as commentary.",rules:s};if(/not an allowed audio codec/i.test(t)){s.push("5.2.4");const a=t.match(/^(\w+)/);return{text:`${a?a[1]:"This audio codec"} is not an allowed audio codec.`,rules:s}}if(/unrecognized codec/i.test(t))return s.push("5.2.4"),{text:"An audio track uses an unrecognized codec — please verify it is permitted.",rules:s};if(/title claims .* but primary audio track is/i.test(t)){s.push("5.2.4");const a=t.match(/title claims (\S+) but primary audio track is (\S+)/i);return{text:a?`Title claims ${a[1]} audio but the primary track in MediaInfo is ${a[2]} — correct the audio codec tag.`:"Audio codec in title does not match the primary track in MediaInfo.",rules:s}}return{text:t,rules:s}}if(n.startsWith("encode_")){if(/no mediainfo available/i.test(t)||/cannot verify/i.test(t)||/could not det/i.test(t))return null;if(/encodes must use x264/i.test(t)){s.push("5.5.3");const a=t.match(/found\s+(\S+)/i);return{text:`Encodes must use x264, x265, or SVT-AV1${a?" (found "+a[1]+")":""}.`,rules:s}}if(/no x264.*detected/i.test(t))return s.push("5.5.3"),{text:"No recognized encoder (x264, x265, or SVT-AV1) detected in the title.",rules:s};if(/no encoder metadata/i.test(t))return s.push("5.5.4"),{text:"Encoder metadata is required in MediaInfo for encodes.",rules:s};if(/use encoder name.*x264/i.test(t)||/use encoder name.*x265/i.test(t)){s.push("9");const a=/x265/i.test(t)?"x265":"x264";return{text:`Title uses ${/H\.265/i.test(t)?"H.265":"H.264"} but the encoder is ${a} — use the encoder name in the title.`,rules:s}}return/typically use encoder name/i.test(t)?(s.push("9"),{text:"Encodes should use the encoder name (x264/x265) rather than the codec name (H.264/H.265) in the title.",rules:s}):/encodes must be 720p/i.test(t)?(s.push("5.5.5"),{text:"Encodes must be 720p or greater in resolution.",rules:s}):/single-pass abr/i.test(t)?(s.push("5.5.6"),{text:"Single-pass ABR is not permitted — encodes must use CRF or multi-pass ABR.",rules:s}):/cbr.*detected/i.test(t)?(s.push("5.5.6"),{text:"CBR encoding is not permitted — encodes must use CRF or multi-pass ABR.",rules:s}):/target bitrate.*without multi-pass/i.test(t)?(s.push("5.5.6"),{text:"Target bitrate encoding without multi-pass is not permitted — use CRF or multi-pass ABR.",rules:s}):{text:t,rules:s}}return n.startsWith("pack_")?/could not detect/i.test(t)?null:(s.push("8.1"),/mixed/i.test(t)?{text:`Mixed ${(e.label||"").toLowerCase()} detected across files in this pack — all files must be uniform.`,rules:s}:{text:t,rules:s}):n==="resolution_type"?/could not detect/i.test(t)?null:(s.push("4"),/mismatch/i.test(t)?{text:`Resolution type tag does not match title — ${e.expected?`expected "${e.expected}" but found "${e.found}"`:t}.`,rules:s}:/should use.*Other/i.test(t)?{text:t,rules:s}:{text:t,rules:s}):{text:t,rules:[]}},buildMessage(e){if(e.length===0)return"";const n=e.filter(u=>u.status==="fail"),t=e.filter(u=>u.status==="warn"),s=new Set,a=[],o=n.map(u=>this.beautify(u)).filter(Boolean),c=t.map(u=>this.beautify(u)).filter(Boolean),r=[],d=[],f=[];for(const u of o)u.rules.forEach(D=>s.add(D)),u.missingElement?d.push(u.missingElement):u.text.startsWith("Title ")?r.push(u.text):f.push(u.text);for(const u of r)a.push(u);if(d.length>0)if(d.length===1)a.push(`Missing ${d[0]} in title.`);else{const u=d.pop();a.push(`Missing ${d.join(", ")} and ${u} in title.`)}for(const u of f)a.push(u);if(c.length>0){const u=[],D=[],x=[];for(const p of c)p.rules.forEach(y=>s.add(y)),p.missingElement?D.push(p.missingElement):p.text.startsWith("Title ")?u.push(p.text):x.push(p.text);for(const p of u)a.push(p);if(D.length>0)if(D.length===1)a.push(`Missing ${D[0]} in title.`);else{const p=D.pop();a.push(`Missing ${D.join(", ")} and ${p} in title.`)}for(const p of x)a.push(p)}const A=new Set;let b=a.filter(u=>A.has(u)?!1:(A.add(u),!0)).join(`
|
||
`);if(s.size>0&&this.RULES_URL){b+=`
|
||
|
||
Please review the [url=${this.RULES_URL}]Naming Guide[/url].`}return b}};
|
||
|
||
/* ========================================================================
|
||
* UI — Panel rendering, injection, and event handling
|
||
* Ported from the original U object.
|
||
* ======================================================================== */
|
||
|
||
const U={getStatusIcon(e){switch(e){case"pass":return'<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><button class="form__button form__button--text mh-btn mh-btn--sm" id="mh-integration-refresh" title="Re-run integration searches"><i class="fas fa-rotate"></i> Re-search</button></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){const conf=bestMatch.confidence||{};const confLevel=conf.level||"uncertain";const fs=conf.fieldScores||{};const issues=conf.issues||[];const alts=bestMatch.alternatives||[];
|
||
// Best match title with optional link
|
||
const titleText=esc(bestMatch.title);const indexerText=esc(bestMatch.indexer);const linkHtml=bestMatch.infoUrl?` <a href="${esc(bestMatch.infoUrl)}" target="_blank" rel="noopener" title="Open on ${indexerText}" class="mh-integration__link"><i class="fas fa-external-link-alt"></i></a>`:"";
|
||
matchHtml+=`<div class="mh-integration__detail"><strong>Best match:</strong> ${titleText} <span class="mh-integration__indexer">[${indexerText}]</span>${linkHtml}</div>`;
|
||
// Summary line — dynamically built from actual field scores (D9 + D11)
|
||
const _fieldLabelsShort={titleName:"title",year:"year",resolution:"resolution",source:"source",vcodec:"video codec",acodec:"audio codec"};
|
||
const _matchedFields=Object.entries(fs).filter(([k,v])=>_fieldLabelsShort[k]&&v>=1.0).map(([k])=>_fieldLabelsShort[k]);
|
||
const _diffFields=Object.entries(fs).filter(([k,v])=>_fieldLabelsShort[k]&&v<1.0&&v!==undefined).map(([k])=>_fieldLabelsShort[k]);
|
||
let summaryText;
|
||
if(confLevel==="match"){summaryText=_matchedFields.length>0?`Name matches release on ${indexerText} — ${_matchedFields.join(", ")} consistent`:`Name matches release on ${indexerText}`}
|
||
else if(confLevel==="likely_match"){summaryText=_diffFields.length>0?`Name likely matches release on ${indexerText} — ${_diffFields.join(", ")} differ${_diffFields.length===1?"s":""}`:`Name likely matches release on ${indexerText} — minor field differences`}
|
||
else if(confLevel==="uncertain"){summaryText="No strong match found — cannot verify release name automatically"}
|
||
else if(confLevel==="likely_renamed"){summaryText=issues.length>0?`Possible rename — ${issues[0].type==="folder"?"folder name differs from torrent name":"filename differs from torrent name"}`:"Possible rename detected — review filenames"}
|
||
else if(confLevel==="renamed"){summaryText="Likely renamed — folder and filename both differ from torrent name"}
|
||
else{summaryText="Review manually"}
|
||
const summaryIcons={"match":"check-circle mh-icon--pass","likely_match":"check-circle mh-icon--pass","uncertain":"info-circle mh-icon--advisory","likely_renamed":"exclamation-triangle mh-icon--warn","renamed":"exclamation-triangle mh-icon--warn"};
|
||
const summaryClasses={"match":"pass","likely_match":"pass","uncertain":"advisory","likely_renamed":"warn","renamed":"warn"};
|
||
matchHtml+=`<div class="mh-integration__detail mh-integration__detail--${summaryClasses[confLevel]||"advisory"}"><i class="fas fa-${summaryIcons[confLevel]||"info-circle mh-icon--advisory"}"></i> ${summaryText}</div>`;
|
||
// Rename issues — reframed labels
|
||
if(confLevel==="likely_renamed"||confLevel==="renamed"){const issueItems=issues.map(i=>{if(i.type==="folder")return`<li>Torrent name: <code>${esc(i.expected)}</code><br>Folder name: <code>${esc(i.found)}</code></li>`;if(i.type==="filename")return`<li>Torrent name: <code>${esc(i.expected)}</code><br>Filename: <code>${esc(i.found)}</code></li>`;return`<li><code>${esc(i.expected)}</code> vs <code>${esc(i.found)}</code></li>`}).join("");if(issueItems)matchHtml+=`<ul class="mh-integration__diffs">${issueItems}</ul>`}
|
||
// Group note
|
||
if(conf.note&&confLevel!=="likely_renamed"&&confLevel!=="renamed"){matchHtml+=`<div class="mh-integration__detail mh-integration__detail--note"><small>${esc(conf.note)}</small></div>`}
|
||
// Collapsible field comparison detail
|
||
const fieldLabels={titleName:"Title",year:"Year",resolution:"Resolution",source:"Source",vcodec:"Video codec",acodec:"Audio codec"};const fieldEntries=Object.entries(fs).filter(([k])=>fieldLabels[k]);if(fieldEntries.length>0){let detailRows=fieldEntries.map(([k,v])=>{const label=fieldLabels[k];const icon=v>=1.0?'<i class="fas fa-check mh-icon--pass"></i>':v>0?'<i class="fas fa-minus mh-icon--warn"></i>':'<i class="fas fa-times mh-icon--fail"></i>';return`<div class="mh-field-row">${icon} <span class="mh-field-label">${label}</span></div>`}).join("");
|
||
// Self-consistency summary
|
||
const selfIssues=issues.filter(i=>i.severity==="high");const folderOk=!selfIssues.some(i=>i.type==="folder");const fileOk=!selfIssues.some(i=>i.type==="filename");detailRows+=`<div class="mh-field-row mh-field-row--self"><small>Self-check: folder ${folderOk?"✓":"✗"} · filename ${fileOk?"✓":"✗"}</small></div>`;
|
||
// Alternatives
|
||
if(alts.length>0){const _fmtAlt=a=>{const name=a.infoUrl?`<a href="${esc(a.infoUrl)}" target="_blank" rel="noopener" class="mh-integration__link">${esc(a.indexer)}</a>`:esc(a.indexer);const age=RenameDetector._relativeAge(a.publishDate);return age?`${name} <span class="mh-age">(${age})</span>`:name};const topAlts=alts.slice(0,3);const extraAlts=alts.slice(3);detailRows+=`<div class="mh-field-row mh-field-row--alts"><small>Also found on: ${topAlts.map(_fmtAlt).join(", ")}</small></div>`;if(extraAlts.length>0){detailRows+=`<details class="mh-alts-expand"><summary><small>+${extraAlts.length} more indexer${extraAlts.length>1?"s":""}</small></summary><div class="mh-alts-list">${extraAlts.map(a=>`<div class="mh-alts-item"><small>${_fmtAlt(a)}</small></div>`).join("")}</div></details>`}}
|
||
matchHtml+=`<details class="mh-integration__expand"><summary class="mh-integration__expand-trigger"><small>Comparison details</small></summary><div class="mh-integration__fields">${detailRows}</div></details>`}}
|
||
const prowlStatusMap={"match":"pass","likely_match":"pass","uncertain":"advisory","likely_renamed":"warn","renamed":"warn"};const prowlIconMap={"match":"check-circle mh-icon--pass","likely_match":"check-circle mh-icon--pass","uncertain":"info-circle mh-icon--advisory","likely_renamed":"exclamation-triangle mh-icon--warn","renamed":"exclamation-triangle mh-icon--warn"};const confLevel=bestMatch?.confidence?.level||"uncertain";const prowlStatus=prowlStatusMap[confLevel]||"advisory";const prowlIcon=prowlIconMap[confLevel]||"info-circle mh-icon--advisory";
|
||
// Header: precise indexer wording
|
||
const headerMsg=count===1?`Found on ${esc(bestMatch?.indexer||"1 indexer")}`:`Best match from ${esc(bestMatch?.indexer||"unknown")} (${count} indexers total)`;
|
||
return`<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">${headerMsg}</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-btn--sm { font-size: 11px; padding: 2px 6px; margin-left: auto; }
|
||
|
||
.mh-body {
|
||
padding: 0 !important;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.mh-group {
|
||
padding: 8px 16px 4px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--text-color, #737373);
|
||
background: var(--surface-01, #1a1a2e);
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-group:first-child {
|
||
padding-top: 10px;
|
||
}
|
||
|
||
.mh-section {
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-section__summary {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 16px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--text-color, #737373);
|
||
background: var(--surface-01, #1a1a2e);
|
||
cursor: pointer;
|
||
list-style: none;
|
||
user-select: none;
|
||
}
|
||
.mh-section__summary::-webkit-details-marker,
|
||
.mh-section__summary::marker {
|
||
display: none;
|
||
}
|
||
.mh-section__summary:hover {
|
||
filter: brightness(1.06);
|
||
}
|
||
.mh-section__chevron {
|
||
margin-left: auto;
|
||
font-size: 10px;
|
||
transition: transform .2s ease;
|
||
color: var(--text-color, #737373);
|
||
}
|
||
.mh-section[open] > .mh-section__summary .mh-section__chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
.mh-alert {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 16px;
|
||
font-size: 13px;
|
||
}
|
||
.mh-alert--fail {
|
||
background: rgba(220, 40, 40, 0.10);
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
color: rgba(232, 114, 114, 0.80);
|
||
}
|
||
.mh-alert__icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
color: rgba(226, 79, 79, 0.80);
|
||
}
|
||
.mh-alert__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.mh-alert__content strong {
|
||
font-size: 13px;
|
||
color: rgba(232, 114, 114, 0.80);
|
||
}
|
||
.mh-alert__content span {
|
||
font-size: 12px;
|
||
color: rgba(226, 79, 79, 0.80);
|
||
}
|
||
|
||
.mh-accordion {
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-accordion:last-child { border-bottom: none; }
|
||
|
||
.mh-accordion__summary {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 16px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
list-style: none;
|
||
transition: background 0.15s;
|
||
border-left: 3px solid transparent;
|
||
}
|
||
.mh-accordion__summary::-webkit-details-marker,
|
||
.mh-accordion__summary::marker {
|
||
display: none;
|
||
content: '';
|
||
}
|
||
.mh-accordion__summary:hover {
|
||
background: var(--surface-01, #1a1a2e);
|
||
filter: brightness(1.06);
|
||
}
|
||
.mh-accordion__summary:focus-visible {
|
||
outline: 2px solid rgba(0, 127, 255, 0.50);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
.mh-accordion__summary--pass { border-left-color: rgba(33, 196, 93, 0.80); }
|
||
.mh-accordion__summary--fail { border-left-color: rgba(226, 79, 79, 0.80); }
|
||
.mh-accordion__summary--warn { border-left-color: rgba(255, 192, 5, 0.80); }
|
||
.mh-accordion__summary--na { border-left-color: rgba(89, 89, 89, 0.80); }
|
||
|
||
.mh-accordion__icon { font-size: 14px; flex-shrink: 0; }
|
||
.mh-accordion__title {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--panel-head-fg, #ccc);
|
||
}
|
||
|
||
.mh-accordion__chevron {
|
||
font-size: 10px;
|
||
color: var(--text-color, #737373);
|
||
transition: transform 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
.mh-accordion[open] > .mh-accordion__summary .mh-accordion__chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.mh-accordion__body {
|
||
padding: 8px 16px 12px 36px;
|
||
background: var(--surface-01, #1a1a2e);
|
||
}
|
||
|
||
.mh-inline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 16px;
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
border-left: 3px solid transparent;
|
||
}
|
||
.mh-inline--pass { border-left-color: rgba(33, 196, 93, 0.80); }
|
||
.mh-inline--fail { border-left-color: rgba(226, 79, 79, 0.80); }
|
||
.mh-inline--warn { border-left-color: rgba(255, 192, 5, 0.80); }
|
||
.mh-inline--na { border-left-color: rgba(89, 89, 89, 0.80); }
|
||
.mh-inline__icon { flex-shrink: 0; font-size: 12px; }
|
||
.mh-inline__title {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--panel-head-fg, #d8d7dc);
|
||
}
|
||
.mh-inline__msg {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: var(--text-color, #8c8c8c);
|
||
}
|
||
|
||
.mh-accordion--alert > .mh-accordion__summary--fail {
|
||
border-left-width: 4px;
|
||
animation: mh-pulse 1.8s ease-in-out infinite;
|
||
}
|
||
@keyframes mh-pulse {
|
||
0%, 100% { border-left-color: rgba(226, 79, 79, 0.80); }
|
||
50% { border-left-color: rgba(246, 85, 85, 0.80); }
|
||
}
|
||
|
||
.mh-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
flex-shrink: 0;
|
||
line-height: 1.4;
|
||
}
|
||
.mh-badge--pass { background: rgba(40, 168, 70, 0.15); color: rgba(45, 219, 109, 0.80); }
|
||
.mh-badge--fail { background: rgba(220, 40, 40, 0.12); color: rgba(226, 79, 79, 0.80); }
|
||
.mh-badge--warn { background: rgba(255, 192, 5, 0.12); color: rgba(255, 201, 40, 0.80); }
|
||
.mh-badge--na { background: rgba(128, 128, 128, 0.12); color: rgba(140, 140, 140, 0.80); }
|
||
.mh-badge--info { background: rgba(40, 168, 70, 0.15); color: rgba(45, 219, 109, 0.80); }
|
||
|
||
.mh-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding: 5px 0;
|
||
font-size: 13px;
|
||
color: var(--text-color, #b8b8b8);
|
||
flex-wrap: wrap;
|
||
}
|
||
.mh-row + .mh-row {
|
||
border-top: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-row__icon { flex-shrink: 0; font-size: 12px; padding-top: 2px; }
|
||
.mh-row__label {
|
||
font-weight: 600;
|
||
color: var(--text-color, #c7c7c7);
|
||
min-width: 120px;
|
||
flex-shrink: 0;
|
||
font-size: 12.5px;
|
||
}
|
||
.mh-row__msg { flex: 1; font-size: 12.5px; }
|
||
|
||
.mh-optional {
|
||
font-weight: 400;
|
||
font-size: 11px;
|
||
color: var(--text-color, #808080);
|
||
}
|
||
|
||
.mh-detail {
|
||
width: 100%;
|
||
padding: 4px 0 2px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.mh-detail__item {
|
||
font-size: 12px;
|
||
color: var(--text-color, #949494);
|
||
line-height: 1.5;
|
||
}
|
||
.mh-detail__item strong {
|
||
color: var(--text-color, #adadad);
|
||
}
|
||
|
||
.mh-violations {
|
||
width: 100%;
|
||
padding: 6px 0 2px 20px;
|
||
}
|
||
.mh-violations__type {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: var(--text-color, #8c8c8c);
|
||
margin-bottom: 4px;
|
||
}
|
||
.mh-violations__list {
|
||
margin: 0;
|
||
padding: 0 0 0 16px;
|
||
list-style: disc;
|
||
}
|
||
.mh-violations__list li {
|
||
font-size: 12px;
|
||
color: #e87272;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.mh-icon--pass { color: rgba(33, 196, 93, 0.80); }
|
||
.mh-icon--fail { color: rgba(226, 79, 79, 0.80); }
|
||
.mh-icon--warn { color: rgba(255, 192, 5, 0.80); }
|
||
.mh-icon--na { color: rgba(107, 107, 107, 0.80); }
|
||
|
||
.mh-message-block {
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-message-block__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 16px 0;
|
||
}
|
||
.mh-message-block__icon {
|
||
font-size: 13px;
|
||
color: rgba(255, 201, 40, 0.80);
|
||
flex-shrink: 0;
|
||
}
|
||
.mh-message-block__title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-color, #bfbfbf);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.mh-message-block__actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
.mh-message-block__content {
|
||
margin: 8px 16px;
|
||
padding: 10px 14px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: var(--text-color, #ccc);
|
||
background: rgba(20, 20, 20, 0.40);
|
||
border: 1px solid rgba(59, 61, 62, 0.70);
|
||
border-radius: 6px;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
cursor: text;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.mh-message-block__content:hover {
|
||
border-color: rgba(95, 97, 98, 0.70);
|
||
}
|
||
.mh-message-block__editor {
|
||
display: none;
|
||
margin: 8px 16px;
|
||
padding: 10px 14px;
|
||
width: calc(100% - 32px);
|
||
min-height: 80px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: var(--text-color, #ccc);
|
||
background: rgba(20, 20, 20, 0.40);
|
||
border: 1px solid rgba(63, 127, 191, 0.50);
|
||
border-radius: 6px;
|
||
resize: vertical;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
.mh-message-block__hint {
|
||
padding: 0 16px 10px;
|
||
font-size: 11px;
|
||
color: var(--text-color, #737373);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.mh-message-block__hint i {
|
||
font-size: 11px;
|
||
color: var(--text-color, #666);
|
||
}
|
||
.mh-btn--success {
|
||
color: rgba(45, 219, 109, 0.80) !important;
|
||
}
|
||
.mh-btn--fail {
|
||
color: rgba(226, 79, 79, 0.80) !important;
|
||
}
|
||
|
||
.mh-filename-block {
|
||
padding: 10px 16px 12px;
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.mh-filename-block__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.mh-filename-block__icon {
|
||
font-size: 12px;
|
||
color: rgba(99, 163, 255, 0.80);
|
||
}
|
||
.mh-filename-block__title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-color, #bfbfbf);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.mh-filename-uploaded-row,
|
||
.mh-filename-input-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.mh-filename-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-color, #737373);
|
||
white-space: nowrap;
|
||
min-width: 68px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
.mh-filename-code {
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
color: var(--text-color, #aaa);
|
||
word-break: break-all;
|
||
}
|
||
.mh-filename-input {
|
||
flex: 1;
|
||
background: rgba(20, 20, 20, 0.40);
|
||
border: 1px solid rgba(59, 61, 62, 0.70);
|
||
border-radius: 4px;
|
||
padding: 5px 8px;
|
||
color: var(--text-color, #ccc);
|
||
font-size: 12px;
|
||
font-family: monospace;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.mh-filename-input:focus {
|
||
border-color: rgba(63, 127, 191, 0.60);
|
||
}
|
||
.mh-filename-result {
|
||
min-height: 0;
|
||
}
|
||
|
||
.mh-diff-hunk {
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-diff-hunk-header {
|
||
background: rgba(20, 20, 20, 0.40);
|
||
color: var(--text-color, #ccc);
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
border-bottom: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-diff-hunk-at { color: var(--text-color, #ccc); }
|
||
.mh-diff-line {
|
||
display: flex;
|
||
align-items: baseline;
|
||
}
|
||
.mh-diff-line--del { background: rgba(239, 68, 68, 0.08); }
|
||
.mh-diff-line--add { background: rgba(34, 197, 94, 0.08); }
|
||
.mh-diff-line--ctx { background: transparent; }
|
||
.mh-diff-gutter {
|
||
width: 26px;
|
||
min-width: 26px;
|
||
text-align: center;
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
padding: 4px 0;
|
||
user-select: none;
|
||
border-right: 1px solid rgba(59, 61, 62, 0.70);
|
||
}
|
||
.mh-diff-line--del .mh-diff-gutter { color: rgba(239, 68, 68, 0.65); }
|
||
.mh-diff-line--add .mh-diff-gutter { color: rgba(34, 197, 94, 0.65); }
|
||
.mh-diff-line--ctx .mh-diff-gutter { color: rgba(140, 140, 140, 0.40); }
|
||
.mh-diff-content {
|
||
flex: 1;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
word-break: break-all;
|
||
padding: 4px 10px;
|
||
line-height: 1.8;
|
||
color: var(--text-color, #ccc);
|
||
background: none;
|
||
border: none;
|
||
}
|
||
.mh-diff-token--ctx { color: var(--text-color, #ccc); }
|
||
.mh-diff-token--del {
|
||
background: rgba(239, 68, 68, 0.28);
|
||
color: rgba(254, 202, 202, 1.0);
|
||
padding: 0 2px;
|
||
}
|
||
.mh-diff-token--add {
|
||
background: rgba(34, 197, 94, 0.28);
|
||
color: rgba(187, 247, 208, 1.0);
|
||
padding: 0 2px;
|
||
}
|
||
.mh-diff-dot { opacity: 0.35; }
|
||
.mh-diff-error {
|
||
font-size: 12px;
|
||
color: rgba(226, 79, 79, 0.80);
|
||
}
|
||
|
||
/* Advisory status */
|
||
.mh-icon--advisory { color: rgba(96, 165, 250, 0.80); }
|
||
.mh-badge--advisory { background: rgba(59, 130, 246, 0.12); color: rgba(96, 165, 250, 0.80); }
|
||
.mh-chip--advisory { background: rgba(59, 130, 246, 0.12); color: rgba(96, 165, 250, 0.80); }
|
||
.mh-accordion__summary--advisory { border-left-color: rgba(96, 165, 250, 0.80); }
|
||
.mh-inline--advisory { border-left-color: rgba(96, 165, 250, 0.80); }
|
||
|
||
/* Integration results */
|
||
.mh-integration {
|
||
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px;
|
||
padding: 8px 12px; border-left: 3px solid transparent;
|
||
font-size: 13px; color: var(--text-color, #8c8c8c);
|
||
}
|
||
.mh-integration + .mh-integration { border-top: 1px solid rgba(255,255,255,0.04); }
|
||
.mh-integration--pass { border-left-color: rgba(33, 196, 93, 0.80); }
|
||
.mh-integration--fail { border-left-color: rgba(226, 79, 79, 0.80); }
|
||
.mh-integration--warn { border-left-color: rgba(255, 192, 5, 0.80); }
|
||
.mh-integration--na { border-left-color: rgba(89, 89, 89, 0.80); }
|
||
.mh-integration--loading { border-left-color: rgba(96, 165, 250, 0.50); opacity: 0.7; }
|
||
.mh-integration__icon { flex-shrink: 0; font-size: 14px; margin-top: 1px; }
|
||
.mh-integration__label { font-weight: 600; color: var(--panel-head-fg, #d8d7dc); min-width: 70px; }
|
||
.mh-integration__msg { flex: 1; }
|
||
.mh-integration__indexer { font-size: 11px; opacity: 0.6; }
|
||
.mh-integration__detail {
|
||
width: 100%; padding: 6px 10px; margin-top: 4px;
|
||
background: rgba(255,255,255,0.02); border-radius: 4px; font-size: 12px;
|
||
}
|
||
.mh-integration__detail--pass { color: rgba(45, 219, 109, 0.80); }
|
||
.mh-integration__detail--fail { color: rgba(226, 79, 79, 0.80); }
|
||
.mh-integration__detail--warn { color: rgba(255, 201, 40, 0.80); }
|
||
.mh-integration__diffs { margin: 4px 0 0 16px; padding: 0; list-style: disc; font-size: 11px; }
|
||
.mh-integration__compare { margin-top: 4px; font-size: 11px; opacity: 0.85; }
|
||
.mh-integration__compare div { padding: 2px 0; }
|
||
.mh-integration__detail--advisory { color: rgba(96, 165, 250, 0.80); }
|
||
.mh-integration__detail--note { color: var(--text-color, #8c8c8c); }
|
||
.mh-integration__link { color: rgba(96, 165, 250, 0.70); text-decoration: none; margin-left: 4px; font-size: 11px; }
|
||
.mh-integration__link:hover { color: rgba(96, 165, 250, 1); }
|
||
.mh-integration__expand { margin-top: 6px; }
|
||
.mh-integration__expand-trigger { cursor: pointer; list-style: none; color: rgba(96, 165, 250, 0.70); user-select: none; }
|
||
.mh-integration__expand-trigger::-webkit-details-marker,
|
||
.mh-integration__expand-trigger::marker { display: none; }
|
||
.mh-integration__expand-trigger::before { content: "▸ "; }
|
||
.mh-integration__expand[open] > .mh-integration__expand-trigger::before { content: "▾ "; }
|
||
.mh-integration__fields { padding: 4px 0 0 8px; }
|
||
.mh-field-row { font-size: 11px; padding: 1px 0; color: var(--text-color, #cbd5e1); display: flex; align-items: center; gap: 4px; }
|
||
.mh-field-row i { font-size: 10px; width: 12px; text-align: center; }
|
||
.mh-field-label { min-width: 80px; }
|
||
.mh-field-row--self { margin-top: 4px; padding-top: 4px; border-top: 1px solid rgba(59, 61, 62, 0.40); color: var(--text-color, #8c8c8c); }
|
||
.mh-field-row--alts { color: var(--text-color, #8c8c8c); }
|
||
.mh-age { color: var(--text-color, #64748b); font-size: 11px; }
|
||
.mh-alts-expand { margin-top: 2px; }
|
||
.mh-alts-expand summary { cursor: pointer; color: var(--text-color, #64748b); }
|
||
.mh-alts-expand summary:hover { color: var(--text-color, #94a3b8); }
|
||
.mh-alts-list { padding-left: 4px; }
|
||
.mh-alts-item { padding: 1px 0; }
|
||
`;;
|
||
|
||
/* ========================================================================
|
||
* BOOTSTRAP — Entry point with instance detection and page guard
|
||
* ======================================================================== */
|
||
|
||
function main() {
|
||
// Page guard: only run on torrent detail pages
|
||
if (!/\/torrents\/\d+/.test(window.location.pathname)) return;
|
||
|
||
// Instance detection
|
||
const hostname = window.location.hostname;
|
||
const instanceConfig = INSTANCE_CONFIGS[hostname] || {};
|
||
const instanceName = instanceConfig.name || hostname;
|
||
|
||
// Resolve selectors: merge instance overrides onto defaults
|
||
_resolvedSelectors = Object.assign({}, DEFAULT_SELECTORS, instanceConfig.selectors || {});
|
||
_resolvedModStatuses = instanceConfig.moderationStatuses || DEFAULT_MOD_STATUSES;
|
||
|
||
// Initialize extractors with resolved selectors
|
||
E = createExtractors(_resolvedSelectors);
|
||
|
||
// Set rules URL from instance config
|
||
G.RULES_URL = instanceConfig.rulesUrl || null;
|
||
|
||
// DOM fingerprint: verify this looks like a UNIT3D torrent page
|
||
const torrentNameEl = document.querySelector(_resolvedSelectors.torrentName);
|
||
if (!torrentNameEl) {
|
||
console.log("[ModQ Helper] No torrent name element found on", instanceName, "- skipping");
|
||
return;
|
||
}
|
||
|
||
console.log("[ModQ Helper] Running on", instanceName, "| Config:", Object.keys(instanceConfig.selectors || {}).length, "selector overrides,", "rulesUrl:", instanceConfig.rulesUrl || "(none)");
|
||
|
||
try {
|
||
// Extract data from page
|
||
const data = {
|
||
torrentName: E.getTorrentName(),
|
||
tmdbTitle: E.getTmdbTitle(),
|
||
tmdbYear: E.getTmdbYear(),
|
||
category: E.getCategory(),
|
||
type: E.getType(),
|
||
resolution: E.getResolution(),
|
||
description: E.getDescription(),
|
||
hasMediaInfo: E.hasMediaInfo(),
|
||
mediaInfoText: E.getMediaInfoText(),
|
||
mediaInfoFilename: E.getMediaInfoFilename(),
|
||
hasBdInfo: E.hasBdInfo(),
|
||
isTV: E.isTV(),
|
||
originalLanguage: E.getOriginalLanguage(),
|
||
mediaInfoLanguages: E.getMediaInfoLanguages(),
|
||
mediaInfoSubtitles: E.getMediaInfoSubtitles(),
|
||
fileStructure: E.getFileStructure(),
|
||
};
|
||
|
||
console.log("[ModQ Helper] Extracted data:", data);
|
||
|
||
// Run all checks
|
||
const results = {
|
||
tmdbMatch: k.tmdbNameMatch(data.torrentName, data.tmdbTitle),
|
||
seasonEpisode: k.seasonEpisodeFormat(data.torrentName, data.isTV),
|
||
namingGuide: k.namingGuideCompliance(data.torrentName, data.type, data.mediaInfoText, data.resolution),
|
||
elementOrder: k.titleElementOrder(data.torrentName, data.type),
|
||
folderStructure: k.movieFolderStructure(data.fileStructure, data.category, data.isTV, data.type),
|
||
mediaInfo: k.mediaInfoPresent(data.hasMediaInfo, data.hasBdInfo, data.type, data.torrentName),
|
||
audioTags: k.audioTagCompliance(data.torrentName, data.originalLanguage, data.mediaInfoLanguages, data.type, data.mediaInfoText),
|
||
subtitleRequirement: k.subtitleRequirement(data.mediaInfoLanguages, data.mediaInfoSubtitles, data.originalLanguage, data.type),
|
||
screenshots: k.screenshotCount(data.description),
|
||
bannedGroup: k.bannedReleaseGroup(data.torrentName, data.isTV, data.type),
|
||
encodeCompliance: k.encodeCompliance(data.torrentName, data.type, data.mediaInfoText),
|
||
upscaleDetection: k.upscaleDetection(data.mediaInfoFilename || data.torrentName),
|
||
containerFormat: k.containerFormat(data.fileStructure, data.type),
|
||
packUniformity: k.packUniformity(data.fileStructure, data.type),
|
||
resolutionTypeMatch: k.resolutionTypeMatch(data.torrentName, data.resolution),
|
||
};
|
||
|
||
console.log("[ModQ Helper] Check results:", results);
|
||
|
||
// DarkPeers-specific checks (gated by features)
|
||
const dpFeatures = instanceConfig.features || {};
|
||
if (dpFeatures.dpChecks) {
|
||
results.nogroup = dpk.nogroupCheck(data.torrentName, data.mediaInfoFilename, data.fileStructure);
|
||
results.unknownLanguage = dpk.unknownLanguageCheck(data.mediaInfoText);
|
||
results.extraneousFiles = dpk.extraneousFiles(data.fileStructure, data.category, data.type);
|
||
results.categoryTypeMismatch = dpk.categoryTypeMismatch(data.category, data.type, data.torrentName);
|
||
results.suspicion = dpk.suspicionHeuristics(data.torrentName, data.type, data.mediaInfoText, data.fileStructure, data.mediaInfoFilename);
|
||
results.bannedFilename = dpk.bannedGroupInFilename(data.torrentName, data.mediaInfoFilename, data.fileStructure, data.isTV, data.type);
|
||
results.singleFileFolder = dpk.singleFileInFolder(data.fileStructure, data.category, data.type);
|
||
results.missingEpisodes = dpk.missingEpisodes(data.fileStructure, data.torrentName, data.isTV);
|
||
}
|
||
|
||
if (dpFeatures.dpTitleValidation) {
|
||
results.dpTitle = TitleValidator.validate(data.torrentName, data.category, data.type, data.mediaInfoText);
|
||
}
|
||
|
||
// Inject CSS via GM_addStyle
|
||
if (typeof GM_addStyle === "function") {
|
||
GM_addStyle(Z);
|
||
} else {
|
||
const s = document.createElement("style");
|
||
s.textContent = Z;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
// Create and inject panel
|
||
const panel = U.createPanel(results);
|
||
U.injectPanel(panel);
|
||
|
||
console.log("[ModQ Helper] Panel injected successfully");
|
||
|
||
// Async integration pipeline (DarkPeers)
|
||
// Extracted into a function so the re-search button can call it again.
|
||
let _integrationSearchRun = false;
|
||
const runIntegrationSearches = async () => {
|
||
if (!dpFeatures.prowlarr && !dpFeatures.srrdb) return;
|
||
const settings = Settings.load();
|
||
|
||
// Reset placeholders to loading state on re-search
|
||
if (_integrationSearchRun) {
|
||
const sEl = document.querySelector('[data-integration="srrdb"]');
|
||
if (sEl) sEl.outerHTML = '<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>';
|
||
const pEl = document.querySelector('[data-integration="prowlarr"]');
|
||
if (pEl) pEl.outerHTML = '<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>';
|
||
}
|
||
_integrationSearchRun = true;
|
||
|
||
// SRRDB: search + file comparison
|
||
if (dpFeatures.srrdb && settings.srrdb?.enabled !== false) {
|
||
(async () => {
|
||
try {
|
||
const searchResult = await Integrations.srrdb.search(data.torrentName);
|
||
|
||
// If found, fetch files and compare against local file structure
|
||
if (searchResult.found && searchResult.release) {
|
||
const filesResult = await Integrations.srrdb.getFiles(searchResult.release.release);
|
||
if (filesResult.files.length > 0 && data.fileStructure?.files?.length > 0) {
|
||
const srrdbNames = new Set(filesResult.files.map(f => (f.name || f).toLowerCase().trim()));
|
||
const localNames = data.fileStructure.files.map(f => {
|
||
const parts = f.split("/");
|
||
return parts[parts.length - 1].toLowerCase().trim();
|
||
});
|
||
const discrepancies = [];
|
||
for (const local of localNames) {
|
||
if (!srrdbNames.has(local)) {
|
||
const close = [...srrdbNames].find(s => s.replace(/[.\-_ ]/g, "") === local.replace(/[.\-_ ]/g, ""));
|
||
if (close) {
|
||
discrepancies.push(`"${local}" differs from SRRDB "${close}"`);
|
||
} else {
|
||
discrepancies.push(`"${local}" not in SRRDB file list`);
|
||
}
|
||
}
|
||
}
|
||
searchResult.fileCheck = discrepancies.length === 0
|
||
? { match: true }
|
||
: { match: false, discrepancies };
|
||
} else if (filesResult.error) {
|
||
searchResult.fileCheck = { match: false, error: filesResult.error };
|
||
} else {
|
||
searchResult.fileCheck = { match: true };
|
||
}
|
||
}
|
||
|
||
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: confidence-based rename detection via RenameDetector
|
||
if (dpFeatures.prowlarr && settings.prowlarr?.enabled && settings.prowlarr?.url) {
|
||
(async () => {
|
||
try {
|
||
const uploadTokens = RenameDetector.tokenize(data.torrentName);
|
||
const tvScope = RenameDetector.classifyTVScope(data.torrentName, data.fileStructure);
|
||
|
||
const searchResult = await Integrations.prowlarr.search(settings.prowlarr, tvScope.searchQuery);
|
||
|
||
if (searchResult.found && searchResult.results.length > 0) {
|
||
const preferredIndexers = settings.prowlarr?.preferredIndexers || [];
|
||
const ignoredIndexers = settings.prowlarr?.ignoredIndexers || [];
|
||
const bestMatch = RenameDetector.findBestMatch(uploadTokens, searchResult.results, preferredIndexers, ignoredIndexers);
|
||
|
||
if (bestMatch) {
|
||
const assessment = RenameDetector.assessRename(data, bestMatch, tvScope);
|
||
searchResult.bestMatch = {
|
||
title: bestMatch.title,
|
||
indexer: bestMatch.indexer,
|
||
size: bestMatch.size,
|
||
infoUrl: bestMatch.infoUrl,
|
||
uploadedTitle: data.torrentName,
|
||
relevanceScore: bestMatch.relevanceScore,
|
||
confidence: assessment,
|
||
alternatives: bestMatch.alternatives || [],
|
||
};
|
||
}
|
||
}
|
||
|
||
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 });
|
||
}
|
||
};
|
||
|
||
// Wire up re-search button
|
||
const refreshBtn = document.querySelector("#mh-integration-refresh");
|
||
if (refreshBtn) {
|
||
refreshBtn.addEventListener("click", () => {
|
||
runIntegrationSearches();
|
||
});
|
||
}
|
||
|
||
// Run initial search
|
||
if (dpFeatures.prowlarr || dpFeatures.srrdb) {
|
||
runIntegrationSearches();
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error("[ModQ Helper] Error:", err);
|
||
}
|
||
}
|
||
|
||
main();
|
||
})();
|