stripExtensionsForAnalysis($name); $coreName = $this->getCoreNameWithoutSeparators($cleaned); return $this->isUuidPattern($coreName) || $this->isPureHexString($coreName) || $this->isBoundedMd5Hash($name) || $this->isBoundedSha1Hash($name) || $this->isBoundedSha256Hash($name) || $this->isBoundedGenericHash($name) || $this->isBase64LikeToken($name) || $this->isObfuscatedUppercaseString($name) || $this->isObfuscatedMixedAlphanumeric($name) || $this->isObfuscatedUsenetFilename($name) || $this->isRandomByCharacterAnalysis($coreName, $name) || $this->hasInsufficientWordStructure($coreName) || $this->isZeroVowelLongToken($coreName) || $this->isRandomDigitPattern($coreName); } // --------------------------------------------------------------- // Hex / hash boundary checks (operate on the raw release name) // --------------------------------------------------------------- /** * Detect MD5 hash (32 hex characters) with word boundaries. */ protected function isBoundedMd5Hash(string $name): bool { return (bool) preg_match('/(?:^|["\'\s\[\]\/\-])([a-f0-9]{32})(?:["\'\s\[\]\/.\-]|$)/i', $name); } /** * Detect SHA-1 hash (40 hex characters) with word boundaries. */ protected function isBoundedSha1Hash(string $name): bool { return (bool) preg_match('/(?:^|["\'\s\[\]\/\-])([a-f0-9]{40})(?:["\'\s\[\]\/.\-]|$)/i', $name); } /** * Detect SHA-256 hash (64 hex characters) with word boundaries. */ protected function isBoundedSha256Hash(string $name): bool { return (bool) preg_match('/(?:^|["\'\s\[\]\/\-])([a-f0-9]{64})(?:["\'\s\[\]\/.\-]|$)/i', $name); } /** * Detect generic long hex hash (32-128 chars) with word boundaries. */ protected function isBoundedGenericHash(string $name): bool { if (! preg_match('/(?:^|["\'\s\[\]\/\-])([a-z0-9]{24,128})(?:["\'\s\[\]\/.\-]|$)/i', $name, $matches)) { return false; } $token = $matches[1]; if (preg_match('/\b(19|20)\d{2}\b/', $token)) { return false; } return preg_match('/\d/', $token) === 1 && $this->looksLikeRandomString($token); } /** * Detect base64/base64url-like tokens that commonly appear in obfuscated subjects. */ protected function isBase64LikeToken(string $name): bool { if (! preg_match('/(?:^|["\'\s\[\]\/\-])([A-Za-z0-9+\/_=-]{24,})(?:["\'\s\[\]\/.\-]|$)/', $name, $matches)) { return false; } $token = rtrim($matches[1], '='); if (strlen($token) < 24 || preg_match('/\b(19|20)\d{2}\b/', $token)) { return false; } if (! preg_match('/[A-Z]/', $token) || ! preg_match('/[a-z]/', $token) || ! preg_match('/\d/', $token)) { return false; } return $this->looksLikeRandomString($token); } // --------------------------------------------------------------- // Core-name checks (operate on stripped / separator-free names) // --------------------------------------------------------------- /** * Detect UUID patterns (with or without dashes stripped). */ protected function isUuidPattern(string $coreName): bool { return (bool) preg_match('/^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$/i', $coreName); } /** * Detect pure hex strings of 16+ characters. */ protected function isPureHexString(string $coreName, int $minLength = 16): bool { return (bool) preg_match('/^[a-f0-9]{'.$minLength.',}$/i', $coreName); } /** * Detect all-uppercase alphanumeric strings of 15+ characters. */ protected function isObfuscatedUppercaseString(string $name): bool { return (bool) preg_match('/^[A-Z0-9]{15,}$/', $name); } /** * Detect mixed-case alphanumeric strings of 15+ characters * that are not CamelCase words and contain no year pattern. */ protected function isObfuscatedMixedAlphanumeric(string $name): bool { return (bool) preg_match('/^[a-zA-Z0-9]{15,}$/', $name) && ! preg_match('/\b(19|20)\d{2}\b/', $name) && ! preg_match('/^[A-Z][a-z]+([A-Z][a-z]+)+$/', $name); } /** * Detect obfuscated filenames embedded in usenet subject lines. * * Matches patterns like: [XX/XX] - "RANDOMSTRING.partXX.rar" */ protected function isObfuscatedUsenetFilename(string $name): bool { if (preg_match('/\[\d+\/\d+]\s*-\s*"([a-zA-Z0-9]{12,})\.(part\d+\.rar|7z\.\d{3}|rar|zip|vol\d+\+\d+\.par2|par2)"/i', $name, $matches)) { $filename = $matches[1]; return ! preg_match('/[._ -]/', $filename) && ! preg_match('/\b(19|20)\d{2}\b/', $filename) && $this->looksLikeRandomString($filename); } return false; } /** * Detect names consisting of only punctuation and numbers with no clear structure. */ protected function isObfuscatedPunctuation(string $name): bool { return (bool) 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); } // --------------------------------------------------------------- // Character-analysis heuristics // --------------------------------------------------------------- /** * Analyse character transitions to detect random strings. * * A high transition rate (upper↔lower, letter↔digit) with no * media keywords in the original name suggests randomness. * * @param string $coreName Name with extensions and separators stripped. * @param string $originalName The unmodified release name (for keyword check). */ protected function isRandomByCharacterAnalysis(string $coreName, string $originalName = ''): bool { $coreLen = strlen($coreName); if ($coreLen < 16 || ! preg_match('/^[a-zA-Z0-9]+$/', $coreName)) { return false; } $transitions = $this->countCharacterTransitions($coreName); $transitionRate = $transitions / ($coreLen - 1); if ($transitionRate > 0.35) { $nameToCheck = $originalName !== '' ? $originalName : $coreName; return ! preg_match( '/\b(movie|film|series|episode|season|show|video|audio|music|album|dvd|bluray|hdtv|webrip|xvid|x264|x265|hevc|aac|mp3|flac|720p|1080p|2160p|4k|complete|proper|repack|dubbed|subbed|english|french|german|spanish|italian|rip|web|hdr|remux|disc|internal|retail)\b/i', $nameToCheck ); } return false; } /** * Detect names that are long alphanumeric but lack word-like letter sequences. * * @param string $coreName Name with extensions and separators stripped. */ protected function hasInsufficientWordStructure(string $coreName): bool { $coreLen = strlen($coreName); if ($coreLen < 20 || ! preg_match('/^[a-zA-Z0-9]+$/', $coreName)) { return false; } $maxConsecutiveLetters = $this->getMaxConsecutiveLetters($coreName); return $maxConsecutiveLetters < 5 && ! preg_match('/^[a-zA-Z]+\d{1,4}$/', $coreName); } /** * Detect random-looking patterns dominated by digits. * * @param string $coreName Name with extensions and separators stripped. */ protected function isRandomDigitPattern(string $coreName): bool { return (bool) ( preg_match('/^[a-zA-Z]{1,3}\d{6,}[a-zA-Z]*$/i', $coreName) || preg_match('/^[a-zA-Z0-9]{2,4}\d{8,}$/i', $coreName) ); } /** * Detect long alphanumeric tokens with effectively no vowel structure. */ protected function isZeroVowelLongToken(string $coreName, int $minLength = 20): bool { if (strlen($coreName) < $minLength || ! preg_match('/^[a-zA-Z0-9]+$/', $coreName)) { return false; } if (preg_match('/\b(19|20)\d{2}\b/', $coreName)) { return false; } $letterOnly = preg_replace('/\d+/', '', $coreName); if ($letterOnly === '' || strlen($letterOnly) < 10) { return false; } return preg_match('/[aeiou]/i', $letterOnly) !== 1; } /** * Check whether the letter-only portion of a token has a normal vowel ratio. */ protected function hasNormalVowelRatio(string $str, float $min = 0.2): bool { $letters = preg_replace('/[^a-z]/i', '', $str); if ($letters === '' || strlen($letters) < 5) { return false; } preg_match_all('/[aeiou]/i', $letters, $matches); $vowelCount = count($matches[0]); return ($vowelCount / strlen($letters)) >= $min; } /** * Check if a string looks like a random/obfuscated string rather than a real title. * * Uses multiple heuristics: character-type transitions, consonant clusters, * mixed case with digits, and vowel-consonant patterns. */ protected function looksLikeRandomString(string $str): bool { // All same-case letters → random if long enough if (preg_match('/^[A-Z]+$/', $str) || preg_match('/^[a-z]+$/', $str)) { return strlen($str) >= 12; } $len = strlen($str); $transitions = $this->countCharacterTransitions($str); $transitionRatio = $transitions / max(1, $len - 1); // Check for common English consonant-vowel patterns $hasWordPattern = preg_match( '/[bcdfghjklmnpqrstvwxyz]{1,2}[aeiou][bcdfghjklmnpqrstvwxyz]{1,2}[aeiou]/i', $str ); // High transition ratio + no word patterns → random if ($transitionRatio > 0.3 && ! $hasWordPattern) { return true; } // Unlikely consonant clusters (≥5 consecutive consonants) if (preg_match('/[bcdfghjklmnpqrstvwxyz]{5,}/i', $str)) { return true; } // Mixed case AND digits with no clear structure if (preg_match('/[A-Z]/', $str) && preg_match('/[a-z]/', $str) && preg_match('/\d/', $str)) { return true; } return false; } // --------------------------------------------------------------- // Helper utilities // --------------------------------------------------------------- /** * Strip common file extensions for analysis. */ protected function stripExtensionsForAnalysis(string $name): string { return preg_replace( '/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx|par2?|nfo|sfv|nzb|rar|r\d{2,3}|zip|7z|gz|tar|001|msix|msixbundle|appx|appxbundle|apk|xap|ipa|deb|rpm|pkg|dmg|exe|msi)$/i', '', trim($name) ); } /** * Get core name by removing all separators. */ protected function getCoreNameWithoutSeparators(string $cleaned): string { return preg_replace('/[.\-_\s]+/', '', $cleaned); } /** * Count character-type transitions (upper↔lower, letter↔digit). */ protected function countCharacterTransitions(string $str): int { $transitions = 0; $len = strlen($str); for ($i = 1; $i < $len; $i++) { $prev = $str[$i - 1]; $curr = $str[$i]; $prevIsDigit = ctype_digit($prev); $currIsDigit = ctype_digit($curr); $prevIsUpper = ctype_upper($prev); $currIsUpper = ctype_upper($curr); if ($prevIsDigit !== $currIsDigit) { $transitions++; } elseif (! $prevIsDigit && ! $currIsDigit && $prevIsUpper !== $currIsUpper) { $transitions++; } } return $transitions; } /** * Get the maximum consecutive letter count in a string. */ protected function getMaxConsecutiveLetters(string $str): int { $maxConsecutive = 0; $currentConsecutive = 0; $lastWasLetter = false; for ($i = 0, $len = strlen($str); $i < $len; $i++) { $isLetter = ctype_alpha($str[$i]); if ($isLetter) { $currentConsecutive = $lastWasLetter ? $currentConsecutive + 1 : 1; $maxConsecutive = max($maxConsecutive, $currentConsecutive); } $lastWasLetter = $isLetter; } return $maxConsecutive; } }