mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 22:01:33 +00:00
262 lines
9.3 KiB
PHP
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);
|
|
}
|
|
}
|