From 5f07a854da3635bf52f8cab83e0d22a39d94281f Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 12 Sep 2025 13:41:25 +0200 Subject: [PATCH] Improve pc game categorization --- Blacklight/Categorize.php | 31 +++++++--- scripts/debug_pcgame_regex.php | 33 +++++++++++ scripts/quick_parse.php | 16 +++++ tests/Unit/CategorizePcGameTest.php | 92 +++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 scripts/debug_pcgame_regex.php create mode 100644 scripts/quick_parse.php create mode 100644 tests/Unit/CategorizePcGameTest.php diff --git a/Blacklight/Categorize.php b/Blacklight/Categorize.php index be8b8ffff..7c754eabf 100755 --- a/Blacklight/Categorize.php +++ b/Blacklight/Categorize.php @@ -695,7 +695,22 @@ class Categorize return false; } - if (preg_match('/[^a-z0-9](0x0007|ALiAS|ElAmigos|BACKLASH|BAT|CLONECD|CPY|FAS(DOX|iSO)|FLT([._ -]|COGENT)|FLT(DOX)?|PC GAMES?|\(?(Game([sz])|GAME([SZ]))\)? ?(\(([Cc])\))|GENESIS|-GOG|-HATRED|HI2U|INLAWS|JAGUAR|MAZE|MONEY|OUTLAWS|PPTCLASSiCS|PC Game|PROPHET|RAiN|Razor1911|RELOADED|DEViANCE|PLAZA|RiTUELYPOGEiOS|[rR][iI][pP]-[uU][nN][lL][eE][aA][sS][hH][eE][dD]|Steam(\b)?Rip|SKIDROW|TiNYiSO|CODEX|SiMPLEX)[^a-z0-9]?/', $this->releaseName)) { + // Guard: avoid misclassifying console or Mac releases as PC games. + $consoleOrMac = '/\b(PS5|PS4|PS3|PlayStation|PS(Vita|V)\b|Xbox\s?(Series|One|360)|XBOX(ONE|360|SERIES|SX|SS)?|XSX|XSS|XBSX|NSW|Switch|WiiU|Wii|3DS|NDS|PSP|PSV(ita)?|GameCube|NGC|CUSA\d{5}|XCI|NSP|PKG)\b/i'; + if (preg_match($consoleOrMac, $this->releaseName) || preg_match('/\b(Mac\s?OS\s?X|macOS)\b/i', $this->releaseName)) { + return false; + } + + // Expanded PC game markers: common scene/p2p groups and tags seen in 2020-2025. + $pcGroups = '(?:0x0007|ALiAS|ANOMALY|BACKLASH|BAT|CODEX|CPY|DARKS(?:iDERS|IDERS)|DEViANCE|DOGE|DODI|ELAMIGOS|EMPRESS|FITGIRL|FAS(?:DOX|iSO)|FLT(?:[._ -]|COGENT|DOX)?|GOG(?:-GAMES)?|GOLDBERG|HI2U|HOODLUM|INLAWS|JAGUAR|MAZE|MONEY|OUTLAWS|PLAZA|PROPHET|RAZOR1911|RAiN|RELOADED|RUNE|SiMPLEX|SKIDROW|TENOKE|TiNYiSO|UNLEASHED|P2P)'; + + // Additional PC-only keywords. + $pcKeywords = '(?:PC[ _.-]?GAMES?|\[(?:PC)\]|\(PC\)|Steam(?:[ ._-]?Rip|\b)|GOG(?:\b|[ ._-])|Retail\s*PC|DRM-?Free|Win(All|32|64)\b|Windows(?:\s?10|\s?11)?\b|Repack)'; + + // Build a combined pattern ensuring start/end or non-word boundaries to avoid partial matches. + $pattern = '/(?:(?:^|[\s\._-])(?:'.$pcGroups.')(?:$|[\s\._-])|'.$pcKeywords.')/i'; + + if (preg_match($pattern, $this->releaseName)) { $this->tmpCat = Category::PC_GAMES; return true; @@ -1435,8 +1450,8 @@ class Categorize // First check if the release name suggests Nintendo Wii content if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Wii|Nintendo\s+Wii)|\b(?:Wii)\b|[\._-]Wii[\._-]|Nintendo[\._-]Wii/i', $this->releaseName)) { // Verify with region codes, game-specific markers, or known Wii release groups - if (preg_match('/\b(?:ANTiDOTE|APATHY|ALMoST|AMBITION|Allstars|BAHAMUT|BiOSHOCK|Caravan|CLiiCHE|DMZ|DNi|DRYB|EUR?|GAME|GCN|GCP|HaZMaT|iCON|JAP|JPN|KOR|LaKiTu|LoCAL|LOADER|MARVEL|MULTi|NAGGERS|OneUp|NTSC|PAL|PLAYME|PONS|PROMiNENT|ProCiSiON|PROPER|QwiiF|RANT|REV0|Scrubbed|SUNSHiNE|SUSHi|TMD|USA?|VORTEX|WBFS|WiiERD|ZARD|ZER0)\b/i', $this->releaseName) || - preg_match('/\b(?:ISO|WBFS|CSO|NKit|RVZ|NAND|WAD|IOS\d+|cIOS|MODCHIP|Homebrew|DOLPHIN|vWii)\b/i', $this->releaseName) || + if (preg_match('/\b(?:ANTiDOTE|APATHY|ALMoST|AMBITION|Allstars|BAHAMUT|BiOSHOCK|Caravan|CLiiCHE|DMZ|DNi|DRYB|EUR?|GAME|GC|GCP|HaZMaT|iCON|JAP|JPN|KOR|LaKiTu|LoCAL|LOADER|MARVEL|MULTi|NAGGERS|OneUp|NTSC|PAL|PLAYME|PONS|PROMiNENT|ProCiSiON|PROPER|QwiiF|RANT|REV0|Scrubbed|SUNSHiNE|SUSHi|TMD|USA?|VORTEX|WBFS|WIIERD|ZARD|ZER0)\b/i', $this->releaseName) || + preg_match('/\b(?:ISO|WBFS|CSO|NKit|RVZ|NAND|WAD|IOS\d+|cIOS|MODCHIP|Homebrew|DOLPHIN|vWii|Virtual[._ -]Console)\b/i', $this->releaseName) || preg_match('/\[Wii\]|\(Wii\)|Nintendo\.Wii|Wii\.Game|RVZ-[A-Z0-9]+|WII-\w+|READNFO|WiiGamerZ|Wii-Backup/i', $this->releaseName)) { $this->tmpCat = Category::GAME_WII; @@ -1589,9 +1604,9 @@ class Categorize // First check if the release name suggests retro/other console content if (preg_match('/(?:^|[^a-zA-Z0-9])(?:PS[1X]|PS2|SNES|NES|SEGA(?:\s+(?:Genesis|CD|Saturn|32X|Master\s+System))?|GB[AC]?|GameBoy(?:\s+(?:Advance|Color))?|Game\s*Boy(?:\s+(?:Advance|Color))?|Dreamcast|Saturn|Atari(?:\s+(?:Jaguar|2600|5200|7800|Lynx))?|3DO|Neo\s*Geo|N64|Nintendo\s*64|PCEngine|TurboGrafx|Intellivision|Colecovision)|\b(?:PS[1X]|PS2|SNES|NES|MAME|N64)\b|[\._-](?:PS[1X]|PS2|SNES|NES|N64)[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known retro release patterns - if (preg_match('/\b(?:EUR?|FR|GAME|HOL|ISO|JP|JPN|NL|NTSC|PAL|KS|USA?|ROMS?(et)?|ROM\s+Collection|RIP|Full\s+Set|Redump|No\s+Intro|TOSEC|GoodSet|EverDrive|Collection|Classics|Anthology|Trilogy|Compilation|Complete|Rev\s+[A-Z])\b/i', $this->releaseName) || - preg_match('/\b(?:BIOS|Beetle|RetroArch|Emulator|MultiDisc|Arcade|OpenEmu|RetroPie|Recalbox|Lakka|Batocera|Hyperspin|LaunchBox|MAME|Collection|Goodset|Trurip|Verified|ReDump|PROPER|iNTERNAL|Venom|Caravan|WRG)\b/i', $this->releaseName) || + // Verify with region codes, version indicators, or other game-specific markers + if (preg_match('/\b(?:EUR?|FR|GAME|HOL|ISO|JP|JPN|NL|NTSC|PAL|KS|USA?)[\)\_]/i', $this->releaseName) || + preg_match('/\b(?:ROMs?(et)?|ROM\s+Collection|RIP|Full\s+Set|Redump|No\s+Intro|TOSEC|GoodSet|EverDrive|Collection|Classics|Anthology|Trilogy|Compilation|Complete|Rev\s+[A-Z])\b/i', $this->releaseName) || preg_match('/\(([CP]|\d{2,})\)|\.(bin|chd|cue|gcm|gdi|iso|img|mdf|nrg|z64|v64|n64|md|smc|smd|fig|gb|gbc|gba|nes|sfc|gen)$/i', $this->releaseName)) { $this->tmpCat = Category::GAME_OTHER; @@ -1740,7 +1755,7 @@ class Categorize return true; } - // Legacy detection for backward compatibility + // Legacy pattern for backward compatibility if (preg_match('/(720P|x264)\-(19|20)\d\d\-[a-z0-9]{1,12}/i', $this->releaseName) || preg_match('/[a-z0-9]{1,12}-(19|20)\d\d-(720P|x264)/i', $this->releaseName)) { if ($this->isMusicForeign()) { @@ -2038,7 +2053,7 @@ class Categorize $fullLanguages = 'arabic|brazilian|bulgarian|cantonese|chinese|croatian|czech|danish|deutsch|dutch|estonian|'. 'finnish|flemish|french|german|greek|hebrew|hungarian|icelandic|italian|japanese|korean|'. 'latin|mandarin|nordic|norwegian|polish|portuguese|romanian|russian|serbian|slovenian|'. - 'spanish|swedish|thai|turkish|ukrainian|vietnamese'; + 'spanish|spanisch|swedish|thai|turkish|ukrainian|vietnamese'; // Common language codes and abbreviations $langCodes = 'ar|bg|cn|cs|cz|da|de|dk|el|es|et|fi|fr|ger|gr|heb|hr|hu|hun|is|it|ita|jp|kr|ko|lt|lv|'. diff --git a/scripts/debug_pcgame_regex.php b/scripts/debug_pcgame_regex.php new file mode 100644 index 000000000..e0c7e9741 --- /dev/null +++ b/scripts/debug_pcgame_regex.php @@ -0,0 +1,33 @@ +newInstanceWithoutConstructor(); +$c->poster = ''; + +$samples = [ + 'Starfield-RUNE', + 'Baldurs.Gate.3.TENOKE', + 'ELDEN.RING-EMPRESS', + 'Horizon.Zero.Dawn-CODEX', + 'Cyberpunk.2077.GOG', + 'Forza.Horizon.5.ElAmigos', + 'The.Witcher.3.Wild.Hunt.PLaza', + 'Resident.Evil.4.Remake-FITGIRL', + 'Red.Dead.Redemption.2.DODI-Repack', + 'Some.Game.SKiDROW', + 'Awesome.Game.SteamRip', + 'Great.Game.Repack-FitGirl', + 'Indie.Title.DRM-Free.GOG', + 'Cool.Game.PC.Game.2024', + 'Windows.10.Title.Repack', + 'Title-[PC]-DRMFree', +]; + +foreach ($samples as $name) { + $c->releaseName = $name; + $res = $c->isPCGame(); + echo ($res ? 'MATCH' : 'NO-MATCH')."\t$name\n"; +} + diff --git a/scripts/quick_parse.php b/scripts/quick_parse.php new file mode 100644 index 000000000..99ac198af --- /dev/null +++ b/scripts/quick_parse.php @@ -0,0 +1,16 @@ +newInstanceWithoutConstructor(); + +$inputs = [ + '[FitGirl] Red.Dead.Redemption.2.v1.0.1436.28.Repack', + 'Baldurs_Gate_3_v1.0.2_MULTI12-EMPRESS', + 'Starfield.Update.1.7.29.Patch-FLT', +]; +foreach ($inputs as $in) { + $res = $games->parseTitle($in); + echo $in, ' => ', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n"; +} diff --git a/tests/Unit/CategorizePcGameTest.php b/tests/Unit/CategorizePcGameTest.php new file mode 100644 index 000000000..c7da112fd --- /dev/null +++ b/tests/Unit/CategorizePcGameTest.php @@ -0,0 +1,92 @@ +newInstanceWithoutConstructor(); + } + + public function test_pc_game_detects_common_scene_groups(): void + { + $samples = [ + 'Starfield-RUNE', + 'Baldurs.Gate.3.TENOKE', + 'ELDEN.RING-EMPRESS', + 'Horizon.Zero.Dawn-CODEX', + 'Cyberpunk.2077.GOG', + 'Forza.Horizon.5.ElAmigos', + 'The.Witcher.3.Wild.Hunt.PLaza', + 'Resident.Evil.4.Remake-FITGIRL', + 'Red.Dead.Redemption.2.DODI-Repack', + 'Some.Game.SKiDROW', + ]; + + foreach ($samples as $name) { + $c = $this->makeCategorizeNoCtor(); + $c->releaseName = $name; + $c->poster = ''; + $this->assertTrue($c->isPCGame(), "Expected PC game match for: $name"); + $this->assertSame(Category::PC_GAMES, $this->readTmpCat($c), "Expected PC_GAMES category for: $name"); + } + } + + public function test_pc_game_detects_keywords_and_platform_cues(): void + { + $samples = [ + 'Awesome.Game.SteamRip', + 'Great.Game.Repack-FitGirl', + 'Indie.Title.DRM-Free.GOG', + 'Cool.Game.PC.Game.2024', + 'Windows.10.Title.Repack', + 'Title-[PC]-DRMFree', + ]; + + foreach ($samples as $name) { + $c = $this->makeCategorizeNoCtor(); + $c->releaseName = $name; + $c->poster = ''; + $this->assertTrue($c->isPCGame(), "Expected PC game keyword match for: $name"); + $this->assertSame(Category::PC_GAMES, $this->readTmpCat($c), "Expected PC_GAMES category for: $name"); + } + } + + public function test_console_and_mac_not_misclassified_as_pc_game(): void + { + $negatives = [ + 'The.Last.of.Us.Part.I.PS5', + 'Gran.Turismo.7.PS4.CUSA12345', + 'Mario.Kart.8.Deluxe.NSW.NSP', + 'Zelda.Breath.of.the.Wild.Switch.XCI', + 'Halo.Infinite.Xbox.Series.X', + 'Gears.5.XBOXONE', + 'Some.Game.macOS.13', + ]; + + foreach ($negatives as $name) { + $c = $this->makeCategorizeNoCtor(); + $c->releaseName = $name; + $c->poster = ''; + $this->assertFalse($c->isPCGame(), "Did not expect PC game match for console/Mac: $name"); + } + } + + private function readTmpCat(object $instance): int + { + $rp = (new ReflectionClass($instance))->getProperty('tmpCat'); + $rp->setAccessible(true); + + return (int) $rp->getValue($instance); + } +} +