mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
468 lines
16 KiB
PHP
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);
|
|
}
|
|
}
|