Files
newznab-tmux-NNTmux/app/Services/AdditionalProcessing/NzbContentParser.php
T
2026-08-11 10:43:22 +02:00

262 lines
9.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\AdditionalProcessing;
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
use App\Services\Nzb\NzbParserService;
use App\Services\Nzb\NzbService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* Service for parsing NZB file contents and extracting file metadata.
* Handles NZB repair, file listing, and message ID extraction.
*/
class NzbContentParser
{
public function __construct(
private readonly NzbService $nzb,
private readonly NzbParserService $nzbParser,
private readonly bool $debugMode = false,
private readonly bool $echoCLI = false
) {}
/**
* Parse an NZB file and return its contents as an array of files.
*
* @param string $guid The release GUID to find the NZB for
* @return array{contents: array<string, mixed>, error: string|null}
*/
public function parseNzb(string $guid): array
{
$nzbPath = $this->nzb->nzbPath($guid);
if ($nzbPath === false) {
return ['contents' => [], 'error' => 'NZB not found for GUID: '.$guid];
}
$nzbContents = unzipGzipFile($nzbPath);
if (! $nzbContents) {
// Try repair on raw file contents
$nzbContents = $this->attemptRawRepair($nzbPath);
if (! $nzbContents) {
return ['contents' => [], 'error' => 'NZB is empty or broken for GUID: '.$guid];
}
}
// Get a list of files in the NZB
$fileList = $this->nzbParser->parseNzbFileList($nzbContents, ['no-file-key' => false, 'strip-count' => true]);
if (count($fileList) === 0) {
// Attempt repair if initial parse yielded no files
$repaired = $this->repairNzb($nzbContents, $nzbPath, $guid);
if ($repaired !== null) {
$fileList = $this->nzbParser->parseNzbFileList($repaired, ['no-file-key' => false, 'strip-count' => true]);
}
if (count($fileList) === 0) {
return ['contents' => [], 'error' => 'NZB is potentially broken for GUID: '.$guid];
}
}
// Sort keys naturally
ksort($fileList, SORT_NATURAL);
return ['contents' => $fileList, 'error' => null];
}
/**
* Attempt to repair raw file contents before XML parsing.
*/
private function attemptRawRepair(string $nzbPath): ?string
{
try {
$rawFile = @File::get($nzbPath);
if (! $rawFile) {
return null;
}
// If gzipped, attempt decompress
if (str_ends_with(strtolower($nzbPath), '.gz')) {
$decompressed = @gzdecode($rawFile);
if ($decompressed !== false) {
return $this->repairNzb($decompressed, $nzbPath, '');
}
} else {
return $this->repairNzb($rawFile, $nzbPath, '');
}
} catch (\Throwable) {
// Ignore
}
return null;
}
/**
* Attempt to repair a potentially broken NZB XML string.
*/
public function repairNzb(string $raw, string $originalPath, string $guid): ?string
{
// Remove common binary / control chars except tab, newline, carriage return
$fixed = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $raw);
// If missing opening <nzb ...> tag, wrap content
if (! str_contains(strtolower($fixed), '<nzb')) {
$fixed = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<nzb xmlns=\"http://www.newzbin.com/DTD/2003/nzb\">\n".$fixed."\n</nzb>";
} else {
// Ensure closing tag
if (! preg_match('/<\/nzb>\s*$/i', $fixed)) {
$fixed .= "\n</nzb>";
}
}
// Try to parse using libxml recovery
$opts = LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_COMPACT | LIBXML_NONET | LIBXML_NOCDATA | LIBXML_PARSEHUGE;
$prev = libxml_use_internal_errors(true);
$xml = simplexml_load_string($fixed, 'SimpleXMLElement', $opts);
$errors = libxml_get_errors();
libxml_clear_errors();
libxml_use_internal_errors($prev);
if ($xml === false || empty($xml->file)) {
if ($this->debugMode && $this->echoCLI) {
$guidInfo = $guid ? ' for GUID: '.$guid : '';
echo 'NZB repair failed'.$guidInfo.' ('.count($errors).' XML errors)'.PHP_EOL;
}
return null;
}
// Persist a repaired version if content changed
try {
if ($fixed !== $raw) {
if (str_ends_with(strtolower($originalPath), '.gz')) {
@File::put($originalPath, gzencode($fixed));
} else {
@File::put($originalPath, $fixed);
}
}
} catch (\Throwable $e) {
if ($this->debugMode) {
Log::debug('Failed to persist repaired NZB: '.$e->getMessage());
}
}
return $fixed;
}
/**
* Process NZB contents to extract message IDs for different file types.
*
* @param list<array<string, mixed>> $nzbContents
* @return array{
* hasCompressedFile: bool,
* sampleMessageIDs: list<string>,
* jpgMessageIDs: list<string>,
* mediaInfoMessageID: string,
* audioInfoMessageID: string,
* audioInfoExtension: string,
* bookFileCount: int
* }
*/
public function extractMessageIDs(
array $nzbContents,
string $groupName,
ProcessingConfiguration $config
): array {
$result = [
'hasCompressedFile' => false,
'sampleMessageIDs' => [],
'jpgMessageIDs' => [],
'mediaInfoMessageID' => '',
'audioInfoMessageID' => '',
'audioInfoExtension' => '',
'bookFileCount' => 0,
];
foreach ($nzbContents as $file) {
try {
$title = $file['title'] ?? '';
$segments = $file['segments'] ?? [];
// Skip support/nfo files
if (preg_match('/(?:'.$config->supportFileRegex.'|nfo\\b|inf\\b|ofn\\b)($|[ ")]|-])(?!.{20,})/i', $title)) {
continue;
}
// Compressed file detection
if (! $result['hasCompressedFile'] && preg_match(
'/(\\.(part\\d+|[rz]\\d+|rar|0+|0*10?|zipr\\d{2,3}|zipx?)("|\\s*\\.rar)*($|[ ")]|-])|"[a-f0-9]{32}\\.[1-9]\\d{1,2}".*\\(\\d+\\/\\d{2,}\\)$)/i',
$title
)) {
$result['hasCompressedFile'] = true;
}
// Look for a video sample (not an image)
if ($config->processThumbnails && empty($result['sampleMessageIDs']) && ! empty($segments)
&& stripos($title, 'sample') !== false
&& ! preg_match('/\.(?:jpe?g|png|webp)$/i', $title)
) {
$result['sampleMessageIDs'] = $this->extractSegments($segments, $config->segmentsToDownload);
}
// Look for a JPG picture (not a CD cover)
if ($config->processJPGSample && empty($result['jpgMessageIDs']) && ! empty($segments)
&& ! preg_match('/flac|lossless|mp3|music|inner-sanctum|sound/i', $groupName)
&& preg_match('/\.(?:jpe?g|png|webp)[. ")\]]/i', $title)
) {
$result['jpgMessageIDs'] = $this->extractSegments($segments, $config->segmentsToDownload);
}
// Look for a video file for MediaInfo (sample video)
if ($config->processMediaInfo && empty($result['mediaInfoMessageID']) && ! empty($segments[0])
&& stripos($title, 'sample') !== false
&& preg_match('/'.$config->videoFileRegex.'[. ")\]]/i', $title)
) {
$result['mediaInfoMessageID'] = (string) $segments[0];
}
// Look for an audio file
if ($config->processAudioInfo && empty($result['audioInfoMessageID']) && ! empty($segments)
&& preg_match('/'.$config->audioFileRegex.'[. ")\]]/i', $title, $type)
) {
$result['audioInfoExtension'] = $type[1];
$result['audioInfoMessageID'] = (string) $segments[0];
}
// Count book files
if (preg_match($config->ignoreBookRegex, $title)) {
$result['bookFileCount']++;
}
} catch (\ErrorException $e) {
Log::debug($e->getTraceAsString());
}
}
return $result;
}
/**
* Extract segment message IDs up to a limit.
*
* @param array<int|string, mixed> $segments
* @return list<string>
*/
private function extractSegments(array $segments, int $limit): array
{
$ids = [];
foreach (array_slice(array_values($segments), 0, max(0, $limit)) as $segment) {
$ids[] = (string) $segment;
}
return $ids;
}
/**
* Get the NZB path for a GUID.
*/
public function getNzbPath(string $guid): string|false
{
return $this->nzb->nzbPath($guid);
}
}