Update nzb creation

This commit is contained in:
DariusIII
2026-07-13 16:51:10 +02:00
parent 1a9a976fa5
commit 5ee33502b1
13 changed files with 1379 additions and 271 deletions
+35 -1
View File
@@ -11,6 +11,7 @@ use App\Services\Releases\ReleaseManagementService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class CleanNZB extends Command
{
@@ -22,6 +23,8 @@ class CleanNZB extends Command
protected $signature = 'nntmux:nzbclean
{--notindb : Delete NZBs that dont exist in database}
{--notondisk : Delete release in database that dont have a NZB on disk}
{--temps : Delete stale temporary NZB files left by interrupted creation}
{--temp-age-hours=24 : Minimum temporary NZB age in hours before deletion}
{--chunksize=25000 : Chunk size for releases query}
{--delete : Pass this argument to actually delete the files. Otherwise it\'s just a dry run.}';
@@ -38,7 +41,7 @@ class CleanNZB extends Command
public function handle(): void
{
// Check if any options are false
if (! $this->option('notindb') && ! $this->option('notondisk')) {
if (! $this->option('notindb') && ! $this->option('notondisk') && ! $this->option('temps')) {
$this->error('You must specify at least one option. See: --help');
exit();
}
@@ -48,6 +51,9 @@ class CleanNZB extends Command
if ($this->option('notondisk')) {
$this->GetReleasesWithNoNZBOnDisk($this->option('delete'));
}
if ($this->option('temps')) {
$this->cleanTemporaryNzbFiles($this->option('delete'));
}
}
private function GetNZBsWithNoDatabaseEntry(mixed $delete = false): void
@@ -105,4 +111,32 @@ class CleanNZB extends Command
});
$this->info("Checked: $checked / Deleted: $deleted");
}
private function cleanTemporaryNzbFiles(mixed $delete = false): void
{
$nzb = app(NzbService::class);
$ageHours = max(1, (int) $this->option('temp-age-hours'));
$olderThanSeconds = $ageHours * 3600;
if (! $delete) {
$paths = $nzb->findStaleTemporaryNzbPaths($olderThanSeconds);
foreach ($paths as $path) {
$this->line("Would delete stale temporary NZB: {$path}");
}
$this->info('Checked stale temporary NZBs: '.count($paths).' would be deleted.');
return;
}
$deleted = $nzb->cleanupStaleTemporaryNzbs($olderThanSeconds);
if ($deleted > 0) {
Log::channel('nzb_creation')->warning('Deleted stale temporary NZB files', [
'count' => $deleted,
'older_than_hours' => $ageHours,
]);
}
$this->info("Deleted stale temporary NZBs: {$deleted}");
}
}
+21 -6
View File
@@ -95,24 +95,33 @@ class Content extends Model
/**
* Scope: Get only active content.
*
* @param Builder<Content> $query
* @return Builder<Content>
*/
public function scopeActive(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeActive(Builder $query): Builder
{
return $query->where('status', self::STATUS_ENABLED);
}
/**
* Scope: Get content of a specific type.
*
* @param Builder<Content> $query
* @return Builder<Content>
*/
public function scopeOfType(Builder $query, int $type): Builder // @phpstan-ignore missingType.generics
public function scopeOfType(Builder $query, int $type): Builder
{
return $query->where('contenttype', $type);
}
/**
* Scope: Get content accessible by a specific role.
*
* @param Builder<Content> $query
* @return Builder<Content>
*/
public function scopeForRole(Builder $query, int $role): Builder // @phpstan-ignore missingType.generics
public function scopeForRole(Builder $query, int $role): Builder
{
// Admins and moderators can see everything
if (\in_array($role, [UserRole::ADMIN->value, UserRole::MODERATOR->value], true)) {
@@ -128,8 +137,11 @@ class Content extends Model
/**
* Scope: Order content by type and ordinal within each type.
*
* @param Builder<Content> $query
* @return Builder<Content>
*/
public function scopeOrdered(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeOrdered(Builder $query): Builder
{
return $query
->orderBy('contenttype')
@@ -138,10 +150,13 @@ class Content extends Model
/**
* Scope: Get front page content.
*
* @param Builder<Content> $query
* @return Builder<Content>
*/
public function scopeFrontPage(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeFrontPage(Builder $query): Builder
{
return $query->active() // @phpstan-ignore method.notFound
return $query->active()
->ofType(self::TYPE_INDEX)
->ordered();
}
+12 -3
View File
@@ -73,24 +73,33 @@ class Genre extends Model
/**
* Scope to filter disabled genres.
*
* @param Builder<Genre> $query
* @return Builder<Genre>
*/
public function scopeDisabled(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeDisabled(Builder $query): Builder
{
return $query->where('disabled', '=', 1);
}
/**
* Scope to filter enabled genres.
*
* @param Builder<Genre> $query
* @return Builder<Genre>
*/
public function scopeEnabled(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeEnabled(Builder $query): Builder
{
return $query->where('disabled', '=', 0);
}
/**
* Scope to filter by type.
*
* @param Builder<Genre> $query
* @return Builder<Genre>
*/
public function scopeOfType(Builder $query, string $type): Builder // @phpstan-ignore missingType.generics
public function scopeOfType(Builder $query, string $type): Builder
{
if (! empty($type)) {
return $query->where('type', '=', $type);
+21 -6
View File
@@ -98,42 +98,57 @@ class Invitation extends Model // @phpstan-ignore missingType.iterableValue
/**
* Scope to get only active invitations
*
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
public function scopeActive(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope to get valid (active and not expired) invitations
*
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
public function scopeValid(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeValid(Builder $query): Builder
{
return $query->active() // @phpstan-ignore method.notFound
return $query->active()
->where('expires_at', '>', now())
->whereNull('used_at');
}
/**
* Scope to get expired invitations
*
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
public function scopeExpired(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeExpired(Builder $query): Builder
{
return $query->where('expires_at', '<=', now());
}
/**
* Scope to get unused invitations
*
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
public function scopeUnused(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeUnused(Builder $query): Builder
{
return $query->whereNull('used_at');
}
/**
* Scope to get used invitations
*
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
public function scopeUsed(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeUsed(Builder $query): Builder
{
return $query->whereNotNull('used_at');
}
+24 -6
View File
@@ -364,16 +364,22 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
/**
* Scope to get active (non-disabled) users.
*
* @param Builder<User> $query
* @return Builder<User>
*/
public function scopeActive(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeActive(Builder $query): Builder
{
return $query->where('roles_id', '!=', UserRole::DISABLED->value);
}
/**
* Scope to get verified users.
*
* @param Builder<User> $query
* @return Builder<User>
*/
public function scopeVerified(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeVerified(Builder $query): Builder
{
return $query->where(function (Builder $verifiedQuery): void {
$verifiedQuery
@@ -428,8 +434,11 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
/**
* Scope to filter users by role.
*
* @param Builder<User> $query
* @return Builder<User>
*/
public function scopeWithRole(Builder $query, int|string $role): Builder // @phpstan-ignore missingType.generics
public function scopeWithRole(Builder $query, int|string $role): Builder
{
if (is_numeric($role)) {
return $query->where('roles_id', (int) $role);
@@ -440,8 +449,11 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
/**
* Scope to get users with roles expiring within specified days.
*
* @param Builder<User> $query
* @return Builder<User>
*/
public function scopeExpiringSoon(Builder $query, int $days = 7): Builder // @phpstan-ignore missingType.generics
public function scopeExpiringSoon(Builder $query, int $days = 7): Builder
{
return $query->whereNotNull('rolechangedate')
->whereBetween('rolechangedate', [now(), now()->addDays($days)]);
@@ -449,8 +461,11 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
/**
* Scope to get users with expired roles.
*
* @param Builder<User> $query
* @return Builder<User>
*/
public function scopeExpired(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeExpired(Builder $query): Builder
{
return $query->whereNotNull('rolechangedate')
->where('rolechangedate', '<', now());
@@ -458,8 +473,11 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
/**
* Scope to exclude sharing email.
*
* @param Builder<User> $query
* @return Builder<User>
*/
public function scopeExcludeSharing(Builder $query): Builder // @phpstan-ignore missingType.generics
public function scopeExcludeSharing(Builder $query): Builder
{
return $query->where('email', '!=', 'sharing@nZEDb.com');
}
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Services\Nzb;
use App\Models\Release;
use App\Models\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class NzbCreationCandidateQuery
{
public const string CLAIMED_AT_COLUMN = 'nzb_creation_claimed_at';
public const string CLAIM_TOKEN_COLUMN = 'nzb_creation_claim_token';
public const string ATTEMPTS_COLUMN = 'nzb_creation_attempts';
public const string LAST_ERROR_COLUMN = 'nzb_creation_last_error';
/**
* @return Builder<Release>
*/
public static function baseBuilder(int|string|null $groupID = null, bool $includeClaimed = false): Builder
{
$query = Release::query()
->from('releases as r')
->where('r.nzbstatus', NzbService::NZB_NONE);
if ($groupID !== null && $groupID !== '' && $groupID !== 0 && $groupID !== '0') {
$query->where('r.groups_id', $groupID);
}
if (! $includeClaimed) {
self::applyClaimWindow($query);
}
return $query;
}
/**
* @param list<string> $columns
* @return EloquentCollection<int, Release>
*/
public static function claimBatch(
int|string|null $groupID,
int $limit,
string $token,
array $columns = ['*'],
): EloquentCollection {
$effectiveLimit = max(1, $limit);
return DB::transaction(function () use ($groupID, $effectiveLimit, $token, $columns): EloquentCollection {
$supportsClaims = self::supportsClaims();
$query = self::baseBuilder($groupID)
->select('r.id')
->orderByDesc('r.postdate')
->orderBy('r.id')
->limit($effectiveLimit);
if (DB::getDriverName() !== 'sqlite') {
$query->lockForUpdate();
}
$ids = $query
->pluck('r.id')
->map(static fn (mixed $id): int => (int) $id)
->all();
if ($ids === []) {
return (new Release)->newCollection();
}
if ($supportsClaims) {
Release::query()
->whereIn('id', $ids)
->update([
self::CLAIMED_AT_COLUMN => now(),
self::CLAIM_TOKEN_COLUMN => $token,
]);
}
return Release::query()
->whereIn('id', $ids)
->with('category.parent')
->select(self::selectableColumns($columns, $supportsClaims))
->orderByRaw(self::idOrderExpression($ids))
->get();
}, 3);
}
public static function clearClaim(int $releaseId, ?string $token = null): void
{
if (! self::supportsClaims()) {
return;
}
$query = Release::query()->where('id', $releaseId);
if ($token !== null && $token !== '') {
$query->where(self::CLAIM_TOKEN_COLUMN, $token);
}
$query->update([
self::CLAIMED_AT_COLUMN => null,
self::CLAIM_TOKEN_COLUMN => null,
]);
}
public static function supportsClaims(): bool
{
if (! Schema::hasTable('releases')) {
return false;
}
return Schema::hasColumn('releases', self::CLAIMED_AT_COLUMN)
&& Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN)
&& Schema::hasColumn('releases', self::ATTEMPTS_COLUMN)
&& Schema::hasColumn('releases', self::LAST_ERROR_COLUMN);
}
/**
* @return array<string, mixed>
*/
public static function failureUpdateValues(string $reason, bool $incrementAttempts): array
{
if (! self::supportsClaims()) {
return [];
}
$values = [
self::CLAIMED_AT_COLUMN => null,
self::CLAIM_TOKEN_COLUMN => null,
self::LAST_ERROR_COLUMN => mb_substr($reason, 0, 1000),
];
if ($incrementAttempts) {
$values[self::ATTEMPTS_COLUMN] = DB::raw(self::ATTEMPTS_COLUMN.' + 1');
}
return $values;
}
/**
* @param Builder<Release> $query
*/
private static function applyClaimWindow(Builder $query): void
{
if (! self::supportsClaims()) {
return;
}
$staleBefore = now()->subSeconds(self::claimTtlSeconds());
$query->where(function (Builder $claimQuery) use ($staleBefore): void {
$claimQuery
->whereNull('r.'.self::CLAIMED_AT_COLUMN)
->orWhere('r.'.self::CLAIMED_AT_COLUMN, '<', $staleBefore);
});
}
private static function claimTtlSeconds(): int
{
$timeout = (int) (Settings::settingValue('releaseprocessingtimeout') ?: 120);
return max(300, $timeout * 2);
}
/**
* @param list<string> $columns
* @return list<string>
*/
private static function selectableColumns(array $columns, bool $supportsClaims): array
{
if ($supportsClaims || $columns === ['*']) {
return $columns;
}
return array_values(array_filter(
$columns,
static fn (string $column): bool => ! in_array($column, [
self::CLAIMED_AT_COLUMN,
self::CLAIM_TOKEN_COLUMN,
self::ATTEMPTS_COLUMN,
self::LAST_ERROR_COLUMN,
], true),
));
}
/**
* @param list<int> $ids
*/
private static function idOrderExpression(array $ids): string
{
if (DB::getDriverName() !== 'sqlite') {
return 'FIELD(id, '.implode(',', $ids).')';
}
$cases = [];
foreach ($ids as $position => $id) {
$cases[] = 'WHEN '.(int) $id.' THEN '.(int) $position;
}
return 'CASE id '.implode(' ', $cases).' ELSE '.count($ids).' END';
}
}
+299 -96
View File
@@ -10,11 +10,13 @@ use App\Models\Part;
use App\Models\Release;
use App\Models\Settings;
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\Str;
use Throwable;
/**
* Service for managing NZB files on disk.
@@ -83,130 +85,180 @@ class NzbService
/**
* Write an NZB file for a release.
*
* @throws \Throwable
* @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();
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'])
->get();
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to load release collections: '.$e->getMessage());
}
if ($collections->isEmpty()) {
return false;
return NzbCreationResult::deterministic('Release has no collections to write into an NZB.');
}
// 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');
$collectionIds = $collections->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
try {
$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');
$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');
} 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(' ');
$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);
$path = null;
$tempPath = null;
$gz = null;
$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
try {
$path = ($this->buildNzbPath($release->guid, $this->nzbSplitLevel, true).$release->guid.'.nzb.gz');
$tempPath = $this->temporaryNzbPath($path);
$gz = $this->openGzipFile($tempPath);
foreach ($collections as $collection) {
$binaries = $binariesByCollection->get($collection->id);
if ($binaries === null || $binaries->isEmpty()) {
return false;
if ($gz === false) {
return NzbCreationResult::transient("Failed to open temporary NZB file for writing: {$tempPath}", $collectionIds, $path);
}
$poster = $collection->fromname;
$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);
foreach ($binaries as $binary) {
$parts = $partsByBinary->get($binary->id);
if ($parts === null || $parts->isEmpty()) {
return false;
$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);
}
foreach ($collections as $collection) {
$binaries = $binariesByCollection->get($collection->id);
if ($binaries === null || $binaries->isEmpty()) {
return NzbCreationResult::deterministic("Collection {$collection->id} has no binaries.", $collectionIds, $path);
}
$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) {
$groups = $this->groupsFromXref((string) $collection->xref);
if ($groups === []) {
return NzbCreationResult::deterministic("Collection {$collection->id} has no valid xref groups.", $collectionIds, $path);
}
$poster = $collection->fromname;
foreach ($binaries as $binary) {
$parts = $partsByBinary->get($binary->id);
if ($parts === null || $parts->isEmpty()) {
return NzbCreationResult::deterministic("Binary {$binary->id} has no parts.", $collectionIds, $path);
}
$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');
foreach ($groups 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);
$XMLWriter->endElement(); // groups
$XMLWriter->startElement('segments');
foreach ($parts as $part) {
$messageId = $this->normalizeSegmentMessageId($part->messageid);
if ($messageId === '') {
return NzbCreationResult::deterministic("Part {$part->partnumber} for binary {$binary->id} has an empty message ID.", $collectionIds, $path);
}
$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
if (! $this->flushXmlWriter($XMLWriter, $gz)) {
return NzbCreationResult::transient("Failed to write NZB file entry to temporary file: {$tempPath}", $collectionIds, $path);
}
} else {
return false;
}
$XMLWriter->endElement(); // groups
$XMLWriter->startElement('segments');
foreach ($parts as $part) {
$messageId = $this->normalizeSegmentMessageId($part->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();
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);
}
}
$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]);
// Delete CBP (Collections, Binaries, Parts) for release that has its NZB created.
// Use a transaction to ensure cascading deletes complete properly.
@@ -216,7 +268,7 @@ class NzbService
'NZB cleanup',
false
);
} catch (\Throwable $e) {
} 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());
}
@@ -224,7 +276,7 @@ class NzbService
// Chmod to fix issues some users have with file permissions.
chmod($path, 0777);
return true;
return NzbCreationResult::success($path, $collectionIds);
}
/**
@@ -340,6 +392,82 @@ class NzbService
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.
*/
@@ -456,4 +584,79 @@ class NzbService
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;
}
}
+132 -20
View File
@@ -14,10 +14,12 @@ use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\Categorization\CategorizationService;
use App\Services\NNTP\NNTPService;
use App\Services\Nzb\NzbCreationCandidateQuery;
use App\Services\Nzb\NzbService;
use App\Services\Releases\ReleaseBrowseService;
use App\Services\Releases\ReleaseDuplicateFinder;
use App\Services\Releases\ReleaseManagementService;
use App\Support\Data\NzbCreationResult;
use App\Support\Data\ProcessReleasesSettings;
use App\Support\Data\ReleaseCreationResult;
use App\Support\Data\ReleaseDeleteStats;
@@ -26,6 +28,7 @@ use DateTimeInterface;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
@@ -51,7 +54,7 @@ final class ReleaseProcessingService
private const int CATEGORIZE_CHUNK_SIZE = 1000;
private const int NZB_CHUNK_SIZE = 100;
private const int NZB_CREATION_MAX_ATTEMPTS = 3;
private bool $echoCLI;
@@ -536,37 +539,146 @@ final class ReleaseProcessingService
$startTime = now()->toImmutable();
$this->outputSubHeader('Creating NZB Files');
$query = Release::query()
->with('category.parent')
->where('nzbstatus', '=', NzbService::NZB_NONE)
->select(['id', 'guid', 'name', 'categories_id']);
if (! empty($groupID)) {
$query->where('releases.groups_id', $groupID);
}
$nzbCount = 0;
$total = $query->count();
$retryCount = 0;
$deletedCount = 0;
$claimToken = bin2hex(random_bytes(16));
$limit = max(1, $this->settings->releaseCreationLimit);
$total = min(NzbCreationCandidateQuery::baseBuilder($groupID)->count(), $limit);
if ($total > 0) {
$query->chunkById(self::NZB_CHUNK_SIZE, function ($releases) use (&$nzbCount, $total): bool {
foreach ($releases as $release) {
if ($this->nzb->writeNzbForReleaseId($release)) {
$nzbCount++;
$this->outputProgress($nzbCount, $total, 'Creating NZBs');
}
}
$columns = [
'id',
'guid',
'name',
'categories_id',
'groups_id',
'postdate',
'nzbstatus',
NzbCreationCandidateQuery::ATTEMPTS_COLUMN,
NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN,
];
return true;
});
$processed = 0;
$releases = NzbCreationCandidateQuery::claimBatch($groupID, $limit, $claimToken, $columns);
foreach ($releases as $release) {
try {
$result = $this->nzb->createNzbForRelease($release);
if ($result->success) {
$nzbCount++;
continue;
}
if ($this->shouldDeleteFailedNzbCreation($release, $result)) {
$this->deleteFailedNzbCreationRelease($release, $result, $claimToken);
$deletedCount++;
continue;
}
$this->recordNzbCreationRetry($release, $result);
$retryCount++;
} catch (Throwable $e) {
$result = NzbCreationResult::transient('Unexpected NZB creation failure: '.$e->getMessage());
if ($this->shouldDeleteFailedNzbCreation($release, $result)) {
$this->deleteFailedNzbCreationRelease($release, $result, $claimToken);
$deletedCount++;
} else {
$this->recordNzbCreationRetry($release, $result);
$retryCount++;
}
} finally {
NzbCreationCandidateQuery::clearClaim((int) $release->id, $claimToken);
$processed++;
$this->outputProgress($processed, $total, 'Creating NZBs');
}
}
}
$this->outputStat('NZBs created', $nzbCount);
$this->outputStat('NZB creation retries', $retryCount);
$this->outputStat('NZB creation failures deleted', $deletedCount);
$this->outputElapsedTime($startTime);
return $nzbCount;
}
private function shouldDeleteFailedNzbCreation(Release $release, NzbCreationResult $result): bool
{
if ($result->isDeterministicFailure()) {
return true;
}
if (! $result->isTransientFailure()) {
return false;
}
return $this->nextNzbCreationAttempt($release) >= self::NZB_CREATION_MAX_ATTEMPTS;
}
private function nextNzbCreationAttempt(Release $release): int
{
return ((int) ($release->{NzbCreationCandidateQuery::ATTEMPTS_COLUMN} ?? 0)) + 1;
}
private function recordNzbCreationRetry(Release $release, NzbCreationResult $result): void
{
$values = NzbCreationCandidateQuery::failureUpdateValues($result->reason, true);
if ($values !== []) {
Release::query()
->where('id', $release->id)
->update($values);
}
Log::channel('nzb_creation')->warning('NZB creation failed; release will be retried', [
'release_id' => $release->id,
'guid' => $release->guid,
'failure_type' => $result->failureType,
'reason' => $result->reason,
'next_attempt' => $this->nextNzbCreationAttempt($release),
'max_attempts' => self::NZB_CREATION_MAX_ATTEMPTS,
]);
}
private function deleteFailedNzbCreationRelease(Release $release, NzbCreationResult $result, string $claimToken): void
{
Log::channel('nzb_creation')->warning('Deleting release after NZB creation failure', [
'release_id' => $release->id,
'guid' => $release->guid,
'failure_type' => $result->failureType,
'reason' => $result->reason,
'attempt' => $this->nextNzbCreationAttempt($release),
'max_attempts' => self::NZB_CREATION_MAX_ATTEMPTS,
]);
try {
$collectionIds = $result->collectionIds !== []
? $result->collectionIds
: Collection::query()
->where('releases_id', $release->id)
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->all();
if ($collectionIds !== []) {
$this->collectionCleanupService->deleteCollectionsAndDescendants(
$collectionIds,
'Failed NZB creation cleanup',
$this->echoCLI
);
}
$this->releaseManagement->deleteSingleWithService(
['g' => $release->guid, 'i' => $release->id],
$this->nzb,
$this->releaseImage
);
} finally {
NzbCreationCandidateQuery::clearClaim((int) $release->id, $claimToken);
}
}
/**
* Categorize releases based on the specified field.
*
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Support\Data;
final readonly class NzbCreationResult
{
public const string FAILURE_NONE = 'none';
public const string FAILURE_DETERMINISTIC = 'deterministic';
public const string FAILURE_TRANSIENT = 'transient';
/**
* @param list<int> $collectionIds
*/
private function __construct(
public bool $success,
public string $failureType,
public string $reason,
public ?string $path,
public array $collectionIds,
) {}
/**
* @param list<int> $collectionIds
*/
public static function success(string $path, array $collectionIds): self
{
return new self(true, self::FAILURE_NONE, '', $path, $collectionIds);
}
/**
* @param list<int> $collectionIds
*/
public static function deterministic(string $reason, array $collectionIds = [], ?string $path = null): self
{
return new self(false, self::FAILURE_DETERMINISTIC, $reason, $path, $collectionIds);
}
/**
* @param list<int> $collectionIds
*/
public static function transient(string $reason, array $collectionIds = [], ?string $path = null): self
{
return new self(false, self::FAILURE_TRANSIENT, $reason, $path, $collectionIds);
}
public function isDeterministicFailure(): bool
{
return $this->failureType === self::FAILURE_DETERMINISTIC;
}
public function isTransientFailure(): bool
{
return $this->failureType === self::FAILURE_TRANSIENT;
}
}