*/ protected array $_nfoKeywords = [ // @phpstan-ignore property.defaultValue // 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', ]; /** * Scene group patterns for improved detection. * * @var array */ protected array $_sceneGroupPatterns = [ // @phpstan-ignore property.defaultValue '/(?:^|\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', ]; /** * Maximum NFO file size in bytes (64KB). */ protected const MAX_NFO_SIZE = 65535; /** * Minimum NFO file size in bytes. */ protected const MIN_NFO_SIZE = 12; /** * Cache TTL for settings in seconds. */ protected const SETTINGS_CACHE_TTL = 300; /** * Lazily loaded from settings + cache when NFO processing runs (see getters). */ private ?int $nzbs = null; /** * Lazily loaded from settings + cache when NFO processing runs. */ protected ?int $maxSize = null; /** * Lazily loaded from settings + cache when NFO processing runs. */ private ?int $maxRetries = null; /** * Lazily loaded from settings + cache when NFO processing runs. */ protected ?int $minSize = null; /** * @var string Temporary path for processing files. */ private string $tmpPath; /** * @var bool Whether to echo output to CLI. */ protected bool $echo; private string|false $unrarPath; private string|false $timeoutPath; private int $timeoutSeconds; public const NFO_FAILED = -9; // We failed to get a NFO after admin set max retries. public const NFO_FAILED_ARCHIVE = -10; // Archive-based NFO extraction also failed; no more retries. public const NFO_UNPROC = -1; // Release has not been processed yet. public const NFO_NONFO = 0; // Release has no NFO. public const NFO_FOUND = 1; // Release has an NFO. /** * Default constructor. * * Heavy settings (batch limits, sizes, retries) load on first NFO processing use via getters, * so constructing this service does not hit the cache or database. */ public function __construct() { $this->echo = (bool) config('nntmux.echocli'); $this->tmpPath = rtrim((string) config('nntmux.tmp_unrar_path'), '/\\').'/'; $this->unrarPath = config('nntmux_settings.unrar_path') ?: false; $this->timeoutPath = config('nntmux_settings.timeout_path') ?: false; $this->timeoutSeconds = (int) (Settings::settingValue('timeoutseconds') ?: 60); } private function getNzbs(): int { if ($this->nzbs === null) { $this->nzbs = (int) $this->rememberNfoSetting('nfo_maxnfoprocessed', function () { $value = Settings::settingValue('maxnfoprocessed'); return $value !== '' ? (int) $value : 100; }); } return $this->nzbs; } private function getMaxRetries(): int { if ($this->maxRetries === null) { $maxRetries = (int) $this->rememberNfoSetting('nfo_maxnforetries', function () { return (int) Settings::settingValue('maxnforetries'); }); $computed = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC; $this->maxRetries = max($computed, -8); } return $this->maxRetries; } private function getMaxSize(): int { if ($this->maxSize === null) { $this->maxSize = (int) $this->rememberNfoSetting('nfo_maxsizetoprocessnfo', function () { return (int) Settings::settingValue('maxsizetoprocessnfo'); }); } return $this->maxSize; } private function getMinSize(): int { if ($this->minSize === null) { $this->minSize = (int) $this->rememberNfoSetting('nfo_minsizetoprocessnfo', function () { return (int) Settings::settingValue('minsizetoprocessnfo'); }); } return $this->minSize; } /** * Read a setting through the cache when available; fall back to the callback if the * cache store is unreachable (e.g. Redis down while CACHE_STORE=redis). * * @param callable(): mixed $callback */ private function rememberNfoSetting(string $key, callable $callback): mixed { try { return Cache::remember($key, self::SETTINGS_CACHE_TTL, $callback); } catch (Throwable $e) { if (config('app.debug')) { Log::debug('NfoService cache bypassed: '.$e->getMessage()); } return $callback(); } } /** * 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{showid: string, site: string}|false Return array with show ID and site source or false on failure. */ public function parseShowId(string $str): array|false { // TVMaze if (preg_match('/tvmaze\.com\/shows\/(\d{1,6})/i', $str, $hits)) { return ['showid' => trim($hits[1]), 'site' => 'tvmaze']; } // IMDB (movies and TV shows) if (preg_match('/imdb\.com\/title\/(tt\d{7,})/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']; } // 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 || $possibleNFO === '') { return false; } $size = \strlen($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; try { // File/GetId3 work with files, so save to disk. File::put($tmpPath, $possibleNFO); // Use 'file' command via fileInfo if available $result = fileInfo($tmpPath); if (! empty($result)) { if (preg_match($this->_textFileRegex, $result)) { $isNfo = true; } elseif (preg_match($this->_binaryFileRegex, $result) || preg_match($this->_binaryCharsRegex, $possibleNFO)) { $isNfo = false; } // If fileInfo gave a result, apply additional heuristics before returning if ($isNfo) { // Additional content validation for text files $isNfo = $this->validateNfoContent($possibleNFO); } return $isNfo; } // Fallback checks if 'file' command is unavailable or inconclusive // Check if it's a PAR2 file $par2info = new Par2Info; $par2info->setData($possibleNFO); if (! $par2info->error) { return false; } // Check if it's an SFV file $sfv = new SfvInfo; $sfv->setData($possibleNFO); if (! $sfv->error) { return false; } // Check for binary characters if (preg_match($this->_binaryCharsRegex, $possibleNFO)) { return false; } // Final content-based validation $isNfo = $this->validateNfoContent($possibleNFO); } catch (Throwable $e) { Log::error("Error processing potential NFO for GUID {$guid}: ".$e->getMessage()); $isNfo = false; } finally { // Ensure temporary file is always deleted if (File::exists($tmpPath)) { try { File::delete($tmpPath); } catch (Throwable $e) { Log::error("Error deleting temporary NFO file {$tmpPath}: ".$e->getMessage()); } } } 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... * * @param bool|string $nfo The nfo. * @param NNTPService $nntp Instance of class NNTPService. * @return bool True on success, False on failure. * * @throws \Exception */ public function addAlternateNfo(bool|string &$nfo, mixed $release, NNTPService $nntp): bool { if ($release->id > 0 && $this->isNFO($nfo, $release->guid)) { $check = ReleaseNfo::whereReleasesId($release->id)->first(['releases_id']); if ($check === null) { ReleaseNfo::query()->insert(['releases_id' => $release->id, 'nfo' => "\x1f\x8b\x08\x00".gzcompress($nfo)]); } Release::whereId($release->id)->update(['nfostatus' => self::NFO_FOUND]); if (! isset($release->completion)) { $release->completion = 0; } if ($release->completion === 0) { $nzbContentsService = app(NzbContentsService::class); $nzbContentsService->setNntp($nntp); $nzbContentsService->setNfo($this); $nzbContentsService->setEchoOutput($this->echo); $nzbContentsService->parseNzb($release->guid, $release->id, $release->groups_id ?? 0); } return true; } return false; } /** * Fallback: attempt to extract an NFO from a compressed archive in the NZB * when the direct NZB-subject-based download has failed. * * Uses the release_files table to know which NFO filename to look for, * then downloads a RAR/ZIP from the NZB and extracts it with unrar/unzip. * * When NFO entries exist in release_files but extraction yields no valid * content (e.g. 0-byte file), sets nfostatus to NFO_NONFO to stop retries. * * @return string|false The NFO content, or false on failure */ public function attemptNfoFromArchive(string $guid, int $releaseId, NNTPService $nntp): string|false { if ($this->unrarPath === false) { return false; } $nfoFiles = ReleaseFile::where('releases_id', $releaseId) ->nfoFiles() ->limit(3) ->pluck('name') ->all(); if (empty($nfoFiles)) { return false; } $hasContentfulNfo = ReleaseFile::where('releases_id', $releaseId) ->nfoFilesWithContent() ->exists(); if (! $hasContentfulNfo) { Release::whereId($releaseId)->update(['nfostatus' => self::NFO_NONFO]); return false; } $nzbService = app(NzbService::class); $nzbContents = $nzbService->readNzbContents($guid); if ($nzbContents === false) { return false; } $parserService = app(NzbParserService::class); $fileList = $parserService->parseNzbFileList($nzbContents); if (empty($fileList)) { return false; } $alternateNntp = (bool) config('nntmux_nntp.use_alternate_nntp_server'); $maxSegments = 40; $triedArchive = false; foreach ($fileList as $nzbFile) { $title = $nzbFile['title'] ?? ''; if (! preg_match( '/(\\.(part0*1|rar|zip))(\\s*\\.rar)*($|[ ")]|-])|"[a-f0-9]{32}\\.[1-9]\\d{1,2}".*\\(\\d+\\/\\d{2,}\\)$/i', $title )) { continue; } $segments = $nzbFile['segments'] ?? []; if (empty($segments)) { continue; } $messageIDs = array_slice($segments, 0, $maxSegments); $compressedData = $nntp->getMessagesByMessageID($messageIDs, $alternateNntp); if (! is_string($compressedData) || $compressedData === '') { continue; } $triedArchive = true; $extracted = $this->extractNfoViaUnrar($compressedData, $nfoFiles, $guid); if ($extracted !== false) { return $extracted; } break; } if ($triedArchive) { Release::whereId($releaseId)->update(['nfostatus' => self::NFO_NONFO]); } return false; } /** * Extract an NFO file from compressed archive data using the unrar binary. * * @param string $compressedData Raw archive bytes * @param array $nfoFilenames NFO filenames to try extracting * @param string $guid Release GUID for NFO validation * @return string|false Extracted NFO content, or false on failure */ private function extractNfoViaUnrar(string $compressedData, array $nfoFilenames, string $guid): string|false { $uniqueId = uniqid('nfo_', true); $archiveFile = $this->tmpPath.'archive_'.$uniqueId.'.rar'; $extractDir = $this->tmpPath.'extract_'.$uniqueId.'/'; try { if (! File::isDirectory($extractDir)) { File::makeDirectory($extractDir, 0777, true, true); } File::put($archiveFile, $compressedData); $killString = $this->getKillString(); foreach ($nfoFilenames as $nfoFilename) { runCmd($killString.$this->unrarPath.'" e -y -c- -inul -p- "'.$archiveFile.'" "'.$nfoFilename.'" "'.$extractDir.'"'); $extractedPath = $extractDir.basename($nfoFilename); if (! File::isFile($extractedPath)) { $allFiles = File::allFiles($extractDir); foreach ($allFiles as $file) { if (strcasecmp($file->getFilename(), basename($nfoFilename)) === 0) { $extractedPath = $file->getPathname(); break; } } } if (File::isFile($extractedPath) && File::size($extractedPath) > 0) { $content = File::get($extractedPath); if ($this->isNFO($content, $guid)) { return $content; } } } } catch (Throwable $e) { Log::debug('NFO unrar extraction failed: '.$e->getMessage()); } finally { File::delete($archiveFile); if (File::isDirectory($extractDir)) { File::deleteDirectory($extractDir); } } return false; } /** * Build the kill/timeout string for wrapping CLI commands. */ private function getKillString(): string { if ($this->timeoutPath && $this->timeoutSeconds > 0) { return '"'.$this->timeoutPath.'" --foreground --signal=KILL '.$this->timeoutSeconds.' "'; } return '"'; } /** * Attempt to find NFO files inside the NZB's of releases. * * @param NNTPService $nntp The NNTP connection object * @param string $groupID (optional) Group ID to filter releases by * @param string $guidChar (optional) First character of the GUID for parallel processing * @param bool $processImdb (optional) Process IMDB IDs (currently unused) * @param bool $processTv (optional) Process TV IDs (currently unused) * @return int Count of successfully processed NFO files * * @throws \Exception If NNTP operations fail */ public function processNfoFiles(NNTPService $nntp, string $groupID = '', string $guidChar = '', bool $processImdb = true, bool $processTv = true): int { $processedCount = 0; // Build base query with all filters $baseQuery = $this->buildNfoProcessingQuery($groupID, $guidChar); // Fetch releases to process $releases = $baseQuery->clone() ->orderBy('nfostatus') ->orderByDesc('postdate') ->limit($this->getNzbs()) ->get(['id', 'guid', 'groups_id', 'name']); $nfoCount = $releases->count(); if ($nfoCount > 0) { // Display processing information $this->displayProcessingHeader($guidChar, $groupID, $nfoCount); // Show detailed stats if echo is enabled if ($this->echo) { $this->displayNfoStatusStats($baseQuery); } // Process each release $nzbContentsService = app(NzbContentsService::class); $nzbContentsService->setNntp($nntp); $nzbContentsService->setNfo($this); foreach ($releases as $release) { try { $groupName = UsenetGroup::getNameByID($release['groups_id']); $fetchedBinary = $nzbContentsService->getNfoFromNzb($release['guid'], $release['id'], $release['groups_id'], $groupName); // Fallback: try extracting NFO from a RAR/ZIP in the NZB if ($fetchedBinary === false) { $fetchedBinary = $this->attemptNfoFromArchive($release['guid'], $release['id'], $nntp); if ($fetchedBinary !== false && $this->echo) { echo 'A'; } } if ($fetchedBinary !== false) { DB::beginTransaction(); try { $exists = ReleaseNfo::whereReleasesId($release['id'])->exists(); if (! $exists) { ReleaseNfo::query()->insert([ 'releases_id' => $release['id'], 'nfo' => "\x1f\x8b\x08\x00".gzcompress($fetchedBinary), ]); } Release::whereId($release['id'])->update(['nfostatus' => self::NFO_FOUND]); DB::commit(); $processedCount++; } catch (\Exception $e) { DB::rollBack(); if ($this->echo) { cli()->error("Error saving NFO for release {$release['id']}: {$e->getMessage()}"); } } } } catch (\Exception $e) { if ($this->echo) { cli()->error("Error processing release {$release['id']}: {$e->getMessage()}"); } } } } // Second pass: attempt archive-based extraction for NFO_FAILED (-9) releases // that have NFO files listed in release_files with non-zero size. $archiveProcessed = $this->processFailedReleasesViaArchive($nntp, $groupID, $guidChar); $processedCount += $archiveProcessed; // Output results if ($this->echo) { if ($nfoCount > 0) { echo PHP_EOL; } if ($processedCount > 0) { cli()->primary($processedCount.' NFO file(s) found/processed.'); } } return $processedCount; } /** * Build base query for NFO processing with all common filters * * @return Builder */ private function buildNfoProcessingQuery(string $groupID, string $guidChar): \Illuminate\Database\Eloquent\Builder // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType { $query = Release::query() ->whereBetween('nfostatus', [$this->getMaxRetries(), self::NFO_UNPROC]); if ($guidChar !== '') { $query->where('leftguid', $guidChar); } if ($groupID !== '') { $query->where('groups_id', $groupID); } if ($this->getMaxSize() > 0) { $query->where('size', '<', $this->getMaxSize() * 1073741824); } if ($this->getMinSize() > 0) { $query->where('size', '>', $this->getMinSize() * 1048576); } return $query; } /** * Display header information about the NFO processing */ private function displayProcessingHeader(string $guidChar, string $groupID, int $nfoCount): void { cli()->primary( PHP_EOL. ($guidChar === '' ? '' : '['.$guidChar.'] '). ($groupID === '' ? '' : '['.$groupID.'] '). 'Processing '.$nfoCount. ' NFO(s), starting at '.$this->getNzbs(). ' * = hidden NFO, + = NFO, - = no NFO, f = download failed.' ); } /** * Display statistics about NFO status counts */ private function displayNfoStatusStats(\Illuminate\Database\Eloquent\Builder $baseQuery): void // @phpstan-ignore missingType.generics { $nfoStats = $baseQuery->clone() ->select(['nfostatus as status', DB::raw('COUNT(id) as count')]) ->groupBy(['nfostatus']) ->orderBy('nfostatus') ->get(); if ($nfoStats instanceof \Traversable && $nfoStats->count() > 0) { $outString = PHP_EOL.'Available to process'; foreach ($nfoStats as $row) { $outString .= ', '.$row['status'].' = '.number_format($row['count']); } cli()->header($outString.'.'); } } /** * Process releases at NFO_FAILED (-9) that have NFO files in release_files * with non-zero size. Attempts archive-based extraction via unrar. * On failure, sets nfostatus to NFO_FAILED_ARCHIVE (-10) to prevent further retries. */ private function processFailedReleasesViaArchive(NNTPService $nntp, string $groupID, string $guidChar): int { $query = Release::query() ->where('nfostatus', self::NFO_FAILED) ->whereExists(function ($sub) { $sub->select(DB::raw(1)) ->from('release_files') ->whereColumn('release_files.releases_id', 'releases.id') ->where('size', '>', 0) ->where(function ($q) { $q->where('name', 'like', '%.nfo') ->orWhere('name', 'like', '%.diz') ->orWhere('name', 'like', '%.inf') ->orWhere('name', 'like', '%file\_id.diz') ->orWhere('name', 'like', '%info.txt'); }); }); if ($guidChar !== '') { $query->where('leftguid', $guidChar); } if ($groupID !== '') { $query->where('groups_id', $groupID); } $releases = $query->limit($this->getNzbs()) ->get(['id', 'guid', 'groups_id', 'name']); if ($releases->isEmpty()) { return 0; } if ($this->echo) { cli()->primary(PHP_EOL.'Attempting archive-based NFO extraction for '.$releases->count().' NFO_FAILED release(s).'); } $processed = 0; foreach ($releases as $release) { try { $fetchedBinary = $this->attemptNfoFromArchive($release['guid'], $release['id'], $nntp); if ($fetchedBinary !== false) { DB::beginTransaction(); try { if (! ReleaseNfo::whereReleasesId($release['id'])->exists()) { ReleaseNfo::query()->insert([ 'releases_id' => $release['id'], 'nfo' => "\x1f\x8b\x08\x00".gzcompress($fetchedBinary), ]); } Release::whereId($release['id'])->update(['nfostatus' => self::NFO_FOUND]); DB::commit(); $processed++; if ($this->echo) { echo 'A'; } } catch (\Exception $e) { DB::rollBack(); if ($this->echo) { cli()->error("Error saving archive NFO for release {$release['id']}: {$e->getMessage()}"); } } } else { Release::whereId($release['id'])->update(['nfostatus' => self::NFO_FAILED_ARCHIVE]); if ($this->echo) { echo '-'; } } } catch (\Exception $e) { Release::whereId($release['id'])->update(['nfostatus' => self::NFO_FAILED_ARCHIVE]); if ($this->echo) { cli()->error("Error in archive NFO extraction for release {$release['id']}: {$e->getMessage()}"); } } } return $processed; } /** * Get a string like this: * "AND r.nfostatus BETWEEN -8 AND -1 AND r.size < 1073741824 AND r.size > 1048576" * To use in a query. * * * @throws \Exception * * @static */ public static function NfoQueryString(): string { $maxSize = (int) Settings::settingValue('maxsizetoprocessnfo'); $minSize = (int) Settings::settingValue('minsizetoprocessnfo'); $dummy = (int) Settings::settingValue('maxnforetries'); $maxRetries = ($dummy >= 0 ? -($dummy + 1) : self::NFO_UNPROC); return sprintf( 'AND r.nfostatus BETWEEN %d AND %d %s %s', ($maxRetries < -8 ? -8 : $maxRetries), self::NFO_UNPROC, ($maxSize > 0 ? ('AND r.size < '.($maxSize * 1073741824)) : ''), ($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 array, string> */ 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,})/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 list> */ 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 list */ 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 list */ 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 = 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'); $this->nzbs = null; $this->maxRetries = null; $this->maxSize = null; $this->minSize = null; } }