Files
newznab-tmux/app/Services/NfoService.php
T
2026-08-10 20:12:00 +02:00

1748 lines
61 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Release;
use App\Models\ReleaseFile;
use App\Models\ReleaseNfo;
use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\NNTP\NNTPService;
use App\Services\Nzb\NzbContentsService;
use App\Services\Nzb\NzbParserService;
use App\Services\Nzb\NzbService;
use dariusiii\rarinfo\Par2Info;
use dariusiii\rarinfo\SfvInfo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Class NfoService - 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 NfoService
{
/**
* 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)|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).*?|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|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\x0B\x0C\x0E-\x1F]/';
/**
* Common NFO keywords that help identify legitimate NFO files.
*
* @var array<string, mixed>
*/
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<string, mixed>
*/
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<string> $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<mixed>
*/
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());
}
if ($this->getMinSize() > 0) {
$query->where('size', '>', $this->getMinSize());
}
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) : ''),
($minSize > 0 ? ('AND r.size > '.$minSize) : '')
);
}
/**
* Extract URLs from NFO content.
*
* @param string $nfoContent The NFO content to parse.
* @return array<string, mixed> 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('/(?<![\/\.])\bwww\.[a-z0-9][-a-z0-9]*\.[^\s<>"\']+/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<int<0, max>, 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<string, mixed> 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<array<string, string>>
*/
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<string, mixed> 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<string>
*/
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<string, mixed> 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<string>
*/
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', // <Section Name>
];
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;
}
}