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;
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,15 @@ return [
|
||||
'permission' => 0775,
|
||||
'locking' => false,
|
||||
],
|
||||
'nzb_creation' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/nzb_creation.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 7,
|
||||
'bubble' => true,
|
||||
'permission' => 0775,
|
||||
'locking' => false,
|
||||
],
|
||||
|
||||
'user_login' => [
|
||||
'driver' => 'daily',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const string RELEASES_NZB_CLAIM_INDEX = 'ix_releases_nzb_creation_queue';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('releases')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('releases', 'nzb_creation_claimed_at')) {
|
||||
$table->timestamp('nzb_creation_claimed_at')->nullable()->after('nzbstatus');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('releases', 'nzb_creation_claim_token')) {
|
||||
$table->string('nzb_creation_claim_token', 64)->nullable()->after('nzb_creation_claimed_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('releases', 'nzb_creation_attempts')) {
|
||||
$table->unsignedSmallInteger('nzb_creation_attempts')->default(0)->after('nzb_creation_claim_token');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('releases', 'nzb_creation_last_error')) {
|
||||
$table->text('nzb_creation_last_error')->nullable()->after('nzb_creation_attempts');
|
||||
}
|
||||
});
|
||||
|
||||
if (! $this->indexExists('releases', self::RELEASES_NZB_CLAIM_INDEX)) {
|
||||
$this->createNzbCreationClaimIndex();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('releases')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->indexExists('releases', self::RELEASES_NZB_CLAIM_INDEX)) {
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
$table->dropIndex(self::RELEASES_NZB_CLAIM_INDEX);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('releases', 'nzb_creation_last_error')) {
|
||||
$table->dropColumn('nzb_creation_last_error');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('releases', 'nzb_creation_attempts')) {
|
||||
$table->dropColumn('nzb_creation_attempts');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('releases', 'nzb_creation_claim_token')) {
|
||||
$table->dropColumn('nzb_creation_claim_token');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('releases', 'nzb_creation_claimed_at')) {
|
||||
$table->dropColumn('nzb_creation_claimed_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function createNzbCreationClaimIndex(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
$table->index([
|
||||
'nzbstatus',
|
||||
'groups_id',
|
||||
'leftguid',
|
||||
'nzb_creation_claimed_at',
|
||||
'postdate',
|
||||
], self::RELEASES_NZB_CLAIM_INDEX);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
'CREATE INDEX `'.self::RELEASES_NZB_CLAIM_INDEX.'` ON `releases` '.
|
||||
'(`nzbstatus`, `groups_id`, `leftguid`, `nzb_creation_claimed_at`, `postdate` DESC)'
|
||||
);
|
||||
}
|
||||
|
||||
private function indexExists(string $table, string $indexName): bool
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
$indexes = DB::select("PRAGMA index_list('{$table}')");
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
if (($index->name ?? null) === $indexName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]) !== [];
|
||||
}
|
||||
};
|
||||
@@ -258,12 +258,6 @@ parameters:
|
||||
count: 2
|
||||
path: app/Http/Controllers/Admin/AdminPageController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Carbon\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 2
|
||||
path: app/Http/Controllers/Admin/AdminRegistrationController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -366,12 +360,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Controllers/Auth/PasskeyManagementController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access on non\-nullable type Spatie\\LaravelPasskeys\\Models\\Passkey\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 4
|
||||
path: app/Http/Controllers/Auth/PasskeyManagementController.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Http\\Controllers\\Auth\\RegisterController\:\:rejectUnavailableRegistration\(\) has parameter \$status with no value type specified in iterable type array\.$#'
|
||||
identifier: missingType.iterableValue
|
||||
@@ -450,84 +438,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Models/Category.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Content\:\:scopeActive\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Content.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Content\:\:scopeForRole\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Content.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Content\:\:scopeFrontPage\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Content.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Content\:\:scopeOfType\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Content.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Content\:\:scopeOrdered\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Content.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Genre\:\:scopeDisabled\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Genre.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Genre\:\:scopeEnabled\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Genre.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Genre\:\:scopeOfType\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Genre.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Invitation\:\:scopeActive\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Invitation.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Invitation\:\:scopeExpired\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Invitation.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Invitation\:\:scopeUnused\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Invitation.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Invitation\:\:scopeUsed\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Invitation.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Invitation\:\:scopeValid\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/Invitation.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Builder\:\:enabled\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -576,42 +486,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\User\:\:scopeActive\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\User\:\:scopeExcludeSharing\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\User\:\:scopeExpired\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\User\:\:scopeExpiringSoon\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\User\:\:scopeVerified\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\User\:\:scopeWithRole\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: app/Models/User.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\UserActivityStat\:\:getDownloadsPerDay\(\) should return array\<string, mixed\> but returns list\<array\<string, int\|string\>\>\.$#'
|
||||
identifier: return.type
|
||||
@@ -1021,13 +895,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/NfoService.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Carbon\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 2
|
||||
path: app/Services/RegistrationStatusService.php
|
||||
|
||||
|
||||
-
|
||||
message: '#^Call to function is_int\(\) with string\|null will always evaluate to false\.$#'
|
||||
identifier: function.impossibleType
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Release;
|
||||
use App\Services\CollectionCleanupService;
|
||||
use App\Services\Nzb\NzbCreationCandidateQuery;
|
||||
use App\Services\Nzb\NzbService;
|
||||
use App\Services\ReleaseImageService;
|
||||
use App\Services\ReleaseProcessingService;
|
||||
use App\Services\Releases\ReleaseManagementService;
|
||||
use App\Support\Data\NzbCreationResult;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PDO;
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NzbCreationReliabilityTest extends TestCase
|
||||
{
|
||||
private string $databasePath = '';
|
||||
|
||||
private string $tempNzbPath = '';
|
||||
|
||||
/**
|
||||
* @var array<string, string|false>
|
||||
*/
|
||||
private array $originalEnvironment = [];
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
$this->databasePath = sys_get_temp_dir().'/nntmux-nzb-creation-reliability-test.sqlite';
|
||||
|
||||
$this->originalEnvironment = [
|
||||
'APP_ENV' => getenv('APP_ENV'),
|
||||
'DB_CONNECTION' => getenv('DB_CONNECTION'),
|
||||
'DB_DATABASE' => getenv('DB_DATABASE'),
|
||||
];
|
||||
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:'.$this->databasePath);
|
||||
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
|
||||
$pdo->exec(
|
||||
'INSERT INTO settings (name, value) VALUES '.
|
||||
"('categorizeforeign', '0'), ".
|
||||
"('catwebdl', '0'), ".
|
||||
"('innerfileblacklist', '')"
|
||||
);
|
||||
|
||||
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
|
||||
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
|
||||
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tempNzbPath = sys_get_temp_dir().'/nntmux-nzb-reliability-'.uniqid('', true);
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
|
||||
config(['nntmux_settings.path_to_nzbs' => $this->tempNzbPath]);
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
DB::connection()->getPdo()->sqliteCreateFunction('UNIX_TIMESTAMP', static fn (?string $value): int => strtotime((string) $value));
|
||||
|
||||
$this->createSchema();
|
||||
$this->seedSettings();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->deleteDirectory($this->tempNzbPath);
|
||||
if ($this->databasePath !== '' && file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
|
||||
foreach ($this->originalEnvironment as $key => $value) {
|
||||
$this->setEnvironmentValue($key, $value === false ? null : $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_candidate_query_skips_active_claims_and_recovers_stale_claims(): void
|
||||
{
|
||||
$this->insertRelease(1, 'a', claimedAt: now(), postdate: '2026-07-13 10:00:00');
|
||||
$this->insertRelease(2, 'b', claimedAt: now()->subSeconds(301), postdate: '2026-07-13 09:00:00');
|
||||
$this->insertRelease(3, 'c', postdate: '2026-07-13 08:00:00');
|
||||
|
||||
$claimed = NzbCreationCandidateQuery::claimBatch(null, 10, 'token-one', ['id']);
|
||||
|
||||
$this->assertSame([2, 3], $claimed->pluck('id')->all());
|
||||
$this->assertSame('token-one', DB::table('releases')->where('id', 2)->value('nzb_creation_claim_token'));
|
||||
$this->assertSame('claimed', DB::table('releases')->where('id', 1)->value('nzb_creation_claim_token'));
|
||||
}
|
||||
|
||||
public function test_deterministic_creation_failure_deletes_release_and_cbp_rows(): void
|
||||
{
|
||||
$this->insertRelease(1, 'a');
|
||||
$this->insertCbp(200, 2000, 1);
|
||||
|
||||
$service = $this->releaseProcessingService(
|
||||
NzbCreationResult::deterministic('Collection has invalid data.', [200])
|
||||
);
|
||||
|
||||
$this->assertSame(0, $service->createNZBs(null));
|
||||
$this->assertSame(0, DB::table('releases')->count());
|
||||
$this->assertSame(0, DB::table('collections')->count());
|
||||
$this->assertSame(0, DB::table('binaries')->count());
|
||||
$this->assertSame(0, DB::table('parts')->count());
|
||||
}
|
||||
|
||||
public function test_transient_creation_failure_records_retry_before_threshold(): void
|
||||
{
|
||||
Log::partialMock();
|
||||
$nzbCreationLogger = new RecordingNzbCreationLogger;
|
||||
Log::shouldReceive('channel')
|
||||
->once()
|
||||
->with('nzb_creation')
|
||||
->andReturn($nzbCreationLogger);
|
||||
|
||||
$this->insertRelease(1, 'a', attempts: 0);
|
||||
$this->insertCbp(200, 2000, 1);
|
||||
|
||||
$service = $this->releaseProcessingService(
|
||||
NzbCreationResult::transient('Temporary filesystem failure.', [200])
|
||||
);
|
||||
|
||||
$this->assertSame(0, $service->createNZBs(null));
|
||||
$this->assertSame(1, DB::table('releases')->count());
|
||||
$this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('nzb_creation_attempts'));
|
||||
$this->assertSame('Temporary filesystem failure.', DB::table('releases')->where('id', 1)->value('nzb_creation_last_error'));
|
||||
$this->assertNull(DB::table('releases')->where('id', 1)->value('nzb_creation_claimed_at'));
|
||||
$this->assertSame(1, DB::table('collections')->count());
|
||||
$this->assertSame('NZB creation failed; release will be retried', $nzbCreationLogger->warnings[0]['message']);
|
||||
$this->assertSame(1, $nzbCreationLogger->warnings[0]['context']['release_id']);
|
||||
$this->assertSame(str_repeat('a', 36), $nzbCreationLogger->warnings[0]['context']['guid']);
|
||||
$this->assertSame(NzbCreationResult::FAILURE_TRANSIENT, $nzbCreationLogger->warnings[0]['context']['failure_type']);
|
||||
$this->assertSame('Temporary filesystem failure.', $nzbCreationLogger->warnings[0]['context']['reason']);
|
||||
$this->assertSame(1, $nzbCreationLogger->warnings[0]['context']['next_attempt']);
|
||||
$this->assertSame(3, $nzbCreationLogger->warnings[0]['context']['max_attempts']);
|
||||
}
|
||||
|
||||
public function test_third_transient_creation_failure_deletes_release_and_cbp_rows(): void
|
||||
{
|
||||
Log::partialMock();
|
||||
$nzbCreationLogger = new RecordingNzbCreationLogger;
|
||||
Log::shouldReceive('channel')
|
||||
->once()
|
||||
->with('nzb_creation')
|
||||
->andReturn($nzbCreationLogger);
|
||||
|
||||
$this->insertRelease(1, 'a', attempts: 2);
|
||||
$this->insertCbp(200, 2000, 1);
|
||||
|
||||
$service = $this->releaseProcessingService(
|
||||
NzbCreationResult::transient('Repeated filesystem failure.', [200])
|
||||
);
|
||||
|
||||
$this->assertSame(0, $service->createNZBs(null));
|
||||
$this->assertSame(0, DB::table('releases')->count());
|
||||
$this->assertSame(0, DB::table('collections')->count());
|
||||
$this->assertSame(0, DB::table('binaries')->count());
|
||||
$this->assertSame(0, DB::table('parts')->count());
|
||||
$this->assertSame('Deleting release after NZB creation failure', $nzbCreationLogger->warnings[0]['message']);
|
||||
$this->assertSame(1, $nzbCreationLogger->warnings[0]['context']['release_id']);
|
||||
$this->assertSame(str_repeat('a', 36), $nzbCreationLogger->warnings[0]['context']['guid']);
|
||||
$this->assertSame(NzbCreationResult::FAILURE_TRANSIENT, $nzbCreationLogger->warnings[0]['context']['failure_type']);
|
||||
$this->assertSame('Repeated filesystem failure.', $nzbCreationLogger->warnings[0]['context']['reason']);
|
||||
$this->assertSame(3, $nzbCreationLogger->warnings[0]['context']['attempt']);
|
||||
$this->assertSame(3, $nzbCreationLogger->warnings[0]['context']['max_attempts']);
|
||||
}
|
||||
|
||||
public function test_writer_classifies_final_rename_failure_as_transient_without_final_file(): void
|
||||
{
|
||||
$this->insertRelease(1, 'd');
|
||||
$this->insertWritableCbp(200, 2000, 1);
|
||||
$release = Release::query()->findOrFail(1);
|
||||
$release->setRelation('category', (object) ['title' => 'Misc', 'parent' => (object) ['title' => 'Other']]);
|
||||
|
||||
$nzb = new RenameFailingNzbService(app(CollectionCleanupService::class));
|
||||
$result = $nzb->createNzbForRelease($release);
|
||||
|
||||
$this->assertFalse($result->success);
|
||||
$this->assertTrue($result->isTransientFailure());
|
||||
$this->assertStringContainsString('move temporary NZB into place', $result->reason);
|
||||
$this->assertFileDoesNotExist($nzb->getNzbPath($release->guid));
|
||||
$this->assertSame(0, (int) DB::table('releases')->where('id', 1)->value('nzbstatus'));
|
||||
}
|
||||
|
||||
public function test_stale_temporary_nzb_cleanup_deletes_only_old_temp_files(): void
|
||||
{
|
||||
$directory = $this->tempNzbPath.'/a';
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$oldTemporary = $directory.'/'.str_repeat('e', 36).'.nzb.gz.tmp.123.'.str_repeat('a', 12);
|
||||
$recentTemporary = $directory.'/'.str_repeat('f', 36).'.nzb.gz.tmp.456.'.str_repeat('b', 12);
|
||||
$finalNzb = $directory.'/'.str_repeat('g', 36).'.nzb.gz';
|
||||
|
||||
file_put_contents($oldTemporary, 'old');
|
||||
file_put_contents($recentTemporary, 'recent');
|
||||
file_put_contents($finalNzb, 'final');
|
||||
touch($oldTemporary, time() - 7200);
|
||||
touch($recentTemporary, time() - 60);
|
||||
touch($finalNzb, time() - 7200);
|
||||
|
||||
$nzb = new NzbService(app(CollectionCleanupService::class));
|
||||
|
||||
$this->assertSame([$oldTemporary], $nzb->findStaleTemporaryNzbPaths(3600));
|
||||
$this->assertSame(1, $nzb->cleanupStaleTemporaryNzbs(3600));
|
||||
$this->assertFileDoesNotExist($oldTemporary);
|
||||
$this->assertFileExists($recentTemporary);
|
||||
$this->assertFileExists($finalNzb);
|
||||
}
|
||||
|
||||
private function releaseProcessingService(NzbCreationResult $result): ReleaseProcessingService
|
||||
{
|
||||
return (new ReleaseProcessingService(
|
||||
nzb: new FakeNzbCreationService($result),
|
||||
releaseManagement: new DatabaseOnlyReleaseManagementService,
|
||||
collectionCleanupService: app(CollectionCleanupService::class),
|
||||
))->setEchoCLI(false);
|
||||
}
|
||||
|
||||
private function insertRelease(
|
||||
int $id,
|
||||
string $leftguid,
|
||||
int $attempts = 0,
|
||||
?\DateTimeInterface $claimedAt = null,
|
||||
string $postdate = '2026-07-13 00:00:00',
|
||||
): void {
|
||||
DB::table('releases')->insert([
|
||||
'id' => $id,
|
||||
'guid' => str_pad($leftguid, 36, $leftguid),
|
||||
'leftguid' => $leftguid,
|
||||
'name' => 'Release.'.$id,
|
||||
'searchname' => 'Release.'.$id,
|
||||
'groups_id' => 1,
|
||||
'categories_id' => 1,
|
||||
'postdate' => $postdate,
|
||||
'nzbstatus' => NzbService::NZB_NONE,
|
||||
'nzb_creation_claimed_at' => $claimedAt?->format('Y-m-d H:i:s'),
|
||||
'nzb_creation_claim_token' => $claimedAt === null ? null : 'claimed',
|
||||
'nzb_creation_attempts' => $attempts,
|
||||
'nzb_creation_last_error' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertCbp(int $collectionId, int $binaryId, int $releaseId): void
|
||||
{
|
||||
DB::table('collections')->insert([
|
||||
'id' => $collectionId,
|
||||
'releases_id' => $releaseId,
|
||||
]);
|
||||
DB::table('binaries')->insert([
|
||||
'id' => $binaryId,
|
||||
'collections_id' => $collectionId,
|
||||
]);
|
||||
DB::table('parts')->insert([
|
||||
'binaries_id' => $binaryId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertWritableCbp(int $collectionId, int $binaryId, int $releaseId): void
|
||||
{
|
||||
DB::table('usenet_groups')->insert(['id' => 1, 'name' => 'alt.test']);
|
||||
DB::table('collections')->insert([
|
||||
'id' => $collectionId,
|
||||
'releases_id' => $releaseId,
|
||||
'fromname' => 'poster@example.test',
|
||||
'date' => '2026-07-13 10:00:00',
|
||||
'xref' => 'alt.test:12345',
|
||||
'groups_id' => 1,
|
||||
]);
|
||||
DB::table('binaries')->insert([
|
||||
'id' => $binaryId,
|
||||
'collections_id' => $collectionId,
|
||||
'name' => 'Example.Release.part01.rar yEnc',
|
||||
'totalparts' => 1,
|
||||
]);
|
||||
DB::table('parts')->insert([
|
||||
'binaries_id' => $binaryId,
|
||||
'messageid' => '<part01@example.test>',
|
||||
'partnumber' => 1,
|
||||
'size' => 100,
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedSettings(): void
|
||||
{
|
||||
foreach ([
|
||||
'categorizeforeign' => '0',
|
||||
'catwebdl' => '0',
|
||||
'releaseprocessingtimeout' => '120',
|
||||
'maxnzbsprocessed' => '1000',
|
||||
'nzbsplitlevel' => '1',
|
||||
] as $name => $value) {
|
||||
DB::table('settings')->insert(['name' => $name, 'value' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
foreach (['parts', 'binaries', 'collections', 'releases', 'categories', 'root_categories', 'usenet_groups', 'settings'] as $table) {
|
||||
DB::statement("DROP TABLE IF EXISTS {$table}");
|
||||
}
|
||||
|
||||
DB::statement('CREATE TABLE settings (name VARCHAR(255) PRIMARY KEY, value TEXT)');
|
||||
DB::statement('CREATE TABLE root_categories (id INTEGER PRIMARY KEY, title VARCHAR(255), status INTEGER DEFAULT 1, disablepreview INTEGER DEFAULT 0)');
|
||||
DB::statement('CREATE TABLE categories (id INTEGER PRIMARY KEY, title VARCHAR(255), root_categories_id INTEGER NULL)');
|
||||
DB::statement('CREATE TABLE releases (
|
||||
id INTEGER PRIMARY KEY,
|
||||
guid VARCHAR(64),
|
||||
leftguid VARCHAR(1),
|
||||
name VARCHAR(255),
|
||||
searchname VARCHAR(255),
|
||||
groups_id INTEGER,
|
||||
categories_id INTEGER,
|
||||
postdate DATETIME NULL,
|
||||
nzbstatus INTEGER,
|
||||
nzb_creation_claimed_at DATETIME NULL,
|
||||
nzb_creation_claim_token VARCHAR(64) NULL,
|
||||
nzb_creation_attempts INTEGER DEFAULT 0,
|
||||
nzb_creation_last_error TEXT NULL
|
||||
)');
|
||||
DB::statement('CREATE TABLE usenet_groups (id INTEGER PRIMARY KEY, name VARCHAR(255))');
|
||||
DB::statement('CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
releases_id INTEGER NULL,
|
||||
fromname VARCHAR(255) NULL,
|
||||
date DATETIME NULL,
|
||||
xref TEXT NULL,
|
||||
groups_id INTEGER NULL
|
||||
)');
|
||||
DB::statement('CREATE TABLE binaries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
collections_id INTEGER,
|
||||
name VARCHAR(255) NULL,
|
||||
totalparts INTEGER NULL
|
||||
)');
|
||||
DB::statement('CREATE TABLE parts (
|
||||
binaries_id INTEGER,
|
||||
messageid VARCHAR(255) NULL,
|
||||
partnumber INTEGER NULL,
|
||||
size INTEGER NULL
|
||||
)');
|
||||
DB::table('root_categories')->insert(['id' => 1, 'title' => 'Other']);
|
||||
DB::table('categories')->insert(['id' => 1, 'title' => 'Misc', 'root_categories_id' => 1]);
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $path): void
|
||||
{
|
||||
if ($path === '' || ! is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
if ($item->isDir() && ! $item->isLink()) {
|
||||
rmdir($item->getPathname());
|
||||
} else {
|
||||
unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($path);
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNzbCreationService extends NzbService
|
||||
{
|
||||
public function __construct(private readonly NzbCreationResult $result)
|
||||
{
|
||||
parent::__construct(app(CollectionCleanupService::class));
|
||||
}
|
||||
|
||||
public function createNzbForRelease(Release $release): NzbCreationResult
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseOnlyReleaseManagementService extends ReleaseManagementService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $identifiers
|
||||
*/
|
||||
public function deleteSingleWithService(array $identifiers, NzbService $nzb, ReleaseImageService $releaseImage): void
|
||||
{
|
||||
Release::query()->where('guid', $identifiers['g'])->delete();
|
||||
}
|
||||
}
|
||||
|
||||
class RenameFailingNzbService extends NzbService
|
||||
{
|
||||
protected function moveTemporaryNzbIntoPlace(string $temporaryPath, string $finalPath): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingNzbCreationLogger extends AbstractLogger
|
||||
{
|
||||
/**
|
||||
* @var list<array{level: mixed, message: string, context: array<string, mixed>}>
|
||||
*/
|
||||
public array $warnings = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function log($level, string|\Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->warnings[] = [
|
||||
'level' => $level,
|
||||
'message' => (string) $message,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user