Files
newznab-tmux/app/Services/Nzb/NzbService.php
T
2026-08-13 14:40:02 +02:00

750 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Nzb;
use App\Models\Collection;
use App\Models\Release;
use App\Models\Settings;
use App\Services\Binaries\BinariesConfig;
use App\Services\CollectionCleanupService;
use App\Support\Data\NzbCreationResult;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;
/**
* 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,
?BinariesConfig $binariesConfig = null,
) {
$this->binariesConfig = $binariesConfig ?? BinariesConfig::fromSettings();
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')
);
}
private readonly BinariesConfig $binariesConfig;
/**
* Write an NZB file for a release.
*
* @throws Throwable
*/
public function writeNzbForReleaseId(Release $release): bool
{
return $this->createNzbForRelease($release)->success;
}
public function createNzbForRelease(Release $release): NzbCreationResult
{
try {
$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'])
->orderBy('collections.id')
->get()
->keyBy('id');
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to load release collections: '.$e->getMessage());
}
if ($collections->isEmpty()) {
return NzbCreationResult::deterministic('Release has no collections to write into an NZB.');
}
$collectionIds = $collections->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
try {
$emptyCollection = DB::selectOne(
'SELECT c.id FROM collections c
LEFT JOIN binaries b ON b.collections_id = c.id
WHERE c.releases_id = ? GROUP BY c.id HAVING COUNT(b.id) = 0 LIMIT 1',
[$release->id]
);
if ($emptyCollection !== null) {
return NzbCreationResult::deterministic("Collection {$emptyCollection->id} has no binaries.", $collectionIds);
}
$emptyBinary = DB::selectOne(
'SELECT b.id FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id
LEFT JOIN parts p ON p.binaries_id = b.id
WHERE c.releases_id = ? GROUP BY b.id HAVING COUNT(p.partnumber) = 0 LIMIT 1',
[$release->id]
);
if ($emptyBinary !== null) {
return NzbCreationResult::deterministic("Binary {$emptyBinary->id} has no parts.", $collectionIds);
}
$groupsByCollection = $this->loadCollectionGroups((int) $release->id, $collections);
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to load NZB binaries or parts: '.$e->getMessage(), $collectionIds);
}
$XMLWriter = new \XMLWriter;
$XMLWriter->openMemory();
$XMLWriter->setIndent(true);
$XMLWriter->setIndentString(' ');
$path = null;
$tempPath = null;
$gz = null;
try {
$path = ($this->buildNzbPath($release->guid, $this->nzbSplitLevel, true).$release->guid.'.nzb.gz');
$tempPath = $this->temporaryNzbPath($path);
$gz = $this->openGzipFile($tempPath);
if ($gz === false) {
return NzbCreationResult::transient("Failed to open temporary NZB file for writing: {$tempPath}", $collectionIds, $path);
}
$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
if (! $this->flushXmlWriter($XMLWriter, $gz)) {
return NzbCreationResult::transient("Failed to write NZB header to temporary file: {$tempPath}", $collectionIds, $path);
}
$cursor = ['collection_id' => 0, 'name' => '', 'binary_id' => 0, 'partnumber' => 0];
$openBinaryId = 0;
do {
$page = $this->loadNzbRowPage((int) $release->id, $cursor);
foreach ($page as $row) {
$binaryId = (int) $row->binary_id;
if ($binaryId !== $openBinaryId) {
if ($openBinaryId > 0) {
$XMLWriter->endElement(); // segments
$XMLWriter->endElement(); // file
if (! $this->flushXmlWriter($XMLWriter, $gz)) {
return NzbCreationResult::transient("Failed to write NZB file entry to temporary file: {$tempPath}", $collectionIds, $path);
}
}
$collection = $collections->get((int) $row->collection_id);
$groups = $groupsByCollection[(int) $row->collection_id] ?? [];
if ($collection === null || $groups === []) {
return NzbCreationResult::deterministic("Collection {$row->collection_id} has no valid cross-post groups.", $collectionIds, $path);
}
$subject = $this->buildBinarySubject((string) $row->binary_name, (int) $row->totalparts);
$XMLWriter->startElement('file');
$XMLWriter->writeAttribute('poster', (string) $collection->fromname);
$XMLWriter->writeAttribute('date', (string) $collection->udate);
$XMLWriter->writeAttribute('subject', (string) $subject);
$XMLWriter->startElement('groups');
foreach ($groups as $group) {
$XMLWriter->writeElement('group', $group);
}
$XMLWriter->endElement(); // groups
$XMLWriter->startElement('segments');
$openBinaryId = $binaryId;
}
$messageId = $this->normalizeSegmentMessageId($row->messageid);
if ($messageId === '') {
return NzbCreationResult::deterministic("Part {$row->partnumber} for binary {$binaryId} has an empty message ID.", $collectionIds, $path);
}
$XMLWriter->startElement('segment');
$XMLWriter->writeAttribute('bytes', (string) $row->size);
$XMLWriter->writeAttribute('number', (string) $row->partnumber);
$XMLWriter->text($messageId);
$XMLWriter->endElement();
$cursor = [
'collection_id' => (int) $row->collection_id,
'name' => (string) $row->binary_name,
'binary_id' => $binaryId,
'partnumber' => (int) $row->partnumber,
];
}
} while (\count($page) === $this->binariesConfig->nzbStreamRows);
if ($openBinaryId > 0) {
$XMLWriter->endElement(); // segments
$XMLWriter->endElement(); // file
}
$XMLWriter->writeComment($this->siteCommentString);
$XMLWriter->endElement(); // nzb
$XMLWriter->endDocument();
if (! $this->flushXmlWriter($XMLWriter, $gz)) {
return NzbCreationResult::transient("Failed to write NZB footer to temporary file: {$tempPath}", $collectionIds, $path);
}
$closed = gzclose($gz);
$gz = null;
if (! $closed) {
return NzbCreationResult::transient("Failed to close temporary NZB file: {$tempPath}", $collectionIds, $path);
}
if (! $this->moveTemporaryNzbIntoPlace($tempPath, $path)) {
return NzbCreationResult::transient("Failed to move temporary NZB into place: {$tempPath} -> {$path}", $collectionIds, $path);
}
$tempPath = null;
if (! File::isFile($path) || ! is_readable($path)) {
return NzbCreationResult::transient("Final NZB file is missing or unreadable: {$path}", $collectionIds, $path);
}
// Mark release as having NZB.
$release->update($this->successfulReleaseUpdateValues());
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to write NZB file: '.$e->getMessage(), $collectionIds, $path);
} finally {
unset($XMLWriter);
if (is_resource($gz)) {
gzclose($gz);
}
if ($tempPath !== null && File::isFile($tempPath)) {
File::delete($tempPath);
}
}
// 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 NzbCreationResult::success($path, $collectionIds);
}
/**
* @param \Illuminate\Support\Collection<int|string, mixed> $collections
* @return array<int, list<string>>
*/
private function loadCollectionGroups(int $releaseId, \Illuminate\Support\Collection $collections): array
{
$groups = [];
if (Schema::hasTable('collection_groups')) {
$rows = DB::select(
'SELECT cg.collections_id, cg.group_name
FROM collection_groups cg INNER JOIN collections c ON c.id = cg.collections_id
WHERE c.releases_id = ? ORDER BY cg.collections_id, cg.group_name',
[$releaseId]
);
foreach ($rows as $row) {
$groups[(int) $row->collections_id][] = (string) $row->group_name;
}
}
foreach ($collections as $collection) {
$collectionId = (int) $collection->id;
if (($groups[$collectionId] ?? []) === []) {
$groups[$collectionId] = $this->groupsFromXref((string) $collection->xref);
}
}
return $groups;
}
/**
* @param array{collection_id: int, name: string, binary_id: int, partnumber: int} $cursor
* @return list<object>
*/
private function loadNzbRowPage(int $releaseId, array $cursor): array
{
$limit = $this->binariesConfig->nzbStreamRows;
return DB::select(
'SELECT b.collections_id AS collection_id, b.id AS binary_id, b.name AS binary_name,
b.totalparts, p.messageid, p.size, p.partnumber
FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id
INNER JOIN parts p ON p.binaries_id = b.id
WHERE c.releases_id = ? AND (
b.collections_id > ? OR
(b.collections_id = ? AND b.name > ?) OR
(b.collections_id = ? AND b.name = ? AND b.id > ?) OR
(b.collections_id = ? AND b.name = ? AND b.id = ? AND p.partnumber > ?)
)
ORDER BY b.collections_id, b.name, b.id, p.partnumber
LIMIT '.$limit,
[
$releaseId,
$cursor['collection_id'],
$cursor['collection_id'], $cursor['name'],
$cursor['collection_id'], $cursor['name'], $cursor['binary_id'],
$cursor['collection_id'], $cursor['name'], $cursor['binary_id'], $cursor['partnumber'],
]
);
}
/**
* 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 = $this->siteNzbPath;
if ($createIfNotExist && ! File::isDirectory($nzbPath)) {
if (! File::makeDirectory($nzbPath, 0775, true) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue
throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath));
}
File::chmod($nzbPath, 02775);
}
for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) {
$nzbPath .= $releaseGuid[$i].'/';
if ($createIfNotExist && ! File::isDirectory($nzbPath)) {
if (! File::makeDirectory($nzbPath, 0775) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue
throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath));
}
File::chmod($nzbPath, 02775);
}
}
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);
}
/**
* @return list<string>
*/
public function findStaleTemporaryNzbPaths(int $olderThanSeconds = 86400): array
{
$cutoff = time() - max(1, $olderThanSeconds);
$paths = [];
foreach (array_unique($this->siteNzbPaths) as $basePath) {
if (! File::isDirectory($basePath)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if (! $file->isFile() || $file->isLink()) {
continue;
}
$path = $file->getPathname();
if (! $this->isTemporaryNzbPath($path)) {
continue;
}
try {
if ($file->getMTime() <= $cutoff) {
$paths[] = $path;
}
} catch (Throwable $e) {
Log::channel('nzb_creation')->warning('Failed to inspect temporary NZB file', [
'path' => $path,
'error' => $e->getMessage(),
]);
}
}
}
sort($paths);
return $paths;
}
public function cleanupStaleTemporaryNzbs(int $olderThanSeconds = 86400): int
{
$deleted = 0;
foreach ($this->findStaleTemporaryNzbPaths($olderThanSeconds) as $path) {
if (! File::isFile($path)) {
continue;
}
try {
if (File::delete($path)) {
$deleted++;
continue;
}
Log::channel('nzb_creation')->warning('Failed to delete stale temporary NZB file', [
'path' => $path,
]);
} catch (Throwable $e) {
Log::channel('nzb_creation')->warning('Failed to delete stale temporary NZB file', [
'path' => $path,
'error' => $e->getMessage(),
]);
}
}
return $deleted;
}
/**
* 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);
}
/**
* @return list<string>
*/
private function groupsFromXref(string $xref): array
{
if (preg_match_all('#(\S+):\S+#', $xref, $hits)) {
return array_values(array_unique($hits[1]));
}
if (preg_match_all('#(\S+)#', $xref, $hits)) {
return array_values(array_unique($hits[1]));
}
return [];
}
private function temporaryNzbPath(string $path): string
{
return $path.'.tmp.'.getmypid().'.'.bin2hex(random_bytes(6));
}
private function isTemporaryNzbPath(string $path): bool
{
return preg_match('/\.nzb\.gz\.tmp\.\d+\.[0-9a-f]{12}$/', basename($path)) === 1;
}
/**
* @return resource|false
*/
protected function openGzipFile(string $path): mixed
{
return gzopen($path, 'wb7');
}
/**
* @param resource $gz
*
* @phpstan-impure
*/
private function flushXmlWriter(\XMLWriter $XMLWriter, mixed $gz): bool
{
$buffer = $XMLWriter->outputMemory(true);
if ($buffer === '') {
return true;
}
$written = gzwrite($gz, $buffer);
return $written !== false && $written === \strlen($buffer);
}
protected function moveTemporaryNzbIntoPlace(string $temporaryPath, string $finalPath): bool
{
return rename($temporaryPath, $finalPath);
}
/**
* @return array<string, mixed>
*/
private function successfulReleaseUpdateValues(): array
{
$values = ['nzbstatus' => self::NZB_ADDED];
if (NzbCreationCandidateQuery::supportsClaims()) {
$values += [
NzbCreationCandidateQuery::ATTEMPTS_COLUMN => 0,
NzbCreationCandidateQuery::LAST_ERROR_COLUMN => null,
NzbCreationCandidateQuery::CLAIMED_AT_COLUMN => null,
NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN => null,
];
}
return $values;
}
}