mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
1748 lines
61 KiB
PHP
1748 lines
61 KiB
PHP
<?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;
|
||
}
|
||
}
|