From 4151ee91d5068fb92360965405b531680a6094e8 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 12 Dec 2025 15:41:02 +0100 Subject: [PATCH] Update nfo files handling --- Blacklight/NZBContents.php | 80 +- Blacklight/NameFixer.php | 259 +++- Blacklight/Nfo.php | 1110 ++++++++++++++++- .../AdditionalProcessingOrchestrator.php | 20 +- .../ArchiveExtractionService.php | 78 +- .../ReleaseFileManager.php | 89 +- 6 files changed, 1551 insertions(+), 85 deletions(-) diff --git a/Blacklight/NZBContents.php b/Blacklight/NZBContents.php index 63f39000a..41c1fe914 100755 --- a/Blacklight/NZBContents.php +++ b/Blacklight/NZBContents.php @@ -158,18 +158,78 @@ class NZBContents } // --- NFO Detection --- - // Check for explicit NFO files first - if ($nfoCheck && ! $foundNFO && isset($firstSegmentId) && preg_match('/\.\b(nfo|diz|info?)\b(?![.-])/i', $subject)) { - $nfoMessageId = ['hidden' => false, 'id' => $firstSegmentId]; - $foundNFO = true; // Found an explicit NFO, prioritize this + // Check for explicit NFO files first (with enhanced patterns) + if ($nfoCheck && ! $foundNFO && isset($firstSegmentId)) { + // Standard NFO extensions + if (preg_match('/\.\b(nfo|diz|info?)\b(?![.-])/i', $subject)) { + $nfoMessageId = ['hidden' => false, 'id' => $firstSegmentId, 'priority' => 1]; + $foundNFO = true; + } + // Alternative NFO naming patterns (group-specific or obfuscated) + elseif (preg_match('/(?:^|["\s])(?:file(?:_?id)?|readme|release|info(?:rmation)?|about|desc(?:ription)?|notes?|read\.?me|00-|000-|0-|_-_).*?\.(?:txt|nfo|diz)(?:["\s]|$)/i', $subject)) { + $nfoMessageId = ['hidden' => false, 'id' => $firstSegmentId, 'priority' => 2]; + $foundNFO = true; + } } - // Check for potential "hidden" NFOs (single segment, common name, not other known types) + + // Check for potential "hidden" NFOs with improved detection // Only consider this if an explicit NFO wasn't found yet - elseif ($nfoCheck && ! $foundNFO && ! $hiddenNFO && isset($firstSegmentId) && $segmentCountInFile === 1 && preg_match('/\(1\/1\)$/i', $subject)) { - // Simplified exclusion: check if it's NOT likely another common file type based on extension pattern - if (! preg_match('/\.(?:exe|com|bat|cmd|scr|dll|zip|rar|[rst]\d{2}|[a-z0-9]{3}|7z|ace|tar|gz|bz2|iso|bin|cue|img|mdf|nrg|dmg|vhd|mp3|flac|ogg|aac|wav|wma|avi|mkv|mp4|mov|wmv|mpg|mpeg|ts|vob|jpg|jpeg|png|gif|bmp|tif|tiff|psd|pdf|doc|docx|xls|xlsx|ppt|pptx|txt|log|xml|html|css|js|php|py|java|c|cpp|h|cs|sql|db|dbf|mdb|accdb|par2?|sfv|md5|sha1|sha256|url|lnk|cfg|ini|inf|sys|tmp|bak|msi|pkg|deb|rpm|apk|ipa)\b/i', $subject)) { - $nfoMessageId = ['hidden' => true, 'id' => $firstSegmentId]; - $hiddenNFO = true; // Found a potential hidden NFO + if ($nfoCheck && ! $foundNFO && ! $hiddenNFO && isset($firstSegmentId)) { + $isHiddenNfoCandidate = false; + + // Pattern 1: Single segment files with (1/1) + if ($segmentCountInFile === 1 && preg_match('/\(1\/1\)$/i', $subject)) { + $isHiddenNfoCandidate = true; + } + + // Pattern 2: Small segment count (1-2) with NFO-like names but no extension + if (! $isHiddenNfoCandidate && $segmentCountInFile <= 2 && preg_match('/(?:^|["\s])(?:nfo|info|readme|release|file_?id|about)(?:["\s]|$)/i', $subject)) { + $isHiddenNfoCandidate = true; + } + + // Pattern 3: Scene-style NFO naming (group-release.nfo without extension visible) + if (! $isHiddenNfoCandidate && $segmentCountInFile === 1 && preg_match('/^[a-z0-9._-]+["\s]*\(1\/1\)/i', $subject)) { + // Check for scene-like naming pattern + if (preg_match('/^[a-z0-9]+[._-][a-z0-9._-]+["\s]*\(1\/1\)/i', $subject)) { + $isHiddenNfoCandidate = true; + } + } + + // Pattern 4: Very small files (NFOs are typically small) + // Files described as very small in bytes could be NFOs + if (! $isHiddenNfoCandidate && $segmentCountInFile === 1 && preg_match('/yEnc\s*\(\d+\)\s*\[1\/1\]/i', $subject)) { + $isHiddenNfoCandidate = true; + } + + if ($isHiddenNfoCandidate) { + // Enhanced exclusion: check if it's NOT likely another common file type + $excludedExtensions = '/\.(?:' . + // Executables + 'exe|com|bat|cmd|scr|dll|msi|pkg|deb|rpm|apk|ipa|app|' . + // Archives + 'zip|rar|[rst]\d{2}|7z|ace|tar|gz|bz2|xz|lzma|cab|iso|bin|cue|img|mdf|nrg|dmg|vhd|' . + // Audio + 'mp3|flac|ogg|aac|wav|wma|m4a|opus|ape|wv|mpc|' . + // Video + 'avi|mkv|mp4|mov|wmv|mpg|mpeg|ts|vob|m2ts|webm|flv|ogv|divx|xvid|' . + // Images + 'jpg|jpeg|png|gif|bmp|tif|tiff|psd|webp|svg|ico|raw|cr2|nef|' . + // Documents + 'pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf|epub|mobi|azw|' . + // Code + 'html|htm|css|js|php|py|java|c|cpp|h|cs|sql|json|xml|yml|yaml|' . + // Data + 'db|dbf|mdb|accdb|sqlite|csv|' . + // Verification + 'par2?|sfv|md5|sha1|sha256|sha512|crc|' . + // Misc + 'url|lnk|cfg|ini|inf|sys|tmp|bak|log|srt|sub|idx|ass|ssa|vtt' . + ')\b/i'; + + if (! preg_match($excludedExtensions, $subject)) { + $nfoMessageId = ['hidden' => true, 'id' => $firstSegmentId, 'priority' => 10]; + $hiddenNFO = true; + } } } diff --git a/Blacklight/NameFixer.php b/Blacklight/NameFixer.php index b2bc7623e..a56da0434 100755 --- a/Blacklight/NameFixer.php +++ b/Blacklight/NameFixer.php @@ -140,7 +140,14 @@ class NameFixer /** * Attempts to fix release names using the NFO. * + * Enhanced to use the new Nfo class metadata extraction features for better + * release name identification from IMDB, TVDB, TMDB, and other media sources. * + * @param int|string $time Time limit for query + * @param bool $echo Whether to actually update the database + * @param int $cats Category filter (2=misc/hashed, 3=predb) + * @param bool $nameStatus Whether to update status columns + * @param bool $show Whether to show output * * @throws \Exception */ @@ -149,6 +156,9 @@ class NameFixer $this->_echoStartMessage($time, '.nfo files'); $type = 'NFO, '; + // Initialize the Nfo parser + $nfoParser = new Nfo(); + // Only select releases we haven't checked here before $preId = false; if ($cats === 3) { @@ -208,7 +218,18 @@ class NameFixer } $this->reset(); - $this->checkName($releaseRow[0], $echo, $type, $nameStatus, $show, $preId); + + // First, try to extract metadata using the enhanced Nfo parser + $nfoMetadata = $nfoParser->parseNfoMetadata($releaseRow[0]->textstring); + + // Try to find a better name using extracted media IDs + $betterNameFound = $this->tryNfoMetadataRename($releaseRow[0], $nfoMetadata, $echo, $type, $nameStatus, $show, $preId); + + // If metadata extraction didn't find a name, fall back to traditional checks + if (! $betterNameFound) { + $this->checkName($releaseRow[0], $echo, $type, $nameStatus, $show, $preId); + } + $this->_echoRenamed($show); } $this->_echoFoundCount($echo, ' NFO\'s'); @@ -217,6 +238,242 @@ class NameFixer } } + /** + * Try to rename a release using extracted NFO metadata. + * + * Uses media IDs (IMDB, TVDB, TMDB) and codec info from NFO to build + * a better release name. + * + * @param object $release The release object + * @param array $nfoMetadata Metadata extracted from NFO by Nfo::parseNfoMetadata() + * @param bool $echo Whether to update database + * @param string $type The type string for logging + * @param bool $nameStatus Whether to update status columns + * @param bool $show Whether to show output + * @param bool $preId Whether processing for PreDB + * @return bool True if a better name was found and applied + * + * @throws \Exception + */ + protected function tryNfoMetadataRename(object $release, array $nfoMetadata, bool $echo, string $type, $nameStatus, bool $show, bool $preId = false): bool + { + // Skip if already processed + if ($this->done || $this->relid === (int) $release->releases_id) { + return false; + } + + // Try to get a name from media database IDs + $mediaIds = $nfoMetadata['media_ids'] ?? []; + $codecInfo = $nfoMetadata['codec_info'] ?? []; + $releaseGroup = $nfoMetadata['group'] ?? null; + + // Priority order: IMDB (movies/TV), TMDB, TVDB, TVMaze + foreach ($mediaIds as $mediaId) { + $newName = $this->getNameFromMediaId($mediaId['source'], $mediaId['id']); + if ($newName !== null) { + // Enhance the name with codec info if available + $enhancedName = $this->enhanceNameWithCodecInfo($newName, $codecInfo, $releaseGroup); + + $this->updateRelease( + $release, + $enhancedName, + 'nfoCheck: Media ID ('.$mediaId['source'].': '.$mediaId['id'].')', + $echo, + $type, + $nameStatus, + $show + ); + + return true; + } + } + + // If we have codec info but no media ID match, try to enhance existing name patterns + if (! empty($codecInfo)) { + // Check if there's a recognizable title pattern in the NFO + $titleFromNfo = $this->extractTitleFromNfoContent($release->textstring); + if ($titleFromNfo !== null) { + $enhancedName = $this->enhanceNameWithCodecInfo($titleFromNfo, $codecInfo, $releaseGroup); + if (strtolower($enhancedName) !== strtolower($release->searchname)) { + $this->updateRelease( + $release, + $enhancedName, + 'nfoCheck: NFO Title with Codec Info', + $echo, + $type, + $nameStatus, + $show + ); + + return true; + } + } + } + + return false; + } + + /** + * Get a release name from a media database ID. + * + * Queries local database or external APIs to resolve media IDs to titles. + * + * @param string $source The source database (imdb, thetvdb, tmdb_movie, tmdb_tv, tvmaze, anidb, mal) + * @param string $id The media ID + * @return string|null The title if found, null otherwise + */ + protected function getNameFromMediaId(string $source, string $id): ?string + { + switch ($source) { + case 'imdb': + // Check if we have this IMDB ID in our movieinfo table + $movie = \App\Models\MovieInfo::where('imdbid', ltrim($id, 't'))->first(['title', 'year']); + if ($movie !== null) { + return $movie->year > 0 ? "{$movie->title} ({$movie->year})" : $movie->title; + } + // Also check Video table for TV shows with IMDB ID + $video = \App\Models\Video::where('imdb', (int) ltrim($id, 't'))->first(['title']); + if ($video !== null) { + return $video->title; + } + break; + + case 'thetvdb': + // Check Video table for TVDB ID + $video = \App\Models\Video::where('tvdb', (int) $id)->first(['title']); + if ($video !== null) { + return $video->title; + } + break; + + case 'tmdb_movie': + // Check local movie database for TMDB ID + $movie = \App\Models\MovieInfo::where('tmdbid', (int) $id)->first(['title', 'year']); + if ($movie !== null) { + return $movie->year > 0 ? "{$movie->title} ({$movie->year})" : $movie->title; + } + break; + + case 'tmdb_tv': + // Check Video table for TMDB ID + $video = \App\Models\Video::where('tmdb', (int) $id)->first(['title']); + if ($video !== null) { + return $video->title; + } + break; + + case 'tvmaze': + // Check Video table for TVMaze ID + $video = \App\Models\Video::where('tvmaze', (int) $id)->first(['title']); + if ($video !== null) { + return $video->title; + } + break; + + case 'anidb': + // Check AniDB table - uses Video table with anidb column + $video = \App\Models\Video::where('anidb', (int) $id)->first(['title']); + if ($video !== null) { + return $video->title; + } + // Also check anidb_titles table + $anime = \App\Models\AnidbTitle::where('anidbid', (int) $id)->first(['title']); + if ($anime !== null) { + return $anime->title; + } + break; + + case 'trakt': + // Check Video table for Trakt ID + $video = \App\Models\Video::where('trakt', (int) $id)->first(['title']); + if ($video !== null) { + return $video->title; + } + break; + + case 'mal': + // MyAnimeList - currently no direct support in the database + break; + } + + return null; + } + + /** + * Enhance a title with codec/resolution information. + * + * @param string $title The base title + * @param array $codecInfo Codec info from Nfo::extractCodecInfo() + * @param string|null $releaseGroup The release group name if found + * @return string The enhanced title + */ + protected function enhanceNameWithCodecInfo(string $title, array $codecInfo, ?string $releaseGroup = null): string + { + $parts = [$title]; + + // Add resolution + if (! empty($codecInfo['resolution'])) { + $parts[] = $codecInfo['resolution']; + } + + // Add video codec + if (! empty($codecInfo['video'])) { + $parts[] = $codecInfo['video']; + } + + // Add audio codec + if (! empty($codecInfo['audio'])) { + $parts[] = $codecInfo['audio']; + } + + // Add release group + if ($releaseGroup !== null) { + $parts[] = '-'.$releaseGroup; + + return implode('.', array_slice($parts, 0, -1)).$parts[count($parts) - 1]; + } + + return implode('.', $parts); + } + + /** + * Extract a recognizable title from NFO content. + * + * Looks for common title patterns like "Title (Year)" or scene-style names. + * + * @param string $nfoContent The NFO content + * @return string|null The extracted title or null if not found + */ + protected function extractTitleFromNfoContent(string $nfoContent): ?string + { + // Look for "Title (Year)" pattern - common in movie NFOs + if (preg_match('/^[\s\S]*?([A-Z][A-Za-z0-9\s\.\'\-\:]+(?:\s+\((?:19|20)\d{2}\)))/m', $nfoContent, $matches)) { + $title = trim($matches[1]); + // Validate it's not too short or too long + if (strlen($title) >= 5 && strlen($title) <= 150) { + return $title; + } + } + + // Look for release name patterns (Scene style) + if (preg_match('/(?:Release|Rls|Name)\s*[:\-]?\s*([A-Za-z0-9][\w.\-]+(?:[\s._-][\w.\-]+)+)/i', $nfoContent, $matches)) { + $title = trim($matches[1]); + if (strlen($title) >= 5 && strlen($title) <= 150) { + return $title; + } + } + + // Look for title in common NFO header patterns + if (preg_match('/(?:presents|proudly brings)\s*[:\-]?\s*([A-Za-z0-9][\w.\s\-\']+(?:[\s._-][\w.\s\-\']+)*)/i', $nfoContent, $matches)) { + $title = trim($matches[1]); + if (strlen($title) >= 5 && strlen($title) <= 150) { + return $title; + } + } + + return null; + } + /** * Attempts to fix release names using the File name. * diff --git a/Blacklight/Nfo.php b/Blacklight/Nfo.php index 459de2b2a..b90d3182e 100755 --- a/Blacklight/Nfo.php +++ b/Blacklight/Nfo.php @@ -10,66 +10,123 @@ use Blacklight\processing\PostProcess; use Blacklight\utility\Utility; use dariusiii\rarinfo\Par2Info; use dariusiii\rarinfo\SfvInfo; -use getID3; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use Throwable; /** - * Class Nfo. + * Class Nfo - Handles NFO file processing, validation, and metadata extraction. + * + * NFO files are text files commonly used in the warez scene to provide information + * about releases. This class handles detection, validation, parsing and storage of NFO content. */ class Nfo { /** * Regex to detect common non-NFO file headers/signatures. + * Matches XML, NZB, RIFF (media), PAR/RAR archives, and other binary formats. */ - protected string $_nonNfoHeaderRegex = '/\A(\s*<\?xml|=newz\[NZB\]=|RIFF|\s*[RP]AR|.{0,10}(JFIF|matroska|ftyp|ID3))|;\s*Generated\s*by.*SF\w/i'; + protected string $_nonNfoHeaderRegex = '/\A(\s*<\?xml|=newz\[NZB\]=|RIFF|\s*[RP]AR|.{0,10}(JFIF|matroska|ftyp|ID3)|PK\x03\x04|\x1f\x8b\x08|MZ|%PDF|GIF8[79]a|\x89PNG)|;\s*Generated\s*by.*SF\w/i'; /** * Regex to identify text encoding from the 'file' command output. */ - protected string $_textFileRegex = '/(ASCII|ISO-8859|UTF-(8|16|32).*?)\s*text/'; + protected string $_textFileRegex = '/(ASCII|ISO-8859|UTF-(8|16|32).*?|Non-ISO extended-ASCII)\s*text/i'; /** * Regex to identify common binary file types from the 'file' command output. */ - protected string $_binaryFileRegex = '/^(JPE?G|Parity|PNG|RAR|XML|(7-)?[Zz]ip)/'; + protected string $_binaryFileRegex = '/^(JPE?G|Parity|PNG|RAR|XML|(7-)?[Zz]ip|PDF|GIF|executable|archive|compressed|data|binary)/i'; /** * Regex to detect binary characters within the content. + * Excludes common control characters that may appear in NFOs (tab, newline, carriage return). */ - protected string $_binaryCharsRegex = '/[\x00-\x08\x12-\x1F\x0B\x0E\x0F]/'; + protected string $_binaryCharsRegex = '/[\x00-\x08\x0B\x0C\x0E-\x1F]/'; /** - * @var int + * Common NFO keywords that help identify legitimate NFO files. */ - private $nzbs; + protected array $_nfoKeywords = [ + // Release information + 'release', 'group', 'date', 'size', 'format', 'source', 'genre', 'codec', + 'bitrate', 'resolution', 'language', 'subtitle', 'ripped', 'cracked', + 'keygen', 'serial', 'patch', 'trainer', 'install', 'notes', 'greets', + 'nfo', 'ascii', 'artwork', 'presents', 'proudly', 'brings', 'another', + // Scene terminology + 'scene', 'rls', 'nuked', 'proper', 'repack', 'internal', 'retail', + 'webdl', 'webrip', 'bluray', 'bdrip', 'dvdrip', 'hdtv', 'pdtv', + // Media info + 'video', 'audio', 'duration', 'runtime', 'aspect', 'fps', 'channels', + 'sample', 'encoder', 'x264', 'x265', 'hevc', 'avc', 'xvid', 'divx', + 'aac', 'ac3', 'dts', 'truehd', 'atmos', 'flac', 'mp3', + // Content info + 'movie', 'film', 'episode', 'season', 'series', 'title', 'year', + 'director', 'cast', 'actors', 'plot', 'synopsis', 'imdb', 'rating', + // Software + 'crack', 'readme', 'setup', 'installer', 'license', 'registration', + 'protection', 'requirements', 'platform', 'operating', 'system', + // Contact/Group info + 'contact', 'irc', 'www', 'http', 'ftp', 'email', 'apply', 'join', + ]; /** - * @var int + * Scene group patterns for improved detection. */ - protected $maxSize; + protected array $_sceneGroupPatterns = [ + '/(?:^|\n)\s*[-=*]{3,}.*?([A-Z0-9]{2,15})\s*[-=*]{3,}/i', + '/(?:presents?|brought\s+(?:to\s+)?(?:you\s+)?by|from)\s*[:\-]?\s*([A-Z][A-Z0-9]{1,14})/i', + '/(?:greets?\s+(?:go(?:es)?\s+)?(?:out\s+)?to|respect\s+to)\s*[:\-]?\s*([\w,\s&]+)/i', + '/(?:^|\n)\s*([A-Z][A-Z0-9]{1,14})\s+(?:nfo|info|release)\s*(?:$|\n)/i', + '/(?:released\s+by|rls\s+by)\s*[:\-]?\s*([A-Z][A-Z0-9]{1,14})/i', + ]; /** - * @var int + * Maximum NFO file size in bytes (64KB). */ - private $maxRetries; + protected const MAX_NFO_SIZE = 65535; /** - * @var int + * Minimum NFO file size in bytes. */ - protected $minSize; + protected const MIN_NFO_SIZE = 12; /** - * @var string + * Cache TTL for settings in seconds. */ - private $tmpPath; + protected const SETTINGS_CACHE_TTL = 300; /** - * @var bool + * @var int Number of NFOs to process per batch. */ - protected $echo; + private int $nzbs; + + /** + * @var int Maximum release size to process NFO (in GB). + */ + protected int $maxSize; + + /** + * @var int Maximum retry attempts for failed NFO fetches. + */ + private int $maxRetries; + + /** + * @var int Minimum release size to process NFO (in MB). + */ + protected int $minSize; + + /** + * @var string Temporary path for processing files. + */ + private string $tmpPath; + + /** + * @var bool Whether to echo output to CLI. + */ + protected bool $echo; public const NFO_FAILED = -9; // We failed to get a NFO after admin set max retries. @@ -84,83 +141,132 @@ class Nfo /** * Default constructor. * + * Initializes NFO processing settings from database/config with caching. + * * @throws \Exception */ public function __construct() { $this->echo = config('nntmux.echocli'); - $this->nzbs = Settings::settingValue('maxnfoprocessed') !== '' ? (int) Settings::settingValue('maxnfoprocessed') : 100; - $this->maxRetries = (int) Settings::settingValue('maxnforetries') >= 0 ? -((int) Settings::settingValue('maxnforetries') + 1) : self::NFO_UNPROC; - $this->maxRetries = $this->maxRetries < -8 ? -8 : $this->maxRetries; - $this->maxSize = (int) Settings::settingValue('maxsizetoprocessnfo'); - $this->minSize = (int) Settings::settingValue('minsizetoprocessnfo'); $this->colorCli = new ColorCLI; - $this->tmpPath = config('nntmux.tmp_unrar_path'); - if (! preg_match('/[\/\\\\]$/', $this->tmpPath)) { - $this->tmpPath .= '/'; - } + // Cache settings to reduce database queries + $this->nzbs = Cache::remember('nfo_maxnfoprocessed', self::SETTINGS_CACHE_TTL, function () { + $value = Settings::settingValue('maxnfoprocessed'); + + return $value !== '' ? (int) $value : 100; + }); + + $maxRetries = Cache::remember('nfo_maxnforetries', self::SETTINGS_CACHE_TTL, function () { + return (int) Settings::settingValue('maxnforetries'); + }); + $this->maxRetries = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC; + $this->maxRetries = max($this->maxRetries, -8); + + $this->maxSize = Cache::remember('nfo_maxsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { + return (int) Settings::settingValue('maxsizetoprocessnfo'); + }); + + $this->minSize = Cache::remember('nfo_minsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { + return (int) Settings::settingValue('minsizetoprocessnfo'); + }); + + $this->tmpPath = rtrim(config('nntmux.tmp_unrar_path'), '/\\').'/'; } /** - * Look for a TV Show ID in a string. + * Look for a TV Show ID or Movie ID in a string. + * + * Supports: TVMaze, IMDB, TVDB (legacy & modern), TMDB, AniDB * * @param string $str The string with a Show ID. - * @return array|false Return array with show ID and site source or false on failure. + * @return array{showid: string, site: string}|false Return array with show ID and site source or false on failure. */ - public function parseShowId(string $str) + public function parseShowId(string $str): array|false { - $return = false; - + // TVMaze if (preg_match('/tvmaze\.com\/shows\/(\d{1,6})/i', $str, $hits)) { - $return = - [ - 'showid' => trim($hits[1]), - 'site' => 'tvmaze', - ]; + return ['showid' => trim($hits[1]), 'site' => 'tvmaze']; } - if (preg_match('/imdb\.com\/title\/(tt\d{1,8})/i', $str, $hits)) { - $return = - [ - 'showid' => trim($hits[1]), - 'site' => 'imdb', - ]; + // IMDB (movies and TV shows) + if (preg_match('/imdb\.com\/title\/(tt\d{7,8})/i', $str, $hits)) { + return ['showid' => trim($hits[1]), 'site' => 'imdb']; } + // TVDB - Legacy URL format if (preg_match('/thetvdb\.com\/\?tab=series&id=(\d{1,8})/i', $str, $hits)) { - $return = - [ - 'showid' => trim($hits[1]), - 'site' => 'thetvdb', - ]; + return ['showid' => trim($hits[1]), 'site' => 'thetvdb']; } - return $return; + // TVDB - Modern URL format (series/slug or series/id) + if (preg_match('/thetvdb\.com\/series\/(\d{1,8}|[\w-]+)/i', $str, $hits)) { + return ['showid' => trim($hits[1]), 'site' => 'thetvdb']; + } + + // TMDB - Movie + if (preg_match('/themoviedb\.org\/movie\/(\d{1,8})/i', $str, $hits)) { + return ['showid' => trim($hits[1]), 'site' => 'tmdb_movie']; + } + + // TMDB - TV Show + if (preg_match('/themoviedb\.org\/tv\/(\d{1,8})/i', $str, $hits)) { + return ['showid' => trim($hits[1]), 'site' => 'tmdb_tv']; + } + + // AniDB + if (preg_match('/anidb\.net\/(?:perl-bin\/animedb\.pl\?show=anime&aid=|anime\/)(\d{1,6})/i', $str, $hits)) { + return ['showid' => trim($hits[1]), 'site' => 'anidb']; + } + + // Trakt.tv + if (preg_match('/trakt\.tv\/(?:shows|movies)\/([\w-]+)/i', $str, $hits)) { + return ['showid' => trim($hits[1]), 'site' => 'trakt']; + } + + return false; } /** * Confirm this is an NFO file. * + * Uses multiple validation strategies: + * 1. Size validation (too large/small = not NFO) + * 2. Binary header detection (known file signatures) + * 3. File type detection via 'file' command + * 4. PAR2/SFV structure detection + * 5. Binary character content analysis + * 6. NFO keyword/content heuristics + * * @param bool|string $possibleNFO The nfo content. * @param string $guid The guid of the release. * @return bool True if it's likely an NFO, False otherwise. */ public function isNFO(bool|string &$possibleNFO, string $guid): bool { - if ($possibleNFO === false) { + if ($possibleNFO === false || $possibleNFO === '') { return false; } $size = \strlen($possibleNFO); - // Basic size and signature checks - if ($size >= 65535 || $size < 12 || preg_match($this->_nonNfoHeaderRegex, $possibleNFO)) { + // Basic size and signature checks using constants + if ($size >= self::MAX_NFO_SIZE || $size < self::MIN_NFO_SIZE) { + return false; + } + + // Quick check for known non-NFO file signatures + if (preg_match($this->_nonNfoHeaderRegex, $possibleNFO)) { + return false; + } + + // Additional binary format checks + if ($this->detectBinaryFormat($possibleNFO)) { return false; } $tmpPath = $this->tmpPath.$guid.'.nfo'; - $isNfo = false; // Default assumption + $isNfo = false; try { // File/GetId3 work with files, so save to disk. @@ -170,41 +276,46 @@ class Nfo $result = Utility::fileInfo($tmpPath); if (! empty($result)) { if (preg_match($this->_textFileRegex, $result)) { - $isNfo = true; // It's text, likely NFO + $isNfo = true; } elseif (preg_match($this->_binaryFileRegex, $result) || preg_match($this->_binaryCharsRegex, $possibleNFO)) { - $isNfo = false; // Detected binary format or characters + $isNfo = false; + } + + // If fileInfo gave a result, apply additional heuristics before returning + if ($isNfo) { + // Additional content validation for text files + $isNfo = $this->validateNfoContent($possibleNFO); } - // If fileInfo gave a result, trust it and return return $isNfo; } // Fallback checks if 'file' command is unavailable or inconclusive - // Check if it's a par2. + // Check if it's a PAR2 file $par2info = new Par2Info; $par2info->setData($possibleNFO); if (! $par2info->error) { - // It's a PAR2 file return false; } - // Check if it's an SFV. + // Check if it's an SFV file $sfv = new SfvInfo; $sfv->setData($possibleNFO); if (! $sfv->error) { - // It's an SFV file return false; } - // If it wasn't identified as a known non-NFO binary type by fileInfo, - // and isn't PAR2 or SFV, assume it might be NFO (especially if fileInfo failed). - // Further checks (like binary char check) could be added here if needed. - $isNfo = ! preg_match($this->_binaryCharsRegex, $possibleNFO); + // Check for binary characters + if (preg_match($this->_binaryCharsRegex, $possibleNFO)) { + return false; + } + + // Final content-based validation + $isNfo = $this->validateNfoContent($possibleNFO); } catch (Throwable $e) { - // Log errors during file operations Log::error("Error processing potential NFO for GUID {$guid}: ".$e->getMessage()); - $isNfo = false; // Treat errors as non-NFO + $isNfo = false; } finally { // Ensure temporary file is always deleted if (File::exists($tmpPath)) { @@ -219,6 +330,126 @@ class Nfo return $isNfo; } + /** + * Detect binary file formats by magic bytes. + * + * @param string $data The file content to check. + * @return bool True if binary format detected. + */ + protected function detectBinaryFormat(string $data): bool + { + if (strlen($data) < 4) { + return false; + } + + // Magic bytes for common binary formats + $magicBytes = [ + "\x50\x4B\x03\x04" => 'ZIP', // ZIP/DOCX/XLSX etc. + "\x50\x4B\x05\x06" => 'ZIP_EMPTY', // Empty ZIP + "\x52\x61\x72\x21" => 'RAR', // RAR + "\x37\x7A\xBC\xAF" => '7Z', // 7-Zip + "\x1F\x8B\x08" => 'GZIP', // GZip + "\x42\x5A\x68" => 'BZIP2', // BZip2 + "\xFD\x37\x7A\x58" => 'XZ', // XZ + "\x89\x50\x4E\x47" => 'PNG', // PNG + "\xFF\xD8\xFF" => 'JPEG', // JPEG + "\x47\x49\x46\x38" => 'GIF', // GIF + "\x25\x50\x44\x46" => 'PDF', // PDF + "\x49\x44\x33" => 'MP3_ID3', // MP3 with ID3 + "\xFF\xFB" => 'MP3', // MP3 + "\x4F\x67\x67\x53" => 'OGG', // OGG + "\x66\x4C\x61\x43" => 'FLAC', // FLAC + "\x52\x49\x46\x46" => 'RIFF', // WAV/AVI + "\x00\x00\x01\xBA" => 'MPEG', // MPEG video + "\x00\x00\x01\xB3" => 'MPEG', // MPEG video + "\x1A\x45\xDF\xA3" => 'MKV', // Matroska/WebM + "\x4D\x5A" => 'EXE', // Windows EXE + "\x7F\x45\x4C\x46" => 'ELF', // Linux executable + "\xCA\xFE\xBA\xBE" => 'JAVA', // Java class + "\xD0\xCF\x11\xE0" => 'OLE', // MS Office old format + ]; + + foreach ($magicBytes as $magic => $type) { + if (str_starts_with($data, $magic)) { + return true; + } + } + + // Check for UTF-16 BOM (could be text, but unlikely NFO) + if (str_starts_with($data, "\xFF\xFE") || str_starts_with($data, "\xFE\xFF")) { + // UTF-16 - could be valid, let other checks handle it + return false; + } + + return false; + } + + /** + * Validate NFO content using heuristics. + * + * @param string $content The content to validate. + * @return bool True if content appears to be a valid NFO. + */ + protected function validateNfoContent(string $content): bool + { + $length = strlen($content); + + // Too short to be meaningful + if ($length < 50) { + return false; + } + + // Count printable ASCII characters + $printableCount = preg_match_all('/[\x20-\x7E]/', $content); + $printableRatio = $printableCount / $length; + + // NFOs should be mostly printable characters + if ($printableRatio < 0.7) { + return false; + } + + // Check for minimum text content (words, not just symbols) + $wordCount = preg_match_all('/[A-Za-z]{2,}/', $content); + if ($wordCount < 5) { + return false; + } + + // Check for NFO-like content patterns + $nfoIndicators = 0; + + // Look for common NFO keywords + foreach ($this->_nfoKeywords as $keyword) { + if (stripos($content, $keyword) !== false) { + $nfoIndicators++; + if ($nfoIndicators >= 3) { + return true; // High confidence if multiple keywords found + } + } + } + + // Check for scene-style formatting + if (preg_match('/[-=*]{5,}/', $content)) { + $nfoIndicators++; + } + + // Check for URL presence (common in NFOs) + if (preg_match('/https?:\/\/|www\./i', $content)) { + $nfoIndicators++; + } + + // Check for media IDs + if (preg_match('/imdb\.com|thetvdb\.com|themoviedb\.org|anidb\.net/i', $content)) { + $nfoIndicators += 2; + } + + // Check for field:value patterns + if (preg_match_all('/^[A-Za-z\s]{2,20}\s*[:\.]\s*.+$/m', $content, $matches)) { + $nfoIndicators += min(count($matches[0]) / 3, 2); + } + + return $nfoIndicators >= 2; + } + /** * Add an NFO from alternate sources. ex.: PreDB, rar, zip, etc... * @@ -479,4 +710,751 @@ class Nfo ($minSize > 0 ? ('AND r.size > '.($minSize * 1048576)) : '') ); } + + /** + * Extract URLs from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array Array of found URLs. + */ + public function extractUrls(string $nfoContent): array + { + $urls = []; + + // Match HTTP/HTTPS URLs + if (preg_match_all('/https?:\/\/[^\s<>"\']+/i', $nfoContent, $matches)) { + $urls = array_merge($urls, $matches[0]); + } + + // Match www URLs without protocol + if (preg_match_all('/(?"\']+/i', $nfoContent, $matches)) { + foreach ($matches[0] as $url) { + $urls[] = 'http://'.$url; + } + } + + return array_unique(array_filter($urls)); + } + + /** + * Extract release group name from NFO content. + * + * Uses multiple detection strategies including: + * - Common presentation phrases + * - Scene-style headers with ASCII borders + * - Greetings sections + * - Footer signatures + * + * @param string $nfoContent The NFO content to parse. + * @return string|null The group name if found, null otherwise. + */ + public function extractGroupName(string $nfoContent): ?string + { + // False positives to filter out + $falsePositives = [ + 'THE', 'AND', 'FOR', 'NFO', 'INFO', 'DVD', 'BLU', 'RAY', 'WEB', 'HDTV', + 'RELEASE', 'GROUP', 'DATE', 'SIZE', 'CODEC', 'VIDEO', 'AUDIO', 'FORMAT', + 'NOTES', 'INSTALL', 'GREETS', 'PRESENTS', 'TEAM', 'SCENE', 'FILE', 'FILES', + ]; + + // Use configured scene group patterns + foreach ($this->_sceneGroupPatterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $groupName = trim($matches[1]); + if (! in_array(strtoupper($groupName), $falsePositives, true) && strlen($groupName) >= 2 && strlen($groupName) <= 20) { + return $groupName; + } + } + } + + // Additional patterns for group name detection + $additionalPatterns = [ + // "GROUP presents" or "GROUP brings you" + '/\b([A-Z][A-Z0-9]{1,14})\s+(?:presents?|brings?\s+you)/i', + // Common footer format: "--- GROUP ---" + '/[-=]{2,}\s*([A-Z][A-Z0-9]{1,14})\s*[-=]{2,}$/mi', + // Contact section: "irc.server.net #GROUP" + '/irc\.[a-z0-9.-]+\s+#([A-Z][A-Z0-9]{1,14})/i', + // Website: "www.GROUP.com/org/net" + '/www\.([a-z][a-z0-9]{1,14})\.(?:com|org|net|info)/i', + // ASCII art name extraction (common pattern at start) + '/^\s*[^a-zA-Z0-9]*([A-Z][A-Z0-9]{2,14})[^a-zA-Z0-9]*\s*$/mi', + ]; + + foreach ($additionalPatterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $groupName = trim($matches[1]); + if (! in_array(strtoupper($groupName), $falsePositives, true) && strlen($groupName) >= 2 && strlen($groupName) <= 20) { + return strtoupper($groupName); + } + } + } + + return null; + } + + /** + * Extract release date from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return string|null ISO date string if found, null otherwise. + */ + public function extractReleaseDate(string $nfoContent): ?string + { + $patterns = [ + // DD/MM/YYYY or MM/DD/YYYY + '/(?:date|released?|rls)\s*[:\-]?\s*(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})/i', + // YYYY-MM-DD + '/(?:date|released?|rls)\s*[:\-]?\s*(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/i', + // Month DD, YYYY + '/(?:date|released?|rls)\s*[:\-]?\s*(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})/i', + ]; + + foreach ($patterns as $index => $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + try { + if ($index === 0) { + // Try both DD/MM and MM/DD formats + $year = strlen($matches[3]) === 2 ? '20'.$matches[3] : $matches[3]; + // Assume DD/MM/YYYY format (more common internationally) + return sprintf('%04d-%02d-%02d', (int) $year, (int) $matches[2], (int) $matches[1]); + } elseif ($index === 1) { + // YYYY-MM-DD + return sprintf('%04d-%02d-%02d', (int) $matches[1], (int) $matches[2], (int) $matches[3]); + } else { + // Month name format + $months = ['jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6, 'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12]; + $month = $months[strtolower(substr($matches[1], 0, 3))] ?? 1; + + return sprintf('%04d-%02d-%02d', (int) $matches[3], $month, (int) $matches[2]); + } + } catch (Throwable) { + continue; + } + } + } + + return null; + } + + /** + * Extract video/audio codec information from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array{video?: string, audio?: string, resolution?: string} Array with codec info. + */ + public function extractCodecInfo(string $nfoContent): array + { + $result = []; + + // Video codecs + $videoPatterns = [ + '/(?:video|codec)\s*[:\-]?\s*(x264|x265|hevc|h\.?264|h\.?265|xvid|divx|av1|vp9|mpeg[24]?)/i', + '/\b(x264|x265|HEVC|H\.?264|H\.?265|XviD|DivX|AV1|VP9)\b/i', + ]; + foreach ($videoPatterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $result['video'] = strtoupper(str_replace('.', '', $matches[1])); + break; + } + } + + // Audio codecs + $audioPatterns = [ + '/(?:audio|sound)\s*[:\-]?\s*(aac|ac3|dts(?:-(?:hd|ma|x))?|truehd|atmos|flac|mp3|eac3|dd[+p]?|dolby)/i', + '/\b(AAC|AC3|DTS(?:-(?:HD|MA|X))?|TrueHD|Atmos|FLAC|EAC3|DD[+P]?)\b/i', + ]; + foreach ($audioPatterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $result['audio'] = strtoupper($matches[1]); + break; + } + } + + // Resolution + $resolutionPatterns = [ + '/(?:resolution|quality)\s*[:\-]?\s*(\d{3,4}[xX×]\d{3,4}|\d{3,4}p|[48]K|UHD|FHD|HD)/i', + '/\b(2160p|1080p|720p|480p|4K|UHD|FHD|HD)\b/i', + '/\b(\d{3,4})\s*[xX×]\s*(\d{3,4})\b/', + ]; + foreach ($resolutionPatterns as $index => $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + if ($index === 2) { + $result['resolution'] = $matches[1].'x'.$matches[2]; + } else { + $result['resolution'] = strtoupper($matches[1]); + } + break; + } + } + + return $result; + } + + /** + * Extract file size information from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return int|null File size in bytes if found, null otherwise. + */ + public function extractFileSize(string $nfoContent): ?int + { + $patterns = [ + '/(?:size|file\s*size)\s*[:\-]?\s*(\d+(?:[.,]\d+)?)\s*(bytes?|[KMGTP]B|[KMGTP]iB)/i', + '/\b(\d+(?:[.,]\d+)?)\s*(GB|GiB|MB|MiB|TB|TiB)\b/i', + ]; + + $multipliers = [ + 'B' => 1, 'BYTE' => 1, 'BYTES' => 1, + 'KB' => 1024, 'KIB' => 1024, + 'MB' => 1024 * 1024, 'MIB' => 1024 * 1024, + 'GB' => 1024 * 1024 * 1024, 'GIB' => 1024 * 1024 * 1024, + 'TB' => 1024 * 1024 * 1024 * 1024, 'TIB' => 1024 * 1024 * 1024 * 1024, + 'PB' => 1024 * 1024 * 1024 * 1024 * 1024, 'PIB' => 1024 * 1024 * 1024 * 1024 * 1024, + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $value = (float) str_replace(',', '.', $matches[1]); + $unit = strtoupper($matches[2]); + + if (isset($multipliers[$unit])) { + return (int) ($value * $multipliers[$unit]); + } + } + } + + return null; + } + + /** + * Extract all media IDs (IMDB, TVDB, TMDB, etc.) from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array Array of media IDs with their sources. + */ + public function extractAllMediaIds(string $nfoContent): array + { + $ids = []; + + // IMDB + if (preg_match_all('/imdb\.com\/title\/(tt\d{7,8})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'imdb']; + } + } + + // TVDB + if (preg_match_all('/thetvdb\.com\/(?:\?tab=series&id=|series\/)(\d{1,8})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'thetvdb']; + } + } + + // TMDB Movie + if (preg_match_all('/themoviedb\.org\/movie\/(\d{1,8})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'tmdb_movie']; + } + } + + // TMDB TV + if (preg_match_all('/themoviedb\.org\/tv\/(\d{1,8})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'tmdb_tv']; + } + } + + // TVMaze + if (preg_match_all('/tvmaze\.com\/shows\/(\d{1,6})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'tvmaze']; + } + } + + // AniDB + if (preg_match_all('/anidb\.net\/(?:perl-bin\/animedb\.pl\?show=anime&aid=|anime\/)(\d{1,6})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'anidb']; + } + } + + // MyAnimeList (MAL) + if (preg_match_all('/myanimelist\.net\/anime\/(\d{1,6})/i', $nfoContent, $matches)) { + foreach ($matches[1] as $id) { + $ids[] = ['id' => $id, 'source' => 'mal']; + } + } + + return $ids; + } + + /** + * Parse and extract comprehensive metadata from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array Associative array with extracted metadata. + */ + public function parseNfoMetadata(string $nfoContent): array + { + return [ + 'urls' => $this->extractUrls($nfoContent), + 'group' => $this->extractGroupName($nfoContent), + 'release_date' => $this->extractReleaseDate($nfoContent), + 'codec_info' => $this->extractCodecInfo($nfoContent), + 'file_size' => $this->extractFileSize($nfoContent), + 'media_ids' => $this->extractAllMediaIds($nfoContent), + 'show_id' => $this->parseShowId($nfoContent), + 'language' => $this->extractLanguage($nfoContent), + 'runtime' => $this->extractRuntime($nfoContent), + 'genre' => $this->extractGenre($nfoContent), + 'software_info' => $this->extractSoftwareInfo($nfoContent), + 'release_title' => $this->extractReleaseTitle($nfoContent), + ]; + } + + /** + * Extract language information from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array Array of detected languages. + */ + public function extractLanguage(string $nfoContent): array + { + $languages = []; + + // Common language patterns in NFOs + $patterns = [ + '/(?:language|audio|spoken?|dialogue)\s*[:\-]?\s*([A-Za-z]+(?:\s*[,\/&]\s*[A-Za-z]+)*)/i', + '/(?:subs?|subtitles?)\s*[:\-]?\s*([A-Za-z]+(?:\s*[,\/&]\s*[A-Za-z]+)*)/i', + ]; + + // Known language names + $knownLanguages = [ + 'english', 'german', 'french', 'spanish', 'italian', 'dutch', 'portuguese', + 'russian', 'japanese', 'korean', 'chinese', 'mandarin', 'cantonese', + 'swedish', 'norwegian', 'danish', 'finnish', 'polish', 'czech', 'hungarian', + 'turkish', 'arabic', 'hindi', 'thai', 'vietnamese', 'indonesian', 'malay', + 'multi', 'dual', 'english/german', 'eng', 'ger', 'fre', 'spa', 'ita', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $langs = preg_split('/[\s,\/&]+/', strtolower($matches[1])); + foreach ($langs as $lang) { + $lang = trim($lang); + if (in_array($lang, $knownLanguages, true) && ! in_array($lang, $languages, true)) { + $languages[] = ucfirst($lang); + } + } + } + } + + return $languages; + } + + /** + * Extract runtime/duration from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return int|null Runtime in minutes, or null if not found. + */ + public function extractRuntime(string $nfoContent): ?int + { + $patterns = [ + // "Runtime: 1h 30m" or "Duration: 90min" + '/(?:runtime|duration|length|playtime)\s*[:\-]?\s*(?:(\d{1,2})\s*h(?:ours?)?\s*)?(\d{1,3})\s*m(?:in(?:utes?)?)?/i', + // "Runtime: 01:30:00" or "1:30:00" + '/(?:runtime|duration|length|playtime)\s*[:\-]?\s*(\d{1,2}):(\d{2})(?::(\d{2}))?/i', + // "90 minutes" standalone + '/\b(\d{2,3})\s*(?:min(?:utes?)?|mins)\b/i', + ]; + + foreach ($patterns as $index => $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + if ($index === 0) { + $hours = ! empty($matches[1]) ? (int) $matches[1] : 0; + $minutes = (int) $matches[2]; + return ($hours * 60) + $minutes; + } elseif ($index === 1) { + $hours = (int) $matches[1]; + $minutes = (int) $matches[2]; + return ($hours * 60) + $minutes; + } else { + return (int) $matches[1]; + } + } + } + + return null; + } + + /** + * Extract genre information from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array Array of detected genres. + */ + public function extractGenre(string $nfoContent): array + { + $genres = []; + + if (preg_match('/(?:genre|category|type)\s*[:\-]?\s*([^\n\r]+)/i', $nfoContent, $matches)) { + $genreString = $matches[1]; + // Split on common separators + $parts = preg_split('/[\s,\/&|]+/', $genreString); + + // Known valid genres + $validGenres = [ + 'action', 'adventure', 'animation', 'biography', 'comedy', 'crime', + 'documentary', 'drama', 'family', 'fantasy', 'history', 'horror', + 'music', 'musical', 'mystery', 'romance', 'sci-fi', 'scifi', 'sport', + 'thriller', 'war', 'western', 'adult', 'xxx', 'erotic', 'anime', + 'rpg', 'fps', 'strategy', 'simulation', 'puzzle', 'racing', 'sports', + 'rock', 'pop', 'electronic', 'hip-hop', 'rap', 'classical', 'jazz', + ]; + + foreach ($parts as $part) { + $part = strtolower(trim($part)); + if (in_array($part, $validGenres, true) && ! in_array(ucfirst($part), $genres, true)) { + $genres[] = ucfirst($part); + } + } + } + + return $genres; + } + + /** + * Extract software-specific information from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return array Software info including platform, version, protection, etc. + */ + public function extractSoftwareInfo(string $nfoContent): array + { + $info = []; + + // Platform/OS detection + $platformPatterns = [ + '/(?:platform|os|system|requires?)\s*[:\-]?\s*(windows?|linux|mac(?:os)?|unix|android|ios)/i', + ]; + foreach ($platformPatterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $info['platform'] = ucfirst(strtolower($matches[1])); + break; + } + } + + // Version detection + if (preg_match('/(?:version|ver|v)\s*[:\-]?\s*(\d+(?:\.\d+)*(?:\s*(?:build|b)\s*\d+)?)/i', $nfoContent, $matches)) { + $info['version'] = trim($matches[1]); + } + + // Protection type + $protectionPatterns = [ + '/(?:protection|drm|copy[ -]?protection)\s*[:\-]?\s*([^\n\r]+)/i', + ]; + foreach ($protectionPatterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $protection = trim($matches[1]); + if (strlen($protection) > 2 && strlen($protection) < 50) { + $info['protection'] = $protection; + } + break; + } + } + + // Crack/Keygen/Serial info + if (preg_match('/\b(cracked|keygen|serial|patch|loader|activator)\b/i', $nfoContent)) { + $info['has_crack'] = true; + } + + return $info; + } + + /** + * Extract release title from NFO content. + * + * @param string $nfoContent The NFO content to parse. + * @return string|null The release title if found. + */ + public function extractReleaseTitle(string $nfoContent): ?string + { + $patterns = [ + // "Title: Movie Name" or "Release: Title.Goes.Here" + '/(?:title|release|name)\s*[:\-]?\s*([^\n\r]{5,100})/i', + // Scene-style title in header + '/(?:^|\n)\s*(?:[\-=*~]{3,}\s*)?([A-Za-z0-9][\w.\-\s]{10,80}?)(?:\s*[\-=*~]{3,})?\s*(?:\n|$)/m', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $nfoContent, $matches)) { + $title = trim($matches[1]); + // Filter out common non-title content + if (! preg_match('/^(?:date|size|codec|format|video|audio|language|runtime|genre)\s*:/i', $title) + && strlen($title) >= 5 && strlen($title) <= 100) { + return $title; + } + } + } + + return null; + } + + /** + * Clean and normalize NFO content. + * + * @param string $nfoContent Raw NFO content. + * @return string Cleaned NFO content. + */ + public function cleanNfoContent(string $nfoContent): string + { + // Convert to UTF-8 if needed (CP437 is common for NFOs) + $content = Utility::cp437toUTF($nfoContent); + + // Normalize line endings + $content = str_replace(["\r\n", "\r"], "\n", $content); + + // Remove excessive whitespace while preserving NFO art + $lines = explode("\n", $content); + $cleanedLines = []; + $emptyLineCount = 0; + + foreach ($lines as $line) { + if (trim($line) === '') { + $emptyLineCount++; + // Allow max 2 consecutive empty lines + if ($emptyLineCount <= 2) { + $cleanedLines[] = ''; + } + } else { + $emptyLineCount = 0; + $cleanedLines[] = rtrim($line); + } + } + + return implode("\n", $cleanedLines); + } + + /** + * Calculate an NFO quality score based on content analysis. + * + * Scoring factors: + * - Content length (too short or too long penalized) + * - Keyword presence (scene terminology, media info) + * - Media ID presence (IMDB, TVDB, etc.) + * - URL presence + * - Codec information + * - ASCII art detection (scene NFOs often have artistic headers) + * - Structural elements (proper formatting) + * + * @param string $nfoContent The NFO content to analyze. + * @return int Quality score from 0-100. + */ + public function calculateNfoQuality(string $nfoContent): int + { + $score = 50; // Base score + + $length = strlen($nfoContent); + + // Length bonus/penalty + if ($length < 100) { + $score -= 20; + } elseif ($length > 500 && $length < 20000) { + $score += 15; + } elseif ($length >= 20000) { + $score += 5; // Longer NFOs might have too much filler + } + + // Keyword matching + $keywordMatches = 0; + foreach ($this->_nfoKeywords as $keyword) { + if (stripos($nfoContent, $keyword) !== false) { + $keywordMatches++; + } + } + $score += min($keywordMatches * 2, 20); + + // Media ID presence bonus + $mediaIds = $this->extractAllMediaIds($nfoContent); + if (! empty($mediaIds)) { + $score += min(count($mediaIds) * 5, 15); + } + + // URL presence + $urls = $this->extractUrls($nfoContent); + if (! empty($urls)) { + $score += min(count($urls) * 2, 10); + } + + // Codec info presence + $codecInfo = $this->extractCodecInfo($nfoContent); + $score += count(array_filter($codecInfo)) * 3; + + // ASCII art detection (scene NFOs often have decorative borders) + if ($this->hasAsciiArt($nfoContent)) { + $score += 10; + } + + // Structural elements bonus + $structuralScore = $this->analyzeStructure($nfoContent); + $score += $structuralScore; + + // Group name detection bonus + if ($this->extractGroupName($nfoContent) !== null) { + $score += 8; + } + + // Release date detection bonus + if ($this->extractReleaseDate($nfoContent) !== null) { + $score += 5; + } + + // Language info bonus + $languages = $this->extractLanguage($nfoContent); + if (! empty($languages)) { + $score += min(count($languages) * 2, 6); + } + + // Runtime detection bonus + if ($this->extractRuntime($nfoContent) !== null) { + $score += 4; + } + + // Penalty for binary content remnants + if (preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $nfoContent, $binaryMatches)) { + $score -= min(count($binaryMatches[0]) * 5, 20); + } + + return max(0, min(100, $score)); + } + + /** + * Detect ASCII art in NFO content. + * + * @param string $nfoContent The NFO content to analyze. + * @return bool True if ASCII art is detected. + */ + protected function hasAsciiArt(string $nfoContent): bool + { + // Check for common ASCII art characters in repeated sequences + $asciiArtPatterns = [ + // Decorative borders + '/[-=*~#@]{5,}/', + // Box drawing characters + '/[┌┐└┘├┤┬┴┼│─╔╗╚╝║═]{3,}/', + // Extended ASCII art characters + '/[░▒▓█▄▀■□▪▫]{3,}/', + // Common ASCII art patterns + '/[\/\\|_]{3,}.*[\/\\|_]{3,}/', + // Repeated special chars in artistic patterns + '/(\S)\1{4,}/', + ]; + + foreach ($asciiArtPatterns as $pattern) { + if (preg_match($pattern, $nfoContent)) { + return true; + } + } + + return false; + } + + /** + * Analyze structural elements of NFO content. + * + * @param string $nfoContent The NFO content to analyze. + * @return int Score based on structural quality (0-15). + */ + protected function analyzeStructure(string $nfoContent): int + { + $score = 0; + + // Check for section headers + $sectionPatterns = [ + '/^[ \t]*[-=*]{2,}.*[-=*]{2,}[ \t]*$/m', // Decorative section dividers + '/^[ \t]*\[.*\][ \t]*$/m', // [Section Name] + '/^[ \t]*<.*>[ \t]*$/m', //
+ ]; + + foreach ($sectionPatterns as $pattern) { + if (preg_match_all($pattern, $nfoContent, $matches)) { + $score += min(count($matches[0]), 3); + } + } + + // Check for labeled fields (Field: Value format) + if (preg_match_all('/^[ \t]*[A-Za-z][A-Za-z\s]{2,20}\s*[:\.].*$/m', $nfoContent, $matches)) { + $score += min(count($matches[0]) / 2, 5); + } + + // Check for consistent line endings and formatting + $lines = explode("\n", $nfoContent); + $nonEmptyLines = array_filter($lines, fn($line) => trim($line) !== ''); + + if (count($nonEmptyLines) >= 10) { + $score += 2; + } + + return min(15, (int) $score); + } + + /** + * Decompress and retrieve NFO content from a release. + * + * @param int $releaseId The release ID. + * @return string|null The NFO content or null if not found. + */ + public function getNfoContent(int $releaseId): ?string + { + $nfoRecord = ReleaseNfo::getReleaseNfo($releaseId); + + if ($nfoRecord === null || empty($nfoRecord->nfo)) { + return null; + } + + return $nfoRecord->nfo; + } + + /** + * Store NFO content for a release. + * + * @param int $releaseId The release ID. + * @param string $nfoContent The NFO content to store. + * @param bool $compress Whether to compress the content. + * @return bool True on success, false on failure. + */ + public function storeNfoContent(int $releaseId, string $nfoContent, bool $compress = true): bool + { + try { + $data = $compress ? "\x1f\x8b\x08\x00".gzcompress($nfoContent) : $nfoContent; + + ReleaseNfo::updateOrCreate( + ['releases_id' => $releaseId], + ['nfo' => $data] + ); + + Release::whereId($releaseId)->update(['nfostatus' => self::NFO_FOUND]); + + return true; + } catch (Throwable $e) { + Log::error("Failed to store NFO for release {$releaseId}: ".$e->getMessage()); + + return false; + } + } + + /** + * Clear the settings cache. + * + * Useful when settings have been updated and need to be reloaded. + */ + public function clearSettingsCache(): void + { + Cache::forget('nfo_maxnfoprocessed'); + Cache::forget('nfo_maxnforetries'); + Cache::forget('nfo_maxsizetoprocessnfo'); + Cache::forget('nfo_minsizetoprocessnfo'); + } } diff --git a/app/Services/AdditionalProcessing/AdditionalProcessingOrchestrator.php b/app/Services/AdditionalProcessing/AdditionalProcessingOrchestrator.php index 9936d4ae0..08c7c70ed 100644 --- a/app/Services/AdditionalProcessing/AdditionalProcessingOrchestrator.php +++ b/app/Services/AdditionalProcessing/AdditionalProcessingOrchestrator.php @@ -626,12 +626,22 @@ class AdditionalProcessingOrchestrator continue; } - // NFO files - if ($context->releaseHasNoNFO && preg_match('/(\.(nfo|inf|ofn)|info\.txt)$/i', $filePath)) { - if ($this->releaseManager->processNfoFile($filePath, $context, $this->downloadService->getNNTP())) { - $this->output->echoNfoFound(); + // NFO files - enhanced detection with multiple patterns + if ($context->releaseHasNoNFO) { + // Standard NFO extensions + if (preg_match('/(\.(nfo|inf|ofn|diz)|info\.txt)$/i', $filePath)) { + if ($this->releaseManager->processNfoFile($filePath, $context, $this->downloadService->getNNTP())) { + $this->output->echoNfoFound(); + } + continue; + } + // Alternative NFO filenames (file_id.diz, readme.txt, etc.) + elseif ($this->releaseManager->isNfoFilename($filePath)) { + if ($this->releaseManager->processNfoFile($filePath, $context, $this->downloadService->getNNTP())) { + $this->output->echoNfoFound(); + } + continue; } - continue; } // Audio files diff --git a/app/Services/AdditionalProcessing/ArchiveExtractionService.php b/app/Services/AdditionalProcessing/ArchiveExtractionService.php index dda9f5701..9693e6ba5 100644 --- a/app/Services/AdditionalProcessing/ArchiveExtractionService.php +++ b/app/Services/AdditionalProcessing/ArchiveExtractionService.php @@ -574,8 +574,82 @@ class ArchiveExtractionService */ private function getAllowedExtensions(): array { - return ['nfo', 'srt', 'mkv', 'mpeg', 'avi', 'jpg', 'jpeg', 'exe', 'mp4', 'mp3', 'm4a', - 'flac', 'png', 'epub', 'cbz', 'cbr', 'djvu']; + return [ + // NFO and info files (prioritized for extraction) + 'nfo', 'diz', 'inf', 'txt', + // Subtitles + 'srt', 'sub', 'idx', 'ass', 'ssa', 'vtt', + // Video + 'mkv', 'mpeg', 'avi', 'mp4', 'm4v', 'mov', 'wmv', 'flv', 'ts', 'vob', 'm2ts', 'webm', + // Audio + 'mp3', 'm4a', 'flac', 'ogg', 'aac', 'wav', 'wma', 'opus', 'ape', + // Images + 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', + // Documents + 'epub', 'pdf', 'cbz', 'cbr', 'djvu', 'mobi', 'azw', 'azw3', + // Executables (for software releases) + 'exe', 'msi', + ]; + } + + /** + * Check if a file is an NFO or info file. + * + * @param string $filename The filename to check. + * @return bool True if it's an NFO-like file. + */ + public function isNfoFile(string $filename): bool + { + $basename = strtolower(basename($filename)); + + // Standard NFO extensions + if (preg_match('/\.(nfo|diz|inf)$/i', $basename)) { + return true; + } + + // Common NFO alternative names + $nfoNames = [ + 'file_id.diz', 'fileid.diz', 'file-id.diz', + 'readme.txt', 'readme.1st', 'read.me', 'readmenow.txt', + 'info.txt', 'information.txt', 'about.txt', 'notes.txt', + 'release.txt', 'release.nfo', + ]; + + if (in_array($basename, $nfoNames, true)) { + return true; + } + + // Scene-style NFO naming: 00-groupname.nfo, group-release.nfo + if (preg_match('/^(?:00?-[a-z0-9_-]+|[a-z0-9]+-[a-z0-9._-]+)\.(?:nfo|txt)$/i', $basename)) { + return true; + } + + return false; + } + + /** + * Sort files to prioritize NFO files for processing. + * + * @param array $files Array of file info arrays. + * @return array Sorted array with NFO files first. + */ + public function sortFilesWithNfoPriority(array $files): array + { + usort($files, function ($a, $b) { + $aIsNfo = $this->isNfoFile($a['name'] ?? ''); + $bIsNfo = $this->isNfoFile($b['name'] ?? ''); + + if ($aIsNfo && ! $bIsNfo) { + return -1; + } + if (! $aIsNfo && $bIsNfo) { + return 1; + } + + return 0; + }); + + return $files; } /** diff --git a/app/Services/AdditionalProcessing/ReleaseFileManager.php b/app/Services/AdditionalProcessing/ReleaseFileManager.php index 960f6b588..a39687dde 100644 --- a/app/Services/AdditionalProcessing/ReleaseFileManager.php +++ b/app/Services/AdditionalProcessing/ReleaseFileManager.php @@ -345,7 +345,12 @@ class ReleaseFileManager } /** - * Process NFO file. + * Process NFO file with enhanced detection capabilities. + * + * Supports multiple NFO naming conventions: + * - Standard: .nfo, .diz, .info + * - Alternative: file_id.diz, readme.txt, info.txt + * - Scene-style: 00-groupname.nfo, groupname-releasename.nfo */ public function processNfoFile( string $fileLocation, @@ -354,6 +359,10 @@ class ReleaseFileManager ): bool { try { $data = File::get($fileLocation); + + // Try to detect and convert encoding + $data = $this->normalizeNfoEncoding($data); + if ($this->nfo->isNFO($data, $context->release->guid) && $this->nfo->addAlternateNfo($data, $context->release, $nntp) ) { @@ -367,6 +376,84 @@ class ReleaseFileManager return false; } + /** + * Check if a filename looks like an NFO file. + * + * @param string $filename The filename to check. + * @return bool True if the filename matches NFO patterns. + */ + public function isNfoFilename(string $filename): bool + { + // Standard NFO extensions + if (preg_match('/\.(?:nfo|diz|info?)$/i', $filename)) { + return true; + } + + // Alternative NFO filenames + $nfoPatterns = [ + '/^(?:file[_-]?id|readme|release|info(?:rmation)?|about|notes?)\.(?:txt|diz)$/i', + '/^00-[a-z0-9_-]+\.nfo$/i', // Scene: 00-group.nfo + '/^0+-[a-z0-9_-]+\.nfo$/i', // Scene variations + '/^[a-z0-9_-]+-[a-z0-9_.-]+\.nfo$/i', // Scene: group-release.nfo + '/info\.txt$/i', // info.txt (common alternative) + ]; + + $basename = basename($filename); + foreach ($nfoPatterns as $pattern) { + if (preg_match($pattern, $basename)) { + return true; + } + } + + return false; + } + + /** + * Normalize NFO encoding to UTF-8. + * + * NFO files often use CP437 (DOS) encoding for ASCII art. + * This method attempts to detect and convert various encodings. + * + * @param string $data Raw NFO data. + * @return string UTF-8 encoded NFO data. + */ + protected function normalizeNfoEncoding(string $data): string + { + // Check for UTF-8 BOM and remove it + if (str_starts_with($data, "\xEF\xBB\xBF")) { + $data = substr($data, 3); + } + + // Check for UTF-16 BOM + if (str_starts_with($data, "\xFF\xFE")) { + // UTF-16 LE + $data = mb_convert_encoding(substr($data, 2), 'UTF-8', 'UTF-16LE'); + } elseif (str_starts_with($data, "\xFE\xFF")) { + // UTF-16 BE + $data = mb_convert_encoding(substr($data, 2), 'UTF-8', 'UTF-16BE'); + } + + // If already valid UTF-8, return as-is + if (mb_check_encoding($data, 'UTF-8')) { + return $data; + } + + // Try CP437 (DOS encoding - common for scene NFOs with ASCII art) + // Use the utility function if available + if (class_exists('\Blacklight\utility\Utility') && method_exists('\Blacklight\utility\Utility', 'cp437toUTF')) { + return \Blacklight\utility\Utility::cp437toUTF($data); + } + + // Fallback: try ISO-8859-1 (Latin-1) + $converted = @mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1'); + if ($converted !== false) { + return $converted; + } + + // Last resort: force UTF-8 with error handling + return mb_convert_encoding($data, 'UTF-8', 'UTF-8'); + } + /** * Handle release name extraction from RAR file content. */