From ed806d27b53afae7d8942c49733e6f853a3fcfb9 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Thu, 21 May 2026 13:07:34 +0200 Subject: [PATCH] Improve XXX VR categorization --- .../Categorizers/XxxCategorizer.php | 68 ++++++-- .../Categorization/ReleaseContext.php | 2 +- tests/Unit/XxxCategorizationTest.php | 159 ++++++++++++++++++ 3 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/XxxCategorizationTest.php diff --git a/app/Services/Categorization/Categorizers/XxxCategorizer.php b/app/Services/Categorization/Categorizers/XxxCategorizer.php index a3409dbd5..f0947b973 100644 --- a/app/Services/Categorization/Categorizers/XxxCategorizer.php +++ b/app/Services/Categorization/Categorizers/XxxCategorizer.php @@ -16,13 +16,19 @@ class XxxCategorizer extends AbstractCategorizer protected int $priority = 10; // High priority - should run early // Known adult studios/sites - comprehensive list - protected const KNOWN_STUDIOS = 'Brazzers|NaughtyAmerica|RealityKings|Bangbros|BangBros18|TeenFidelity|PornPros|SexArt|WowGirls|Vixen|Blacked|Tushy|Deeper|Bellesa|Defloration|MetArt|MetArtX|TheLifeErotic|VivThomas|JoyMii|Nubiles|NubileFilms|Anilos|FamilyStrokes|X-Art|Babes|Twistys|WetAndPuffy|WowPorn|MomsTeachSex|Mofos|BangBus|Passion-HD|EvilAngel|DorcelClub|Private|Hustler|CherryPimps|PureTaboo|LadyLyne|TeamSkeet|GirlsWay|SweetSinner|NewSensations|Digital[._ -]?Playground|Wicked|Penthouse|Playboy|Kink|HardX|ArchAngel|JulesJordan|ManuelFerrara|LesbianX|AllAnal|DarkX|Elegant[._ -]?Angel|ZeroTolerance|Score|PornFidelity|Kelly[._ -]?Madison|DDF[._ -]?Network|21Sextury|21Naturals|Colette|SexMex|Bang|SpankBang|PornWorld|LegalPorno|AnalVids|GonzoXXX|RoccoSiffredi|Fake[._ -]?Hub|FakeAgent|FakeTaxi|FakeHostel|PublicAgent|StrandedTeens|Property[._ -]?Sex|Dane[._ -]?Jones|Lets[._ -]?Doe[._ -]?It|Office[._ -]?Obsession|SexyHub|Massage[._ -]?Rooms|Fitness[._ -]?Rooms|Female[._ -]?Agent|MissaX|All[._ -]?Girl[._ -]?Massage|Fantasy[._ -]?Massage|Nurumassage|Soapymassage|Reality[._ -]?Junkies|Perv[._ -]?Mom|Bad[._ -]?Milfs|Milf[._ -]?Body|Step[._ -]?Siblings|Sis[._ -]?Loves[._ -]?Me|Brother[._ -]??Crush|Dad[._ -]?Crush|Mom[._ -]?Knows[._ -]?Best|Bratty[._ -]?Sis|My[._ -]?Family[._ -]?Pies|Family[._ -]?Therapy|Nubiles[._ -]?Porn|Step[._ -]?Fantasy|Caught[._ -]?Fapping|She[._ -]?Will[._ -]?Cheat|Dirty[._ -]?Wives[._ -]?Club|Big[._ -]?Tits[._ -]?Round[._ -]?Asses|Ass[._ -]?Parade|Monsters[._ -]?Of[._ -]?Cock|Brown[._ -]?Bunnies|Teens[._ -]?Love[._ -]?Huge[._ -]?Cocks|Ass[._ -]?Masterpiece|Bang[._ -]?Casting|Holed|Tiny4K|Lubed|POVD|Exotic4K|CastingCouch[._ -]?X|Casting[._ -]?Couch|Creampie[._ -]?Angels|Digital[._ -]?Desire|Femjoy|Hegre|Joymii|Met[._ -]?Art|MPL[._ -]?Studios|Rylsky[._ -]?Art|Showy[._ -]?Beauty|Stunning18|Photodromm|Watch4Beauty|Wow[._ -]?Girls|Yonitale|Mommys[._ -]?Boy|AllOver30|MyFirst|10musume|Caribbeancom|Heyzo|Pacopacomama|1Pondo|TokyoHot|Mommy[._ -]?Blows[._ -]?Best|Milfs[._ -]?Like[._ -]?It[._ -]?Big|Mommy[._ -]?Got[._ -]?Boobs|My[._ -]?Friends[._ -]?Hot[._ -]?Mom|Seduced[._ -]?By[._ -]?A[._ -]?Cougar|Hot[._ -]?Mom[._ -]?Next[._ -]?Door'; + protected const KNOWN_STUDIOS = 'Brazzers|NaughtyAmerica|RealityKings|Bangbros|BangBros18|TeenFidelity|PornPros|SexArt|WowGirls|Vixen|Blacked|Tushy|Deeper|Bellesa|Defloration|MetArt|MetArtX|TheLifeErotic|VivThomas|JoyMii|Nubiles|NubileFilms|Anilos|FamilyStrokes|X-Art|Babes|Twistys|WetAndPuffy|WowPorn|MomsTeachSex|Mofos|BangBus|Passion-HD|EvilAngel|DorcelClub|Private|Hustler|CherryPimps|PureTaboo|Lady[._ -]?Lyne|TeamSkeet|GirlsWay|SweetSinner|NewSensations|Digital[._ -]?Playground|Wicked|Penthouse|Playboy|Kink|HardX|ArchAngel|JulesJordan|ManuelFerrara|LesbianX|AllAnal|DarkX|Elegant[._ -]?Angel|ZeroTolerance|Score|PornFidelity|Kelly[._ -]?Madison|DDF[._ -]?Network|21Sextury|21Naturals|Colette|SexMex|Bang|SpankBang|PornWorld|LegalPorno|AnalVids|GonzoXXX|RoccoSiffredi|Fake[._ -]?Hub|FakeAgent|FakeTaxi|FakeHostel|PublicAgent|StrandedTeens|Property[._ -]?Sex|Dane[._ -]?Jones|Lets[._ -]?Doe[._ -]?It|Office[._ -]?Obsession|SexyHub|Massage[._ -]?Rooms|Fitness[._ -]?Rooms|Female[._ -]?Agent|MissaX|All[._ -]?Girl[._ -]?Massage|Fantasy[._ -]?Massage|Nurumassage|Soapymassage|Reality[._ -]?Junkies|Perv[._ -]?Mom|Bad[._ -]?Milfs|Milf[._ -]?Body|Step[._ -]?Siblings|Sis[._ -]?Loves[._ -]?Me|Brother[._ -]??Crush|Dad[._ -]?Crush|Mom[._ -]?Knows[._ -]?Best|Bratty[._ -]?Sis|My[._ -]?Family[._ -]?Pies|Family[._ -]?Therapy|Nubiles[._ -]?Porn|Step[._ -]?Fantasy|Caught[._ -]?Fapping|She[._ -]?Will[._ -]?Cheat|Dirty[._ -]?Wives[._ -]?Club|Big[._ -]?Tits[._ -]?Round[._ -]?Asses|Ass[._ -]?Parade|Monsters[._ -]?Of[._ -]?Cock|Brown[._ -]?Bunnies|Teens[._ -]?Love[._ -]?Huge[._ -]?Cocks|Ass[._ -]?Masterpiece|Bang[._ -]?Casting|Holed|Tiny4K|Lubed|POVD|Exotic4K|CastingCouch[._ -]?X|Casting[._ -]?Couch|Creampie[._ -]?Angels|Digital[._ -]?Desire|Femjoy|Hegre|Joymii|Met[._ -]?Art|MPL[._ -]?Studios|Rylsky[._ -]?Art|Showy[._ -]?Beauty|Stunning18|Photodromm|Watch4Beauty|Wow[._ -]?Girls|Yonitale|Mommys[._ -]?Boy|AllOver30|MyFirst|10musume|Caribbeancom|Heyzo|Pacopacomama|1Pondo|TokyoHot|Mommy[._ -]?Blows[._ -]?Best|Milfs[._ -]?Like[._ -]?It[._ -]?Big|Mommy[._ -]?Got[._ -]?Boobs|My[._ -]?Friends[._ -]?Hot[._ -]?Mom|Seduced[._ -]?By[._ -]?A[._ -]?Cougar|Hot[._ -]?Mom[._ -]?Next[._ -]?Door'; // Adult keywords protected const ADULT_KEYWORDS = 'Anal|Ass|BBW|BDSM|Blow|Boob|Bukkake|Casting|Couch|Cock|Compilation|Creampie|Cum|Dick|Dildo|Facial|Fetish|Fuck|Gang|Hardcore|Homemade|Horny|Interracial|Lesbian|MILF|Masturbat|Nympho|Oral|Orgasm|Penetrat|Pornstar|POV|Pussy|Riding|Seduct|Sex|Shaved|Slut|Squirt|Suck|Swallow|Threesome|Tits|Titty|Toy|Virgin|Whore'; // VR sites - protected const VR_SITES = 'SexBabesVR|LittleCapriceVR|VRoomed|VRMagic|TonightsGirlfriend|NaughtyAmericaVR|BaDoinkVR|WankzVR|VRBangers|StripzVR|RealJamVR|TmwVRnet|MilfVR|KinkVR|CzechVR(?:Fetish)?|HoloGirlsVR|WetVR|XSinsVR|VRCosplayX|BIBIVR|SLR|SexLikeReal|CutiesVR|18VR|PornCornVR|DarkRoomVR|JackAndJillVR|PasstroughVR|JUVR|KIWVR|KAVR|NKKVR|VRSPy|BadoinkVR|SinsVR|FuckPassVR|CRVR|CatalinaCruzVR'; + protected const VR_SITES = 'SexBabesVR|LittleCapriceVR|VRoomed|VRMagic|TonightsGirlfriend|NaughtyAmericaVR|BaDoinkVR|WankzVR|VRBangers|StripzVR|RealJamVR|TmwVRnet|MilfVR|KinkVR|CzechVR(?:Fetish)?|HoloGirlsVR|WetVR|XSinsVR|VRCosplayX|BIBIVR|SLR|SexLikeReal|CutiesVR|18VR|PornCornVR|DarkRoomVR|JackAndJillVR|PasstroughVR|JUVR|KIWVR|KAVR|NKKVR|VRSPy|BadoinkVR|SinsVR|FuckPassVR|CRVR|CatalinaCruzVR|StockingsVR|VirtualRealPorn|VRHush|VRConk|SwallowBay'; + + // Generic VR adult site pattern (e.g. StockingsVR.com, SomeSiteVR.net) + protected const GENERIC_VR_SITE = '\b[A-Z][A-Za-z0-9]{2,}VR(?:\.com|\.net|\.tv)?\b'; + + // VR headset / platform tokens + protected const VR_DEVICES = 'GearVR|Oculus|Quest[123]?|PSVR|Vive|Index|Pimax'; public function getName(): string { @@ -139,6 +145,17 @@ class XxxCategorizer extends AbstractCategorizer return true; } + // Generic VR adult site (e.g. StockingsVR.com) + if (preg_match('/'.self::GENERIC_VR_SITE.'/i', $name)) { + return true; + } + + // VR device combined with HD/UHD video markers + if (preg_match('/\b(?:'.self::VR_DEVICES.')\b/i', $name) && + preg_match('/\b(720p|1080p|2160p|4K|VR180|VR360)\b/i', $name)) { + return true; + } + // Check for adult content indicators combined with video markers if (preg_match('/\b('.self::ADULT_KEYWORDS.')\b/i', $name) && preg_match('/\b(720p|1080p|2160p|4k|mp4|mkv|avi|wmv)\b/i', $name)) { @@ -187,32 +204,53 @@ class XxxCategorizer extends AbstractCategorizer protected function checkVR(string $name): ?CategorizationResult { - if (stripos($name, 'vr') === false && stripos($name, 'oculus') === false && stripos($name, 'quest') === false) { + $hasKnownVRSite = (bool) preg_match('/\b('.self::VR_SITES.')\b/i', $name); + $hasGenericVRSite = (bool) preg_match('/'.self::GENERIC_VR_SITE.'/i', $name); + $hasVRSite = $hasKnownVRSite || $hasGenericVRSite; + $hasVRDevice = (bool) preg_match('/\b(?:'.self::VR_DEVICES.')\b/i', $name); + $hasVR180360 = (bool) preg_match('/\bVR(?:180|360)\b/i', $name); + + if (! $hasVRSite && ! $hasVRDevice && ! $hasVR180360 && + stripos($name, 'vr') === false && stripos($name, 'oculus') === false && stripos($name, 'quest') === false) { return null; } - // Check for known VR site - $hasVRSite = preg_match('/\b('.self::VR_SITES.')\b/i', $name); - // Require either a VR site token, explicit VR180/VR360, or VR device - if (! preg_match('/\bVR(?:180|360)\b/i', $name) && - ! $hasVRSite && - ! preg_match('/\b(?:GearVR|Oculus|Quest[123]?|PSVR|Vive|Index|Pimax)\b/i', $name)) { + if (! $hasVR180360 && ! $hasVRSite && ! $hasVRDevice) { return null; } - // VR pattern matching - includes VR devices - $vrPattern = '/\b('.self::VR_SITES.')\b|\bVR(?:180|360)\b|\b(?:5K|6K|7K|8K)\b.*\bVR\b|\b(?:GearVR|Oculus|Quest[123]?|PSVR|Vive|Index|Pimax)\b/i'; + // VR pattern matching - includes VR devices and generic VR sites + $vrPattern = '/\b('.self::VR_SITES.')\b|'.self::GENERIC_VR_SITE.'|\bVR(?:180|360)\b|\b(?:5K|6K|7K|8K)\b.*\bVR\b|\b(?:'.self::VR_DEVICES.')\b/i'; if (preg_match($vrPattern, $name)) { - // VR sites are definitively adult content - if ($hasVRSite) { + // Known VR sites are definitively adult content + if ($hasKnownVRSite) { return $this->matched(Category::XXX_VR, 0.95, 'vr_site'); } - // VR device with adult keywords - if (preg_match('/\bXXX\b/i', $name) || preg_match('/\b('.self::ADULT_KEYWORDS.')\b/i', $name)) { + + // Generic VR site pattern (e.g. StockingsVR.com) + if ($hasGenericVRSite) { + return $this->matched(Category::XXX_VR, 0.9, 'vr_site_generic'); + } + + // VR device with adult studio, keywords, or XXX marker + if ($hasVRDevice && ( + preg_match('/\bXXX\b/i', $name) || + preg_match('/\b('.self::ADULT_KEYWORDS.')\b/i', $name) || + preg_match('/\b('.self::KNOWN_STUDIOS.')\b/i', $name) + )) { return $this->matched(Category::XXX_VR, 0.9, 'vr_device'); } + + // VR180/VR360 with adult markers + if ($hasVR180360 && ( + preg_match('/\bXXX\b/i', $name) || + preg_match('/\b('.self::ADULT_KEYWORDS.')\b/i', $name) || + preg_match('/\b('.self::VR_SITES.')\b/i', $name) + )) { + return $this->matched(Category::XXX_VR, 0.9, 'vr_180_360'); + } } return null; diff --git a/app/Services/Categorization/ReleaseContext.php b/app/Services/Categorization/ReleaseContext.php index 2c740d60e..9585f40bd 100644 --- a/app/Services/Categorization/ReleaseContext.php +++ b/app/Services/Categorization/ReleaseContext.php @@ -59,7 +59,7 @@ class ReleaseContext public function hasAdultMarkers(): bool { // Check for explicit XXX markers and common adult keywords/studios - if (preg_match('/\b(XXX|Porn|Anal|Brazzers|BangBros|Bangbros|NaughtyAmerica|RealityKings|Tushy|Vixen|Blacked|OnlyFans|MetArt|JoyMii|Creampie|MP4-XXX|PureTaboo|LadyLyne|TeamSkeet|GirlsWay|EvilAngel|Kink|FakeHub|FakeTaxi|SexArt|Nubiles|Defloration|Deeper|Bellesa|Twistys|Mofos|MissaX|LegalPorno|AnalVids|JAV|Hentai|RoccoSiffredi|DivineBitches|Device[._ -]?Bondage|Hogtied|Wired[._ -]?Pussy|Fucking[._ -]?Machines|Ultimate[._ -]?Surrender|Public[._ -]?Disgrace|Sex[._ -]?And[._ -]?Submission|Bound[._ -]?Gang[._ -]?Bangs|Electro[._ -]?Sluts|Whipped[._ -]?Ass|TS[._ -]?Seduction|Infernal[._ -]?Restraints|Sexually[._ -]?Broken)\b/i', $this->releaseName)) { + if (preg_match('/\b(XXX|Porn|Anal|Brazzers|BangBros|Bangbros|NaughtyAmerica|RealityKings|Tushy|Vixen|Blacked|OnlyFans|MetArt|JoyMii|Creampie|MP4-XXX|PureTaboo|Lady[._ -]?Lyne|TeamSkeet|GirlsWay|EvilAngel|Kink|FakeHub|FakeTaxi|SexArt|Nubiles|Defloration|Deeper|Bellesa|Twistys|Mofos|MissaX|LegalPorno|AnalVids|JAV|Hentai|RoccoSiffredi|DivineBitches|Device[._ -]?Bondage|Hogtied|Wired[._ -]?Pussy|Fucking[._ -]?Machines|Ultimate[._ -]?Surrender|Public[._ -]?Disgrace|Sex[._ -]?And[._ -]?Submission|Bound[._ -]?Gang[._ -]?Bangs|Electro[._ -]?Sluts|Whipped[._ -]?Ass|TS[._ -]?Seduction|Infernal[._ -]?Restraints|Sexually[._ -]?Broken)\b/i', $this->releaseName)) { return true; } diff --git a/tests/Unit/XxxCategorizationTest.php b/tests/Unit/XxxCategorizationTest.php new file mode 100644 index 000000000..f04b432d7 --- /dev/null +++ b/tests/Unit/XxxCategorizationTest.php @@ -0,0 +1,159 @@ + + */ + private function buildPipes(): array + { + return [ + new MiscPipe, + new GroupNamePipe, + new XxxPipe, + new TvPipe, + new MoviePipe, + new BookPipe, + new MusicPipe, + new PcPipe, + new ConsolePipe, + new MiscSafetyNetPipe, + ]; + } + + private function runPipeline(string $releaseName, string $groupName = ''): CategorizationPassable + { + $context = new ReleaseContext( + releaseName: $releaseName, + groupId: 0, + groupName: $groupName, + poster: '', + ); + + $passable = new CategorizationPassable($context, debug: true); + $pipes = $this->buildPipes(); + + foreach ($pipes as $pipe) { + $passable = $pipe->handle($passable, fn ($p) => $p); + } + + return $passable; + } + + /** + * @return array + */ + public static function vrReleasesProvider(): array + { + return [ + 'StockingsVR GearVR release' => [ + 'StockingsVR.com.Lady.Lyne.You.Can.Watch.with.DD.Cup.Lady.Lyne.GearVR.1080p', + Category::XXX_VR, + 'vr_site', + ], + 'Known VR site with VR180' => [ + 'SexBabesVR.23.10.20.Virtual.Reality.VR180.3D.SBS.2160p-VRSins', + Category::XXX_VR, + 'vr_site', + ], + 'Generic VR site with GearVR' => [ + 'SomeNewSiteVR.com.Performer.GearVR.1080p', + Category::XXX_VR, + 'vr_site_generic', + ], + 'VR device with known studio' => [ + 'Brazzers.24.01.15.Performer.Oculus.Quest3.1080p', + Category::XXX_VR, + 'vr_device', + ], + ]; + } + + /** + * @return array + */ + public static function nonVrReleasesProvider(): array + { + return [ + 'Regular movie release' => [ + 'Some.Movie.2024.1080p.BluRay.x264-GROUP', + Category::MOVIE_HD, + ], + ]; + } + + #[DataProvider('vrReleasesProvider')] + public function test_vr_releases_are_categorized_as_xxx_vr(string $releaseName, int $expectedCategoryId, string $expectedMatchedBy): void + { + $categorizer = new XxxCategorizer; + $context = new ReleaseContext(releaseName: $releaseName, groupId: 0); + $result = $categorizer->categorize($context); + + $this->assertTrue($result->isSuccessful(), "Expected successful match for: {$releaseName}"); + $this->assertSame($expectedCategoryId, $result->categoryId, "Wrong category for: {$releaseName}"); + $this->assertSame($expectedMatchedBy, $result->matchedBy, "Wrong matched_by for: {$releaseName}"); + } + + #[DataProvider('vrReleasesProvider')] + public function test_vr_releases_through_full_pipeline(string $releaseName, int $expectedCategoryId): void + { + $passable = $this->runPipeline($releaseName); + + $this->assertSame($expectedCategoryId, $passable->bestResult->categoryId, "Pipeline wrong category for: {$releaseName}"); + } + + #[DataProvider('nonVrReleasesProvider')] + public function test_non_vr_releases_are_not_categorized_as_xxx_vr(string $releaseName, int $expectedCategoryId): void + { + $passable = $this->runPipeline($releaseName); + + $this->assertNotSame(Category::XXX_VR, $passable->bestResult->categoryId, "Should not be XXX VR: {$releaseName}"); + $this->assertSame($expectedCategoryId, $passable->bestResult->categoryId, "Wrong category for: {$releaseName}"); + } + + public function test_lady_lyne_dotted_name_is_recognized_as_adult(): void + { + $categorizer = new XxxCategorizer; + $context = new ReleaseContext( + releaseName: 'Lady.Lyne.24.01.15.Performer.Title.1080p', + groupId: 0, + ); + $result = $categorizer->categorize($context); + + $this->assertTrue($result->isSuccessful()); + $this->assertSame(Category::XXX_CLIPHD, $result->categoryId); + } + + public function test_lady_and_lyne_as_separate_tokens_do_not_trigger_xxx(): void + { + $categorizer = new XxxCategorizer; + $context = new ReleaseContext( + releaseName: 'The.Lady.and.the.Lyne.2022.1080p.WEB-DL.x264-GROUP', + groupId: 0, + ); + $result = $categorizer->categorize($context); + + $this->assertFalse($result->isSuccessful()); + } +}