diff --git a/Blacklight/Categorize.php b/Blacklight/Categorize.php deleted file mode 100755 index 16a9267ef..000000000 --- a/Blacklight/Categorize.php +++ /dev/null @@ -1,2615 +0,0 @@ -categorizeForeign = (bool) Settings::settingValue('categorizeforeign'); - $this->catWebDL = (bool) Settings::settingValue('catwebdl'); - } - - // Add helper (e.g. near other helpers) - private function hasAdultMarkers(): bool - { - // Only match genuine adult / XXX indicators. Do NOT treat plain resolution keywords as adult. - return (bool) preg_match( - '/\b(XXX|Porn|Sex|Anal|Brazzers|BangBros|Bangbros|NaughtyAmerica|RealityKings|Tushy|Vixen|Blacked|OnlyFans|MetArt|JoyMii|Girth|Creampie|MP4)\b/i', - $this->releaseName - ); - } - - /** - * Determine the most appropriate category for a release based on name and group. - * - * @param int|string $groupId The usenet group ID - * @param string $releaseName The name of the release to categorize - * @param null|string $poster The person/entity who posted the release - * @param bool $debug Whether to include debug information in the return value - * @return array The categorization result with category ID and optional debug info - * - * @throws \Exception - */ - public function determineCategory(int|string $groupId, string $releaseName = '', ?string $poster = '', bool $debug = false): array - { - // Initialize properties - $this->releaseName = $releaseName; - $this->groupId = $groupId; - $this->poster = $poster ?? ''; // Use null coalescing operator to ensure a string is assigned - $this->groupName = UsenetGroup::whereId($this->groupId)->value('name') ?? ''; - $this->tmpCat = Category::OTHER_MISC; - - // Store original category for debugging - $originalCategory = $this->tmpCat; - $matchedBy = 'default'; - - // Define categorization methods in priority order - // More specific categories are checked first, then fall back to broader categories - $categorizationMethods = [ - // Check by group name first (can provide specific category info) - 'byGroupName' => 'Group Name', - // Check XXX categories - 'isXxx' => 'Adult Content', - - // Check media categories in order of specificity - 'isTV' => 'TV Content', - 'isMovie' => 'Movie Content', - - // Check other content types - 'isBook' => 'Book Content', - 'isMusic' => 'Music Content', - - // Check digital content categories - 'isPC' => 'PC Software/Games', - 'isConsole' => 'Console Games', - - // Check for miscellaneous file types last (as fallback) - 'isMisc' => 'Miscellaneous/Hash Detection', - ]; - - // Process each categorization method in priority order - foreach ($categorizationMethods as $method => $description) { - // Skip empty method names (allows easy commenting out for testing) - if (empty($method)) { - continue; - } - - // Call the method and if it returns true, capture which method matched - if ($this->{$method}()) { - $matchedBy = $description; - break; - } - } - - // Prepare result array - $result = ['categories_id' => $this->tmpCat]; - - // Add debug information if requested - if ($debug) { - $result['debug'] = [ - 'original_category' => $originalCategory, - 'final_category' => $this->tmpCat, - 'matched_by' => $matchedBy, - 'release_name' => $this->releaseName, - 'group_name' => $this->groupName, - ]; - } - - return $result; - } - - /** - * Determine category based on the Usenet group name. - * - * @return bool True if categorization was successful, false otherwise - */ - public function byGroupName(): bool - { - switch (true) { - case preg_match('/alt\.binaries\.erotica([.]\w+)?/i', $this->groupName): - if ($this->isXxx()) { - return true; - } - $this->tmpCat = Category::XXX_OTHER; - - return true; - case preg_match('/alt\.binaries\.podcast$/i', $this->groupName): - $this->tmpCat = Category::MUSIC_PODCAST; - - return true; - case preg_match('/alt\.binaries\.music\.(\w+)?/i', $this->groupName): - if ($this->isMusic()) { - return true; - } - $this->tmpCat = Category::MUSIC_OTHER; - - return true; - default: - return false; - } - } - - // - /** - * Check if the release name appears to be a hash/random string that should not be categorized as TV. - * This helps prevent false positives from random alphanumeric strings that accidentally match - * TV patterns like \d+x\d+ (e.g., "1X7" in "IvwWjFAxJ9m3WT1X7BzWZd"). - * - * @return bool True if the name appears to be a hash/random string - */ - private function looksLikeHash(): bool - { - // Remove common usenet prefixes like "[01/16] - " and file extensions - $cleanName = preg_replace('/^\[\d+\/\d+\]\s*-\s*"?|"?\s*yEnc.*$|\.(?:7z|rar|zip|par2?|nfo|sfv|nzb)(?:\.\d+)?$/i', '', $this->releaseName); - $cleanName = trim($cleanName, '"'); - - // If the name is just one long alphanumeric string without proper separators, it's likely a hash - // This matches strings like "IvwWjFAxJ9m3WT1X7BzWZd" but not "Show.Name.S01E01" - if (preg_match('/^[a-zA-Z0-9]{16,}$/', $cleanName)) { - // Exception: if it contains known TV markers with proper word boundaries, allow it - if (preg_match('/\b(S\d{1,4}[._ -]?E\d{1,4}|Season|Episode|Complete|HDTV|BluRay|WEB[._ -]?DL)\b/i', $cleanName)) { - return false; - } - return true; - } - - // Check for release names that are mostly random characters without meaningful separators - // Count the ratio of separators (dots, spaces, underscores, hyphens) to total length - $separatorCount = preg_match_all('/[._ -]/', $cleanName); - $alphanumCount = preg_match_all('/[a-zA-Z0-9]/', $cleanName); - - // Normal release names have regular separators (e.g., "Show.Name.S01E01.720p") - // Random strings have few to no separators - // If less than 10% of the name is separators AND there are no clear TV patterns, it's suspicious - if ($alphanumCount > 15 && ($separatorCount / max($alphanumCount, 1)) < 0.05) { - // Final check: must have proper TV identifiers if it's a long string without separators - if (! preg_match('/S\d{1,4}[._ -]?(E|D)\d{1,4}|Season[._ -]?\d{1,3}|HDTV|BluRay|WEB[._ -]?(DL|RIP)|720p|1080p|2160p/i', $cleanName)) { - return true; - } - } - - return false; - } - - // Beginning of functions to determine category by release name. - // - - public function isTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Guard: Reject hash-like release names that may accidentally match TV patterns - // Example: "IvwWjFAxJ9m3WT1X7BzWZd" contains "1X7" which matches \d+x\d+ but isn't TV - if ($this->looksLikeHash()) { - return false; - } - - // Guard: Ignore generic multi-part archive segment names like *.7z.001 lacking TV markers. - // Example: [01/16] - "7JjWLzlJVuUR5alnVLdd5X7kTSwrKrij.7z.001" yEnc - // These typically represent raw dumps or data sets, not episodic TV content. - if ( - preg_match('/\.7z\.\d{3}\b/i', $this->releaseName) && - ! preg_match('/S\d{1,4}[._ -]?(E|D)\d{1,4}|Season[._ -]?\d{1,3}|Complete[._ -]Season|E\d{1,4}|(19|20)\d{2}|720p|1080p|2160p|HDTV|BluRay|WEB[._ -]?(DL|RIP)/i', $this->releaseName) - ) { - return false; - } - - // Main TV pattern check - require proper word boundaries for \d+x\d+ pattern - // Changed \d+x\d+ to [._ -]\d+x\d+[._ -] to require separators around episode format - if (preg_match('/Daily[\-_\.]Show|Nightly News|^\[[a-zA-Z\.\-]+\].*[\-_].*\d{1,3}[\-_. ](([\[\(])(h264-)?\d{3,4}([pi])([\]\)])\s?(\[AAC\])?|\[[a-fA-F0-9]{8}\]|(8|10)BIT|hi10p)(\[[a-fA-F0-9]{8}\])?|(\d\d-){2}[12]\d{3}|[12]\d{3}(\.\d\d){2}|[._ -]\d+x\d+[._ -]|\.e\d{1,3}\.|s\d{1,4}[._ -]?[ed]\d{1,3}([ex]\d{1,3}|[\-.\w ])|[._ -](\dx\d\d|C4TV|Complete[._ -]Season|DSR|([DHPS])DTV|EP[._ -]?\d{1,3}|S\d{1,3}.+Extras|SUBPACK|Season[._ -]\d{1,2})([._ -]|$)|TVRIP|TV[._ -](19|20)\d\d|Troll(HD|UHD)/i', $this->releaseName) - && ! preg_match('/^(Defloration|MetArt|MetArtX|SexArt|TheLifeErotic|VivThomas|CzechVR|VRBangers|WankzVR|BadoinkVR|NaughtyAmerica)(.|\s)?\d{4}[._ -]\d{2}[._ -]\d{2}|[._ -](flac|imageset|mp3|xxx|XXX|porn|adult|sex)[._ -]|[ .]exe$|[._ -](shemale|transsexual|bisexual|siterip|JAV|JavHD)\b/i', $this->releaseName)) { - switch (true) { - case $this->isOtherTV(): - case $this->categorizeForeign && $this->isForeignTV(): - case $this->isSportTV(): - case $this->isDocumentaryTV(): - case $this->isTVx265(): - case $this->isUHDTV(): - case $this->catWebDL && $this->isWEBDL(): - case $this->isAnimeTV(): - case $this->isHDTV(): - case $this->isSDTV(): - case $this->isOtherTV2(): - return true; - default: - $this->tmpCat = Category::TV_OTHER; - - return true; - } - } - - if (preg_match('/[._ -]((19|20)\d\d[._ -]\d{1,2}[._ -]\d{1,2}[._ -]VHSRip|Indy[._ -]?Car|(iMPACT|Smoky[._ -]Mountain|Texas)[._ -]Wrestling|Moto[._ -]?GP|NSCS[._ -]ROUND|NECW[._ -]TV|(Per|Post)\-Show|PPV|WrestleMania|WCW|WEB[._ -]HD|WWE[._ -](Monday|NXT|RAW|Smackdown|Superstars|WrestleMania))[._ -]/i', $this->releaseName)) { - if ($this->isSportTV()) { - return true; - } - $this->tmpCat = Category::TV_OTHER; - - return true; - } - - return false; - } - - public function isOtherTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/[._ -]S\d{1,3}.+(EP\d{1,3}|Extras|SUBPACK)[._ -]|News/i', $this->releaseName) - // special case for "Have.I.Got.News.For.You" tv show - && ! preg_match('/[._ -]Got[._ -]News[._ -]For[._ -]You/i', $this->releaseName) - ) { - $this->tmpCat = Category::TV_OTHER; - - return true; - } - - return false; - } - - public function isForeignTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/[._ -](chinese|dk|fin|french|ger?|heb|ita|jap|kor|nor|nordic|nl|pl|swe)[._ -]?(sub|dub)(ed|bed|s)?|/i', $this->releaseName): - case preg_match('/[._ -](brazilian|chinese|croatian|danish|deutsch|dutch|estonian|flemish|finnish|french|german|greek|hebrew|icelandic|italian|ita|latin|mandarin|nordic|norwegian|polish|portuguese|japenese|japanese|russian|serbian|slovenian|spanish|spanisch|swedish|thai|turkish).+(720p|1080p|Divx|DOKU|DUB(BED)?|DLMUX|NOVARIP|RealCo|Sub(bed|s)?|Web[._ -]?Rip|WS|Xvid|x264)[._ -]/i', $this->releaseName): - case preg_match('/[._ -](720p|1080p|Divx|DOKU|DUB(BED)?|DLMUX|NOVARIP|RealCo|Sub(bed|s)?|WEB(-DL|-?RIP)|WS|Xvid).+(brazilian|chinese|croatian|danish|deutsch|dutch|estonian|flemish|finnish|french|german|greek|hebrew|icelandic|italian|ita|latin|mandarin|nordic|norwegian|polish|portuguese|japenese|japanese|russian|serbian|slovenian|spanish|spanisch|swedish|thai|turkish)[._ -]/i', $this->releaseName): - case preg_match('/(S\d\d[EX]\d\d|DOCU(MENTAIRE)?|TV)?[._ -](FRENCH|German|Dutch)[._ -](720p|1080p|dv([bd])r(ip)?|LD|HD\-?TV|TV[._ -]?RIP|x264|WEB(-DL|-?RIP))[._ -]/i', $this->releaseName): - case preg_match('/[._ -]FastSUB|NL|nlvlaams|patrfa|RealCO|Seizoen|slosinh|Videomann|Vostfr|xslidian[._ -]|x264\-iZU/i', $this->releaseName): - $this->tmpCat = Category::TV_FOREIGN; - - return true; - default: - return false; - } - } - - public function isSportTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/[._ -]?(Bellator|bundesliga|EPL|ESPN|FIA|la[._ -]liga|MMA|motogp|NFL|MLB|NCAA|PGA|FIM|NJPW|red[._ -]bull|.+race|Sengoku|Strikeforce|supercup|uefa|UFC|wtcc|WWE)[._ -]/i', $this->releaseName): - case preg_match('/[._ -]?(DTM|FIFA|formula[._ -]1|indycar|Rugby|NASCAR|NBA|NHL|NRL|netball[._ -]anz|ROH|SBK|Superleague|The[._ -]Ultimate[._ -]Fighter|TNA|V8[._ -]Supercars|WBA|WrestleMania)[._ -]/i', $this->releaseName): - case preg_match('/[._ -]?(AFL|Grand Prix|Indy[._ -]Car|(iMPACT|Smoky[._ -]Mountain|Texas)[._ -]Wrestling|Moto[._ -]?GP|NSCS[._ -]ROUND|NECW|Poker|PWX|Rugby|WCW)[._ -]/i', $this->releaseName): - case preg_match('/[._ -]?(Horse)[._ -]Racing[._ -]/i', $this->releaseName): - case preg_match('/[._ -](VERUM|GRiP|Ebi|OVERTAKE|LEViTATE|WiNNiNG|ADMIT)/i', $this->releaseName): - $this->tmpCat = Category::TV_SPORT; - - return true; - default: - return false; - } - } - - public function isDocumentaryTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/[._ -](Docu|Documentary)[._ -]/i', $this->releaseName)) { - $this->tmpCat = Category::TV_DOCU; - - return true; - } - - return false; - } - - public function isWEBDL(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Match WEB-DL, WEBRIP patterns - if (preg_match('/(S\d+).*.web[._-]?(dl|rip).*/i', $this->releaseName)) { - $this->tmpCat = Category::TV_WEBDL; - - return true; - } - - // Match .WEB. followed by codec (h264, h265, x264, x265, HEVC, AVC) - // e.g., "Show.S01E01.1080p.WEB.h265-GROUP" or "Show.S01E01.2160p.WEB.H265-GROUP" - if (preg_match('/S\d+[._ -]?E\d+/i', $this->releaseName) && - preg_match('/[._-]WEB[._-](h\.?26[45]|x\.?26[45]|HEVC|AVC)/i', $this->releaseName)) { - $this->tmpCat = Category::TV_WEBDL; - - return true; - } - - return false; - } - - public function isAnimeTV(): bool - { - - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/[._ -]Anime[._ -]|^\[[a-zA-Z\.\-]+\].*[\-_].*\d{1,3}[\-_. ](([\[\(])((\d{1,4}x\d{1,4})|(h264-)?\d{3,4}([pi]))([\]\)])\s?(\[AAC\])?|\[[a-fA-F0-9]{8}\]|(8|10)BIT|hi10p)(\[[a-fA-F0-9]{8}\])?/i', $this->releaseName)) { - $this->tmpCat = Category::TV_ANIME; - - return true; - } - if (preg_match('/((\[[a-fA-F0-9]{8}\])|(\[[a-z0-9]{8}\])|URANiME|ANiHLS|HaiKU|ANiURL|SkyAnime|2jzgte|^shiteater|(Erai|New)\-raws)|(LostYears|Vodes)$/i', $this->releaseName)) { - $this->tmpCat = Category::TV_ANIME; - - return true; - } - if (preg_match('/Sokudo|Ninja Kamui|Synduality Noir|Komyo|Yoohoo/i', $this->releaseName)) { - $this->tmpCat = Category::TV_ANIME; - - return true; - } - - return false; - } - - public function isHDTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/1080([ip])|720p|bluray/i', $this->releaseName)) { - $this->tmpCat = Category::TV_HD; - - return true; - } - if (! $this->catWebDL && preg_match('/web[._ -]dl|web-?rip/i', $this->releaseName)) { - $this->tmpCat = Category::TV_HD; - - return true; - } - - return false; - } - - public function isUHDTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Match any TV show with 2160p resolution (UHD/4K) - // Must have season/episode pattern and 2160p anywhere in the name - if (preg_match('/S\d+[._ -]?E\d+/i', $this->releaseName) && - preg_match('/2160p/i', $this->releaseName)) { - $this->tmpCat = Category::TV_UHD; - - return true; - } - - // Also match streaming service releases with specific groups (legacy pattern) - if (preg_match('/(S\d+).*(2160p).*(Netflix|Amazon|NF|AMZN).*(TrollUHD|NTb|VLAD|DEFLATE|POFUDUK|CMRG)/i', $this->releaseName)) { - $this->tmpCat = Category::TV_UHD; - - return true; - } - - return false; - } - - public function isSDTV(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/(360|480|576)p|Complete[._ -]Season|dvdr(ip)?|dvd5|dvd9|\.pdtv|SD[._ -]TV|TVRip|NTSC|BDRip|hdtv|xvid/i', $this->releaseName): - case preg_match('/(([HP])D[._ -]?TV|DSR|WebRip)[._ -]x264/i', $this->releaseName): - case preg_match('/s\d{1,3}[._ -]?[ed]\d{1,3}([ex]\d{1,3}|[\-.\w ])|\s\d{3,4}\s/i', $this->releaseName) && preg_match('/([HP])D[._ -]?TV|BDRip|WEB[._ -]x264/i', $this->releaseName): - $this->tmpCat = Category::TV_SD; - - return true; - default: - return false; - } - } - - public function isOtherTV2(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/[._ -]s\d{1,3}[._ -]?(e|d(isc)?)\d{1,3}([._ -]|$)/i', $this->releaseName)) { - $this->tmpCat = Category::TV_OTHER; - - return true; - } - - return false; - } - - public function isTVx265(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/(S\d+).*(x265).*(rmteam|MeGusta|HETeam|PSA|ONLY|H4S5S|TrollHD|ImE)/i', $this->releaseName)) { - $this->tmpCat = Category::TV_X265; - - return true; - } - - return false; - } - - // Movies. - - public function isMovie(): bool - { - if (preg_match('/[._ -]AVC|[BH][DR]RIP|(Bluray|Blu-Ray)|BD[._ -]?(25|50)?|\bBR\b|Camrip|[._ -]\d{4}[._ -].+(720p|1080p|Cam|HDTS|2160p)|DIVX|[._ -]DVD[._ -]|DVD-?(5|9|R|Rip)|Untouched|VHSRip|XVID|[._ -](DTS|TVrip|webrip|WEBDL|WEB-DL)[._ -]|\b(2160)p\b.*\b(Netflix|Amazon|NF|AMZN|Disney)\b/i', $this->releaseName) && ! preg_match('/s\d{1,3}[._ -]?[ed]\d{1,3}|auto(cad|desk)|divx[._ -]plus|[._ -]exe$|[._ -](jav|XXX)[._ -]|SWE6RUS|\wXXX(1080p|720p|DVD)|Xilisoft|\.S[0-9]\d{1,3}\./i', $this->releaseName)) { - return match (true) { - $this->categorizeForeign && $this->isMovieForeign(), $this->isMovieDVD(), $this->isMovieX265(), $this->isMovieUHD(), $this->catWebDL && $this->isMovieWEBDL(), $this->isMovieSD(), $this->isMovie3D(), $this->isMovieBluRay(), $this->isMovieHD(), $this->isMovieOther() => true, - default => false, - }; - } - - return false; - } - - public function isMovieForeign(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/(danish|flemish|Deutsch|dutch|french|german|heb|hebrew|nl[._ -]?sub|dub(bed|s)?|\.NL|norwegian|swedish|swesub|spanish|Staffel)[._ -]|\(german\)|Multisub/i', $this->releaseName): - case stripos($this->releaseName, 'Castellano') !== false: - case preg_match('/(720p|1080p|AC3|AVC|DIVX|DVD(5|9|RIP|R)|XVID)[._ -](Dutch|French|German|ITA)|\(?(Dutch|French|German|ITA)\)?[._ -](720P|1080p|AC3|AVC|DIVX|DVD(5|9|RIP|R)|WEB(-DL|-?RIP)|HD[._ -]|XVID)/i', $this->releaseName): - $this->tmpCat = Category::MOVIE_FOREIGN; - - return true; - default: - return false; - } - } - - public function isMovieDVD(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/(dvd\-?r|[._ -]dvd|dvd9|dvd5|[._ -]r5)[._ -]/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_DVD; - - return true; - } - - return false; - } - - public function isMovieSD(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/(divx|dvdscr|extrascene|dvdrip|\.CAM|HDTS(-LINE)?|vhsrip|xvid(vd)?)[._ -]/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_SD; - - return true; - } - - return false; - } - - public function isMovie3D(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/[._ -]3D\s?[\.\-_\[ ](1080p|(19|20)\d\d|AVC|BD(25|50)|Blu[._ -]?ray|CEE|Complete|GER|MVC|MULTi|SBS|H(-)?SBS)[._ -]/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_3D; - - return true; - } - - return false; - } - - public function isMovieBluRay(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/bluray-|[._ -]bd?[._ -]?(25|50)|blu-ray|Bluray\s-\sUntouched|[._ -]untouched[._ -]/i', $this->releaseName) - && ! preg_match('/SecretUsenet\.com$/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_BLURAY; - - return true; - } - - return false; - } - - public function isMovieHD(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/720p|1080p|AVC|VC1|VC-1|web-dl|wmvhd|x264|XvidHD|bdrip/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_HD; - - return true; - } - if ($this->catWebDL === false && preg_match('/web[._ -]dl|web-?rip/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_HD; - - return true; - } - - return false; - } - - public function isMovieUHD(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Skip TV shows that match the streaming service pattern - if (preg_match('/(S\d+).*(2160p).*(Netflix|Amazon|NF|AMZN).*(TrollUHD|NTb|VLAD|DEFLATE|CMRG)/i', $this->releaseName)) { - return false; - } - - // Check for common UHD indicators - if (stripos($this->releaseName, '2160p') !== false || - preg_match('/\b(UHD|Ultra[._ -]HD|4K)\b/i', $this->releaseName) || - (preg_match('/\b(HDR|HDR10|HDR10\+|Dolby[._ -]?Vision)\b/i', $this->releaseName) && - preg_match('/\b(HEVC|H\.?265|x265)\b/i', $this->releaseName)) || - (stripos($this->releaseName, 'UHD') !== false && - preg_match('/\b(BR|BluRay|Blu[._ -]?Ray)\b/i', $this->releaseName))) { - - $this->tmpCat = Category::MOVIE_UHD; - - return true; - } - - return false; - } - - public function isMovieOther(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/[._ -]cam[._ -]/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_OTHER; - - return true; - } - - return false; - } - - public function isMovieWEBDL(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/web[._ -]dl|web-?rip/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_WEBDL; - - return true; - } - - return false; - } - - public function isMovieX265(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/(\w+[\.-_\s]+).*(x265).*(Tigole|SESKAPiLE|CHD|IAMABLE|THREESOME|OohLaLa|DEFLATE|NCmt)/i', $this->releaseName)) { - $this->tmpCat = Category::MOVIE_X265; - - return true; - } - - return false; - } - - // PC. - - public function isPC(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - return match (true) { - $this->isPhone(), $this->isMac(), $this->isPCGame(), $this->isISO(), $this->is0day() => true, - default => false, - }; - } - - public function isPhone(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/[^a-z0-9](IPHONE|ITOUCH|IPAD)[._ -]/i', $this->releaseName): - $this->tmpCat = Category::PC_PHONE_IOS; - break; - case preg_match('/[._ -]?(ANDROID)[._ -]/i', $this->releaseName): - $this->tmpCat = Category::PC_PHONE_ANDROID; - break; - case preg_match('/[^a-z0-9](symbian|xscale|wm5|wm6)[._ -]/i', $this->releaseName): - $this->tmpCat = Category::PC_PHONE_OTHER; - break; - default: - return false; - } - - return true; - } - - public function isISO(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/[._ -]([a-zA-Z]{2,10})?iso[ _.-]|[\-. ]([a-z]{2,10})?iso$/i', $this->releaseName): - case preg_match('/[._ -](DYNAMiCS|INFINITESKILLS|UDEMY|kEISO|PLURALSIGHT|DIGITALTUTORS|TUTSPLUS|OSTraining|PRODEV|CBT\.Nuggets|COMPRISED)/i', $this->releaseName): - $this->tmpCat = Category::PC_ISO; - - return true; - default: - return false; - } - } - - public function is0day(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - switch (true) { - case preg_match('/[._ -]exe$|[._ -](utorrent|Virtualbox)[._ -]|\b0DAY\b|incl.+crack| DRM$|>DRMreleaseName): - case preg_match('/[._ -]((32|64)bit|converter|i\d86|key(gen|maker)|freebsd|GAMEGUiDE|hpux|irix|linux|multilingual|Patch|Pro v\d{1,3}|portable|regged|software|solaris|template|unix|win2kxp2k3|win64|win(2k|32|64|all|dows|nt(2k)?(xp)?|xp)|win9x(me|nt)?|x(32|64|86))[._ -]/i', $this->releaseName): - case preg_match('/\b(Adobe|auto(cad|desk)|-BEAN|Cracked|Cucusoft|CYGNUS|Divx[._ -]Plus|\.(deb|exe)|DIGERATI|FOSI|-FONT|Key(filemaker|gen|maker)|Lynda\.com|lz0|MULTiLANGUAGE|Microsoft\s*(Office|Windows|Server)|MultiOS|-(iNViSiBLE|SPYRAL|SUNiSO|UNION|TE)|v\d{1,3}.*?Pro|[._ -]v\d{1,3}[._ -]|\(x(64|86)\)|Xilisoft)\b/i', $this->releaseName): - $this->tmpCat = Category::PC_0DAY; - - return true; - default: - return false; - } - } - - public function isMac(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - if (preg_match('/(\b|[._ -])mac([\.\s])?osx(\b|[\-_. ])/i', $this->releaseName)) { - $this->tmpCat = Category::PC_MAC; - - return true; - } - - return false; - } - - public function isPCGame(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // 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; - } - - // Guard: avoid misclassifying TV shows as PC games. - // TV patterns like S01E01, 1x01, Season 1, etc. should not be classified as games. - $tvPatterns = '/\b(S\d{1,4}[._ -]?E\d{1,4}|S\d{1,4}[._ -]?D\d{1,4}|\d{1,2}x\d{2,3}|Season[._ -]?\d{1,3}|Episode[._ -]?\d{1,4}|HDTV|PDTV|DSR|WEB[._ -]?DL|WEB[._ -]?RIP|TVRip)\b/i'; - if (preg_match($tvPatterns, $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; - } - - if ($this->checkPoster('//i', $this->poster, Category::PC_GAMES)) { - return true; - } - - return false; - } - - // XXX. - - public function isXxx(): bool - { - return match (true) { - $this->isXXXOnlyFans(), $this->isXxxVr(), $this->isXxxUHD(), $this->isXxxClipHD(), $this->isXxxPack(), $this->isXxxClipSD(), $this->isXxxSD(), $this->catWebDL && $this->isXxxWEBDL(), $this->isXxx264(), $this->isXxxXvid(), $this->isXxxImageset(), $this->isXxxWMV(), $this->isXxxDVD(), $this->isXxxOther() => true, - default => false, - }; - } - - public function isXxx264(): bool - { - $name = $this->releaseName; - - // Exclude HEVC/x265 encodes explicitly - if (preg_match('/\b(x265|hevc)\b/i', $name)) { - return false; - } - - // Require an explicit H.264/x264/AVC indicator - if (! preg_match('/\b((x|h)[\.\-_ ]?264|AVC)\b/i', $name)) { - return false; - } - - // Reject obvious non‑targets (wmv container, TV style S01E02, raw dimension tokens) - if (preg_match('/\bwmv\b|S\d{1,2}E\d{1,2}|\d+x\d+/i', $name)) { - return false; - } - - // Adult site / content keywords (merged both original lists, normalized) - $adultPattern = '/\bXXX\b|a\.b\.erotica|BangBros(?:18)?|ClubSeventeen|Cum(ming|shot)|Defloration|Err?oticax?|JoyMii|MetArt|MetArtX|Nubiles|Porn(o|lation)?|SexArt|TheLifeErotic|Tushy|Vixen|VivThomas|X-Art|JAV(?:\sUncensored)?|lesb(ians?|os?)|mastur(bation|e?bate)|nympho?|OLDER ANGELS|Brazzers|NaughtyAmerica|RealityKings|sexontv|slut|Squirt|Transsexual|WowGirls|Playboy/i'; - - if (! preg_match($adultPattern, $name)) { - return false; - } - - // (Optional) If catWebDL is false we still allow WEB-DL / WEBRip, but codec already enforced. - $this->tmpCat = Category::XXX_X264; - - return true; - } - - public function isXxxUHD(): bool - { - $name = $this->releaseName; - - // Quick reject: must have a UHD indicator. - if (! preg_match('/\b(2160p|4k|UHD|Ultra[._ -]?HD)\b/i', $name)) { - return false; - } - - // Known adult site / brand tokens (lowercase for consistency). - $adultSites = '(mypervyfamily|mywifeshotfriend|clubsweethearts|brazzers|bangbros|bangbros18|realitykings|naughtyamerica|vixen|tushy|blacked|deeper|sexart|metartx?|joymii|vivthomas|thelifeerotic|defloration|nubiles|familystrokes|passion-?hd|evilangel|dorcelclub|private|hustler)'; - - // Adult markers: XXX, site names, or common explicit genre words. - $hasAdultMarker = - preg_match('/\bXXX\b/i', $name) || - preg_match('/\b'.$adultSites.'\b/i', strtolower($name)) || - preg_match('/\b(Hardcore|Porn|Sex|Anal|Creampie|MILF|Lesbian|Teen|Interracial)\b/i', $name); - - if (! $hasAdultMarker) { - return false; - } - - // Legacy very specific scene/group pattern (retain for backward compatibility). - if (preg_match('/XXX.+2160p[\w\-.]+M[PO][V4]-(KTR|GUSH|FaiLED|SEXORS|hUSHhUSH|YAPG|WRB|NBQ|FETiSH)/i', $name)) { - $this->tmpCat = Category::XXX_UHD; - - return true; - } - - // Common structured pattern: Site.YY.MM.DD..XXX?.(2160p|4k) - if (preg_match('/\b'.$adultSites.'\.\d{2}\.\d{2}\.\d{2}\.[\w\.]+?(?:XXX\.)?(?:2160p|4k)\b/i', strtolower($name))) { - $this->tmpCat = Category::XXX_UHD; - - return true; - } - - // Order‑independent XXX + UHD proximity (allow some text in between). - if (preg_match('/XXX[.\-_ ].{0,80}\b(2160p|4k)\b/i', $name) || - preg_match('/\b(2160p|4k)\b.{0,80}XXX\b/i', $name)) { - $this->tmpCat = Category::XXX_UHD; - - return true; - } - - // Performer / descriptive titles ending in UHD (e.g. model.scene.title.XXX.2160p.*) - if (preg_match('/\b[A-Za-z][\w]+(?:\.[A-Za-z][\w]+){1,6}\.(?:XXX\.)?(2160p|4k)\b/i', $name) && - preg_match('/XXX|'.$adultSites.'|Porn|Sex/i', $name)) { - $this->tmpCat = Category::XXX_UHD; - - return true; - } - - // Fallback: adult marker + UHD indicator already confirmed. - $this->tmpCat = Category::XXX_UHD; - - return true; - } - - public function isXxxClipHD(): bool - { - // First check for specific adult content to exclude that's not clips - // Refined to exclude only if these words are standalone or at the beginning - if (preg_match('/^(Complete|Pack|Collection|Anthology|Siterip|SiteRip|Website\.Rip|WEBRip)\b|\b(Complete|Pack|Collection|Anthology)\b.+(Pack|Set|of|[0-9]{2,})/i', $this->releaseName)) { - return false; - } - - if (preg_match('/\b(S\d{1,2}E\d{1,2}|S\d{1,2}|Season\s\d{1,2}|E\d{1,2})\b/i', $this->releaseName) || - preg_match('/\b(Rick\.And\.Morty|Game\.Of\.Thrones|Walking\.Dead|Breaking\.Bad|Stranger\.Things)\b/i', $this->releaseName)) { - return false; - } - - // Exclude known talk shows and late-night shows - if (preg_match('/\b(Seth\.Meyers|Jimmy\.Fallon|Jimmy\.Kimmel|Stephen\.Colbert|James\.Corden|Conan|Tonight\.Show|Late\.Show|Late\.Night|Daily\.Show|Last\.Week\.Tonight|Real\.Time|The\.View|Ellen|Oprah|Graham\.Norton|Jonathan\.Ross|Chelsea\.Lately|Craig\.Ferguson|David\.Letterman|Jay\.Leno)\b/i', $this->releaseName)) { - return false; - } - - // Adult keywords commonly found in titles - $adultKeywords = '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'; - - // Known adult studios - $knownStudios = 'Brazzers|NaughtyAmerica|RealityKings|Bangbros|BangBros18|TeenFidelity|PornPros|SexArt|WowGirls|Vixen|Blacked|Tushy|Deeper|Bellesa|Defloration|MetArt|MetArtX|TheLifeErotic|VivThomas|JoyMii|Nubiles|NubileFilms|FamilyStrokes|X-Art|Babes|Twistys|WetAndPuffy|WowPorn|MomsTeachSex|Mofos|BangBus|Passion-HD|EvilAngel|DorcelClub|Private|Hustler|CherryPimps|HuCows|TransSensual|SexMex|FamilyTherapy|ATKGirlfriends'; - - // Match performer name pattern with descriptive title, spelled-out month name, and HD resolution - if (preg_match('/^([A-Z][a-z]+)(\.|\s)([A-Z][a-z]+)(\.|\s)([A-Z][a-z]+)?(\.|\s)([A-Z][a-z]+)?(\.|\s)?(January|February|March|April|May|June|July|August|September|October|November|December)[._ -](\d{1,2})[_.-]*(\d{4})[._ -]?(720p|1080p|2160p|HD|4K)/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Match studio name + performer names + descriptive title + HD resolution (without requiring date) - if (preg_match('/^('.$knownStudios.')\.([A-Z][a-z]+)(\.([A-Z][a-z]+))?(\.and\.|\.&\.)([A-Z][a-z]+)(\.([A-Z][a-z]+))?\.([A-Z][a-z]+).*?(720p|1080p|2160p|HD|4K)/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Rest of the existing patterns remain unchanged - if (preg_match('/^('.$knownStudios.')\.([A-Z][a-z]+)(\.([A-Z][a-z]+))?\.([A-Z][a-z]+\.)+.*?(720p|1080p|2160p|HD|4K)/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Match studio name + YY.MM.DD + model name + XXX identifier + HD resolution - if (preg_match('/^([A-Z][a-zA-Z0-9]+)\.(\d{2})\.(\d{2})\.(\d{2})\.([A-Z][a-z]+)(\.([A-Z][a-z]+))?.*?(XXX|Porn|Sex|Adult).*?(720p|1080p|2160p|HD|4K)/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Match releases with month name in date format and HD resolution - if (preg_match('/^([A-Z][a-z]+)[._ -]([A-Z][a-z]+).*?(January|February|March|April|May|June|July|August|September|October|November|December)[._ -](\d{1,2})[_._ -](\d{4})[._ -]?(720p|1080p|2160p|HD|4K)/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Match releases with model name, descriptive title with adult keywords, date and HD resolution - if (preg_match('/^([A-Z][a-z]+)(\.|\s)([A-Z][a-z]+).*?('.$adultKeywords.').*?(\d{2})\.(\d{2})\.(\d{4}|20\d{2}).*?(720p|1080p|2160p|4k|HD)/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Match common date formats found in adult content with HD resolution - if (preg_match('/([A-Z][a-z]+)(\.|\s)([A-Z][a-z]+).*?(\d{2})[\.\-](\d{2})[\.\-](20\d{2}|\d{2}).*?(720p|1080p|2160p|HD|4K)/i', $this->releaseName) && - preg_match('/('.$adultKeywords.')/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - // Rest of the existing patterns remain unchanged - if (preg_match('/^([A-Z][a-zA-Z0-9]+)\.(20\d\d)\.(\d{2})\.(\d{2})\.[A-Z][a-z]/i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^([A-Z][a-zA-Z0-9]+)\.(\d{2})\.(\d{2})\.(\d{2})\./i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^([A-Z][a-zA-Z0-9]+)(\.Com)?\.\.(\d{2})\.(\d{2})\.(\d{2})\./i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/\b(Scene[._-]?\d+|MILF|Anal|Hardcore|Sex|Porn|XXX|Explicit|Adult).*?(720p|1080p|2160p|HD|4K)\b|\b(720p|1080p|2160p|HD|4K).*?(Scene[._-]?\d+|MILF|Anal|Hardcore|Sex|Porn|XXX)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^('.$knownStudios.'|[A-Z][a-zA-Z0-9]{2,})[._ -]+(?:\d{4}|\d{2})[\.\-_ ]\d{2}[\.\-_ ]\d{2,4}[._ -]/i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^([A-Z][a-zA-Z0-9]+)\b.*\d{4}[._ -]\d{2}[._ -]\d{2}/i', $this->releaseName) && - preg_match('/(720p|1080p|1440p|2160p|HD|4K)/i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^([A-Z][a-zA-Z0-9]+)[._ -]+\d{4}[._ -]\d{2}[._ -]\d{2}[._ -]([A-Z][a-z]+[._ -][A-Z][a-z]+|[A-Z][a-z]+)/i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^([A-Z][a-zA-Z0-9]+)\.(\d{4}|\d{2})[\.\-_ ](\d{2})[\.\-_ ](\d{2})(\.[A-Z][\w]+)?/i', $this->releaseName) && - ! preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/\b(XXX|MILF|Anal|Sex|Porn)[._ -]+(720p|1080p|2160p|HD|4K)\b|\b(720p|1080p|2160p|HD|4K)[._ -]+(XXX|MILF|Anal|Sex|Porn)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - if (preg_match('/^[\w\-.]+(\d{2}\.\d{2}\.\d{2}).+(720|1080)+[\w\-.]+(M[PO][V4]-(KTR|GUSH|FaiLED|SEXORS|hUSHhUSH|YAPG|TRASHBIN|WRB|NBQ|FETiSH))/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_CLIPHD; - - return true; - } - - return false; - } - - public function isXXXOnlyFans(): bool - { - $name = $this->releaseName; - - // Skip obvious image / photo packs unless there is a video hint - if ( - preg_match('/\b(photo(set)?|image(set)?|pics?|wallpapers?|collection|pack)\b/i', $name) && - ! preg_match('/\b(mp4|mkv|mov|wmv|avi|webm|h\.?264|x264|h\.?265|x265)\b/i', $name) - ) { - return false; - } - - // Match OnlyFans brand (OnlyFans / Only-Fans / Only_Fans / Only Fans) or legacy leading OF. token. - // Quality (720p/1080p/etc.) is now optional. - if (preg_match('/\bOnly[-_ ]?Fans\b|^OF\./i', $name)) { - $this->tmpCat = Category::XXX_ONLYFANS; - - return true; - } - - return false; - } - - public function isXxxWMV(): bool - { - // First check for formats that should NOT be categorized as WMV - if (preg_match('/\b(720p|1080p|2160p|x264|x265|h264|h265|hevc|XviD|MP4-|\.mp4)[._ -]/i', $this->releaseName) || - stripos($this->releaseName, 'SDX264XXX') !== false) { - return false; - } - - // Check for explicit WMV indicators - if (preg_match('/( - # Explicit WMV format mentions - \b(WMV|Windows\s?Media\s?Video)\b| - # WMV file extensions - \b\w+\.wmv\b|[._ -]wmv[._ -]|\.wmv$| - # WMV scene release groups - \b(WMV-SEXORS|KTR-wmv|FaiLED-wmv|wmv-PORNO)\b| - # WMV specific sizes - \b(wmv|windows\s?media)[._ -]\d+(\.\d+)?\s?(mb|gb)\b - )/ix', $this->releaseName)) { - $this->tmpCat = Category::XXX_WMV; - - return true; - } - - // Check for older legacy formats often associated with WMV - if (preg_match('/( - # Older video formats commonly used with WMV - \b(wm9|wmvhd)\b| - # Legacy scene patterns for WMV - \b(REALMEDIA|DIVX-WMVHD)\b| - # Additional reliable WMV identifiers - (WMAZ|WMAS|Windows-Media|MS-Video) - )/ix', $this->releaseName)) { - $this->tmpCat = Category::XXX_WMV; - - return true; - } - - // Original pattern but much more restricted to avoid false positives - if (preg_match('/[^a-z0-9](wmv)[^a-z0-9]/i', $this->releaseName) && - ! preg_match('/\b(mp4|xvid|webm|mkv|avi)\b/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_WMV; - - return true; - } - - return false; - } - - public function isXxxXvid(): bool - { - if (preg_match('/(b[dr]|dvd)rip|detoxication|divx|nympho|pornolation|swe6|tesoro|xvid/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_XVID; - - return true; - } - - return false; - } - - public function isXxxDVD(): bool - { - if (preg_match('/dvdr[^i]|dvd[59]/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_DVD; - - return true; - } - - return false; - } - - public function isXxxVr(): bool - { - $name = $this->releaseName; - - // Fast reject: must contain 'vr' - if (stripos($name, 'vr') === false) { - return false; - } - - // Recognized VR sites - $vrSites = '(?:SexBabesVR|LittleCapriceVR|VRoomed|VRMagic|TonightsGirlfriend|NaughtyAmericaVR|BaDoinkVR|WankzVR|VRBangers|StripzVR|RealJamVR|TmwVRnet|MilfVR|KinkVR|CzechVR(?:Fetish)?|HoloGirlsVR|WetVR|XSinsVR|VRCosplayX|BIBIVR|SLR|SexLikeReal)'; - - // Require either a site token or explicit VR180/VR360 first - if ( - ! preg_match('!(?i)\bVR(?:180|360)\b!', $name) && - ! preg_match('!(?i)\b'.$vrSites.'\b!', $name) - ) { - return false; - } - - // Main VR feature pattern (extended mode with ! delimiter to avoid / conflicts) - $vrPattern = '!(?xi) - ( - \b'.$vrSites.'\b - | \bVR(?:180|360)\b - | \bVR(?:180|360)[._ -]?(?:3D|H?SBS)\b - | \b(?:5K|6K|7K|8K)\b .* \bVR\b - | \bVR\b .* \b(?:2560|3072|3360|3480|3584|3840|3968|4096|4320)p\b - | \b(?:2560|3072|3360|3480|3584|3840|3968|4096|4320)p\b .* \bVR\b - | \b180x180_3dh\b - | \b(?:GearVR|Oculus|Quest[123]?|PSVR|Vive|Index|Pimax|Reverb|RiftS)\b - | ^SLR.+(?:VR|LR[_-]180|3072p) - | ^REQUEST\.SLR - | \[VR\][.\s]Pack - ) - !'; - - if (! preg_match($vrPattern, $name)) { - return false; - } - - // If only generic VR tokens matched, enforce XXX or known site to cut false positives - if ( - ! preg_match('!(?i)\b'.$vrSites.'\b!', $name) && - ! preg_match('!(?i)\bXXX\b!', $name) - ) { - return false; - } - - $this->tmpCat = Category::XXX_VR; - - return true; - } - - public function isXxxImageset(): bool - { - if (preg_match('/IMAGESET|PICTURESET|ABPEA/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_IMAGESET; - - return true; - } - - return false; - } - - public function isXxxPack(): bool - { - if (preg_match('/[ .]PACK[ .]/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_PACK; - - return true; - } - - return false; - } - - public function isXxxOther(): bool - { - // If nothing else matches, then try these words. - if (preg_match('/[._ -]Brazzers|Creampie|[._ -]JAV[._ -]|North\.Pole|^Nubiles|She[._ -]?Male|Transsexual|OLDER ANGELS/i', $this->releaseName)) { - $this->tmpCat = Category::XXX_OTHER; - - return true; - } - - return false; - } - - public function isXxxClipSD(): bool - { - switch (true) { - case $this->checkPoster('/anon@y[.]com/i', $this->poster, Category::XXX_CLIPSD): - case $this->checkPoster('/@md-hobbys[.]com/i', $this->poster, Category::XXX_CLIPSD): - case $this->checkPoster('/oz@lot[.]com/i', $this->poster, Category::XXX_CLIPSD): - return true; - case preg_match('/(iPT\sTeam|KLEENEX)/i', $this->releaseName): - case stripos($this->releaseName, 'SDPORN') !== false: - $this->tmpCat = Category::XXX_CLIPSD; - - return true; - default: - return false; - } - } - - public function isXxxSD(): bool - { - if (preg_match('/SDX264XXX|XXX\.HR\./i', $this->releaseName)) { - $this->tmpCat = Category::XXX_SD; - - return true; - } - - return false; - } - - public function isXxxWEBDL(): bool - { - // First check if this is a TV show to exclude it - if (preg_match('/\b(S\d{1,2}E\d{1,2}|S\d{1,2}|Season\s\d{1,2}|E\d{1,2})\b/i', $this->releaseName) || - preg_match('/\b(Rick\.And\.Morty|Game\.Of\.Thrones|Walking\.Dead|Breaking\.Bad|Stranger\.Things)\b/i', $this->releaseName)) { - return false; - } - - // Adult keywords commonly found in titles - $adultKeywords = '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'; - - // Known adult studios - $knownStudios = 'Brazzers|NaughtyAmerica|RealityKings|Bangbros|BangBros18|TeenFidelity|PornPros|SexArt|WowGirls|Vixen|Blacked|Tushy|Deeper|Bellesa|Defloration|MetArt|MetArtX|TheLifeErotic|VivThomas|JoyMii|Nubiles|NubileFilms|FamilyStrokes|X-Art|Babes|Twistys|WetAndPuffy|WowPorn|MomsTeachSex|Mofos|BangBus|Passion-HD|EvilAngel|DorcelClub'; - - // Check for web-dl/webrip and require adult content keywords - if (preg_match('/web[._ -]dl|web-?rip/i', $this->releaseName) && - (preg_match('/('.$adultKeywords.')/i', $this->releaseName) || - preg_match('/('.$knownStudios.')/i', $this->releaseName) || - preg_match('/\b(XXX|Porn|Adult|JAV|Hentai)\b/i', $this->releaseName))) { - $this->tmpCat = Category::XXX_WEBDL; - - return true; - } - - return false; - } - - // Console. - - public function isConsole(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - return match (true) { - $this->isGameNDS(), $this->isGame3DS(), $this->isGamePS3(), $this->isGamePS4(), $this->isGamePSP(), $this->isGamePSVita(), $this->isGameWiiWare(), $this->isGameWiiU(), $this->isGameWii(), $this->isGameNGC(), $this->isGameXBOX360DLC(), $this->isGameXBOX360(), $this->isGameXBOXONE(), $this->isGameXBOX(), $this->isGameOther() => true, - default => false, - }; - } - - public function isGameNDS(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Nintendo DS content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:NDS|nintendo\s+ds)|\b(?:nds|NDS)\b|nintendo.+(?<!3)(?:nds|ndsi)\b/i', $this->releaseName)) { - // Check for region codes, version indicators, or ROM collections - if (preg_match('/\((DE|DSi(?: Enhanced)?|_NDS-|EUR|FR|GAME|HOL|JP|JPN|NL|NTSC|PAL|KS|USA)\)/i', $this->releaseName) || - preg_match('/\b(EUR|FR|GAME|HOL|JP|JPN|NL|NTSC|PAL|KS|USA|ROMs?(et)?)\b/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_NDS; - - return true; - } - } - - return false; - } - - public function isGame3DS(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Nintendo 3DS content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:3DS|nintendo\s+3ds)|\b(?:3ds)\b|nintendo.+3ds|(?<!max\.)[_\.-]3DS(?![_\.-]max)/i', $this->releaseName)) { - // Verify with region codes, version indicators, or other game-specific markers - if (preg_match('/\((DE|EUR|FR|GAME|HOL|JP|JPN|NL|NTSC|PAL|KS|USA|ASIA)\)/i', $this->releaseName) || - preg_match('/\b(EUR|FR|GAME|HOL|JP|JPN|NL|NTSC|PAL|KS|USA|ASIA|ROMs?(et)?)\b/i', $this->releaseName) || - preg_match('/\b(CIA|3DS[_\.-]?ROM|eShop|Region\s*Free)\b/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_3DS; - - return true; - } - } - - return false; - } - - public function isGameNGC(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Nintendo GameCube content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:NGC|Nintendo\s+GameCube)|\b(?:GameCube)\b|[\._-]N?G(AME)?C(UBE)?[-_\.]/i', $this->releaseName)) { - // Check for region codes or known GameCube release groups - if (preg_match('/[(\_\-](?:DE|EUR?|FR|GAME|HOL|JP|JPN|NL|NTSC|PAL|KS|USA?)[\)\_]/i', $this->releaseName) || - preg_match('/\b(?:EUR?|FR|GAME|HOL|JP|JPN|NL|NTSC|PAL|KS|USA?|ROMs?(et)?)\b/i', $this->releaseName) || - preg_match('/-(?:(?:STAR|DEATH|STINKY|MOON|HOLY|G)?CUBE(?:SOFT)?|DARKFORCE|DNL|GP|ICP|iNSOMNIA|JAY|LaKiTu|METHS|NOMIS|QUBiSM|PANDORA|REACT0R|SUNSHiNE|SAVEPOiNT|SYNDiCATE|WAR3X|WRG)/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_OTHER; - - return true; - } - } - - return false; - } - - public function isGamePS3(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests PlayStation 3 content - if (preg_match('/(?:^|[^a-zA-Z0-9e])(?:PS3|PlayStation\s+3)|\b(?:PS3)\b|[\._-]PS3[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known PS3 release groups - if (preg_match('/\b(?:ANTiDOTE|APATHY|AGENCY|Caravan|DUPLEX|DLC|EUR?|Googlecus|GOTY|iNSOMNi|JAP|JPN|KONDIOS|MULTi|NRP|NTSC|PAL|PSN|SPLiT|STRiKE|USA?|ZRY)\b/i', $this->releaseName) || - preg_match('/\[PS3\]|\-HR/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PS3; - - return true; - } - } - - return false; - } - - public function isGamePS4(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Common PS4 game edition indicators - $editionPatterns = '(?:Gold|Deluxe|Complete|Definitive|GOTY|Game\s?of\s?the\s?Year|Digital|Standard|Ultimate|Special|Premium|Legacy|Collector\'?s?|Limited|Anniversary|Remastered|Collection)'; - - // Most common PS4 release groups - $releaseGroups = '(?:ANTiDOTE|AGENCY|APATHY|Caravan|COMPLEX|DUPLEX|DARKSiDERS|DODI|FALLEN|GC|HAREM|HRENO|iNLAWS|iNSOMNi|INTERNAL|KEPLER|LEMON|MarvTM|MULTi\d+|OPOISSO|PARADOX|PKG|PRELUDE|PROTOKOL|REGION1|REGION4|RELOADED|RESPAWN|REVENGE|SiMPLEX|SKIDROW|SPLiT|STRiKE|TKC|WaYsTeD|ZRY)'; - - // Check for PS4 at start of filename with underscores - if (preg_match('/^PS4[_\.\-]/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PS4; - - return true; - } - - // Check for CUSA pattern which is specific to PS4 games - if (preg_match('/CUSA\d{5}/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PS4; - - return true; - } - - // Direct check for the PS4-DUPLEX pattern at the end of the name - if (preg_match('/\.PS4-DUPLEX$/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PS4; - - return true; - } - - // First check if the release name suggests PlayStation 4 content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:PS4|PlayStation\s*4)|\bPS4\b|[_\.\-]PS4[_\.\-]/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PS4; - - return true; - } - - // Check for files with Game_Full_psgames which indicates PS4 game - if (preg_match('/Game_Full_psgames/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PS4; - - return true; - } - - return false; - } - - public function isGamePSP(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests PlayStation Portable content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:PSP|PlayStation\s+Portable)|\b(?:PSP)\b|[\._-]PSP[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known PSP release groups - if (preg_match('/\b(?:BAHAMUT|Caravan|EBOOT|EMiNENT|EUR?|EvoX|GAME|GHS|Googlecus|HandHeld|JAP|JPN|KLOTEKLAPPERS|KOR|NTSC|PAL|USA?)\b/i', $this->releaseName) || - preg_match('/\b(?:Dynarox|HAZARD|ITALIAN|KLB|KuDoS|LIGHTFORCE|MiRiBS|POPSTATiON|(PLAY)?ASiA|PSN|PSX2?PSP|SUXXORS|UMD(RIP)?|YARR)\b/i', $this->releaseName) || - preg_match('/\b(?:CSO|ISO)\b|\-HR|[._-](?:v\d+\.\d+)|\.(PSP)$/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PSP; - - return true; - } - } - - return false; - } - - public function isGamePSVita(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests PlayStation Vita content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:PS ?Vita|PlayStation\s+Vita|PSV)|\b(?:PSVita|PSVITA)\b|[\._-](?:PSV|Vita)[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known PS Vita release groups - if (preg_match('/\b(?:ANTiDOTE|APATHY|Caravan|DUPLEX|DLC|EUR?|GAME|GOTY|GRiDLOCK|iNSOMNi|JAP|JPN|KONDIOS|MULTi|NTSC|PAL|PSN|SPLiT|STRiKE|USA?|VENOM|VPK)\b/i', $this->releaseName) || - preg_match('/\b(?:3\.60|3\.65|3\.68|Vitamin|NoNpDrm|MaiDump|UNDUB|PCSE\d{5}|PCSB\d{5}|PCSG\d{5}|PCSH\d{5})\b/i', $this->releaseName) || - preg_match('/\[PSVita\]|\(PSV\)|\.PSV$|Vita[._-]?(ROM|GAME|ISO)/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_PSVITA; - - return true; - } - } - - return false; - } - - public function isGameWiiWare(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests WiiWare content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:WiiWare|Wii\s+Ware)|\b(?:WiiWare)\b|[\._-]Wii[\._-]?(?:Ware)|(?:Console|DLC|VC)[._ -]WII|WII[._ -](?:Console|DLC|VC)|WII[._ -].+(?:Console|DLC|VC)|(?:Console|DLC|VC).+[._ -]WII/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known WiiWare release groups - if (preg_match('/\b(?:PROPER|READNFO|UPDATE|REPACK|WiiERD|DNi|JAP|JPN|USA?|EUR?|PAL|NTSC|iNSOMNi|MULTi|LOADER|VENOM|WBFS|WII\d+|NRP|WWII|VORTEX|DiSONiK|DNi|DRYB)\b/i', $this->releaseName) || - preg_match('/\b(?:VC|Virtual[._ -]Console|WiiShop|Shop[._ -]Channel|WAD|IOS\d+|eShop)\b/i', $this->releaseName) || - preg_match('/\[Wii\]|\(Wii\)|Wii\.Point|NintendoWare|CLASSIC/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_WIIWARE; - - return true; - } - } - - return false; - } - - public function isGameWiiU(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Wii U content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Wii\s*U|WiiU)|\b(?:WiiU)\b|[\._-]WiiU[\._-]|Nintendo[\._-]WiiU/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known Wii U release groups - if (preg_match('/\b(?:ANTiDOTE|APATHY|ALMoST|AMBITION|Allstars|BAHAMUT|BiOSHOCK|Caravan|CLiiCHE|DMZ|DNi|DRYB|DLC|EUR?|GAME|HaZMaT|iCON|JPN|JAP|KOR|LaKiTu|LoCAL|LOADER|MARVEL|MULTi|NAGGERS|OneUp|NTSC|PAL|PLAYME|PONS|PROMiNENT|ProCiSiON|PROPER|QwiiF|RANT|REV0|Scrubbed|SUNSHiNE|SUSHi|TMD|USA?|VORTEX|ZARD|ZER0)\b/i', $this->releaseName) || - preg_match('/\b(?:WUD|WUX|WUP-[A-Z0-9]+|eShop|LOADIINE|WUDUMP|CONSOLE-WiiU|WiiU-\w+|UPDATE|vWii|WiiVC|Virtual\s*Console)\b/i', $this->releaseName) || - preg_match('/\[WiiU\]|\(WiiU\)|Wii\.U|Nintendo\.WiiU|15GB\+?|RETAiL|Loadiine|CFW|CEMU|NUS|Installable/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_WIIU; - - return true; - } - } - - // Fallback to original pattern matching for better backward compatibility - switch (true) { - case preg_match('/[._ -](Allstars|BiOSHOCK|dumpTruck|DNi|iCON|JAP|NTSC|PAL|ProCiSiON|PROPER|RANT|REV0|SUNSHiNE|SUSHi|TMD|USA?)$/i', $this->releaseName): - case preg_match('/[._ -](APATHY|BAHAMUT|DMZ|ERD|GAME|JPN|LoCAL|MULTi|NAGGERS|OneUp|PLAYME|PONS|Scrubbed|VORTEX|ZARD|ZER0)$/i', $this->releaseName): - case preg_match('/[._ -](ALMoST|AMBITION|Caravan|CLiiCHE|DRYB|HaZMaT|KOR|LOADER|MARVEL|PROMiNENT|LaKiTu|LOCAL|QwiiF|RANT)$/i', $this->releaseName): - $this->tmpCat = Category::GAME_WIIU; - - return true; - default: - return false; - } - } - - public function isGameWii(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // 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|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; - - return true; - } - } - - // Fallback to original pattern matching for backward compatibility - switch (true) { - case preg_match('/[._ -](Allstars|BiOSHOCK|dumpTruck|DNi|iCON|JAP|NTSC|PAL|ProCiSiON|PROPER|RANT|REV0|SUNSHiNE|SUSHi|TMD|USA?)/i', $this->releaseName): - case preg_match('/[._ -](APATHY|BAHAMUT|DMZ|ERD|GAME|JPN|LoCAL|MULTi|NAGGERS|OneUp|PLAYME|PONS|Scrubbed|VORTEX|ZARD|ZER0)/i', $this->releaseName): - case preg_match('/[._ -](ALMoST|AMBITION|Caravan|CLiiCHE|DRYB|HaZMaT|KOR|LOADER|MARVEL|PROMiNENT|LaKiTu|LOCAL|QwiiF|RANT)/i', $this->releaseName): - $this->tmpCat = Category::GAME_WII; - - return true; - default: - return false; - } - } - - public function isGameXBOX360DLC(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Xbox 360 DLC content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:DLC|XBLA|Add[._ -]?On|Expansion|Content).*(?:Xbox360|XBOX360|X360)|\b(?:Xbox360|XBOX360|X360).*(?:DLC|XBLA|Add[._ -]?On|Expansion|Content)\b|[\._-](?:DLC|XBLA)[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known Xbox 360 DLC release groups - if (preg_match('/\b(?:COMPLEX|REPACK|READNFO|REGION|FREE|RGH|JTAG|ARCADE|MARKETPLACE|LIVE|XBLA|Games[._ -]On[._ -]Demand|GOD|FULL|iNT|JPN|RF|NTSC|PAL|Region[._ -]Free|USA?|ASIA|EUR?|KOR|WAVE\d+|XGD\d|SWAG|CCCLX|DAGGER)\b/i', $this->releaseName) || - preg_match('/\b(?:TU\d+|Patch|Update|v\d+\.\d+|Package|Addon|MAP[._ -]PACK|Character[._ -]Pack|Skin[._ -]Pack|Unlock|Premium|Season[._ -]Pass|Episode|Part|Pack\d+)\b/i', $this->releaseName) || - preg_match('/\[DLC\]|\(DLC\)|Xbox[._ -]360[._ -]DLC|MSPOINTS|Microsoft[._ -]Points|\d{4}[._ -]MS[._ -]Points|XBL[._ -]Arcade/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOX360DLC; - - return true; - } - } - - // Check standalone XBLA releases - if (preg_match('/\bXBLA[._ -](?!x360|xbox360)|\b(?:Xbox360|XBOX360|X360)[._ -]Arcade|\bXbox[._ -]LIVE[._ -]Arcade\b/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOX360DLC; - - return true; - } - - return false; - } - - public function isGameXBOX360(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Xbox 360 content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Xbox360|XBOX360|X360)|\b(?:Xbox360|XBOX360|X360)\b|[\._-](?:Xbox360|XBOX360|X360)[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known Xbox 360 release groups - if (preg_match('/\b(?:Allstars|ASiA|CCCLX|COMPLEX|DAGGER|GLoBAL|iMARS|JAP|JPN|MULTi|NTSC|PAL|REPACK|RRoD|RF|SWAG|USA?|REGION|FREE|WAVE\d+|XGD\d|SPARE|JTAG|iNT|FULL|MARVEL|GOD|SPARE)\b/i', $this->releaseName) || - preg_match('/\b(?:DAMNATION|GERMAN|GOTY|iNT|iTA|JTAG|KINECT|MARVEL|MUX360|RANT|SPANISH|VATOS|XBOX360|WiiERD|XBLA|Region[._ -]Free|RGH|ISO|COMPLEX)\b/i', $this->releaseName) || - preg_match('/\[XBOX360\]|\(XBOX360\)|XBOX[._-]360[._-]|TU\d+|Patch|Update|v\d+\.\d+|\.(iso|xex|xbla)$/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOX360; - - return true; - } - } - - // Check for X360 specific patterns for backward compatibility - if (preg_match('/\bx360\b|[\._-]x360[\._-]/i', $this->releaseName)) { - if (preg_match('/\b(?:Allstars|ASiA|CCCLX|COMPLEX|DAGGER|GLoBAL|iMARS|JAP|JPN|MULTi|NTSC|PAL|REPACK|RRoD|RF|SWAG|USA?)\b/i', $this->releaseName) || - preg_match('/\b(?:DAMNATION|GERMAN|GOTY|iNT|iTA|JTAG|KINECT|MARVEL|MUX360|RANT|SPARE|SPANISH|VATOS|XGD)\b/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOX360; - - return true; - } - } - - return false; - } - - public function isGameXBOXONE(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests Xbox One content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:XboxOne|XBOX\s*One|XBONE|XB1)|\b(?:XboxOne|XBOX\s*One|XBONE|XB1)\b|[\._-](?:XboxOne|XBOX\s*One|XBONE|XB1)[\._-]/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known Xbox One release groups - if (preg_match('/\b(?:ANYiSO|AVENGED|CODEX|COMPLEX|DAGGER|DLCS|DODI|EUR?|FitGirl|FULLGAME|Googlecus|GOTY|iNLAWS|JAP|JPN|MULTi|NTSC|PAL|REPACK|RF|RSGTACTICS|SKIDROW|TiNYiSO|CODEX|SiMPLEX)\b/i', $this->releaseName) || - preg_match('/\b(?:Enhanced\s?for\s?Xbox|Optimized\s?for\s?Series|SmartDelivery|Console\s?Exclusive|Xbox\s?Play\s?Anywhere|Game\s?Preview|Game\s?Pass|RETAIL|INTERNAL|REDEVEiL|HOODLUM|BALiSTIC)\b/i', $this->releaseName) || - preg_match('/\b(?:CUSA\d{5}|Update\s?[0-9.]+|Season\s?Pass|Premium\s?Edition|Definitive\s?Edition|Complete\s?Edition|Deluxe\s?Edition|READNFO|ISO|PKG|NSP)\b/i', $this->releaseName) || - preg_match('/\[XBOX\s?(ONE|1)\]|\(XBOX\s?(ONE|1)\)|\d{5}\.[0-9]{2}[\._-]|Microsoft[\._-]Store/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOXONE; - - return true; - } - } - - // Check for Xbox Series S|X content which uses same category - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Xbox\s?Series[._ -]?[SX]|XSX|XSS)|\b(?:Xbox\s?Series[._ -]?[SX]|XSX|XSS)\b|[\._-](?:Xbox\s?Series[._ -]?[SX]|XSX|XSS)[\._-]/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOXONE; - - return true; - } - - // Legacy detection for backward compatibility - if (preg_match('/XBOXONE|XBOX\.ONE|XBOX[._-]?ONE/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOXONE; - - return true; - } - - return false; - } - - public function isGameXBOX(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests original Xbox content (while excluding 360/One/Series) - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:XBOX|X-BOX)(?!(?:360|ONE|Series|One))\b|\b(?:XBOX)\b(?!(?:360|ONE|Series|One))|[\._-]XBOX[\._-](?!(?:360|ONE|Series|One))/i', $this->releaseName)) { - // Verify with region codes, game-specific markers, or known original Xbox release groups - if (preg_match('/\b(?:USA?|PAL|NTSC|JPN|JAP|RF|REGION|FREE|EUR?|ASiA|Allstars|iNT|MULTi|REPACK|PROPER|READNFO|DVD[59]?|RETAIL|ISO|RIP|GOTY|UNCUT|GERMAN|FRENCH|SPANiSH|iTALiAN|DUTCH|SWEDiSH|DANiSH|FiNNiSH|NORWEGIAN|RUSSiAN)\b/i', $this->releaseName) || - preg_match('/\b(?:XPG|ProjectX|DAGGER|STRANGE|SWAG|DEMONZ85|PROTOCOL|ICONCLAS|DNL|DRTL|RiNGERS|ORiGiNAL|Empire|Protocol|iMARS|GOTY|Caravan|PROPHETS|1TM|WaLMaRT|Eroticl1|LaKiTu|FLS)\b/i', $this->releaseName) || - preg_match('/\[XBOX\]|\(XBOX\)|XBOX[._-]([^3]|$)|\.(iso|xbe)$/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOX; - - return true; - } - } - - // Legacy detection for backward compatibility, but with better exclusion of other Xbox platforms - if (preg_match('/\bXBOX\b/i', $this->releaseName) && - ! preg_match('/\b(XBOX\s?360|XBOX\s?ONE|XBONE|XB1|Xbox\s?Series|XSX|XSS)\b/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_XBOX; - - return true; - } - - return false; - } - - public function isGameOther(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // 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, 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; - - return true; - } - } - - // Legacy detection for backward compatibility - if (preg_match('/\b(PS(1|One|X)|PS2|PlayStation\s+(1|2|One)|SNES|Super\s+Nintendo|NES|Nintendo\s+Entertainment\s+System|SEGA\s+(GENESIS|CD|SATURN|32X)|GB([AC])?|GameBoy(\s+(Advance|Color))?|Game\s*Boy(\s+(Advance|Color))?|Dreamcast|SEGA\s+Saturn|Atari(\s+Jaguar)?|3DO|Neo\s*Geo|N64|Nintendo\s*64)\b/i', $this->releaseName) && - preg_match('/\b(EUR|FR|GAME|HOL|ISO|JP|JPN|NL|NTSC|PAL|KS|USA|ROMS?(et)?)\b/i', $this->releaseName)) { - $this->tmpCat = Category::GAME_OTHER; - - return true; - } - - return false; - } - - // Music. - - public function isMusic(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - return match (true) { - $this->isMusicVideo(), $this->isAudiobook(), $this->isMusicLossless(), $this->isMusicMP3(), $this->isMusicPodcast(),$this->isMusicOther() => true, - default => false, - }; - } - - public function isMusicForeign(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Skip processing if foreign categorization is disabled - if (! $this->categorizeForeign) { - return false; - } - - // Organized language pattern with word boundaries - // Full language names - $fullLanguages = 'arabic|brazilian|bulgarian|cantonese|chinese|croatian|czech|danish|deutsch|dutch|estonian|'. - 'finnish|flemish|french|german|greek|hebrew|hungarian|icelandic|indian|iranian|italian|'. - 'japanese|korean|latin|latvian|lithuanian|macedonian|mandarin|nordic|norwegian|persian|'. - 'polish|portuguese|romanian|russian|serbian|slovenian|spanish|spanisch|swedish|'. - 'thai|turkish|ukrainian|vietnamese'; - - // Common language codes and abbreviations - $langCodes = 'ar|bg|bl|cs|cz|da|de|dk|el|es|et|fi|fr|ger|gr|heb|hr|hu|hun|is|it|ita|jp|jap|ko|kor|lt|lv|'. - 'mk|nl|no|pl|pt|ro|rs|ru|se|sk|sl|sr|sv|th|tr|ua|vi|zh'; - - // Italian with year pattern - $italianWithYear = 'it(a|\s+19|\s+20\d\d)'; - - // Combined pattern with improved word boundaries - if (preg_match('/(?:^|[\s\.\-_])(?:'.$fullLanguages.'|'.$langCodes.'|'.$italianWithYear.')(?:$|[\s\.\-_])/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_FOREIGN; - - return true; - } - - return false; - } - - public function isAudiobook(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests audiobook content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Audiobook|Audio\s*Book|Talking\s*Book|ABEE|Audible)|\b(?:Audiobook|Audio\s*Book|A\s*Book)\b|[\._-](?:Audiobook|AB)[\._-]/i', $this->releaseName)) { - // Verify with audiobook-specific markers - if (preg_match('/\b(?:Unabridged|Abridged|Narrated|Narrator|Chapter|MP3|M4A|M4B|AAC|Read\s+By|Reader|Retail|Complete|SAGA|Tantor|Blackstone|Brilliance|GraphicAudio|Macmillan|Penguin|Random\s+House|Hachette|Harper|Podium|Audible|Originals)\b/i', $this->releaseName) || - preg_match('/\d+\s*CDs|\d+\s*Hours|\d+\s*Hrs|Spoken\s+Word|Audiofy|\b(?:MPEG|FLR|SPX|CBR|DAISY|UB|GAB|ATBR)\b/i', $this->releaseName) || - preg_match('/\.(mp3|m4a|m4b|aac|flac|ogg|wma)$/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_AUDIOBOOK; - - return true; - } - } - - // Check for known audiobook publishing patterns - if (preg_match('/(?:[\(_\[])(?:Audiobook|AB|Unabridged)(?:[\)_\]])/i', $this->releaseName) || - preg_match('/Read\s+By\s+[A-Z][a-z]+\s+[A-Z][a-z]+/i', $this->releaseName) || - preg_match('/\b(?:Audiobook|AB)\s+(?:Collection|Series|Compilation)\b/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_AUDIOBOOK; - - return true; - } - - // Legacy detection for backward compatibility - if (preg_match('/(Audiobook|Audio.?Book)/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_AUDIOBOOK; - - return true; - } - - return false; - } - - public function isMusicVideo(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests music video content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Music\s*Video|Concert|Live\s*Show|Tour|Festival|Performan[cs]e|MV|MTV)|\b(?:MVID|MVid)\b|[\._-](?:MV|MVID)[\._-]/i', $this->releaseName)) { - // Verify with music video specific markers - if (preg_match('/\b(?:720p|1080[pi]|2160p|BDRip|BluRay|DVDRip|HDTV|WebRip|WEB-DL|x264|x265|h264|h265|XviD|AVC|HEVC|AMVC|MBLURAY)\b/i', $this->releaseName) || - preg_match('/\b(?:Live|Unplugged|Acoustic|World\s*Tour|in\s*Concert|Official\s*Video|Music\s*Collection|Bootleg|Remastered|Directors\s*Cut|Documentary|Recorded\s*Live)\b/i', $this->releaseName) || - preg_match('/\.(mkv|mp4|avi|ts|m2ts|mpg|mpeg|mov|wmv|vob|m4v)$/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_VIDEO; - - return true; - } - } - - // Artist/band pattern with year and video format - if (preg_match('/^[A-Z0-9][A-Za-z0-9\.\s\&\'\(\)\-]+\s+\-\s+[A-Z0-9][A-Za-z0-9\.\s\&\'\(\)\-]+(\s+(19|20)\d\d)?\s+\d+p\b/i', $this->releaseName) || - preg_match('/^[A-Z0-9][A-Za-z0-9\.\s\&\'\(\)\-]+\s+\-\s+[A-Z0-9][A-Za-z0-9\.\s\&\'\(\)\-]+\s+\[?(720p|1080[pi]|2160p|Bluray|x264|x265)\]?/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_VIDEO; - - return true; - } - - // Check for common music video release naming patterns - if (preg_match('/\b(?:JAM|CLASSiC|DiRFiX|MBLURAY|NTSC|PAL|REMASTERED|UMV|VEVO|UHDTV|JUSTiCE|DTS|DTSDD|DTSHD|MBluRay)\b.*(?:720p|1080p|2160p|x264|x265|h264|h265)/i', $this->releaseName) && - ! preg_match('/\b(?:SEASON|EPISODE|S\d+E\d+|HDTV|TV[\s\.\-_]SHOW)\b/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_VIDEO; - - return true; - } - - // 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()) { - return true; - } - $this->tmpCat = Category::MUSIC_VIDEO; - - return true; - } - - return false; - } - - public function isMusicLossless(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests lossless audio content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:FLAC|APE|WAV|ALAC|DSD|DSF|AIFF|PCM|Lossless)|\b(?:FLAC|APE|WAV|ALAC|DSD|DSF|AIFF|PCM)\b|[\._-](?:FLAC|APE|WAV|ALAC|DSD|AIFF)[\._-]/i', $this->releaseName)) { - // Verify with lossless-specific markers - if (preg_match('/\b(?:24[Bb]it|96kHz|192kHz|Hi[- ]?Res|HD[- ]?Tracks|Vinyl[- ]?Rip|CD[- ]?Rip|WEB[- ]?Rip|Decca|Deutsche[- ]?Grammophon|ECM|Nonesuch|HDtracks|7Digital|Qobuz|Tidal|Master[- ]?Quality|MQA|SACD)\b/i', $this->releaseName) || - preg_match('/\b(?:Bowers[- ]?&[- ]?Wilkins|B&W|Society[- ]?of[- ]?Sound|Blue[- ]?Coast|Reference[- ]?Recordings|MA[- ]?Recordings|2L|ATMA|BIS|Channel[- ]?Classics|Harmonia[- ]?Mundi)\b/i', $this->releaseName) || - preg_match('/\.(flac|ape|wav|aiff|dsf|dff|m4a|tak)$/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_LOSSLESS; - - return true; - } - } - - // Check for artist-title format with FLAC keywords - if (preg_match('/^[a-zA-Z0-9_]+(_|\s+)-(_|\s+)[a-zA-Z0-9_\s]+_(19|20)\d\d.*-FLAC/i', $this->releaseName) || - preg_match('/_(19|20)\d\d.*FLAC.*_\d{2}_/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_LOSSLESS; - - return true; - } - - // Check for double FLAC patterns (common in some releases) - if (preg_match('/-FLAC-[a-zA-Z0-9]+-FLAC/i', $this->releaseName) || - preg_match('/-FLAC.*FLAC_/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_LOSSLESS; - - return true; - } - - // Check for specific FLAC release patterns - if (preg_match('/\[(19|20)\d\d\][._ -]\[FLAC\]|([\(\[])flac([\)\]])|FLAC\-(19|20)\d\d\-[a-z0-9]{1,12}|\.flac"|(19|20)\d\d\sFLAC|[._ -]FLAC.+(19|20)\d\d[._ -]| FLAC$/i', $this->releaseName) || - preg_match('/\d{3,4}kbps[._ -]FLAC|\[FLAC\]|\(FLAC\)|FLACME|FLAC[._ -]\d{3,4}(kbps)?|WEB[._ -]FLAC/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_LOSSLESS; - - return true; - } - - // Check for other lossless formats - if (preg_match('/\b(?:APE|Monkey\'s[._ -]Audio|WavPack|WV|TAK|TTA|ALAC|Apple[._ -]Lossless)\b|\.(ape|wv|tak|tta)$/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_LOSSLESS; - - return true; - } - - // Check for lossless release groups and scene tags - if (preg_match('/\b(?:DYNAMIC|EOS|TFM|DFA|CODEC|PERFECT|ENSLAVE|YARD|FLACKED|DEMOGORGE|PmK|DiTCH|DATA-FLACx)\b/i', $this->releaseName) && - ! preg_match('/\b(?:mp3|320|256|192|128|CBR|VBR)\b/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_LOSSLESS; - - return true; - } - - return false; - } - - public function isMusicMP3(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name suggests MP3 audio content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:MP3|320kbps|256kbps|192kbps|128kbps|CBR|VBR)|\b(?:MP3)\b|[\._-](?:MP3)[\._-]|\.mp3$/i', $this->releaseName)) { - // Verify with MP3-specific bitrate markers - if (preg_match('/\b(?:320|256|192|128)[._-]?kbps|\b(?:320|256|192|128)[._-]?K|\((?:320|256|192|128)\)|\[(?:320|256|192|128)\]|(?:320|256|192|128)[._-]?CBR|V0|V2|VBR|MP3\s*\-\s*\d{3}kbps/i', $this->releaseName) || - preg_match('/\b(?:CD[._-]?Rip|Web[._-]?Rip|WEB|iTunes[._-]?(Plus|Match|Rip)?|AmazonRip|Spotify[._-]?Rip|M3U|ID3|EDM|Dance|House|Bootleg|Remix|MPEG|Exclu|Proper|Repack|RETAIL)\b/i', $this->releaseName) || - preg_match('/\.(m3u|mp3)"|rip(?:192|256|320)|[._-]FM[._-].+MP3/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_MP3; - - return true; - } - } - - // Check for MP3 scene release patterns - if (preg_match('/^[a-zA-Z0-9]{1,12}[._-](19|20)\d\d[._-][a-zA-Z0-9]{1,12}$|[a-z0-9]{1,12}\-(19|20)\d\d\-[a-z0-9]{1,12}/i', $this->releaseName) || - preg_match('/\b(?:DEMONiC|SiRE|SPiKE|MiNDTRiP|AMRC|btl|TrT|RKS|UPE|hbZ|HB|UMT|TBM|VAG|MAHOU|PMSF|RNS|SPK)\b[._-](?!FLAC|APE|WAV|ALAC|DSD)/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_MP3; - - return true; - } - - // Check for MP3 source indicators with year patterns - if (preg_match('/[._-](?:CDR|SBD|WEB|SAT|FM|DAB)[._-]+(19|20)\d\d([._-]|$)|[._-](19|20)\d\d[._-]+(?:CDR|SBD|WEB|SAT|FM|DAB)([._-]|$)/i', $this->releaseName) || - preg_match('/\-web-(19|20)\d\d([\.\s$])|[._-](SAT|SBD|WEB)[._-]+(19|20)\d\d([._-]|$)|[._-](19|20)\d\d[._-]+(?:SAT|WEB)([._-]|$)/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_MP3; - - return true; - } - - // Check for CD collection and album indicators - if (preg_match('/\s\dCDs|FIH\_INT|\(320\)\.|\-\((Bootleg|Promo)\)|\-\sMP3\s(19|20)\d\d|\(vbr\)/i', $this->releaseName) || - preg_match('/\s(19|20)\d\d\s([a-z0-9]{3}|[a-z]{2,})$|\-(19|20)\d\d\-(C4|MTD)([\s\.])|NMR\s\d{2,3}\skbps| MP3$/i', $this->releaseName)) { - - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_MP3; - - return true; - } - - // Check for MP3 recording specifications - if (preg_match('/[\.\-\(\[_ ]\d{2,3}k[\.\-\)\]_ ]|\((192|256|320)\)|(320|cd|eac|vbr)[._-]+mp3|(cd|eac|mp3|vbr)[._-]+320/i', $this->releaseName)) { - if ($this->isMusicForeign()) { - return true; - } - $this->tmpCat = Category::MUSIC_MP3; - - return true; - } - - return false; - } - - public function isMusicOther(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check for various compilation, VA, and multi-artist indicators - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Compilation|Various[._ -]Artists|OST|Soundtrack|B-Sides|Greatest[._ -]Hits|Anthology)|\b(?:VA|V\.A|Bonus[._ -]Track|Discography|Box[._ -]Set)\b|[\._-](?:VA|OST|Bootleg)[\._-]/i', $this->releaseName)) { - if (! $this->isMusicForeign()) { - $this->tmpCat = Category::MUSIC_OTHER; - } - - return true; - } - - // Check for specific music formats/releases not covered by MP3 or Lossless categories - if (preg_match('/(?:\d)[._ -](?:CD|Albums|LP)[._ -](?:Set|Compilation)|CD[._ -](Collection|Box|SET)|(\d)-?CD[._ -]|Disc[._ -]\d+[._ -](?:of|OF)[._ -]\d+/i', $this->releaseName) || - preg_match('/Vinyl[._ -](?:24[._ -]96|2496|Collection|RIP)|WEB[._ -](?:Single|Album)|EP[._ -]\d{4}|\bEP\b.+(?:19|20)\d\d|Live[._ -](?:at|At|@)/i', $this->releaseName) || - preg_match('/\b(?:Bootleg|Remastered|Anniversary[._ -]Edition|Deluxe[._ -]Edition|Special[._ -]Edition|Collectors[._ -]Edition|Complete[._ -]Edition|Definitive[._ -]Edition)\b/i', $this->releaseName)) { - - if (! $this->isMusicForeign()) { - $this->tmpCat = Category::MUSIC_OTHER; - } - - return true; - } - - // Check for labels, music series, and DJ mixes - if (preg_match('/\b(?:Ministry[._ -]of[._ -]Sound|Hed[._ -]Kandi|Cream|Fabric[._ -]Live|Back[._ -]?2[._ -]?Back|Ultra[._ -]Music|Euphoria|Sensual[._ -]Chill|Top[._ -]Hits)\b/i', $this->releaseName) || - preg_match('/\b(?:DJ[._ -]Mix|Mixed[._ -]By|Tiesto[._ -]Club|D\.O\.M|NMR|pure_fm|Radio[._ -]Show|Reggaeton|Club[._ -]Hits|Summer[._ -](?:Set|Mix))\b/i', $this->releaseName)) { - - if (! $this->isMusicForeign()) { - $this->tmpCat = Category::MUSIC_OTHER; - } - - return true; - } - - // Original patterns for backward compatibility - if (preg_match('/(19|20)\d\d\-(C4)$|[._ -]\d?CD[._ -](19|20)\d\d|\(\d\-?CD\)|\-\dcd\-|\d[._ -]Albums|Albums.+(EP)|Bonus.+Tracks|Box.+?CD.+SET|Discography|D\.O\.M|Greatest\sSongs|Live.+(Bootleg|Remastered)|Music.+Vol|([\(\[\s])NMR([\)\]\s])|Promo.+CD|Reggaeton|Tiesto.+Club|Vinyl\s2496|\WV\.A\.|^\(VA\s|^VA[._ -]/i', $this->releaseName)) { - if (! $this->isMusicForeign()) { - $this->tmpCat = Category::MUSIC_OTHER; - } - - return true; - } - - // Format/edition patterns for backward compatibility - if (preg_match('/\(pure_fm\)|-+\(?(2lp|cd[ms]([\-_ .][a-z]{2})?|cover|ep|ltd_ed|mix|original|ost|.*?(edit(ion)?|remix(es)?|vinyl)|web)\)?-+((19|20)\d\d|you$)/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_OTHER; - - return true; - } - - return false; - } - - public function isMusicPodcast(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check if the release name explicitly indicates podcast content - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Podcast|Pod[._ -]?cast|Pod[._ -]Show)|\b(?:Podcast)\b|[\._-](?:POD)[\._-]/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_PODCAST; - - return true; - } - - // Check for common podcast naming patterns with episode numbers/dates - if (preg_match('/(?:EP?[._ -]?\d+|Episode[._ -]?\d+|S\d+[._ -]?EP?[._ -]?\d+|[._ -](?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[._ -]\d{1,2}[._ -]\d{4})[._ -](?:Interview|Show|Talk|Discussion|Podcast)/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_PODCAST; - - return true; - } - - // Check for popular podcast networks/distributors - if (preg_match('/\b(?:NPR|BBC[._ -]Sounds|Gimlet|Wondery|Stitcher|iHeart[._ -]?Radio|Spotify[._ -]?Original|Audible[._ -]?Original|Joe[._ -]Rogan|RadioLab|This[._ -]American[._ -]Life|Serial|Pod[._ -]?Save[._ -]America|The[._ -]Daily)\b/i', $this->releaseName) && - preg_match('/\b(?:Podcast|Episode|EP?[._ -]?\d+|Show|Interview|Discussion|Talk)\b/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_PODCAST; - - return true; - } - - // Check for podcast recording formats and encoding descriptors - if (preg_match('/\b(?:MP3|AAC|M4A|OPUS|WAV|FLAC)[._ -](?:Podcast|Talk[._ -]Show)\b/i', $this->releaseName) || - preg_match('/\b(?:Podcast|Talk[._ -]Show)[._ -](?:MP3|AAC|M4A|OPUS|WAV|FLAC)\b/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_PODCAST; - - return true; - } - - // Legacy pattern for backward compatibility - if (preg_match('/podcast/i', $this->releaseName)) { - $this->tmpCat = Category::MUSIC_PODCAST; - - return true; - } - - return false; - } - - // Books. - - public function isBook(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - return match (true) { - $this->isComic(), $this->isTechnicalBook(), $this->isMagazine(), $this->isBookOther(), $this->isEBook() => true, - default => false, - }; - } - - public function isBookForeign(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Skip processing if foreign categorization is disabled - if (! $this->categorizeForeign) { - return false; - } - - // Full language names - $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|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|'. - 'mk|nl|no|pl|pt|ro|rs|ru|se|sk|sl|sr|sv|th|tr|ua|vi|zh'; - - // Combined pattern with improved word boundaries - if (preg_match('/(?:^|[\s\.\-_])(?:'.$fullLanguages.'|'.$langCodes.')(?:$|[\s\.\-_])/i', $this->releaseName)) { - $this->tmpCat = Category::BOOKS_FOREIGN; - - return true; - } - - return false; - } - - public function isComic(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Check for comic file formats and common identifiers - if (preg_match('/(?:^|[^a-zA-Z0-9])(?:CBR|CBZ|Comics?|Comic[._ -]Book|Graphic[._ -]Novel)|\b(?:CBR|CBZ|C2C)\b|\.(?:cbr|cbz)$|[\(\[](?:c2c|cbr|cbz)[\)\]]/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_COMICS; - } - - return true; - } - - // Check for popular comic publishers and imprints - if (preg_match('/\b(?:Marvel|DC[._ -]Comics|Image[._ -]Comics|Dark[._ -]Horse|IDW|Vertigo|Wildstorm|Dynamite|Valiant|Archie|Top[._ -]Cow|Boom[._ -]Studios|Oni[._ -]Press)\b/i', $this->releaseName) && - preg_match('/\b(?:Comics?|Annual|Special|Issue|Vol|Volume|Chapter|No\.\d+|\#\d+|TPB|Trade[._ -]Paperback)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_COMICS; - } - - return true; - } - - // Check for manga, manhwa, and other international comics - if (preg_match('/\b(?:Manga|Manhwa|Manhua|Webtoon|Doujinshi|Tankobon|Weekly[._ -]Jump|Shonen|Shojo|Seinen|Josei)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_COMICS; - } - - return true; - } - - // Check for series naming patterns and digital release indicators - if (preg_match('/(?:Comic[._ -]Collection|TPB|Digital[._ -](?:Comic|Edition)|(?:Complete|Collected)[._ -](?:Series|Edition|Works)|(?:Omnibus|Compendium))/i', $this->releaseName) || - preg_match('/\b(?:DC[._ -](?:Adventures|Universe)|Total[._ -]Marvel|Digital[._ -](?:Son|Zone)|Covers[._ -]Digital)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_COMICS; - } - - return true; - } - - // Legacy pattern for backward compatibility - if (preg_match('/[\. ](cbr|cbz)|[\( ]c2c|cbr|cbz[\) ]|comix|^\(comic|[\.\-_\(\[ ]comics?[._ -]|comic.+book|covers.+digital|DC.+(Adventures|Universe)|digital.+(son|zone)|Graphic.+Novel|[\.\-_h ]manga|Total[._ -]Marvel/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_COMICS; - } - - return true; - } - - return false; - } - - public function isTechnicalBook(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Publishers and imprints - $publishers = 'Apress|Addison[._ -]Wesley|AK[._ -]Peters|Birkhauser|Cengage|CRC[._ -]Press|Focal[._ -]Press|'. - 'For[._ -]Dummies|Head[._ -]First|Manning|MIT[._ -]Press|Morgan[._ -]Kaufmann|No[._ -]Starch|OReilly|'. - 'Packt|Peachpit|Pragmatic|Prentice[._ -]Hall|Que|Sams|Springer|Sybex|Syngress|Vieweg|Wiley|Wrox'; - - // Technical subjects and disciplines - $subjects = 'Algorithms|Analysis|Algebra|Architecture|Artificial[._ -]Intelligence|Assembly|Blockchain|'. - 'Calculus|Chemistry|Circuits|Computer[._ -]Science|Cryptography|Cyber[._ -]Security|Data[._ -]Mining|'. - 'Data[._ -]Science|Database|Deep[._ -]Learning|DevOps|Electronics|Engineer(ing|s)|'. - 'Hacking|Information[._ -]Security|Linear[._ -]Algebra|Machine[._ -]Learning|Mathematics|'. - 'Network(ing|s)|Physics|Programming|Quantum|Robotics|Security|Statistics|'. - 'System[._ -]Administration|Web[._ -]Development'; - - // Programming languages and frameworks - $programming = 'Ajax|Angular(JS)?|Assembly|AWS|Azure|Bash|C(\+\+|#)?|CSS|Django|Docker|Express|'. - 'Flutter|GCP|Git|Go(lang)?|GraphQL|HTML|Java(Script)?|jQuery|JSON|Kotlin|Kubernetes|'. - 'Laravel|Linux|MATLAB|Node(js)?|Objective[._ -]C|Perl|PHP|PowerShell|Python|React(JS)?|'. - 'Ruby(\s+on\s+Rails)?|Rust|Scala|Shell|Spring|SQL|Swift|Terraform|TypeScript|Vue(js)?|XML|YAML'; - - // Software and platforms - $software = 'Adobe|Android|AutoCAD|Blender|Dreamweaver|Excel|Firebase|GIMP|GitHub|Google[._ -]Cloud|'. - 'Hadoop|Illustrator|InDesign|iOS|Kubernetes|LibreOffice|MATLAB|Microsoft[._ -](Office|Azure|SQL|Teams)|'. - 'MongoDB|MySQL|Nginx|Office|Photoshop|PostgreSQL|PowerBI|PowerPoint|Redis|Salesforce|'. - 'Tableau|Ubuntu|Unity|Unix|VMWare|VS[._ -]Code|Windows|WordPress'; - - // Book types and formats - $bookTypes = 'Beginner\'?s[._ -]Guide|Bible|Cookbook|Complete[._ -]Guide|Crash[._ -]Course|'. - 'Definitive[._ -]Guide|Encyclopedia|Essential[._ -]Guide|Field[._ -]Guide|Guide[._ -](For|To)|'. - 'Handbook|How[._ -]To|Introduction[._ -]To|Learn|Mastering|Practical|Professional|'. - 'Quick[._ -]Start|Reference|Solutions|Textbook|Training|Tutorial|Understanding'; - - // Combined pattern with word boundaries - $technicalPattern = '/(?:^|[^a-zA-Z0-9])(?:'. - $publishers.'|'. - $subjects.'|'. - $programming.'|'. - $software.'|'. - $bookTypes. - ')(?:$|[^a-zA-Z0-9])/i'; - - // Check if there's no match for technical book patterns - if (! preg_match($technicalPattern, $this->releaseName)) { - // Additional check for specific technical indicators - if (! preg_match('/\b(?:Course|Certification|Exam|Tutorial|Workshop|Learning|Mastering|Programming)\b.*\b(?:Videos?|Tutorials?|Lectures?|Series|Courses?)\b/i', $this->releaseName)) { - return false; - } - } - - // Check if it should be categorized as foreign instead - if ($this->isBookForeign()) { - return true; - } - - // If we've reached here, categorize as technical book - $this->tmpCat = Category::BOOKS_TECHNICAL; - - return true; - } - - public function isMagazine(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Key magazine title groups used in pattern checks. - $majorTitles = 'Bloomberg|Cosmopolitan|Economist|Elle|Esquire|FHM|Forbes|Fortune|GQ|Hustler|Life|Maxim|Mens[._ -](Health|Fitness)|National[._ -]Geographic|Newsweek|New[._ -]Yorker|Penthouse|People|Playboy|Rolling[._ -]Stone|Time|Vanity[._ -]Fair|Vogue|Wired'; - $techGaming = 'Android[._ -](Magazine|World)|Computer(world|active|bild)|Digital[._ -](Camera|Photography)|GameInformer|Game[._ -]?(Master|Markt|star|TM)|Maximum[._ -]PC|MacLife|MacWorld|PC[._ -](Format|Gamer|Magazine|World|Welt)|PCGames|Popular[._ -](Mechanics|Science)|T3|TechRadar|Web[._ -]Designer'; - $lifestyle = 'Architectural[._ -]Digest|Better[._ -]Homes[._ -]Gardens|Bon[._ -]Appetit|Brides|Car[._ -]and[._ -]Driver|Conde[._ -]Nast|Cook\'?s[._ -]Illustrated|Gardening|Golf[._ -]Digest|Good[._ -]Housekeeping|GuitarPlayer|Martha[._ -]Stewart|Motor[._ -]Trend|Mountain[._ -]Bike|Outdoor[._ -]Life|Photography|Readers[._ -]Digest|Road[._ -]and[._ -]Track|Runner\'?s[._ -]World|Top[._ -]Gear'; - - // Quick special publication patterns (frequency / issue markers or major/lifestyle titles preceded by date) - if (preg_match('/[._ -](Annual|Bimonthly|Monthly|Quarterly|Special[._ -]Issue)[._ -]/i', $this->releaseName) || - preg_match('/\d{1,2}[._ -]?\d{4}[._ -](?:'.$majorTitles.'|'.$techGaming.'|'.$lifestyle.')/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_MAGAZINES; - } - - return true; - } - - // Large legacy / catch‑all pattern preserved from earlier implementation for backward compatibility. - $legacyPattern = '/[a-z\-\._ ][._ -](January|February|March|April|May|June|July|August|September|October|November|December)[._ -](\d{1,2},)?20\d\d[._ -]|^\(.+[ .]\d{1,2}[ .]20\d\d[ .].+\.scr|[._ -](Catalogue|FHM|NUTS|Pictorial|Tatler|XXX)[._ -]|^\(?(Allehanda|Club|Computer([a-z0-9]+)?|Connect \d+|Corriere|ct|Diario|Digit(al)?|Esquire|FHM|Gadgets|Galileo|Glam|GQ|Infosat|Inked|Instyle|io|Kicker|Liberation|New Scientist|NGV|Nuts|Popular|Professional|Reise|Sette(tv)?|Springer|Stuff|Studentlitteratur|Vegetarian|Vegetable|Videomarkt|Wired)[._ -]|Brady(.+)?Games|Catalog|Columbus.+Dispatch|Correspondenten|Corriere[._ -]Della[._ -]Sera|Cosmopolitan|Dagbladet|Digital[._ -]Guide|Economist|Eload ?24|ExtraTime|Fatto[._ -]Quotidiano|Flight[._ -](International|Journal)|Finanzwoche|France.+Football|Foto.+Video|Games?(Master|Markt|tar|TM)|Gardening|Gazzetta|Globe[._ -]And[._ -]Mail|Guitar|Heimkino|Hustler|La.+(Lettura|Rblica|Stampa)|Le[._ -](Monde|Temps)|Les[._ -]Echos|e?Magazin(es?)?|Mac(life|welt)|Marie.+Claire|Maxim|Men.+(Health|Fitness)|Motocross|Motorcycle|Mountain[._ -]Bike|MusikWoche|National[._ -]Geographic|New[._ -]Yorker|PC([._ -](Gamer|Welt|World)|Games|Go|Tip)|Penthouse|Photograph(er|ic)|Playboy|Posten|Quotidiano|(Golf|Readers?).+Digest|SFX[._ -]UK|Recipe(.+Guide|s)|SkyNews|Sport[._ -]?Week|Strategy.+Guide|TabletPC|Tattoo[._ -]Life|The[._ -]Guardian|Tageszeitung|Tid(bits|ning)|Top[._ -]Gear[._ -]|Total[._ -]Guitar|Travel[._ -]Guides?|Tribune[._ -]De[._ -]|US[._ -]Weekly|USA[._ -]Today|TruePDF|Vogue|Verlag|Warcraft|Web.+Designer|What[._ -]Car|Zeitung/i'; - if (preg_match($legacyPattern, $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_MAGAZINES; - } - - return true; - } - - return false; - } - - public function isBookOther(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // First check for specific PS4 format patterns that should never be books - if (preg_match('/\.PS4-[A-Z0-9]+$/i', $this->releaseName)) { - return false; - } - - // Check for period-separated titles ending with PS4 marker - if (preg_match('/\.[tT]he\.[a-zA-Z]+\.[eE]dition\.PS4/i', $this->releaseName) || - preg_match('/\.(Gold|Deluxe|Complete|Definitive|GOTY|Digital|Standard|Ultimate|Special|Premium|Legacy)\.Edition\.PS4/i', $this->releaseName)) { - return false; - } - - // More comprehensive check for gaming platforms - if (preg_match('/\b(?:PS[1-5]|PlayStation[1-5]?|Xbox(?:360|One|Series[SX])?|Switch|Nintendo|Wii[U]?|3DS|GameCube)\b|[\._-](?:PS[1-5]|XONE|NSW|WiiU)[\._-]|\.(PS[1-5]|XONE|NSW|WiiU)-/i', $this->releaseName)) { - return false; - } - - // Exclude common game release groups and patterns - if (preg_match('/[\._-](?:DUPLEX|CODEX|RELOADED|SKIDROW|PLAZA|HOODLUM|ALI213|DODI|FitGirl)$|[.\-_](Game|Games)[.\-_]/i', $this->releaseName)) { - return false; - } - - // Exclude Grand Theft Auto and other major game series - if (preg_match('/Grand[\._-]Theft[\._-]Auto|GTA|Call[\._-]of[\._-]Duty|Assassins[\._-]Creed|Final[\._-]Fantasy/i', $this->releaseName)) { - return false; - } - - // Exclude games with "Edition" in the name - if (preg_match('/\.(Definitive|Complete|Special|Deluxe|Collectors|Ultimate|Enhanced|Remastered)\.Edition/i', $this->releaseName)) { - return false; - } - - // The rest of the original method follows... - $formats = 'PDF|EPUB|MOBI|AZW\d?|FB2|LIT|LRF|RTF|ODF|DJVU|IBA|DOC[X]?'; - - // Fiction genres and categories - $fiction = 'Novel|Fiction|Thriller|Mystery|Fantasy|SciFi|Romance|Horror|Western|'. - 'Literature|Classics|Contemporary|Historical|Adventure|Detective|Drama'; - - // Non-fiction categories (excluding technical) - $nonfiction = 'Biography|Memoir|Autobiography|History|Philosophy|Psychology|Self[._ -]Help|'. - 'Business|Cooking|Gardening|Health|Religion|Travel|Art|Music|Spirituality|Politics'; - - // Book publishing indicators - $publishing = 'ISBN|Retail|Publisher|Imprint|Edition|Chapter|Prologue|Epilogue|Foreword|'. - 'Paperback|Hardcover|Hardback|Softcover|Softback|Print[._ -]On[._ -]Demand'; - - // Book collection patterns - $collections = 'Anthology|Collection|Complete[._ -]Works|Series|Box[._ -]Set|Library|'. - 'Set[._ -]of|Volume[s]?|Compendium|Omnibus|Trilogy|Saga'; - - // Check date patterns more comprehensively - if (preg_match('/"\d\d-\d\d-20\d\d\.|[\._-]\d{2}[\._-]\d{2}[\._-]20\d{2}[\._-]|[\(\[]\d{2}[\._-]\d{2}[\._-]20\d{2}[\)\]]/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_UNKNOWN; - - return true; - } - - return true; - } - - // Check for e-book formats that aren't caught by specific book types - if (preg_match('/\.('.$formats.')$|\b('.$formats.')\b|[\._\-]('.$formats.')[\._\-]/i', $this->releaseName) && - ! preg_match('/\b(?:Magazine|Comic|Technical)\b/i', $this->releaseName)) { - - // Additional validation to exclude other book types - if (! $this->isComic() && ! $this->isTechnicalBook() && ! $this->isMagazine() && ! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_UNKNOWN; - - return true; - } - } - - // Check for fiction indicators - if (preg_match('/\b('.$fiction.')\b/i', $this->releaseName) && - ! preg_match('/\b(?:Magazine|Comic|Technical)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_UNKNOWN; - - return true; - } - - return true; - } - - // Check for non-fiction indicators - if (preg_match('/\b('.$nonfiction.')\b/i', $this->releaseName) && - ! preg_match('/\b(?:Magazine|Comic|Technical)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_UNKNOWN; - - return true; - } - - return true; - } - - // Check for book publishing or collections - if ((preg_match('/\b('.$publishing.')\b/i', $this->releaseName) || - preg_match('/\b('.$collections.')\b/i', $this->releaseName)) && - ! preg_match('/\b(?:Magazine|Comic|Technical)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_UNKNOWN; - - return true; - } - - return true; - } - - // Check for common author-title patterns - if (preg_match('/^[A-Z][a-zA-Z\s\.\-]+\s+\-\s+[A-Z][a-zA-Z0-9\s\.\-\:]+\s+\(?(19|20)\d{2}\)?/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_UNKNOWN; - - return true; - } - - return true; - } - - return false; - } - - public function isEBook(): bool - { - if ($this->hasAdultMarkers()) { - return false; - } - - // Common e-book formats - $formats = 'EPUB|MOBI|AZW\d?|KFX|PDF|FB2|DJVU|LIT|LRF|RTF|TXT|DOC[X]?|HTM[L]?|CBZ|CBR|IBA|IBOOKS'; - - // E-book descriptors and indicators - $indicators = 'E-?book|E-?pub|E-?edition|E-?text|Electronic[._ -]Book|Digital[._ -]Book|Digital[._ -]Edition'; - - // E-book platforms and stores - $platforms = 'Kindle|Kobo|Nook|Google[._ -]Play[._ -]Books|iBooks|Smashwords|Gutenberg|Scribd|eReader'; - - // E-book publishers and sources - $publishers = 'O\'?Reilly|Packt|Apress|Manning|Wiley|Pearson|Addison[._ -]Wesley|No[._ -]Starch|Pragmatic'; - - // Check for explicit e-book format extensions - if (preg_match('/\.('.$formats.')$/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_EBOOK; - } - - return true; - } - - // Check for e-book formats with word boundaries or common delimiters - if (preg_match('/\b('.$formats.')\b|[._ -]('.$formats.')[._ -]/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_EBOOK; - } - - return true; - } - - // Check for e-book indicators - if (preg_match('/\b('.$indicators.')\b|[._ -]('.$indicators.')[._ -]/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_EBOOK; - } - - return true; - } - - // Check for e-book platforms when associated with book content - if (preg_match('/\b('.$platforms.')\b/i', $this->releaseName) && - preg_match('/\b(Book|Novel|Edition|Title|Author|Chapter)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_EBOOK; - } - - return true; - } - - // Check for common e-book publishers in digital format - if (preg_match('/\b('.$publishers.')\b.*?\b(Digital|Electronic|E-?book)\b/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_EBOOK; - } - - return true; - } - - // Legacy pattern matching for backward compatibility - if (preg_match('/^ePub|[._ -](Ebook|E?\-book|\) WW|Publishing)|[\.\-_\(\[ ](azw|epub|html|mobi|pdf|rtf|tif|txt)[\.\-_\)\] ]|[\. ](azw|doc|epub|mobi|pdf)(?![\w .])|\.ebook-\w$/i', $this->releaseName)) { - if (! $this->isBookForeign()) { - $this->tmpCat = Category::BOOKS_EBOOK; - } - - return true; - } - - return false; - } - - public function isMisc(): bool - { - // Hash patterns - detect common hash formats - $hashPatterns = [ - // MD5 hash (32 hex characters) - '/\b[a-f0-9]{32}\b/i', - // SHA-1 hash (40 hex characters) - '/\b[a-f0-9]{40}\b/i', - // SHA-256 hash (64 hex characters) - '/\b[a-f0-9]{64}\b/i', - // Generic hex hash pattern (32-128 chars) - '/\b[a-f0-9]{32,128}\b/i', - ]; - - // Archive and compression formats - $archiveFormats = '/\.(zip|rar|7z|tar|gz|bz2|xz|tgz|tbz2|cab|iso|img|dmg|pkg|archive)$/i'; - - // Dataset and dump file patterns - $datasetPatterns = [ - // Database dumps - '/\b(sql|csv|dump|backup|dataset|collection)\b/i', - // Data leaks and dumps - '/\b(leak|breach|data|dump|database)\b/i', - ]; - - // Generic misc patterns for unidentifiable content - $miscPatterns = [ - // Long alphanumeric strings (likely encoded/obfuscated) - '/[a-z0-9]{20,}/i', - // Release names consisting only of uppercase letters and numbers - '/^[A-Z0-9]{1,}$/i', - // Unusual punctuation patterns - '/^[^a-zA-Z]*[A-Z0-9\._\-]{5,}[^a-zA-Z]*$/i', - ]; - - // Check for hash patterns first (highest priority) - foreach ($hashPatterns as $pattern) { - if (preg_match($pattern, $this->releaseName)) { - $this->tmpCat = Category::OTHER_HASHED; - - return true; - } - } - - // Check for archive formats - if (preg_match($archiveFormats, $this->releaseName)) { - $this->tmpCat = Category::OTHER_MISC; - - return true; - } - - // Check for dataset/dump patterns - foreach ($datasetPatterns as $pattern) { - if (preg_match($pattern, $this->releaseName) && - ! preg_match('/\b(movie|tv|show|audio|video|book|game)\b/i', $this->releaseName)) { - $this->tmpCat = Category::OTHER_MISC; - - return true; - } - } - - // Check for generic misc patterns - foreach ($miscPatterns as $pattern) { - if (preg_match($pattern, $this->releaseName)) { - $this->tmpCat = Category::OTHER_MISC; - - return true; - } - } - - // Legacy pattern checks for backward compatibility - if (preg_match('/[a-f0-9]{32,64}/i', $this->releaseName)) { - $this->tmpCat = Category::OTHER_HASHED; - - return true; - } - - return false; - } - - /** - * @param string $regex Regex to use for match - * @param string $fromName Poster that needs to be matched by regex - * @param string $category Category to set if there is a match - */ - public function checkPoster(string $regex, string $fromName, string $category): bool - { - if (preg_match($regex, $fromName)) { - $this->tmpCat = $category; - - return true; - } - - return false; - } -} diff --git a/Blacklight/NZBImport.php b/Blacklight/NZBImport.php index c5f5dd2d9..d2303ca0f 100755 --- a/Blacklight/NZBImport.php +++ b/Blacklight/NZBImport.php @@ -6,6 +6,7 @@ use App\Models\Release; use App\Models\Settings; use App\Models\UsenetGroup; use App\Services\BlacklistService; +use App\Services\Categorization\CategorizationService; use Blacklight\utility\Utility; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\File; @@ -27,7 +28,7 @@ class NZBImport */ protected mixed $crossPostt; - protected Categorize $category; + protected CategorizationService $category; /** * List of all the group names/ids in the DB. @@ -67,7 +68,7 @@ class NZBImport { $this->echoCLI = config('nntmux.echocli'); $this->blacklistService = new BlacklistService; - $this->category = new Categorize; + $this->category = new CategorizationService(); $this->nzb = new NZB; $this->releaseCleaner = new ReleaseCleaning; $this->colorCli = new ColorCLI; diff --git a/Blacklight/NameFixer.php b/Blacklight/NameFixer.php index 0fb356517..b2bc7623e 100755 --- a/Blacklight/NameFixer.php +++ b/Blacklight/NameFixer.php @@ -6,6 +6,7 @@ use App\Models\Category; use App\Models\Predb; use App\Models\Release; use App\Models\UsenetGroup; +use App\Services\Categorization\CategorizationService; use Blacklight\utility\Utility; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -104,7 +105,7 @@ class NameFixer public ColorCLI $colorCLI; /** - * @var Categorize + * @var CategorizationService */ public mixed $category; @@ -131,7 +132,7 @@ class NameFixer $this->_fileName = ''; $this->done = $this->matched = false; $this->colorCLI = new ColorCLI; - $this->category = new Categorize; + $this->category = new CategorizationService(); $this->manticore = new ManticoreSearch; $this->elasticsearch = new ElasticSearchSiteSearch; } diff --git a/Blacklight/processing/ProcessReleases.php b/Blacklight/processing/ProcessReleases.php index 09421d06a..6f99e8d6d 100755 --- a/Blacklight/processing/ProcessReleases.php +++ b/Blacklight/processing/ProcessReleases.php @@ -8,9 +8,9 @@ use App\Models\MusicInfo; use App\Models\Release; use App\Models\Settings; use App\Models\UsenetGroup; +use App\Services\Categorization\CategorizationService; use App\Services\CollectionCleanupService; use App\Services\ReleaseCreationService; -use Blacklight\Categorize; use Blacklight\ColorCLI; use Blacklight\Genres; use Blacklight\NNTP; @@ -190,7 +190,7 @@ class ProcessReleases */ public function categorizeRelease(string $type, $groupId): int { - $cat = new Categorize; + $cat = new CategorizationService(); $categorized = $total = 0; $releasesQuery = Release::query()->where(['categories_id' => Category::OTHER_MISC, 'iscategorized' => 0]); if (! empty($groupId)) { diff --git a/Blacklight/processing/post/ProcessAdditional.php b/Blacklight/processing/post/ProcessAdditional.php index 1b26b5bbf..cf9de53e2 100755 --- a/Blacklight/processing/post/ProcessAdditional.php +++ b/Blacklight/processing/post/ProcessAdditional.php @@ -9,7 +9,7 @@ use App\Models\Release; use App\Models\ReleaseFile; use App\Models\Settings; use App\Models\UsenetGroup; -use Blacklight\Categorize; +use App\Services\Categorization\CategorizationService; use Blacklight\ColorCLI; use Blacklight\ElasticSearchSiteSearch; use Blacklight\ManticoreSearch; @@ -111,7 +111,7 @@ class ProcessAdditional protected NNTP $_nntp; - protected Categorize $_categorize; + protected CategorizationService $_categorize; protected NameFixer $_nameFixer; @@ -276,7 +276,7 @@ class ProcessAdditional $this->_nzb = new NZB; $this->_archiveInfo = new ArchiveInfo; - $this->_categorize = new Categorize; + $this->_categorize = new CategorizationService(); $this->_nameFixer = new NameFixer; $this->_releaseExtra = new ReleaseExtra; $this->_releaseImage = new ReleaseImage; diff --git a/app/Console/Commands/RecategorizeReleases.php b/app/Console/Commands/RecategorizeReleases.php index 4abc4bb3c..dea243243 100644 --- a/app/Console/Commands/RecategorizeReleases.php +++ b/app/Console/Commands/RecategorizeReleases.php @@ -4,7 +4,7 @@ namespace App\Console\Commands; use App\Models\Category; use App\Models\Release; -use Blacklight\Categorize; +use App\Services\Categorization\CategorizationService; use Illuminate\Console\Command; class RecategorizeReleases extends Command @@ -66,7 +66,7 @@ class RecategorizeReleases extends Command $count = $countQuery->count(); - $cat = new Categorize; + $cat = new CategorizationService(); $results = $countQuery->select(['id', 'searchname', 'fromname', 'groups_id', 'categories_id'])->get(); $bar = $this->output->createProgressBar($count); $bar->start(); diff --git a/app/Console/Commands/TestCategorization.php b/app/Console/Commands/TestCategorization.php new file mode 100644 index 000000000..5e830e9c1 --- /dev/null +++ b/app/Console/Commands/TestCategorization.php @@ -0,0 +1,137 @@ +<?php + +namespace App\Console\Commands; + +use App\Services\Categorization\CategorizationService; +use Illuminate\Console\Command; + +class TestCategorization extends Command +{ + protected $signature = 'nntmux:test-categorization + {--release= : Release name to test} + {--compare : Compare pipeline vs legacy categorizer} + {--list-categorizers : List all registered categorizers}'; + + protected $description = 'Test the new pipeline-based categorization service'; + + public function handle(): int + { + $service = new CategorizationService(); + + if ($this->option('list-categorizers')) { + $this->listCategorizers($service); + return 0; + } + + $releaseName = $this->option('release'); + + if (!$releaseName) { + // Test with sample releases + $this->testSampleReleases($service); + return 0; + } + + if ($this->option('compare')) { + $this->compareResult($service, $releaseName); + } else { + $this->categorizeRelease($service, $releaseName); + } + + return 0; + } + + protected function listCategorizers(CategorizationService $service): void + { + $stats = $service->getCategorizerStats(); + + $this->info('Registered Categorizers:'); + $this->table( + ['Name', 'Priority', 'Class'], + collect($stats)->map(fn ($s) => [$s['name'], $s['priority'], $s['class']])->toArray() + ); + } + + protected function categorizeRelease(CategorizationService $service, string $releaseName): void + { + $result = $service->determineCategory(0, $releaseName, '', true); + + $this->info("Release: {$releaseName}"); + $this->info("Category ID: {$result['categories_id']}"); + + if (isset($result['debug'])) { + $this->info("Matched By: {$result['debug']['matched_by']}"); + $this->info("Confidence: " . ($result['debug']['final_confidence'] ?? 'N/A')); + } + } + + protected function compareResult(CategorizationService $service, string $releaseName): void + { + $comparison = $service->compare(0, $releaseName); + + $this->info("Release: {$releaseName}"); + $this->newLine(); + + $matchStatus = $comparison['match'] ? '<fg=green>MATCH</>' : '<fg=red>MISMATCH</>'; + $this->line("Result: {$matchStatus}"); + $this->newLine(); + + $this->table( + ['', 'Pipeline', 'Legacy'], + [ + ['Category ID', $comparison['pipeline']['category_id'], $comparison['legacy']['category_id']], + ['Category Name', $comparison['pipeline']['category_name'], $comparison['legacy']['category_name']], + ] + ); + } + + protected function testSampleReleases(CategorizationService $service): void + { + $samples = [ + // TV + 'Game.of.Thrones.S08E06.720p.BluRay.x264-DEMAND', + 'The.Mandalorian.S03E08.2160p.WEB-DL.DDP5.1.Atmos.H.265-FLUX', + '[SubsPlease] Jujutsu Kaisen - 47 (1080p) [ABC12345].mkv', + + // Movies + 'The.Matrix.Resurrections.2021.2160p.UHD.BluRay.x265-SURCODE', + 'Inception.2010.1080p.BluRay.x264-SPARKS', + 'Oppenheimer.2023.WEB-DL.1080p.H.264.AAC-LOL', + + // XXX + 'Brazzers.23.11.15.Model.Name.XXX.1080p.MP4-KTR', + 'SexBabesVR.23.10.20.Virtual.Reality.VR180.3D.SBS.2160p-VRSins', + + // Games + 'Cyberpunk.2077.Ultimate.Edition.v2.12.1-RUNE', + 'Elden.Ring.Shadow.of.the.Erdtree.PS5-DUPLEX', + + // Music + 'Taylor.Swift.Midnights.2022.FLAC-dL', + 'Various.Artists.Now.Thats.What.I.Call.Music.100.2018.MP3.320kbps', + + // Books + 'Brandon.Sanderson.Mistborn.Trilogy.EPUB', + 'OReilly.Learning.Python.6th.Edition.PDF', + ]; + + $this->info('Testing sample releases with both pipeline and legacy categorizers:'); + $this->newLine(); + + $results = []; + foreach ($samples as $sample) { + $comparison = $service->compare(0, $sample); + $results[] = [ + substr($sample, 0, 50) . (strlen($sample) > 50 ? '...' : ''), + $comparison['pipeline']['category_name'], + $comparison['legacy']['category_name'], + $comparison['match'] ? '✓' : '✗', + ]; + } + + $this->table( + ['Release', 'Pipeline', 'Legacy', 'Match'], + $results + ); + } +} + diff --git a/app/Facades/Categorization.php b/app/Facades/Categorization.php new file mode 100644 index 000000000..cb1fa1ad6 --- /dev/null +++ b/app/Facades/Categorization.php @@ -0,0 +1,22 @@ +<?php + +namespace App\Facades; + +use App\Services\Categorization\CategorizationPipeline; +use Illuminate\Support\Facades\Facade; + +/** + * @method static array categorize(int|string $groupId, string $releaseName, ?string $poster = '', bool $debug = false) + * @method static \Illuminate\Support\Collection getCategorizers() + * @method static CategorizationPipeline addCategorizer(\App\Services\Categorization\Contracts\CategorizerInterface $categorizer) + * + * @see \App\Services\Categorization\CategorizationPipeline + */ +class Categorization extends Facade +{ + protected static function getFacadeAccessor(): string + { + return CategorizationPipeline::class; + } +} + diff --git a/app/Providers/CategorizationServiceProvider.php b/app/Providers/CategorizationServiceProvider.php new file mode 100644 index 000000000..3dfaf0ba8 --- /dev/null +++ b/app/Providers/CategorizationServiceProvider.php @@ -0,0 +1,34 @@ +<?php + +namespace App\Providers; + +use App\Services\Categorization\CategorizationPipeline; +use App\Services\Categorization\Categorizers; +use App\Services\Categorization\Contracts\CategorizerInterface; +use Illuminate\Support\ServiceProvider; + +class CategorizationServiceProvider extends ServiceProvider +{ + /** + * Register services. + */ + public function register(): void + { + // Register the pipeline as a singleton + $this->app->singleton(CategorizationPipeline::class, function ($app) { + return CategorizationPipeline::createDefault(); + }); + + // Alias for easier access + $this->app->alias(CategorizationPipeline::class, 'categorization'); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} + diff --git a/app/Services/Categorization/CategorizationPipeline.php b/app/Services/Categorization/CategorizationPipeline.php new file mode 100644 index 000000000..18329d66a --- /dev/null +++ b/app/Services/Categorization/CategorizationPipeline.php @@ -0,0 +1,153 @@ +<?php + +namespace App\Services\Categorization; + +use App\Models\Category; +use App\Models\Settings; +use App\Models\UsenetGroup; +use App\Services\Categorization\Contracts\CategorizerInterface; +use Illuminate\Support\Collection; + +/** + * Pipeline-based categorization service. + * + * This service orchestrates multiple categorizers to determine the best + * category for a release. Each categorizer is responsible for a specific + * category domain and returns a result with a confidence score. + */ +class CategorizationPipeline +{ + /** + * @var Collection<CategorizerInterface> + */ + protected Collection $categorizers; + + protected bool $categorizeForeign; + protected bool $catWebDL; + + /** + * @param iterable<CategorizerInterface> $categorizers + */ + public function __construct(iterable $categorizers = []) + { + $this->categorizers = collect($categorizers) + ->sortBy(fn (CategorizerInterface $c) => $c->getPriority()); + + $this->categorizeForeign = (bool) Settings::settingValue('categorizeforeign'); + $this->catWebDL = (bool) Settings::settingValue('catwebdl'); + } + + /** + * Register a categorizer in the pipeline. + */ + public function addCategorizer(CategorizerInterface $categorizer): self + { + $this->categorizers->push($categorizer); + $this->categorizers = $this->categorizers->sortBy(fn (CategorizerInterface $c) => $c->getPriority()); + + return $this; + } + + /** + * Determine the category for a release. + * + * @param int|string $groupId The usenet group ID + * @param string $releaseName The name of the release + * @param string|null $poster The poster name + * @param bool $debug Whether to include debug information + * @return array The categorization result + */ + public function categorize( + int|string $groupId, + string $releaseName, + ?string $poster = '', + bool $debug = false + ): array { + $groupName = UsenetGroup::whereId($groupId)->value('name') ?? ''; + + $context = new ReleaseContext( + releaseName: $releaseName, + groupId: $groupId, + groupName: $groupName, + poster: $poster ?? '', + categorizeForeign: $this->categorizeForeign, + catWebDL: $this->catWebDL, + ); + + $bestResult = CategorizationResult::noMatch(); + $allResults = []; + + foreach ($this->categorizers as $categorizer) { + // Skip if categorizer determines it shouldn't process this release + if ($categorizer->shouldSkip($context)) { + continue; + } + + $result = $categorizer->categorize($context); + + if ($debug) { + $allResults[$categorizer->getName()] = [ + 'category_id' => $result->categoryId, + 'confidence' => $result->confidence, + 'matched_by' => $result->matchedBy, + ]; + } + + // If this result is better than our current best, use it + if ($result->isSuccessful() && $result->shouldOverride($bestResult)) { + $bestResult = $result; + + // If we have a very high confidence match, we can stop early + if ($result->confidence >= 0.95) { + break; + } + } + } + + // Build the return array + $returnValue = ['categories_id' => $bestResult->categoryId]; + + if ($debug) { + $returnValue['debug'] = [ + 'final_category' => $bestResult->categoryId, + 'final_confidence' => $bestResult->confidence, + 'matched_by' => $bestResult->matchedBy, + 'release_name' => $releaseName, + 'group_name' => $groupName, + 'all_results' => $allResults, + 'categorizer_details' => $bestResult->debug, + ]; + } + + return $returnValue; + } + + /** + * Get all registered categorizers. + * + * @return Collection<CategorizerInterface> + */ + public function getCategorizers(): Collection + { + return $this->categorizers; + } + + /** + * Create a default pipeline with all standard categorizers. + */ + public static function createDefault(): self + { + return new self([ + new Categorizers\GroupNameCategorizer(), + new Categorizers\XxxCategorizer(), + new Categorizers\TvCategorizer(), + new Categorizers\MovieCategorizer(), + new Categorizers\BookCategorizer(), + new Categorizers\MusicCategorizer(), + new Categorizers\PcCategorizer(), + new Categorizers\ConsoleCategorizer(), + new Categorizers\MiscCategorizer(), + ]); + } +} + diff --git a/app/Services/Categorization/CategorizationResult.php b/app/Services/Categorization/CategorizationResult.php new file mode 100644 index 000000000..146fb3ec4 --- /dev/null +++ b/app/Services/Categorization/CategorizationResult.php @@ -0,0 +1,72 @@ +<?php + +namespace App\Services\Categorization; + +use App\Models\Category; + +/** + * Value object representing the result of a categorization attempt. + */ +class CategorizationResult +{ + /** + * @param int $categoryId The determined category ID + * @param float $confidence Confidence level (0.0 to 1.0) + * @param string $matchedBy Description of what matched + * @param array $debug Additional debug information + */ + public function __construct( + public readonly int $categoryId = Category::OTHER_MISC, + public readonly float $confidence = 0.0, + public readonly string $matchedBy = 'none', + public readonly array $debug = [] + ) {} + + /** + * Check if this result represents a successful categorization. + */ + public function isSuccessful(): bool + { + return $this->categoryId !== Category::OTHER_MISC && $this->confidence > 0; + } + + /** + * Check if this result should take precedence over another. + */ + public function shouldOverride(CategorizationResult $other): bool + { + // Higher confidence always wins + if ($this->confidence > $other->confidence) { + return true; + } + + // If same confidence, prefer non-misc categories + if ($this->confidence === $other->confidence) { + return $this->categoryId !== Category::OTHER_MISC && $other->categoryId === Category::OTHER_MISC; + } + + return false; + } + + /** + * Create a failed/empty result. + */ + public static function noMatch(): self + { + return new self(Category::OTHER_MISC, 0.0, 'no_match'); + } + + /** + * Create a result with debug info merged. + */ + public function withDebug(array $additionalDebug): self + { + return new self( + $this->categoryId, + $this->confidence, + $this->matchedBy, + array_merge($this->debug, $additionalDebug) + ); + } +} + diff --git a/app/Services/Categorization/CategorizationService.php b/app/Services/Categorization/CategorizationService.php new file mode 100644 index 000000000..5a7e0b806 --- /dev/null +++ b/app/Services/Categorization/CategorizationService.php @@ -0,0 +1,97 @@ +<?php + +namespace App\Services\Categorization; + +use App\Models\Category; + +/** + * Categorization service using the new pipeline-based system. + * + * This class is a drop-in replacement for the legacy Blacklight\Categorize + * with additional features like confidence scoring and debug information. + */ +class CategorizationService +{ + protected CategorizationPipeline $pipeline; + + public function __construct(?CategorizationPipeline $pipeline = null) + { + $this->pipeline = $pipeline ?? CategorizationPipeline::createDefault(); + } + + /** + * Determine category for a release. + * + * @param int|string $groupId The usenet group ID + * @param string $releaseName The name of the release + * @param string|null $poster The poster name + * @param bool $debug Whether to include debug information + * @return array The categorization result with category ID and optional debug info + */ + public function determineCategory( + int|string $groupId, + string $releaseName = '', + ?string $poster = '', + bool $debug = false + ): array { + return $this->pipeline->categorize($groupId, $releaseName, $poster, $debug); + } + + /** + * Batch categorize multiple releases. + * + * @param array $releases Array of ['group_id' => x, 'name' => y, 'poster' => z] + * @return array Array of categorization results + */ + public function batchCategorize(array $releases): array + { + $results = []; + + foreach ($releases as $release) { + $groupId = $release['group_id'] ?? $release['groupId'] ?? 0; + $name = $release['name'] ?? $release['releaseName'] ?? ''; + $poster = $release['poster'] ?? ''; + + $results[] = [ + 'release' => $release, + 'result' => $this->determineCategory($groupId, $name, $poster), + ]; + } + + return $results; + } + + /** + * Get the underlying pipeline. + */ + public function getPipeline(): CategorizationPipeline + { + return $this->pipeline; + } + + /** + * Add a custom categorizer to the pipeline. + */ + public function addCategorizer(Contracts\CategorizerInterface $categorizer): self + { + $this->pipeline->addCategorizer($categorizer); + return $this; + } + + /** + * Get statistics about categorizer usage. + */ + public function getCategorizerStats(): array + { + $categorizers = $this->pipeline->getCategorizers(); + + return $categorizers->map(function ($categorizer) { + return [ + 'name' => $categorizer->getName(), + 'priority' => $categorizer->getPriority(), + 'class' => get_class($categorizer), + ]; + })->toArray(); + } +} + diff --git a/app/Services/Categorization/Categorizers/AbstractCategorizer.php b/app/Services/Categorization/Categorizers/AbstractCategorizer.php new file mode 100644 index 000000000..a62e745ee --- /dev/null +++ b/app/Services/Categorization/Categorizers/AbstractCategorizer.php @@ -0,0 +1,16 @@ +<?php +namespace App\Services\Categorization\Categorizers; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\Contracts\CategorizerInterface; +use App\Services\Categorization\ReleaseContext; +abstract class AbstractCategorizer implements CategorizerInterface +{ + protected int $priority = 50; + public function getPriority(): int { return $this->priority; } + public function shouldSkip(ReleaseContext $context): bool { return false; } + protected function matched(int $categoryId, float $confidence, string $matchedBy, array $debug = []): CategorizationResult + { + return new CategorizationResult($categoryId, $confidence, $matchedBy, $debug); + } + protected function noMatch(): CategorizationResult { return CategorizationResult::noMatch(); } +} diff --git a/app/Services/Categorization/Categorizers/BookCategorizer.php b/app/Services/Categorization/Categorizers/BookCategorizer.php new file mode 100644 index 000000000..7bae184c7 --- /dev/null +++ b/app/Services/Categorization/Categorizers/BookCategorizer.php @@ -0,0 +1,63 @@ +<?php +namespace App\Services\Categorization\Categorizers; +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; +class BookCategorizer extends AbstractCategorizer +{ + protected int $priority = 45; + public function getName(): string { return 'Book'; } + public function shouldSkip(ReleaseContext $context): bool + { + if ($context->hasAdultMarkers()) return true; + if (preg_match('/\.PS4-[A-Z0-9]+$/i', $context->releaseName)) return true; + if (preg_match('/\b(?:PS[1-5]|PlayStation|Xbox|Switch|Nintendo|Wii|3DS|GameCube)\b/i', $context->releaseName)) return true; + // Skip TV shows (season patterns) + if (preg_match('/[._ -]S\d{1,3}[._ -]?(E\d|Complete|Full|1080|720|480|2160|WEB|HDTV|BluRay)/i', $context->releaseName)) return true; + // Skip movies (year + quality patterns) + if (preg_match('/\b(19|20)\d{2}\b.*\b(1080p|720p|2160p|BluRay|WEB-DL|BDRip|DVDRip)\b/i', $context->releaseName)) return true; + return false; + } + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + if ($result = $this->checkComic($name)) return $result; + if ($result = $this->checkTechnical($name)) return $result; + if ($result = $this->checkMagazine($name)) return $result; + if ($result = $this->checkEbook($name)) return $result; + return $this->noMatch(); + } + protected function checkComic(string $name): ?CategorizationResult + { + if (preg_match('/\b(?:CBR|CBZ|C2C)\b|\.(?:cbr|cbz)$/i', $name)) return $this->matched(Category::BOOKS_COMICS, 0.9, 'comic_format'); + if (preg_match('/\b(?:Marvel|DC[._ -]Comics|Image[._ -]Comics|Dark[._ -]Horse|IDW)\b/i', $name) && + preg_match('/\b(?:Comics?|Annual|Issue|Vol|TPB)\b/i', $name)) return $this->matched(Category::BOOKS_COMICS, 0.85, 'comic_publisher'); + if (preg_match('/\b(?:Manga|Manhwa|Manhua|Webtoon)\b/i', $name)) return $this->matched(Category::BOOKS_COMICS, 0.85, 'manga'); + return null; + } + protected function checkTechnical(string $name): ?CategorizationResult + { + $publishers = 'Apress|Addison[._ -]Wesley|Manning|No[._ -]Starch|OReilly|Packt|Pragmatic|Wiley|Wrox'; + if (preg_match('/\b(' . $publishers . ')\b/i', $name)) return $this->matched(Category::BOOKS_TECHNICAL, 0.9, 'technical_publisher'); + $subjects = 'Programming|Python|JavaScript|Java|Database|Linux|DevOps|Machine[._ -]Learning|Data[._ -]Science'; + if (preg_match('/\b(' . $subjects . ')\b/i', $name) && preg_match('/\b(Book|Guide|Tutorial|Learn)\b/i', $name)) { + return $this->matched(Category::BOOKS_TECHNICAL, 0.85, 'technical_subject'); + } + return null; + } + protected function checkMagazine(string $name): ?CategorizationResult + { + if (preg_match('/[._ -](Monthly|Weekly|Annual|Quarterly|Issue)[._ -]/i', $name)) return $this->matched(Category::BOOKS_MAGAZINES, 0.9, 'magazine_frequency'); + $magazines = 'Forbes|Fortune|GQ|National[._ -]Geographic|Newsweek|Time|Vogue|Wired|PC[._ -]Gamer'; + if (preg_match('/\b(' . $magazines . ')\b/i', $name)) return $this->matched(Category::BOOKS_MAGAZINES, 0.85, 'magazine_title'); + return null; + } + protected function checkEbook(string $name): ?CategorizationResult + { + $formats = 'EPUB|MOBI|AZW\d?|PDF|FB2|DJVU|LIT'; + if (preg_match('/\.(' . $formats . ')$/i', $name)) return $this->matched(Category::BOOKS_EBOOK, 0.9, 'ebook_format'); + if (preg_match('/\b(' . $formats . ')\b/i', $name)) return $this->matched(Category::BOOKS_EBOOK, 0.85, 'ebook_indicator'); + if (preg_match('/\b(E-?book|Kindle|Kobo|Nook)\b/i', $name)) return $this->matched(Category::BOOKS_EBOOK, 0.8, 'ebook_platform'); + return null; + } +} diff --git a/app/Services/Categorization/Categorizers/ConsoleCategorizer.php b/app/Services/Categorization/Categorizers/ConsoleCategorizer.php new file mode 100644 index 000000000..899d180ae --- /dev/null +++ b/app/Services/Categorization/Categorizers/ConsoleCategorizer.php @@ -0,0 +1,103 @@ +<?php +namespace App\Services\Categorization\Categorizers; +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; +class ConsoleCategorizer extends AbstractCategorizer +{ + protected int $priority = 35; + public function getName(): string { return 'Console'; } + public function shouldSkip(ReleaseContext $context): bool { + if ($context->hasAdultMarkers()) return true; + // Skip TV shows (season patterns) + if (preg_match('/[._ -]S\d{1,3}[._ -]?(E\d|Complete|Full|1080|720|480|2160|WEB|HDTV|BluRay)/i', $context->releaseName)) return true; + return false; + } + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + if ($result = $this->checkPS4($name)) return $result; + if ($result = $this->checkPS3($name)) return $result; + if ($result = $this->checkPSVita($name)) return $result; + if ($result = $this->checkPSP($name)) return $result; + if ($result = $this->checkXboxOne($name)) return $result; + if ($result = $this->checkXbox360($name)) return $result; + if ($result = $this->checkXbox($name)) return $result; + if ($result = $this->checkWiiU($name)) return $result; + if ($result = $this->checkWii($name)) return $result; + if ($result = $this->check3DS($name)) return $result; + if ($result = $this->checkNDS($name)) return $result; + if ($result = $this->checkOther($name)) return $result; + return $this->noMatch(); + } + protected function checkPS4(string $name): ?CategorizationResult + { + if (preg_match('/^PS4[_\.\-]/i', $name) || preg_match('/CUSA\d{5}/i', $name) || + preg_match('/\.PS4-DUPLEX$/i', $name) || preg_match('/\bPS4\b|PlayStation\s*4/i', $name)) { + return $this->matched(Category::GAME_PS4, 0.9, 'ps4'); + } + return null; + } + protected function checkPS3(string $name): ?CategorizationResult + { + if (preg_match('/\bPS3\b|PlayStation\s*3/i', $name)) return $this->matched(Category::GAME_PS3, 0.9, 'ps3'); + return null; + } + protected function checkPSVita(string $name): ?CategorizationResult + { + if (preg_match('/\bPS\s?Vita\b|PSV(ita)?\b/i', $name)) return $this->matched(Category::GAME_PSVITA, 0.9, 'psvita'); + return null; + } + protected function checkPSP(string $name): ?CategorizationResult + { + if (preg_match('/\bPSP\b|PlayStation\s*Portable/i', $name)) return $this->matched(Category::GAME_PSP, 0.9, 'psp'); + return null; + } + protected function checkXboxOne(string $name): ?CategorizationResult + { + if (preg_match('/\b(XboxOne|XBOX\s*One|XBONE|XB1|Xbox\s*Series[._ -]?[SX]|XSX|XSS)\b/i', $name)) { + return $this->matched(Category::GAME_XBOXONE, 0.9, 'xboxone'); + } + return null; + } + protected function checkXbox360(string $name): ?CategorizationResult + { + if (preg_match('/\b(Xbox360|XBOX360|X360)\b/i', $name)) return $this->matched(Category::GAME_XBOX360, 0.9, 'xbox360'); + return null; + } + protected function checkXbox(string $name): ?CategorizationResult + { + if (preg_match('/\bXBOX\b/i', $name) && !preg_match('/\b(XBOX\s?360|XBOX\s?ONE|Series)\b/i', $name)) { + return $this->matched(Category::GAME_XBOX, 0.85, 'xbox'); + } + return null; + } + protected function checkWiiU(string $name): ?CategorizationResult + { + if (preg_match('/\bWii\s*U\b|WiiU/i', $name)) return $this->matched(Category::GAME_WIIU, 0.9, 'wiiu'); + return null; + } + protected function checkWii(string $name): ?CategorizationResult + { + if (preg_match('/\bWii\b/i', $name) && !preg_match('/WiiU/i', $name)) return $this->matched(Category::GAME_WII, 0.85, 'wii'); + return null; + } + protected function check3DS(string $name): ?CategorizationResult + { + if (preg_match('/\b3DS\b|Nintendo\s*3DS/i', $name)) return $this->matched(Category::GAME_3DS, 0.9, '3ds'); + return null; + } + protected function checkNDS(string $name): ?CategorizationResult + { + if (preg_match('/\bNDS\b|Nintendo\s*DS/i', $name)) return $this->matched(Category::GAME_NDS, 0.9, 'nds'); + return null; + } + protected function checkOther(string $name): ?CategorizationResult + { + if (preg_match('/\b(PS[12X]|PS2|SNES|NES|SEGA|GB[AC]?|Dreamcast|Saturn|Atari|N64)\b/i', $name) && + preg_match('/\b(EUR|JP|JPN|NTSC|PAL|USA|ROM)\b/i', $name)) { + return $this->matched(Category::GAME_OTHER, 0.8, 'retro_console'); + } + return null; + } +} diff --git a/app/Services/Categorization/Categorizers/GroupNameCategorizer.php b/app/Services/Categorization/Categorizers/GroupNameCategorizer.php new file mode 100644 index 000000000..dd172db98 --- /dev/null +++ b/app/Services/Categorization/Categorizers/GroupNameCategorizer.php @@ -0,0 +1,23 @@ +<?php +namespace App\Services\Categorization\Categorizers; +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; +class GroupNameCategorizer extends AbstractCategorizer +{ + protected int $priority = 5; + public function getName(): string { return 'GroupName'; } + public function categorize(ReleaseContext $context): CategorizationResult + { + $groupName = $context->groupName; + if (empty($groupName)) return $this->noMatch(); + if (preg_match('/alt\.binaries\..*?(tv|hdtv|tvseries)/i', $groupName)) return $this->matched(Category::TV_OTHER, 0.6, 'group_tv'); + if (preg_match('/alt\.binaries\..*?(movies?|dvd|bluray|x264)/i', $groupName)) return $this->matched(Category::MOVIE_OTHER, 0.6, 'group_movie'); + if (preg_match('/alt\.binaries\..*?(erotica|pictures\.erotica|xxx)/i', $groupName)) return $this->matched(Category::XXX_OTHER, 0.7, 'group_xxx'); + if (preg_match('/alt\.binaries\..*?(sounds?|mp3|music|lossless)/i', $groupName)) return $this->matched(Category::MUSIC_OTHER, 0.6, 'group_music'); + if (preg_match('/alt\.binaries\..*?(games?|console|psx|nintendo)/i', $groupName)) return $this->matched(Category::GAME_OTHER, 0.6, 'group_game'); + if (preg_match('/alt\.binaries\..*?(warez|0day|apps?|software)/i', $groupName)) return $this->matched(Category::PC_0DAY, 0.6, 'group_pc'); + if (preg_match('/alt\.binaries\..*?(e-?book|ebook|comics?)/i', $groupName)) return $this->matched(Category::BOOKS_EBOOK, 0.6, 'group_book'); + return $this->noMatch(); + } +} diff --git a/app/Services/Categorization/Categorizers/MiscCategorizer.php b/app/Services/Categorization/Categorizers/MiscCategorizer.php new file mode 100644 index 000000000..2d30e1b66 --- /dev/null +++ b/app/Services/Categorization/Categorizers/MiscCategorizer.php @@ -0,0 +1,123 @@ +<?php + +namespace App\Services\Categorization\Categorizers; + +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; + +/** + * Categorizer for miscellaneous content and hash detection. + * This runs last as a fallback. + */ +class MiscCategorizer extends AbstractCategorizer +{ + protected int $priority = 100; // Lowest priority - run last + + public function getName(): string + { + return 'Misc'; + } + + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + + // Check for hash patterns first + if ($result = $this->checkHash($name)) { + return $result; + } + + // Check for archive formats + if ($result = $this->checkArchive($name)) { + return $result; + } + + // Check for dataset/dump patterns + if ($result = $this->checkDataset($name)) { + return $result; + } + + // Check for obfuscated/encoded patterns + if ($result = $this->checkObfuscated($name)) { + return $result; + } + + return $this->noMatch(); + } + + protected function checkHash(string $name): ?CategorizationResult + { + // MD5 hash (32 hex characters) + if (preg_match('/\b[a-f0-9]{32}\b/i', $name)) { + return $this->matched(Category::OTHER_HASHED, 0.8, 'hash_md5'); + } + + // SHA-1 hash (40 hex characters) + if (preg_match('/\b[a-f0-9]{40}\b/i', $name)) { + return $this->matched(Category::OTHER_HASHED, 0.85, 'hash_sha1'); + } + + // SHA-256 hash (64 hex characters) + if (preg_match('/\b[a-f0-9]{64}\b/i', $name)) { + return $this->matched(Category::OTHER_HASHED, 0.9, 'hash_sha256'); + } + + // Generic long hex hash + if (preg_match('/\b[a-f0-9]{32,128}\b/i', $name)) { + return $this->matched(Category::OTHER_HASHED, 0.75, 'hash_generic'); + } + + return null; + } + + protected function checkArchive(string $name): ?CategorizationResult + { + if (preg_match('/\.(zip|rar|7z|tar|gz|bz2|xz|tgz|tbz2|cab|iso|img|dmg|pkg|archive)$/i', $name)) { + return $this->matched(Category::OTHER_MISC, 0.5, 'archive'); + } + + return null; + } + + protected function checkDataset(string $name): ?CategorizationResult + { + // Dataset/dump patterns that aren't media + if (preg_match('/\b(sql|csv|dump|backup|dataset|collection)\b/i', $name) && + !preg_match('/\b(movie|tv|show|audio|video|book|game)\b/i', $name)) { + return $this->matched(Category::OTHER_MISC, 0.6, 'dataset'); + } + + // Data leaks/dumps (be careful with these) + if (preg_match('/\b(leak|breach|data|database)\b/i', $name) && + preg_match('/\b(dump|export|backup)\b/i', $name) && + !preg_match('/\b(movie|tv|show|audio|video|book|game)\b/i', $name)) { + return $this->matched(Category::OTHER_MISC, 0.6, 'data_dump'); + } + + return null; + } + + protected function checkObfuscated(string $name): ?CategorizationResult + { + // Release names consisting only of uppercase letters and numbers + if (preg_match('/^[A-Z0-9]{15,}$/', $name)) { + return $this->matched(Category::OTHER_HASHED, 0.7, 'obfuscated_uppercase'); + } + + // Long alphanumeric strings without typical release patterns + if (preg_match('/^[a-zA-Z0-9]{25,}$/', $name) && + !preg_match('/\b(19|20)\d{2}\b/', $name)) { + return $this->matched(Category::OTHER_HASHED, 0.65, 'obfuscated_long'); + } + + // Only punctuation and numbers with no clear structure + if (preg_match('/^[^a-zA-Z]*[A-Z0-9\._\-]{5,}[^a-zA-Z]*$/', $name) && + !preg_match('/\.(mkv|avi|mp4|mp3|flac|pdf|epub|exe|iso)$/i', $name)) { + return $this->matched(Category::OTHER_MISC, 0.5, 'obfuscated_pattern'); + } + + return null; + } +} + diff --git a/app/Services/Categorization/Categorizers/MovieCategorizer.php b/app/Services/Categorization/Categorizers/MovieCategorizer.php new file mode 100644 index 000000000..2347bc725 --- /dev/null +++ b/app/Services/Categorization/Categorizers/MovieCategorizer.php @@ -0,0 +1,221 @@ +<?php + +namespace App\Services\Categorization\Categorizers; + +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; + +/** + * Categorizer for Movie content including HD, SD, UHD, 3D, Blu-ray, DVD, etc. + */ +class MovieCategorizer extends AbstractCategorizer +{ + protected int $priority = 25; + + public function getName(): string + { + return 'Movie'; + } + + public function shouldSkip(ReleaseContext $context): bool + { + // Skip if this looks like adult content + if ($context->hasAdultMarkers()) { + return true; + } + + // Skip if it looks like a TV episode (S01E01) or season pack (S01.1080p) + if (preg_match('/[._ -]S\d{1,3}[._ -]?(E\d|D\d|Complete|Full|1080|720|480|2160|WEB|HDTV|BluRay|NF|AMZN)/i', $context->releaseName)) { + return true; + } + + // Skip episode-only patterns (E01, E02) - common in anime + if (preg_match('/[._ -]E\d{1,4}[._ -]/i', $context->releaseName)) { + return true; + } + + // Skip known anime release groups + if (preg_match('/[.\-_ ](URANiME|ANiHLS|HaiKU|ANiURL|SkyAnime|Erai-raws|LostYears|Vodes|SubsPlease|Judas|Ember|YuiSubs|ASW|Tsundere-Raws|Anime-Raws)[.\-_ ]?/i', $context->releaseName)) { + return true; + } + + return false; + } + + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + + // Check if it looks like movie content + if (!$this->looksLikeMovie($name)) { + return $this->noMatch(); + } + + // Try specific movie subcategories in order of specificity + if ($context->categorizeForeign && ($result = $this->checkForeign($name))) { + return $result; + } + + if ($result = $this->checkX265($name)) { + return $result; + } + + if ($result = $this->checkUHD($name)) { + return $result; + } + + if ($result = $this->check3D($name)) { + return $result; + } + + if ($result = $this->checkBluRay($name)) { + return $result; + } + + if ($result = $this->checkDVD($name)) { + return $result; + } + + if ($context->catWebDL && ($result = $this->checkWebDL($name))) { + return $result; + } + + if ($result = $this->checkHD($name, $context->catWebDL)) { + return $result; + } + + if ($result = $this->checkSD($name)) { + return $result; + } + + if ($result = $this->checkOther($name)) { + return $result; + } + + return $this->noMatch(); + } + + /** + * Check if release name looks like movie content. + */ + protected function looksLikeMovie(string $name): bool + { + return (bool) preg_match('/[._ -]AVC|[BH][DR]RIP|(Bluray|Blu-Ray)|BD[._ -]?(25|50)?|\bBR\b|Camrip|[._ -]\d{4}[._ -].+(720p|1080p|Cam|HDTS|2160p)|DIVX|[._ -]DVD[._ -]|DVD-?(5|9|R|Rip)|Untouched|VHSRip|XVID|[._ -](DTS|TVrip|webrip|WEBDL|WEB-DL)[._ -]|\b(2160)p\b.*\b(Netflix|Amazon|NF|AMZN|Disney)\b/i', $name); + } + + protected function checkForeign(string $name): ?CategorizationResult + { + if (preg_match('/(danish|flemish|Deutsch|dutch|french|german|heb|hebrew|nl[._ -]?sub|dub(bed|s)?|\.NL|norwegian|swedish|swesub|spanish|Staffel)[._ -]|\(german\)|Multisub/i', $name)) { + return $this->matched(Category::MOVIE_FOREIGN, 0.8, 'foreign_language'); + } + + if (stripos($name, 'Castellano') !== false) { + return $this->matched(Category::MOVIE_FOREIGN, 0.8, 'foreign_castellano'); + } + + if (preg_match('/(720p|1080p|AC3|AVC|DIVX|DVD(5|9|RIP|R)|XVID)[._ -](Dutch|French|German|ITA)|\(?(Dutch|French|German|ITA)\)?[._ -](720P|1080p|AC3|AVC|DIVX|DVD(5|9|RIP|R)|WEB(-DL|-?RIP)|HD[._ -]|XVID)/i', $name)) { + return $this->matched(Category::MOVIE_FOREIGN, 0.85, 'foreign_pattern'); + } + + return null; + } + + protected function checkX265(string $name): ?CategorizationResult + { + if (preg_match('/(\w+[\.-_\s]+).*(x265).*(Tigole|SESKAPiLE|CHD|IAMABLE|THREESOME|OohLaLa|DEFLATE|NCmt)/i', $name)) { + return $this->matched(Category::MOVIE_X265, 0.9, 'x265_group'); + } + + return null; + } + + protected function checkUHD(string $name): ?CategorizationResult + { + // Skip TV shows + if (preg_match('/(S\d+).*(2160p).*(Netflix|Amazon|NF|AMZN).*(TrollUHD|NTb|VLAD|DEFLATE|CMRG)/i', $name)) { + return null; + } + + // Check for UHD indicators + if (stripos($name, '2160p') !== false || + preg_match('/\b(UHD|Ultra[._ -]HD|4K)\b/i', $name) || + (preg_match('/\b(HDR|HDR10|HDR10\+|Dolby[._ -]?Vision)\b/i', $name) && + preg_match('/\b(HEVC|H\.?265|x265)\b/i', $name)) || + (stripos($name, 'UHD') !== false && + preg_match('/\b(BR|BluRay|Blu[._ -]?Ray)\b/i', $name))) { + return $this->matched(Category::MOVIE_UHD, 0.9, 'uhd'); + } + + return null; + } + + protected function check3D(string $name): ?CategorizationResult + { + if (preg_match('/[._ -]3D\s?[\.\-_\[ ](1080p|(19|20)\d\d|AVC|BD(25|50)|Blu[._ -]?ray|CEE|Complete|GER|MVC|MULTi|SBS|H(-)?SBS)[._ -]/i', $name)) { + return $this->matched(Category::MOVIE_3D, 0.9, '3d'); + } + + return null; + } + + protected function checkBluRay(string $name): ?CategorizationResult + { + if (preg_match('/bluray-|[._ -]bd?[._ -]?(25|50)|blu-ray|Bluray\s-\sUntouched|[._ -]untouched[._ -]/i', $name) && + !preg_match('/SecretUsenet\.com$/i', $name)) { + return $this->matched(Category::MOVIE_BLURAY, 0.9, 'bluray'); + } + + return null; + } + + protected function checkDVD(string $name): ?CategorizationResult + { + if (preg_match('/(dvd\-?r|[._ -]dvd|dvd9|dvd5|[._ -]r5)[._ -]/i', $name)) { + return $this->matched(Category::MOVIE_DVD, 0.85, 'dvd'); + } + + return null; + } + + protected function checkWebDL(string $name): ?CategorizationResult + { + if (preg_match('/web[._ -]dl|web-?rip/i', $name)) { + return $this->matched(Category::MOVIE_WEBDL, 0.85, 'webdl'); + } + + return null; + } + + protected function checkHD(string $name, bool $catWebDL): ?CategorizationResult + { + if (preg_match('/720p|1080p|AVC|VC1|VC-1|web-dl|wmvhd|x264|XvidHD|bdrip/i', $name)) { + return $this->matched(Category::MOVIE_HD, 0.85, 'hd'); + } + + if (!$catWebDL && preg_match('/web[._ -]dl|web-?rip/i', $name)) { + return $this->matched(Category::MOVIE_HD, 0.8, 'hd_webdl_fallback'); + } + + return null; + } + + protected function checkSD(string $name): ?CategorizationResult + { + if (preg_match('/(divx|dvdscr|extrascene|dvdrip|\.CAM|HDTS(-LINE)?|vhsrip|xvid(vd)?)[._ -]/i', $name)) { + return $this->matched(Category::MOVIE_SD, 0.8, 'sd'); + } + + return null; + } + + protected function checkOther(string $name): ?CategorizationResult + { + if (preg_match('/[._ -]cam[._ -]/i', $name)) { + return $this->matched(Category::MOVIE_OTHER, 0.6, 'cam'); + } + + return null; + } +} + diff --git a/app/Services/Categorization/Categorizers/MusicCategorizer.php b/app/Services/Categorization/Categorizers/MusicCategorizer.php new file mode 100644 index 000000000..2ed7e179f --- /dev/null +++ b/app/Services/Categorization/Categorizers/MusicCategorizer.php @@ -0,0 +1,242 @@ +<?php + +namespace App\Services\Categorization\Categorizers; + +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; + +/** + * Categorizer for Music content (MP3, Lossless, Video, Audiobook, Podcast). + */ +class MusicCategorizer extends AbstractCategorizer +{ + protected int $priority = 40; + + // Language patterns for foreign music + protected const FOREIGN_LANGUAGES = 'arabic|brazilian|bulgarian|cantonese|chinese|croatian|czech|danish|deutsch|dutch|estonian|finnish|flemish|french|german|greek|hebrew|hungarian|icelandic|indian|iranian|italian|japanese|korean|latin|latvian|lithuanian|macedonian|mandarin|nordic|norwegian|persian|polish|portuguese|romanian|russian|serbian|slovenian|spanish|spanisch|swedish|thai|turkish|ukrainian|vietnamese'; + + protected const LANGUAGE_CODES = 'ar|bg|bl|cs|cz|da|de|dk|el|es|et|fi|fr|ger|gr|heb|hr|hu|hun|is|it|ita|jp|jap|ko|kor|lt|lv|mk|nl|no|pl|pt|ro|rs|ru|se|sk|sl|sr|sv|th|tr|ua|vi|zh'; + + public function getName(): string + { + return 'Music'; + } + + public function shouldSkip(ReleaseContext $context): bool + { + if ($context->hasAdultMarkers()) return true; + // Skip TV shows (season patterns) + if (preg_match('/[._ -]S\d{1,3}[._ -]?(E\d|Complete|Full|1080|720|480|2160|WEB|HDTV|BluRay)/i', $context->releaseName)) return true; + return false; + } + + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + + // Try each music category + if ($result = $this->checkAudiobook($name)) { + return $result; + } + + if ($result = $this->checkPodcast($name)) { + return $result; + } + + if ($result = $this->checkMusicVideo($name, $context->categorizeForeign)) { + return $result; + } + + if ($result = $this->checkLossless($name, $context->categorizeForeign)) { + return $result; + } + + if ($result = $this->checkMP3($name, $context->categorizeForeign)) { + return $result; + } + + if ($result = $this->checkOther($name, $context->categorizeForeign)) { + return $result; + } + + return $this->noMatch(); + } + + protected function checkForeign(string $name): bool + { + return (bool) preg_match('/(?:^|[\s\.\-_])(?:' . self::FOREIGN_LANGUAGES . '|' . self::LANGUAGE_CODES . ')(?:$|[\s\.\-_])/i', $name); + } + + protected function checkAudiobook(string $name): ?CategorizationResult + { + // Explicit audiobook indicators + if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Audiobook|Audio\s*Book|Talking\s*Book|ABEE|Audible)/i', $name)) { + if (preg_match('/\b(?:Unabridged|Abridged|Narrated|Narrator|MP3|M4A|M4B|AAC|Read\s+By|Tantor|Blackstone|Brilliance|GraphicAudio|Penguin|Audible)\b/i', $name) || + preg_match('/\d+\s*CDs|\d+\s*Hours|Spoken\s+Word/i', $name) || + preg_match('/\.(mp3|m4a|m4b|aac|flac|ogg|wma)$/i', $name)) { + return $this->matched(Category::MUSIC_AUDIOBOOK, 0.95, 'audiobook'); + } + } + + // Audiobook patterns + if (preg_match('/(?:[\(_\[])(?:Audiobook|AB|Unabridged)(?:[\)_\]])/i', $name) || + preg_match('/Read\s+By\s+[A-Z][a-z]+\s+[A-Z][a-z]+/i', $name)) { + return $this->matched(Category::MUSIC_AUDIOBOOK, 0.9, 'audiobook_pattern'); + } + + // Legacy pattern + if (preg_match('/(Audiobook|Audio.?Book)/i', $name)) { + return $this->matched(Category::MUSIC_AUDIOBOOK, 0.85, 'audiobook_legacy'); + } + + return null; + } + + protected function checkPodcast(string $name): ?CategorizationResult + { + if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Podcast|Pod[._ -]?cast|Pod[._ -]Show)/i', $name)) { + return $this->matched(Category::MUSIC_PODCAST, 0.9, 'podcast'); + } + + // Known podcast networks with episode indicators + if (preg_match('/\b(?:NPR|BBC[._ -]Sounds|Gimlet|Wondery|Stitcher|iHeart[._ -]?Radio|Joe[._ -]Rogan|RadioLab|Serial)\b/i', $name) && + preg_match('/\b(?:Podcast|Episode|EP?[._ -]?\d+|Show)\b/i', $name)) { + return $this->matched(Category::MUSIC_PODCAST, 0.85, 'podcast_network'); + } + + // Simple podcast match + if (preg_match('/podcast/i', $name)) { + return $this->matched(Category::MUSIC_PODCAST, 0.8, 'podcast_simple'); + } + + return null; + } + + protected function checkMusicVideo(string $name, bool $categorizeForeign): ?CategorizationResult + { + // Music video indicators + if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Music\s*Video|Concert|Live\s*Show|Tour|Festival|MV|MTV)|\b(?:MVID|MVid)\b/i', $name)) { + if (preg_match('/\b(?:720p|1080[pi]|2160p|BDRip|BluRay|DVDRip|HDTV|WebRip|WEB-DL|x264|x265)\b/i', $name) || + preg_match('/\b(?:Live|Unplugged|Acoustic|World\s*Tour|in\s*Concert|Official\s*Video|Bootleg|Remastered)\b/i', $name) || + preg_match('/\.(mkv|mp4|avi|ts|m2ts|mpg|mpeg|mov|wmv|vob|m4v)$/i', $name)) { + + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.85, 'music_video_foreign'); + } + return $this->matched(Category::MUSIC_VIDEO, 0.9, 'music_video'); + } + } + + // Artist-title pattern with video format + if (preg_match('/^[A-Z0-9][A-Za-z0-9\.\s\&\'\(\)\-]+\s+\-\s+[A-Z0-9][A-Za-z0-9\.\s\&\'\(\)\-]+.*?\b(720p|1080[pi]|2160p|Bluray|x264|x265)\b/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.8, 'music_video_foreign'); + } + return $this->matched(Category::MUSIC_VIDEO, 0.8, 'music_video_artist'); + } + + return null; + } + + protected function checkLossless(string $name, bool $categorizeForeign): ?CategorizationResult + { + // Lossless format indicators + if (preg_match('/(?:^|[^a-zA-Z0-9])(?:FLAC|APE|WAV|ALAC|DSD|DSF|AIFF|PCM|Lossless)|\b(?:FLAC|APE|WAV|ALAC|DSD|DSF|AIFF|PCM)\b/i', $name)) { + if (preg_match('/\b(?:24[Bb]it|96kHz|192kHz|Hi[- ]?Res|HD[- ]?Tracks|Vinyl[- ]?Rip|CD[- ]?Rip|WEB[- ]?Rip|HDtracks|Qobuz|Tidal|MQA|SACD)\b/i', $name) || + preg_match('/\.(flac|ape|wav|aiff|dsf|dff|m4a|tak)$/i', $name)) { + + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.9, 'lossless_foreign'); + } + return $this->matched(Category::MUSIC_LOSSLESS, 0.9, 'lossless'); + } + } + + // FLAC patterns + if (preg_match('/\[(19|20)\d\d\][._ -]\[FLAC\]|([\(\[])flac([\)\]])|FLAC\-(19|20)\d\d\-[a-z0-9]{1,12}|\.flac"|(19|20)\d\d\sFLAC|[._ -]FLAC.+(19|20)\d\d[._ -]| FLAC$/i', $name) || + preg_match('/\d{3,4}kbps[._ -]FLAC|\[FLAC\]|\(FLAC\)|FLACME|FLAC[._ -]\d{3,4}(kbps)?|WEB[._ -]FLAC/i', $name)) { + + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.85, 'flac_foreign'); + } + return $this->matched(Category::MUSIC_LOSSLESS, 0.85, 'flac'); + } + + // Other lossless formats + if (preg_match('/\b(?:APE|Monkey\'s[._ -]Audio|WavPack|WV|TAK|TTA|ALAC|Apple[._ -]Lossless)\b|\.(ape|wv|tak|tta)$/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.85, 'lossless_format_foreign'); + } + return $this->matched(Category::MUSIC_LOSSLESS, 0.85, 'lossless_format'); + } + + return null; + } + + protected function checkMP3(string $name, bool $categorizeForeign): ?CategorizationResult + { + // MP3 indicators + if (preg_match('/(?:^|[^a-zA-Z0-9])(?:MP3|320kbps|256kbps|192kbps|128kbps|CBR|VBR)|\b(?:MP3)\b|[\._-](?:MP3)[\._-]|\.mp3$/i', $name)) { + if (preg_match('/\b(?:320|256|192|128)[._-]?kbps|\b(?:320|256|192|128)[._-]?K|\((?:320|256|192|128)\)|\[(?:320|256|192|128)\]|V0|V2|VBR/i', $name) || + preg_match('/\b(?:CD[._-]?Rip|Web[._-]?Rip|WEB|iTunes|AmazonRip|Spotify[._-]?Rip|MP3\s*\-\s*\d{3}kbps)\b/i', $name) || + preg_match('/\.(m3u|mp3)"|rip(?:192|256|320)|[._-]FM[._-].+MP3/i', $name)) { + + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.85, 'mp3_foreign'); + } + return $this->matched(Category::MUSIC_MP3, 0.85, 'mp3'); + } + } + + // MP3 scene patterns + if (preg_match('/^[a-zA-Z0-9]{1,12}[._-](19|20)\d\d[._-][a-zA-Z0-9]{1,12}$|[a-z0-9]{1,12}\-(19|20)\d\d\-[a-z0-9]{1,12}/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.75, 'mp3_scene_foreign'); + } + return $this->matched(Category::MUSIC_MP3, 0.75, 'mp3_scene'); + } + + // Bitrate patterns + if (preg_match('/[\.\-\(\[_ ]\d{2,3}k[\.\-\)\]_ ]|\((192|256|320)\)|(320|cd|eac|vbr)[._-]+mp3|(cd|eac|mp3|vbr)[._-]+320/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.8, 'mp3_bitrate_foreign'); + } + return $this->matched(Category::MUSIC_MP3, 0.8, 'mp3_bitrate'); + } + + return null; + } + + protected function checkOther(string $name, bool $categorizeForeign): ?CategorizationResult + { + // Compilation and VA indicators + if (preg_match('/(?:^|[^a-zA-Z0-9])(?:Compilation|Various[._ -]Artists|OST|Soundtrack|B-Sides|Greatest[._ -]Hits|Anthology)|\b(?:VA|V\.A|Bonus[._ -]Track|Discography|Box[._ -]Set)\b/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.8, 'music_other_foreign'); + } + return $this->matched(Category::MUSIC_OTHER, 0.8, 'music_other'); + } + + // Album/CD patterns + if (preg_match('/(?:\d)[._ -](?:CD|Albums|LP)[._ -](?:Set|Compilation)|CD[._ -](Collection|Box|SET)|(\d)-?CD[._ -]/i', $name) || + preg_match('/Vinyl[._ -](?:24[._ -]96|2496|Collection|RIP)|WEB[._ -](?:Single|Album)|EP[._ -]\d{4}|\bEP\b.+(?:19|20)\d\d|Live[._ -](?:at|At|@)/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.75, 'music_album_foreign'); + } + return $this->matched(Category::MUSIC_OTHER, 0.75, 'music_album'); + } + + // DJ mixes and labels + if (preg_match('/\b(?:Ministry[._ -]of[._ -]Sound|Hed[._ -]Kandi|Cream|Fabric[._ -]Live|Ultra[._ -]Music)\b/i', $name) || + preg_match('/\b(?:DJ[._ -]Mix|Mixed[._ -]By|Tiesto[._ -]Club|Radio[._ -]Show|Club[._ -]Hits)\b/i', $name)) { + if ($categorizeForeign && $this->checkForeign($name)) { + return $this->matched(Category::MUSIC_FOREIGN, 0.75, 'music_dj_foreign'); + } + return $this->matched(Category::MUSIC_OTHER, 0.75, 'music_dj'); + } + + return null; + } +} + diff --git a/app/Services/Categorization/Categorizers/PcCategorizer.php b/app/Services/Categorization/Categorizers/PcCategorizer.php new file mode 100644 index 000000000..0ce5b5ebf --- /dev/null +++ b/app/Services/Categorization/Categorizers/PcCategorizer.php @@ -0,0 +1,159 @@ +<?php + +namespace App\Services\Categorization\Categorizers; + +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; + +/** + * Categorizer for PC content (Games, Software, ISO, 0day, Mac, Phone apps). + */ +class PcCategorizer extends AbstractCategorizer +{ + protected int $priority = 30; + + // Common PC game release groups + protected const PC_GROUPS = '0x0007|ALiAS|ANOMALY|BACKLASH|BAT|CODEX|CPY|DARKS(?:iDERS|IDERS)|DEViANCE|DOGE|DODI|ELAMIGOS|EMPRESS|FITGIRL|FAS(?:DOX|iSO)|FLT|GOG(?:-GAMES)?|GOLDBERG|HI2U|HOODLUM|INLAWS|JAGUAR|MAZE|MONEY|OUTLAWS|PLAZA|PROPHET|RAZOR1911|RAiN|RELOADED|RUNE|SiMPLEX|SKIDROW|TENOKE|TiNYiSO|UNLEASHED|P2P'; + + // PC-only keywords + protected const PC_KEYWORDS = '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'; + + public function getName(): string + { + return 'PC'; + } + + public function shouldSkip(ReleaseContext $context): bool + { + if ($context->hasAdultMarkers()) return true; + // Skip TV shows (season patterns) + if (preg_match('/[._ -]S\d{1,3}[._ -]?(E\d|Complete|Full|1080|720|480|2160|WEB|HDTV|BluRay)/i', $context->releaseName)) return true; + return false; + } + + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + + // Try each PC category + if ($result = $this->checkPhone($name)) { + return $result; + } + + if ($result = $this->checkMac($name)) { + return $result; + } + + if ($result = $this->checkPCGame($name, $context->poster)) { + return $result; + } + + if ($result = $this->checkISO($name)) { + return $result; + } + + if ($result = $this->check0day($name)) { + return $result; + } + + return $this->noMatch(); + } + + protected function checkPhone(string $name): ?CategorizationResult + { + // iOS + if (preg_match('/[^a-z0-9](IPHONE|ITOUCH|IPAD)[._ -]/i', $name)) { + return $this->matched(Category::PC_PHONE_IOS, 0.9, 'ios'); + } + + // Android + if (preg_match('/[._ -]?(ANDROID)[._ -]/i', $name)) { + return $this->matched(Category::PC_PHONE_ANDROID, 0.9, 'android'); + } + + // Other mobile platforms + if (preg_match('/[^a-z0-9](symbian|xscale|wm5|wm6)[._ -]/i', $name)) { + return $this->matched(Category::PC_PHONE_OTHER, 0.85, 'phone_other'); + } + + return null; + } + + protected function checkMac(string $name): ?CategorizationResult + { + if (preg_match('/(\b|[._ -])mac([\.\s])?osx(\b|[\-_. ])/i', $name)) { + return $this->matched(Category::PC_MAC, 0.9, 'mac'); + } + + if (preg_match('/\b(Mac\s?OS\s?X|macOS)\b/i', $name)) { + return $this->matched(Category::PC_MAC, 0.9, 'macos'); + } + + return null; + } + + protected function checkPCGame(string $name, string $poster): ?CategorizationResult + { + // Exclude console releases + $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, $name) || preg_match('/\b(Mac\s?OS\s?X|macOS)\b/i', $name)) { + return null; + } + + // Exclude TV shows + $tvPatterns = '/\b(S\d{1,4}[._ -]?E\d{1,4}|S\d{1,4}[._ -]?D\d{1,4}|\d{1,2}x\d{2,3}|Season[._ -]?\d{1,3}|Episode[._ -]?\d{1,4}|HDTV|PDTV|DSR|WEB[._ -]?DL|WEB[._ -]?RIP|TVRip)\b/i'; + if (preg_match($tvPatterns, $name)) { + return null; + } + + // Check for PC game patterns + $pattern = '/(?:(?:^|[\s\._-])(?:' . self::PC_GROUPS . ')(?:$|[\s\._-])|' . self::PC_KEYWORDS . ')/i'; + + if (preg_match($pattern, $name)) { + return $this->matched(Category::PC_GAMES, 0.9, 'pc_game'); + } + + // Check poster + if (preg_match('/<PC@MASTER\.RACE>/i', $poster)) { + return $this->matched(Category::PC_GAMES, 0.85, 'pc_game_poster'); + } + + return null; + } + + protected function checkISO(string $name): ?CategorizationResult + { + if (preg_match('/[._ -]([a-zA-Z]{2,10})?iso[ _.-]|[\-. ]([a-z]{2,10})?iso$/i', $name)) { + return $this->matched(Category::PC_ISO, 0.85, 'iso'); + } + + // Training/Tutorial ISOs + if (preg_match('/[._ -](DYNAMiCS|INFINITESKILLS|UDEMY|kEISO|PLURALSIGHT|DIGITALTUTORS|TUTSPLUS|OSTraining|PRODEV|CBT\.Nuggets|COMPRISED)/i', $name)) { + return $this->matched(Category::PC_ISO, 0.9, 'training_iso'); + } + + return null; + } + + protected function check0day(string $name): ?CategorizationResult + { + // Explicit 0day indicators + if (preg_match('/[._ -]exe$|[._ -](utorrent|Virtualbox)[._ -]|\b0DAY\b|incl.+crack| DRM$|>DRM</i', $name)) { + return $this->matched(Category::PC_0DAY, 0.9, '0day_explicit'); + } + + // System/architecture indicators + if (preg_match('/[._ -]((32|64)bit|converter|i\d86|key(gen|maker)|freebsd|GAMEGUiDE|hpux|irix|linux|multilingual|Patch|Pro v\d{1,3}|portable|regged|software|solaris|template|unix|win2kxp2k3|win64|win(2k|32|64|all|dows|nt(2k)?(xp)?|xp)|win9x(me|nt)?|x(32|64|86))[._ -]/i', $name)) { + return $this->matched(Category::PC_0DAY, 0.85, '0day_system'); + } + + // Software vendors and patterns + if (preg_match('/\b(Adobe|auto(cad|desk)|-BEAN|Cracked|Cucusoft|CYGNUS|Divx[._ -]Plus|\.(deb|exe)|DIGERATI|FOSI|-FONT|Key(filemaker|gen|maker)|Lynda\.com|lz0|MULTiLANGUAGE|Microsoft\s*(Office|Windows|Server)|MultiOS|-(iNViSiBLE|SPYRAL|SUNiSO|UNION|TE)|v\d{1,3}.*?Pro|[._ -]v\d{1,3}[._ -]|\(x(64|86)\)|Xilisoft)\b/i', $name)) { + return $this->matched(Category::PC_0DAY, 0.85, '0day_software'); + } + + return null; + } +} + diff --git a/app/Services/Categorization/Categorizers/TvCategorizer.php b/app/Services/Categorization/Categorizers/TvCategorizer.php new file mode 100644 index 000000000..f82c95320 --- /dev/null +++ b/app/Services/Categorization/Categorizers/TvCategorizer.php @@ -0,0 +1,223 @@ +<?php + +namespace App\Services\Categorization\Categorizers; + +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; + +/** + * Categorizer for TV content including HD, SD, UHD, Anime, Sports, Documentaries, etc. + */ +class TvCategorizer extends AbstractCategorizer +{ + protected int $priority = 20; + + public function getName(): string + { + return 'TV'; + } + + public function shouldSkip(ReleaseContext $context): bool + { + return $context->hasAdultMarkers(); + } + + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + + if (!$this->looksLikeTV($name)) { + return $this->noMatch(); + } + + if ($result = $this->checkAnime($name)) { + return $result; + } + if ($result = $this->checkSport($name)) { + return $result; + } + if ($result = $this->checkDocumentary($name)) { + return $result; + } + if ($result = $this->checkForeign($context)) { + return $result; + } + if ($result = $this->checkX265($name)) { + return $result; + } + if ($context->catWebDL && ($result = $this->checkWebDL($name))) { + return $result; + } + if ($result = $this->checkUHD($name)) { + return $result; + } + if ($result = $this->checkHD($name, $context->catWebDL)) { + return $result; + } + if ($result = $this->checkSD($name)) { + return $result; + } + if ($result = $this->checkOther($name)) { + return $result; + } + + return $this->noMatch(); + } + + protected function looksLikeTV(string $name): bool + { + // Season + Episode pattern: S01E01, S01.E01, S1D1, etc. + if (preg_match('/[._ -]s\d{1,3}[._ -]?(e|d(isc)?)\d{1,3}([._ -]|$)/i', $name)) { + return true; + } + // Episode-only pattern: .E01., .E02., E01.1080p (common in anime) + if (preg_match('/[._ -]E\d{1,4}[._ -]/i', $name)) { + return true; + } + // Season pack with Complete/Full: S01.Complete, S01.Full + if (preg_match('/[._ -]S\d{1,3}[._ -]?(Complete|COMPLETE|Full|FULL)/i', $name)) { + return true; + } + // Season pack with resolution/quality: S01.1080p, S01.720p, S01.2160p, S02.WEB-DL + if (preg_match('/[._ -]S\d{1,3}[._ -](480p|720p|1080[pi]|2160p|4K|UHD|WEB|HDTV|BluRay|NF|AMZN|DSNP|ATVP|HMAX)/i', $name)) { + return true; + } + // Episode pattern: Episode 01, Ep.01, Ep 1 + if (preg_match('/\b(Episode|Ep)[._ -]?\d{1,4}\b/i', $name)) { + return true; + } + // TV source markers + if (preg_match('/\b(HDTV|PDTV|DSR|TVRip|SATRip|DTHRip)\b/i', $name)) { + return true; + } + // Daily show pattern: Show.Name.2024.01.15 or Show.Name.2024-01-15 + if (preg_match('/[._ -](19|20)\d{2}[._ -]\d{2}[._ -]\d{2}[._ -]/i', $name)) { + return true; + } + // Known anime release groups (should be treated as TV) + if (preg_match('/[.\-_ ](URANiME|ANiHLS|HaiKU|ANiURL|SkyAnime|Erai-raws|LostYears|Vodes|SubsPlease|Judas|Ember|EMBER|YuiSubs|ASW|Tsundere-Raws|Anime-Raws)[.\-_ ]?/i', $name)) { + return true; + } + return false; + } + + protected function checkAnime(string $name): ?CategorizationResult + { + if (preg_match('/[._ -]Anime[._ -]/i', $name)) { + return $this->matched(Category::TV_ANIME, 0.95, 'anime_pattern'); + } + // Known anime release groups - now matches anywhere in the name, not just at the end + if (preg_match('/[.\-_ ](URANiME|ANiHLS|HaiKU|ANiURL|SkyAnime|Erai-raws|LostYears|Vodes|SubsPlease|Judas|Ember|EMBER|YuiSubs|ASW|Tsundere-Raws|Anime-Raws)[.\-_ ]?/i', $name)) { + return $this->matched(Category::TV_ANIME, 0.95, 'anime_group'); + } + // Anime hash pattern: [GroupName] Title - 01 [ABCD1234] + if (preg_match('/^\[.+\].*\d{2,3}.*\[[a-fA-F0-9]{8}\]/i', $name)) { + return $this->matched(Category::TV_ANIME, 0.9, 'anime_hash'); + } + // Episode pattern with known anime indicators + if (preg_match('/[._ -]E\d{1,4}[._ -]/i', $name) && + preg_match('/\b(BluRay|BD|BDRip)\b/i', $name) && + !preg_match('/\bS\d{1,3}\b/i', $name)) { + // Episode-only pattern with BluRay but no season - likely anime + return $this->matched(Category::TV_ANIME, 0.8, 'anime_episode_bluray'); + } + return null; + } + + protected function checkSport(string $name): ?CategorizationResult + { + if (preg_match('/\b(NFL|NBA|NHL|MLB|MLS|UFC|WWE|Boxing|F1|Formula[._ -]?1|NASCAR|PGA|Tennis|Golf|Soccer|Football|Cricket|Rugby)\b/i', $name) && + preg_match('/\d{4}|\b(Season|Week|Round|Match|Game)\b/i', $name)) { + return $this->matched(Category::TV_SPORT, 0.85, 'sport'); + } + return null; + } + + protected function checkDocumentary(string $name): ?CategorizationResult + { + if (preg_match('/\b(Documentary|Docu[._ -]?Series|DOCU)\b/i', $name)) { + return $this->matched(Category::TV_DOCU, 0.85, 'documentary'); + } + return null; + } + + protected function checkForeign(ReleaseContext $context): ?CategorizationResult + { + if (!$context->categorizeForeign) { + return null; + } + if (preg_match('/(danish|flemish|Deutsch|dutch|french|german|hebrew|nl[._ -]?sub|dub(bed|s)?|\.NL|norwegian|swedish|swesub|spanish|Staffel)[._ -]|\(german\)|Multisub/i', $context->releaseName)) { + return $this->matched(Category::TV_FOREIGN, 0.8, 'foreign_language'); + } + return null; + } + + protected function checkX265(string $name): ?CategorizationResult + { + if (preg_match('/(S\d+).*(x265).*(rmteam|MeGusta|HETeam|PSA|ONLY|H4S5S|TrollHD|ImE)/i', $name)) { + return $this->matched(Category::TV_X265, 0.9, 'x265_group'); + } + return null; + } + + protected function checkWebDL(string $name): ?CategorizationResult + { + if (preg_match('/web[._ -]dl|web-?rip/i', $name)) { + return $this->matched(Category::TV_WEBDL, 0.85, 'webdl'); + } + return null; + } + + protected function checkUHD(string $name): ?CategorizationResult + { + // Single episode UHD + if (preg_match('/S\d+[._ -]?E\d+/i', $name) && preg_match('/2160p/i', $name)) { + return $this->matched(Category::TV_UHD, 0.9, 'uhd_episode'); + } + // Season pack UHD + if (preg_match('/[._ -]S\d+[._ -].*2160p/i', $name)) { + return $this->matched(Category::TV_UHD, 0.9, 'uhd_season'); + } + // UHD with streaming service markers + if (preg_match('/(S\d+).*(2160p).*(Netflix|Amazon|NF|AMZN).*(TrollUHD|NTb|VLAD|DEFLATE|POFUDUK|CMRG)/i', $name)) { + return $this->matched(Category::TV_UHD, 0.9, 'uhd_streaming'); + } + return null; + } + + protected function checkHD(string $name, bool $catWebDL): ?CategorizationResult + { + if (preg_match('/1080([ip])|720p|bluray/i', $name)) { + return $this->matched(Category::TV_HD, 0.85, 'hd_resolution'); + } + if (!$catWebDL && preg_match('/web[._ -]dl|web-?rip/i', $name)) { + return $this->matched(Category::TV_HD, 0.8, 'hd_webdl_fallback'); + } + return null; + } + + protected function checkSD(string $name): ?CategorizationResult + { + if (preg_match('/(360|480|576)p|Complete[._ -]Season|dvdr(ip)?|dvd5|dvd9|\.pdtv|SD[._ -]TV|TVRip|NTSC|BDRip|hdtv|xvid/i', $name)) { + return $this->matched(Category::TV_SD, 0.8, 'sd_format'); + } + if (preg_match('/(([HP])D[._ -]?TV|DSR|WebRip)[._ -]x264/i', $name)) { + return $this->matched(Category::TV_SD, 0.8, 'sd_codec'); + } + return null; + } + + protected function checkOther(string $name): ?CategorizationResult + { + // Season + episode pattern + if (preg_match('/[._ -]s\d{1,3}[._ -]?(e|d(isc)?)\d{1,3}([._ -]|$)/i', $name)) { + return $this->matched(Category::TV_OTHER, 0.6, 'tv_other'); + } + // Season pack pattern (S01, S02, etc.) with any quality marker + if (preg_match('/[._ -]S\d{1,3}[._ -]/i', $name)) { + return $this->matched(Category::TV_OTHER, 0.6, 'tv_season_pack'); + } + return null; + } +} diff --git a/app/Services/Categorization/Categorizers/XxxCategorizer.php b/app/Services/Categorization/Categorizers/XxxCategorizer.php new file mode 100644 index 000000000..9117caea8 --- /dev/null +++ b/app/Services/Categorization/Categorizers/XxxCategorizer.php @@ -0,0 +1,381 @@ +<?php + +namespace App\Services\Categorization\Categorizers; + +use App\Models\Category; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; + +/** + * Categorizer for Adult/XXX content. + */ +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|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'; + + // 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'; + + public function getName(): string + { + return 'XXX'; + } + + public function categorize(ReleaseContext $context): CategorizationResult + { + $name = $context->releaseName; + + // Check if it looks like adult content + if (!$this->looksLikeXxx($name)) { + return $this->noMatch(); + } + + // Try specific XXX subcategories in order of specificity + if ($result = $this->checkOnlyFans($name)) { + return $result; + } + + if ($result = $this->checkVR($name)) { + return $result; + } + + if ($result = $this->checkUHD($name)) { + return $result; + } + + if ($result = $this->checkClipHD($name)) { + return $result; + } + + if ($result = $this->checkPack($name)) { + return $result; + } + + if ($result = $this->checkClipSD($name, $context->poster)) { + return $result; + } + + if ($result = $this->checkSD($name)) { + return $result; + } + + if ($context->catWebDL && ($result = $this->checkWebDL($name))) { + return $result; + } + + if ($result = $this->checkX264($name)) { + return $result; + } + + if ($result = $this->checkXvid($name)) { + return $result; + } + + if ($result = $this->checkImageset($name)) { + return $result; + } + + if ($result = $this->checkWMV($name)) { + return $result; + } + + if ($result = $this->checkDVD($name)) { + return $result; + } + + if ($result = $this->checkOther($name)) { + return $result; + } + + return $this->noMatch(); + } + + /** + * Check if release name looks like adult content. + */ + protected function looksLikeXxx(string $name): bool + { + // Check for XXX marker + if (preg_match('/\bXXX\b/i', $name)) { + return true; + } + + // Check for known studios/sites + if (preg_match('/\b(' . self::KNOWN_STUDIOS . ')\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)) { + return true; + } + + // Site with date pattern: sitename.YYYY.MM.DD or sitename.YY.MM.DD + // This pattern is very common for adult sites but rare for regular content + if (preg_match('/^[A-Za-z]+[.\-_ ](19|20)?\d{2}[.\-_ ]\d{2}[.\-_ ]\d{2}[.\-_ ][A-Za-z]/i', $name)) { + // Check it's not a TV daily show by checking for adult keywords or specific patterns + if (preg_match('/\b(' . self::ADULT_KEYWORDS . ')\b/i', $name)) { + return true; + } + // Check for performer name patterns (firstname.lastname) after the date + if (preg_match('/\d{2}[.\-_ ]([a-z]+)[.\-_ ]([a-z]+)[.\-_ ]/i', $name)) { + // Has a "firstname.lastname" pattern after date - likely adult + // But exclude obvious TV patterns + if (!preg_match('/\b(S\d{1,2}E\d{1,2}|Episode|Season|HDTV|PDTV)\b/i', $name)) { + return true; + } + } + } + + return false; + } + + protected function checkOnlyFans(string $name): ?CategorizationResult + { + // Skip photo packs unless there's a video hint + if (preg_match('/\b(photo(set)?|image(set)?|pics?|wallpapers?|collection|pack)\b/i', $name) && + !preg_match('/\b(mp4|mkv|mov|wmv|avi|webm|h\.?264|x264|h\.?265|x265)\b/i', $name)) { + return null; + } + + if (preg_match('/\bOnly[-_ ]?Fans\b|^OF\./i', $name)) { + return $this->matched(Category::XXX_ONLYFANS, 0.95, 'onlyfans'); + } + + return null; + } + + protected function checkVR(string $name): ?CategorizationResult + { + if (stripos($name, 'vr') === false) { + return null; + } + + // Require either a VR site token or explicit VR180/VR360 + if (!preg_match('/\bVR(?:180|360)\b/i', $name) && + !preg_match('/\b(' . self::VR_SITES . ')\b/i', $name)) { + return null; + } + + // VR pattern matching + $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'; + + if (preg_match($vrPattern, $name)) { + // Verify XXX content + if (preg_match('/\b(' . self::VR_SITES . ')\b/i', $name) || preg_match('/\bXXX\b/i', $name)) { + return $this->matched(Category::XXX_VR, 0.95, 'vr'); + } + } + + return null; + } + + protected function checkUHD(string $name): ?CategorizationResult + { + if (!preg_match('/\b(2160p|4k|UHD|Ultra[._ -]?HD)\b/i', $name)) { + return null; + } + + // Check for adult markers + $hasAdultMarker = preg_match('/\bXXX\b/i', $name) || + preg_match('/\b(' . self::KNOWN_STUDIOS . ')\b/i', strtolower($name)) || + preg_match('/\b(Hardcore|Porn|Sex|Anal|Creampie|MILF|Lesbian|Teen|Interracial)\b/i', $name); + + if (!$hasAdultMarker) { + return null; + } + + // Known UHD release groups + if (preg_match('/XXX.+2160p[\w\-.]+M[PO][V4]-(KTR|GUSH|FaiLED|SEXORS|hUSHhUSH|YAPG|WRB|NBQ|FETiSH)/i', $name)) { + return $this->matched(Category::XXX_UHD, 0.95, 'uhd_group'); + } + + return $this->matched(Category::XXX_UHD, 0.9, 'uhd'); + } + + protected function checkClipHD(string $name): ?CategorizationResult + { + // Exclude packs and collections + if (preg_match('/^(Complete|Pack|Collection|Anthology|Siterip|SiteRip)\b/i', $name)) { + return null; + } + + // Exclude TV shows + if (preg_match('/\b(S\d{1,2}E\d{1,2}|S\d{1,2}|Season\s\d{1,2})\b/i', $name)) { + return null; + } + + // Check for HD resolution + $hasHD = preg_match('/\b(720p|1080p|2160p|HD|4K)\b/i', $name); + + // Studio + performer + HD resolution + if (preg_match('/^(' . self::KNOWN_STUDIOS . ')\.([A-Z][a-z]+).*?(720p|1080p|2160p|HD|4K)/i', $name)) { + return $this->matched(Category::XXX_CLIPHD, 0.9, 'clip_hd_studio'); + } + + // Known studio with date pattern: site.YYYY.MM.DD or site.YY.MM.DD + if (preg_match('/^(' . self::KNOWN_STUDIOS . ')[.\-_ ](19|20)?\d{2}[.\-_ ]\d{2}[.\-_ ]\d{2}/i', $name)) { + if ($hasHD) { + return $this->matched(Category::XXX_CLIPHD, 0.95, 'clip_hd_studio_date'); + } + // Even without HD marker, if it's a known studio with date pattern, likely XXX + return $this->matched(Category::XXX_X264, 0.85, 'studio_date'); + } + + // Date pattern with 4-digit year: site.YYYY.MM.DD.performer.title.1080p + if (preg_match('/^([A-Z][a-zA-Z0-9]+)[.\-_ ](19|20)\d{2}[.\-_ ]\d{2}[.\-_ ]\d{2}[.\-_ ]/i', $name) && + !preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $name)) { + // Check if it has adult keywords or HD resolution + if ($hasHD || preg_match('/\b(' . self::ADULT_KEYWORDS . ')\b/i', $name)) { + return $this->matched(Category::XXX_CLIPHD, 0.85, 'clip_hd_date_4digit'); + } + } + + // Date pattern with 2-digit year: site.YY.MM.DD.performer.title.1080p + if (preg_match('/^([A-Z][a-zA-Z0-9]+)\.(\d{2})\.(\d{2})\.(\d{2})\..*?(720p|1080p|2160p|HD|4K)/i', $name) && + !preg_match('/\b(S\d{2}E\d{2}|Documentary|Series)\b/i', $name)) { + return $this->matched(Category::XXX_CLIPHD, 0.85, 'clip_hd_date'); + } + + // XXX with HD resolution + if (preg_match('/\b(XXX|MILF|Anal|Sex|Porn)[._ -]+(720p|1080p|2160p|HD|4K)\b/i', $name) || + preg_match('/\b(720p|1080p|2160p|HD|4K)[._ -]+(XXX|MILF|Anal|Sex|Porn)\b/i', $name)) { + return $this->matched(Category::XXX_CLIPHD, 0.8, 'clip_hd_xxx'); + } + + return null; + } + + protected function checkPack(string $name): ?CategorizationResult + { + if (preg_match('/[ .]PACK[ .]/i', $name)) { + return $this->matched(Category::XXX_PACK, 0.85, 'pack'); + } + + return null; + } + + protected function checkClipSD(string $name, string $poster): ?CategorizationResult + { + if (preg_match('/anon@y[.]com|@md-hobbys[.]com|oz@lot[.]com/i', $poster)) { + return $this->matched(Category::XXX_CLIPSD, 0.85, 'clip_sd_poster'); + } + + if (preg_match('/(iPT\sTeam|KLEENEX)/i', $name) || stripos($name, 'SDPORN') !== false) { + return $this->matched(Category::XXX_CLIPSD, 0.85, 'clip_sd'); + } + + return null; + } + + protected function checkSD(string $name): ?CategorizationResult + { + if (preg_match('/SDX264XXX|XXX\.HR\./i', $name)) { + return $this->matched(Category::XXX_SD, 0.85, 'sd'); + } + + return null; + } + + protected function checkWebDL(string $name): ?CategorizationResult + { + // Exclude TV shows + if (preg_match('/\b(S\d{1,2}E\d{1,2})\b/i', $name)) { + return null; + } + + if (preg_match('/web[._ -]dl|web-?rip/i', $name) && + (preg_match('/\b(' . self::ADULT_KEYWORDS . ')\b/i', $name) || + preg_match('/\b(' . self::KNOWN_STUDIOS . ')\b/i', $name) || + preg_match('/\b(XXX|Porn|Adult|JAV|Hentai)\b/i', $name))) { + return $this->matched(Category::XXX_WEBDL, 0.85, 'webdl'); + } + + return null; + } + + protected function checkX264(string $name): ?CategorizationResult + { + // Exclude HEVC/x265 + if (preg_match('/\b(x265|hevc)\b/i', $name)) { + return null; + } + + // Require H.264/x264/AVC + if (!preg_match('/\b((x|h)[\.\-_ ]?264|AVC)\b/i', $name)) { + return null; + } + + // Reject obvious non-targets + if (preg_match('/\bwmv\b|S\d{1,2}E\d{1,2}|\d+x\d+/i', $name)) { + return null; + } + + // Check for adult content + $adultPattern = '/\bXXX\b|a\.b\.erotica|BangBros|Cum|Defloration|Err?oticax?|JoyMii|MetArt|Nubiles|Porn|SexArt|Tushy|Vixen|JAV|Brazzers|NaughtyAmerica|RealityKings/i'; + + if (preg_match($adultPattern, $name)) { + return $this->matched(Category::XXX_X264, 0.85, 'x264'); + } + + return null; + } + + protected function checkXvid(string $name): ?CategorizationResult + { + if (preg_match('/(b[dr]|dvd)rip|detoxication|divx|nympho|pornolation|swe6|tesoro|xvid/i', $name)) { + return $this->matched(Category::XXX_XVID, 0.8, 'xvid'); + } + + return null; + } + + protected function checkImageset(string $name): ?CategorizationResult + { + if (preg_match('/IMAGESET|PICTURESET|ABPEA/i', $name)) { + return $this->matched(Category::XXX_IMAGESET, 0.9, 'imageset'); + } + + return null; + } + + protected function checkWMV(string $name): ?CategorizationResult + { + // Exclude modern formats + if (preg_match('/\b(720p|1080p|2160p|x264|x265|h264|h265|hevc|XviD|MP4-|\.mp4)[._ -]/i', $name)) { + return null; + } + + if (preg_match('/\b(WMV|Windows\s?Media\s?Video)\b|\.wmv$|[._ -]wmv[._ -]/i', $name)) { + return $this->matched(Category::XXX_WMV, 0.8, 'wmv'); + } + + return null; + } + + protected function checkDVD(string $name): ?CategorizationResult + { + if (preg_match('/dvdr[^i]|dvd[59]/i', $name)) { + return $this->matched(Category::XXX_DVD, 0.85, 'dvd'); + } + + return null; + } + + protected function checkOther(string $name): ?CategorizationResult + { + if (preg_match('/[._ -]Brazzers|Creampie|[._ -]JAV[._ -]|North\.Pole|^Nubiles|She[._ -]?Male|Transsexual|OLDER ANGELS/i', $name)) { + return $this->matched(Category::XXX_OTHER, 0.7, 'other'); + } + + return null; + } +} + diff --git a/app/Services/Categorization/Contracts/CategorizerInterface.php b/app/Services/Categorization/Contracts/CategorizerInterface.php new file mode 100644 index 000000000..f1c2ab402 --- /dev/null +++ b/app/Services/Categorization/Contracts/CategorizerInterface.php @@ -0,0 +1,34 @@ +<?php +namespace App\Services\Categorization\Contracts; +use App\Services\Categorization\CategorizationResult; +use App\Services\Categorization\ReleaseContext; +/** + * Interface for all categorization handlers. + * + * Each categorizer is responsible for determining if a release + * belongs to its category domain. + */ +interface CategorizerInterface +{ + /** + * Get the priority of this categorizer (lower = higher priority). + * Categorizers are executed in priority order. + */ + public function getPriority(): int; + /** + * Get the name of this categorizer for debugging/logging. + */ + public function getName(): string; + /** + * Attempt to categorize the given release. + * + * @param ReleaseContext $context The release information + * @return CategorizationResult The categorization result + */ + public function categorize(ReleaseContext $context): CategorizationResult; + /** + * Check if this categorizer should be skipped for the given context. + * Useful for early-exit optimizations. + */ + public function shouldSkip(ReleaseContext $context): bool; +} diff --git a/app/Services/Categorization/ReleaseContext.php b/app/Services/Categorization/ReleaseContext.php new file mode 100644 index 000000000..2a2e5934b --- /dev/null +++ b/app/Services/Categorization/ReleaseContext.php @@ -0,0 +1,70 @@ +<?php + +namespace App\Services\Categorization; + +/** + * Value object containing release information for categorization. + */ +class ReleaseContext +{ + public function __construct( + public readonly string $releaseName, + public readonly int|string $groupId, + public readonly string $groupName = '', + public readonly string $poster = '', + public readonly bool $categorizeForeign = true, + public readonly bool $catWebDL = true, + ) {} + + /** + * Get the release name in lowercase for case-insensitive matching. + */ + public function getLowerReleaseName(): string + { + return strtolower($this->releaseName); + } + + /** + * Check if the release name matches a pattern. + */ + public function matchesPattern(string $pattern): bool + { + return (bool) preg_match($pattern, $this->releaseName); + } + + /** + * Check if the release name contains a substring (case-insensitive). + */ + public function containsString(string $needle): bool + { + return stripos($this->releaseName, $needle) !== false; + } + + /** + * Check if the group name matches a pattern. + */ + public function groupMatchesPattern(string $pattern): bool + { + return (bool) preg_match($pattern, $this->groupName); + } + + /** + * Check if this release has adult/XXX markers. + */ + public function hasAdultMarkers(): bool + { + // Check for explicit XXX markers and common adult keywords + 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)\b/i', $this->releaseName)) { + return true; + } + + // Check for adult keywords combined with resolution (likely adult clip) + if (preg_match('/\b(Fuck|Fucked|Fucking|Cock|Dick|Pussy|Cum|Cumshot|Blowjob|Handjob|MILF|Teen|Lesbian|Threesome|Gangbang|Hardcore|Interracial)\b/i', $this->releaseName) && + preg_match('/\b(720p|1080p|2160p|4k|mp4)\b/i', $this->releaseName)) { + return true; + } + + return false; + } +} + diff --git a/app/Services/MediaProcessingService.php b/app/Services/MediaProcessingService.php index 3030b2527..d81637bc3 100644 --- a/app/Services/MediaProcessingService.php +++ b/app/Services/MediaProcessingService.php @@ -4,7 +4,7 @@ namespace App\Services; use App\Models\Category; use App\Models\Release; -use Blacklight\Categorize; +use App\Services\Categorization\CategorizationService; use Blacklight\ElasticSearchSiteSearch; use Blacklight\ManticoreSearch; use Blacklight\ReleaseExtra; @@ -30,7 +30,7 @@ class MediaProcessingService private readonly ReleaseExtra $releaseExtra, private readonly ManticoreSearch $manticore, private readonly ElasticSearchSiteSearch $elasticsearch, - private readonly Categorize $categorize, + private readonly CategorizationService $categorize, ) {} public function getVideoTime(string $videoLocation): string diff --git a/app/Services/ReleaseCreationService.php b/app/Services/ReleaseCreationService.php index db27c743b..99333a60a 100644 --- a/app/Services/ReleaseCreationService.php +++ b/app/Services/ReleaseCreationService.php @@ -9,7 +9,7 @@ use App\Models\Release; use App\Models\ReleaseRegex; use App\Models\ReleasesGroups; use App\Models\UsenetGroup; -use Blacklight\Categorize; +use App\Services\Categorization\CategorizationService; use Blacklight\ColorCLI; use Blacklight\NZB; use Blacklight\processing\ProcessReleases; @@ -32,7 +32,7 @@ class ReleaseCreationService public function createReleases(int|string|null $groupID, int $limit, bool $echoCLI): array { $startTime = now()->toImmutable(); - $categorize = new Categorize; + $categorize = new CategorizationService(); $returnCount = 0; $duplicate = 0; diff --git a/bootstrap/providers.php b/bootstrap/providers.php index beb282252..d5ad2c084 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,7 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\CategorizationServiceProvider::class, App\Providers\ForumServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\RouteServiceProvider::class, diff --git a/misc/testing/Releases/recategorize.php b/misc/testing/Releases/recategorize.php index d193f262b..dddcf8623 100644 --- a/misc/testing/Releases/recategorize.php +++ b/misc/testing/Releases/recategorize.php @@ -4,7 +4,7 @@ require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php'; use App\Models\Category; use App\Models\Release; -use Blacklight\Categorize; +use App\Services\Categorization\CategorizationService; use Blacklight\ColorCLI; use Blacklight\ConsoleTools; @@ -69,7 +69,7 @@ function categorizeRelease($argv, $echoOutput = false): int $relCount = $chgCount = 0; if ($total > 0) { $query->chunk('100', function ($results) use ($update, $relCount, $chgCount) { - $cat = new Categorize; + $cat = new CategorizationService(); foreach ($results as $result) { $catId = $cat->determineCategory($result->groups_id, $result->searchname, $result->fromname); if ((int) $result->categories_id !== (int) $catId['categories_id']) { diff --git a/scripts/debug_pcgame_regex.php b/scripts/debug_pcgame_regex.php deleted file mode 100644 index 58d3f0d9c..000000000 --- a/scripts/debug_pcgame_regex.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php - -require __DIR__.'/../vendor/autoload.php'; - -use Blacklight\Categorize; - -$c = (new ReflectionClass(Categorize::class))->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 deleted file mode 100644 index 99ac198af..000000000 --- a/scripts/quick_parse.php +++ /dev/null @@ -1,16 +0,0 @@ -<?php - -require __DIR__.'/../vendor/autoload.php'; - -$rc = new ReflectionClass(\Blacklight\Games::class); -$games = $rc->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/MediaProcessingServiceTest.php b/tests/Unit/MediaProcessingServiceTest.php index 061be61dd..f99024cb3 100644 --- a/tests/Unit/MediaProcessingServiceTest.php +++ b/tests/Unit/MediaProcessingServiceTest.php @@ -3,8 +3,8 @@ namespace Tests\Unit; use App\Models\Release as ReleaseModel; +use App\Services\Categorization\CategorizationService; use App\Services\MediaProcessingService; -use Blacklight\Categorize; use Blacklight\ElasticSearchSiteSearch; use Blacklight\ManticoreSearch; use Blacklight\ReleaseExtra; @@ -53,7 +53,7 @@ class MediaProcessingServiceTest extends TestCase ?ReleaseExtra $releaseExtra = null, ?ManticoreSearch $manticore = null, ?ElasticSearchSiteSearch $elastic = null, - ?Categorize $categorize = null + ?CategorizationService $categorize = null ): MediaProcessingService { $ffmpeg ??= Mockery::mock(FFMpeg::class); $ffprobe ??= Mockery::mock(FFProbe::class); @@ -62,7 +62,7 @@ class MediaProcessingServiceTest extends TestCase $releaseExtra ??= Mockery::mock(ReleaseExtra::class); $manticore ??= Mockery::mock(ManticoreSearch::class); $elastic ??= Mockery::mock(ElasticSearchSiteSearch::class); - $categorize ??= Mockery::mock(Categorize::class); + $categorize ??= Mockery::mock(CategorizationService::class); return new MediaProcessingService($ffmpeg, $ffprobe, $mediaInfo, $releaseImage, $releaseExtra, $manticore, $elastic, $categorize); }