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