mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Update nzb creation
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user