Files
newznab-tmux/app/Services/Nzb/NzbService.php
T
2026-05-06 10:20:05 +02:00

468 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Nzb;
use App\Models\Binary;
use App\Models\Collection;
use App\Models\Part;
use App\Models\Release;
use App\Models\Settings;
use App\Services\CollectionCleanupService;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Service for managing NZB files on disk.
* Handles reading, writing, and path management for NZB files.
*/
class NzbService
{
public const NZB_NONE = 0; // Release has no NZB file yet.
public const NZB_ADDED = 1; // Release had an NZB file created.
protected const NZB_DTD_NAME = 'nzb';
protected const NZB_DTD_PUBLIC = '-//newzBin//DTD NZB 1.1//EN';
protected const NZB_DTD_EXTERNAL = 'http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd';
protected const NZB_XML_NS = 'http://www.newzbin.com/DTD/2003/nzb';
/**
* Levels deep to store NZB files.
*/
protected int $nzbSplitLevel;
/**
* Path to store NZB files.
*/
protected string $siteNzbPath;
/**
* Candidate base paths to search for existing NZB files.
*
* @var list<string>
*/
protected array $siteNzbPaths = [];
/**
* String used for head in NZB XML file.
*/
protected string $nzbCommentString;
protected string $siteCommentString;
public function __construct(
private readonly CollectionCleanupService $collectionCleanupService,
) {
try {
$nzbSplitLevel = (int) Settings::settingValue('nzbsplitlevel');
} catch (QueryException $e) {
// Table doesn't exist yet (e.g., during migrations or tests)
$nzbSplitLevel = 1;
}
$this->nzbSplitLevel = $nzbSplitLevel;
$this->siteNzbPaths = $this->buildCandidateNzbBasePaths((string) config('nntmux_settings.path_to_nzbs'));
$this->siteNzbPath = $this->selectPreferredNzbBasePath($this->siteNzbPaths);
$this->nzbCommentString = sprintf(
'NZB Generated by: NNTmux %s',
now()->format('F j, Y, g:i a O')
);
$this->siteCommentString = sprintf(
'NZB downloaded from %s',
config('app.name')
);
}
/**
* Write an NZB file for a release.
*
* @throws \Throwable
*/
public function writeNzbForReleaseId(Release $release): bool
{
$collections = Collection::whereReleasesId($release->id)
->join('usenet_groups', 'collections.groups_id', '=', 'usenet_groups.id')
->select(['collections.*', DB::raw('UNIX_TIMESTAMP(collections.date) AS udate'), 'usenet_groups.name as groupname'])
->get();
if ($collections->isEmpty()) {
return false;
}
// Pre-load every binary and part for the release in two flat queries
// and group them in PHP, instead of issuing one binaries SELECT per
// collection and one parts SELECT per binary (1 + N + N*M queries).
$collectionIds = $collections->pluck('id')->all();
$binariesByCollection = Binary::query()
->whereIn('collections_id', $collectionIds)
->orderBy('name')
->get(['id', 'collections_id', 'name', 'totalparts'])
->groupBy('collections_id');
$allBinaryIds = $binariesByCollection->flatten(1)->pluck('id')->all();
$partsByBinary = $allBinaryIds === []
? collect()
: Part::query()
->whereIn('binaries_id', $allBinaryIds)
// distinct() preserves the dedup the previous per-binary
// query had, in case two rows share (messageid, size,
// partnumber) for the same binary (defensive).
->distinct()
->orderBy('partnumber')
->get(['binaries_id', 'messageid', 'size', 'partnumber'])
->groupBy('binaries_id');
$XMLWriter = new \XMLWriter;
$XMLWriter->openMemory();
$XMLWriter->setIndent(true);
$XMLWriter->setIndentString(' ');
$nzb_guid = '';
$XMLWriter->startDocument('1.0', 'UTF-8');
$XMLWriter->startDtd(self::NZB_DTD_NAME, self::NZB_DTD_PUBLIC, self::NZB_DTD_EXTERNAL);
$XMLWriter->endDtd();
$XMLWriter->writeComment($this->nzbCommentString);
$XMLWriter->startElement('nzb');
$XMLWriter->writeAttribute('xmlns', self::NZB_XML_NS);
$XMLWriter->startElement('head');
$XMLWriter->startElement('meta');
$XMLWriter->writeAttribute('type', 'category');
$XMLWriter->text(! empty($release->category->parent) ? $release->category->parent->title.' >'.$release->category->title : 'Other > Misc');
$XMLWriter->endElement();
$XMLWriter->startElement('meta');
$XMLWriter->writeAttribute('type', 'name');
$XMLWriter->text($release->name);
$XMLWriter->endElement();
$XMLWriter->endElement(); // head
foreach ($collections as $collection) {
$binaries = $binariesByCollection->get($collection->id);
if ($binaries === null || $binaries->isEmpty()) {
return false;
}
$poster = $collection->fromname;
foreach ($binaries as $binary) {
$parts = $partsByBinary->get($binary->id);
if ($parts === null || $parts->isEmpty()) {
return false;
}
$subject = $this->buildBinarySubject($binary->name, $binary->totalparts);
$XMLWriter->startElement('file');
$XMLWriter->writeAttribute('poster', (string) $poster);
$XMLWriter->writeAttribute('date', (string) $collection->udate);
$XMLWriter->writeAttribute('subject', (string) $subject);
$XMLWriter->startElement('groups');
if (preg_match_all('#(\S+):\S+#', $collection->xref, $hits)) {
$hits = array_values(array_unique($hits[1]));
foreach ($hits as $group) {
$XMLWriter->writeElement('group', $group);
}
} elseif (preg_match_all('#(\S+)#', $collection->xref, $hits)) {
$hits = array_values(array_unique($hits[1]));
foreach ($hits as $group) {
$XMLWriter->writeElement('group', $group);
}
} else {
return false;
}
$XMLWriter->endElement(); // groups
$XMLWriter->startElement('segments');
foreach ($parts as $part) {
$messageId = $this->normalizeSegmentMessageId($part->messageid);
if ($nzb_guid === '') {
$nzb_guid = $messageId;
}
$XMLWriter->startElement('segment');
$XMLWriter->writeAttribute('bytes', (string) $part->size);
$XMLWriter->writeAttribute('number', (string) $part->partnumber);
$XMLWriter->text($messageId);
$XMLWriter->endElement();
}
$XMLWriter->endElement(); // segments
$XMLWriter->endElement(); // file
}
}
$XMLWriter->writeComment($this->siteCommentString);
$XMLWriter->endElement(); // nzb
$XMLWriter->endDocument();
$path = ($this->buildNzbPath($release->guid, $this->nzbSplitLevel, true).$release->guid.'.nzb.gz');
$fp = gzopen($path, 'wb7');
if (! $fp) {
return false;
}
gzwrite($fp, $XMLWriter->outputMemory());
gzclose($fp);
unset($XMLWriter);
if (! File::isFile($path)) {
echo "ERROR: $path does not exist.\n";
return false;
}
// Mark release as having NZB.
$release->update(['nzbstatus' => self::NZB_ADDED]);
if (! empty($nzb_guid)) {
$release->update(['nzb_guid' => DB::raw('UNHEX( '.escapeString(md5((string) $nzb_guid)).' )')]);
}
// Delete CBP (Collections, Binaries, Parts) for release that has its NZB created.
// Use a transaction to ensure cascading deletes complete properly.
try {
$this->collectionCleanupService->deleteCollectionsAndDescendants(
$collectionIds,
'NZB cleanup',
false
);
} catch (\Throwable $e) {
// Log the error but don't fail the NZB creation since the file was written successfully
Log::warning('Failed to delete collections for release '.$release->id.': '.$e->getMessage());
}
// Chmod to fix issues some users have with file permissions.
chmod($path, 0777);
return true;
}
/**
* Build a folder path on the hard drive where the NZB file will be stored.
*
* @param string $releaseGuid The guid of the release.
* @param int $levelsToSplit How many sub-paths the folder will be in.
* @param bool $createIfNotExist Create the folder if it doesn't exist.
* @return string $nzbpath The path to store the NZB file.
*/
public function buildNzbPath(string $releaseGuid, int $levelsToSplit, bool $createIfNotExist): string
{
$nzbPath = '';
for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) {
$nzbPath .= $releaseGuid[$i].'/';
}
$nzbPath = $this->siteNzbPath.$nzbPath;
if ($createIfNotExist && ! File::isDirectory($nzbPath) && ! File::makeDirectory($nzbPath, 0777, true) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue
throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath));
}
return $nzbPath;
}
/**
* Build a path on a specific NZB base directory.
*/
protected function buildNzbPathAtBasePath(string $basePath, string $releaseGuid, int $levelsToSplit): string
{
$nzbPath = '';
for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) {
$nzbPath .= $releaseGuid[$i].'/';
}
return $basePath.$nzbPath;
}
/**
* Retrieve path + filename of the NZB to be stored.
*
* @param string $releaseGuid The guid of the release.
* @param int $levelsToSplit How many sub-paths the folder will be in. (optional)
* @param bool $createIfNotExist Create the folder if it doesn't exist. (optional)
* @return string Path+filename.
*/
public function getNzbPath(string $releaseGuid, int $levelsToSplit = 0, bool $createIfNotExist = false): string
{
if ($levelsToSplit === 0) {
$levelsToSplit = $this->nzbSplitLevel;
}
return $this->buildNzbPath($releaseGuid, $levelsToSplit, $createIfNotExist).$releaseGuid.'.nzb.gz';
}
/**
* Determine if an NZB exists, returning the path+filename, if not return false.
*
* @param string $releaseGuid The guid of the release.
* @return false|string On success: (string) Path+file name of the nzb.
* On failure: false.
*/
public function nzbPath(string $releaseGuid): bool|string
{
$levelsToSplit = $this->nzbSplitLevel === 0 ? 1 : $this->nzbSplitLevel;
foreach ($this->siteNzbPaths as $basePath) {
$nzbFile = $this->buildNzbPathAtBasePath($basePath, $releaseGuid, $levelsToSplit).$releaseGuid.'.nzb.gz';
if (is_file($nzbFile)) {
return $nzbFile;
}
}
$nzbFile = $this->getNzbPath($releaseGuid);
return is_file($nzbFile) ? $nzbFile : false;
}
/**
* Read and decompress an NZB file contents.
*
* @param string $releaseGuid The release GUID
* @return string|false The decompressed NZB contents or false on failure
*/
public function readNzbContents(string $releaseGuid): string|false
{
$nzbPath = $this->nzbPath($releaseGuid);
if ($nzbPath === false) {
return false;
}
$contents = unzipGzipFile($nzbPath);
return ! empty($contents) ? $contents : false;
}
/**
* Delete an NZB file.
*
* @param string $releaseGuid The release GUID
* @return bool True if deleted, false otherwise
*/
public function deleteNzb(string $releaseGuid): bool
{
$nzbPath = $this->nzbPath($releaseGuid);
if ($nzbPath === false) {
return false;
}
return File::delete($nzbPath);
}
/**
* Get the default NZB split level.
*/
public function getNzbSplitLevel(): int
{
return $this->nzbSplitLevel;
}
/**
* Get the base NZB storage path.
*/
public function getSiteNzbPath(): string
{
return $this->siteNzbPath;
}
/**
* @return list<string>
*/
protected function buildCandidateNzbBasePaths(string $configuredPath): array
{
$defaultPath = $this->normalizeNzbBasePath(storage_path('nzb'));
$configuredPath = $configuredPath !== '' ? $configuredPath : $defaultPath;
$configuredPath = $this->normalizeNzbBasePath($configuredPath);
$candidatePaths = [$configuredPath];
$remappedStoragePath = $this->remapStoragePathToCurrentApplication($configuredPath);
if ($remappedStoragePath !== null && ! in_array($remappedStoragePath, $candidatePaths, true)) {
$candidatePaths[] = $remappedStoragePath;
}
if (! in_array($defaultPath, $candidatePaths, true)) {
$candidatePaths[] = $defaultPath;
}
return $candidatePaths;
}
/**
* @param list<string> $candidatePaths
*/
protected function selectPreferredNzbBasePath(array $candidatePaths): string
{
$configuredPath = $candidatePaths[0] ?? $this->normalizeNzbBasePath(storage_path('nzb'));
$remappedStoragePath = $candidatePaths[1] ?? null;
if (
$remappedStoragePath !== null &&
$configuredPath !== $remappedStoragePath &&
! is_dir($configuredPath) &&
is_dir($remappedStoragePath) &&
str_contains(str_replace('\\', '/', $configuredPath), '/storage/')
) {
return $remappedStoragePath;
}
return $configuredPath;
}
protected function remapStoragePathToCurrentApplication(string $path): ?string
{
$normalizedPath = str_replace('\\', '/', $path);
$storageMarker = '/storage/';
if (! str_contains($normalizedPath, $storageMarker)) {
return null;
}
$relativeStoragePath = Str::after($normalizedPath, $storageMarker);
return $this->normalizeNzbBasePath(storage_path($relativeStoragePath));
}
protected function normalizeNzbBasePath(string $path): string
{
$normalizedPath = str_replace('\\', '/', trim($path));
return rtrim($normalizedPath, '/').'/';
}
/**
* Build the NZB subject line in the shape expected by downstream parsers.
*/
private function buildBinarySubject(string $binaryName, int $totalParts): string
{
return rtrim($binaryName).' (1/'.$totalParts.')';
}
/**
* Normalize stored message IDs before writing them into NZB segments.
*/
private function normalizeSegmentMessageId(string $messageId): string
{
$messageId = trim($messageId);
if ($messageId === '') {
return '';
}
if (
\strlen($messageId) >= 2
&& (($messageId[0] === '"' && str_ends_with($messageId, '"'))
|| ($messageId[0] === "'" && str_ends_with($messageId, "'")))
) {
$messageId = substr($messageId, 1, -1);
}
$messageId = trim($messageId);
if (str_starts_with($messageId, '<') && str_ends_with($messageId, '>')) {
$messageId = substr($messageId, 1, -1);
}
return trim($messageId);
}
}