diff --git a/Blacklight/NZBContents.php b/Blacklight/NZBContents.php index 41c1fe914..4787c50c5 100755 --- a/Blacklight/NZBContents.php +++ b/Blacklight/NZBContents.php @@ -1,16 +1,17 @@ echooutput = config('nntmux.echocli'); - $this->nntp = new NNTP; - $this->nfo = new Nfo; - $this->pp = new PostProcess; - $this->nzb = new NZB; + $this->echooutput = (bool) config('nntmux.echocli'); + $this->nntp = new NNTP(); + $this->nfo = new Nfo(); + $this->postProcessService = app(PostProcessService::class); + $this->nzb = new NZB(); $this->lookuppar2 = (int) Settings::settingValue('lookuppar2') === 1; - $this->alternateNNTP = config('nntmux_nntp.use_alternate_nntp_server'); + $this->alternateNNTP = (bool) config('nntmux_nntp.use_alternate_nntp_server'); } /** @@ -237,9 +234,8 @@ class NZBContents // Look specifically for the .par2 index file (often small, but not always 1/1) if ($this->lookuppar2 && ! $foundPAR2 && isset($firstSegmentId) && preg_match('/\.par2$/i', $subject)) { // Attempt to parse the PAR2 file using its first segment ID - // Ensure $this->pp is initialized and parsePAR2 exists and accepts these parameters - if (method_exists($this->pp, 'parsePAR2') && $this->pp->parsePAR2($firstSegmentId, $relID, $groupID, $this->nntp, 1) === true) { - Release::query()->where('id', $relID)->update(['proc_par2' => 1]); // Use constant if available + if ($this->postProcessService->parsePAR2($firstSegmentId, $relID, $groupID, $this->nntp, 1) === true) { + Release::query()->where('id', $relID)->update(['proc_par2' => 1]); $foundPAR2 = true; } } @@ -329,7 +325,6 @@ class NZBContents /** * Attempts to get the release name from a par2 file. * - * * @throws \Exception */ public function checkPAR2(string $guid, int $relID, int $groupID, int $nameStatus, int $show): bool @@ -337,13 +332,17 @@ class NZBContents $nzbFile = $this->loadNzb($guid); if ($nzbFile !== false) { foreach ($nzbFile->file as $nzbContents) { - if ($nameStatus === 1 && $this->pp->parsePAR2((string) $nzbContents->segments->segment, $relID, $groupID, $this->nntp, $show) && preg_match('/\.(par[2" ]|\d{2,3}").+\(1\/1\)/i', (string) $nzbContents->attributes()->subject)) { + if ($nameStatus === 1 + && $this->postProcessService->parsePAR2((string) $nzbContents->segments->segment, $relID, $groupID, $this->nntp, $show) + && preg_match('/\.(par[2" ]|\d{2,3}").+\(1\/1\)/i', (string) $nzbContents->attributes()->subject) + ) { Release::query()->where('id', $relID)->update(['proc_par2' => 1]); return true; } } } + if ($nameStatus === 1) { Release::query()->where('id', $relID)->update(['proc_par2' => 1]); } diff --git a/Blacklight/Nfo.php b/Blacklight/Nfo.php index b90d3182e..0c295c0ab 100755 --- a/Blacklight/Nfo.php +++ b/Blacklight/Nfo.php @@ -1,12 +1,14 @@ echo = config('nntmux.echocli'); - $this->colorCli = new ColorCLI; + $this->echo = (bool) config('nntmux.echocli'); + $this->colorCli = new ColorCLI(); // Cache settings to reduce database queries - $this->nzbs = Cache::remember('nfo_maxnfoprocessed', self::SETTINGS_CACHE_TTL, function () { + // Note: Cast after Cache::remember as cached values may be stored as strings + $this->nzbs = (int) Cache::remember('nfo_maxnfoprocessed', self::SETTINGS_CACHE_TTL, function () { $value = Settings::settingValue('maxnfoprocessed'); return $value !== '' ? (int) $value : 100; }); - $maxRetries = Cache::remember('nfo_maxnforetries', self::SETTINGS_CACHE_TTL, function () { + $maxRetries = (int) Cache::remember('nfo_maxnforetries', self::SETTINGS_CACHE_TTL, function () { return (int) Settings::settingValue('maxnforetries'); }); $this->maxRetries = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC; $this->maxRetries = max($this->maxRetries, -8); - $this->maxSize = Cache::remember('nfo_maxsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { + $this->maxSize = (int) Cache::remember('nfo_maxsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { return (int) Settings::settingValue('maxsizetoprocessnfo'); }); - $this->minSize = Cache::remember('nfo_minsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { + $this->minSize = (int) Cache::remember('nfo_minsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { return (int) Settings::settingValue('minsizetoprocessnfo'); }); - $this->tmpPath = rtrim(config('nntmux.tmp_unrar_path'), '/\\').'/'; + $this->tmpPath = rtrim((string) config('nntmux.tmp_unrar_path'), '/\\') . '/'; } /** @@ -481,7 +484,7 @@ class Nfo 'NNTP' => $nntp, 'Nfo' => $this, 'Settings' => null, - 'PostProcess' => new PostProcess(['Echo' => $this->echo, 'Nfo' => $this]), + 'PostProcess' => app(PostProcessService::class), ] ); $nzbContents->parseNZB($release->guid, $release->id, $release->guid); diff --git a/Blacklight/processing/PostProcess.php b/Blacklight/processing/PostProcess.php deleted file mode 100755 index f2ddf685e..000000000 --- a/Blacklight/processing/PostProcess.php +++ /dev/null @@ -1,260 +0,0 @@ -echooutput = config('nntmux.echocli'); - - // Class instances. - $this->_par2Info = new Par2Info; - $this->nameFixingService = new NameFixingService; - $this->Nfo = new Nfo; - - // Site settings. - $this->addpar2 = config('nntmux_settings.add_par2'); - $this->alternateNNTP = config('nntmux_nntp.use_alternate_nntp_server'); - - // Services. - $this->par2Processor = new Par2Processor($this->nameFixingService, $this->_par2Info, $this->addpar2, $this->alternateNNTP); - $this->tvProcessor = new TvProcessor($this->echooutput); - $this->nfoProcessor = new NfoProcessor($this->Nfo, $this->echooutput); - - $this->moviesProcessor = new MoviesProcessor($this->echooutput); - $this->musicProcessor = new MusicProcessor($this->echooutput); - $this->booksProcessor = new BooksProcessor($this->echooutput); - $this->consolesProcessor = new ConsolesProcessor($this->echooutput); - $this->gamesProcessor = new GamesProcessor($this->echooutput); - $this->animeProcessor = new AnimeProcessor($this->echooutput); - $this->xxxProcessor = new XXXProcessor($this->echooutput); - } - - /** - * Go through every type of post proc. - * - * - * @throws \Exception - */ - public function processAll($nntp): void - { - $this->processAdditional(); - $this->processNfos($nntp); - $this->processMovies(); - $this->processMusic(); - $this->processConsoles(); - $this->processGames(); - $this->processAnime(); - $this->processTv(); - $this->processXXX(); - $this->processBooks(); - } - - /** - * Lookup anidb if enabled. - * - * @param string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First letter of a release GUID to use to get work. - * - * @throws \Exception - */ - public function processAnime(string $groupID = '', string $guidChar = ''): void - { - $this->animeProcessor->process($groupID, $guidChar); - } - - /** - * Process books using amazon.com. - * - * @param string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First letter of a release GUID to use to get work. - * - * @throws \Exception - */ - public function processBooks(string $groupID = '', string $guidChar = ''): void - { - $this->booksProcessor->process($groupID, $guidChar); - } - - /** - * @throws \Exception - */ - public function processConsoles(): void - { - $this->consolesProcessor->process(); - } - - /** - * @throws \Exception - */ - public function processGames(): void - { - $this->gamesProcessor->process(); - } - - /** - * Lookup imdb if enabled. - * - * @param string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First letter of a release GUID to use to get work. - * @param int|string|null $processMovies (Optional) 0 Don't process, 1 process all releases, - * 2 process renamed releases only, '' check site setting - * - * @throws \Exception - */ - public function processMovies(string $groupID = '', string $guidChar = '', int|string|null $processMovies = ''): void - { - $this->moviesProcessor->process($groupID, $guidChar, $processMovies); - } - - /** - * @throws \Exception - */ - public function processMusic(): void - { - $this->musicProcessor->process(); - } - - /** - * Process nfo files. - * - * @param string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First letter of a release GUID to use to get work. - * - * @throws \Exception - */ - public function processNfos(NNTP $nntp, string $groupID = '', string $guidChar = ''): void - { - $this->nfoProcessor->process($nntp, $groupID, $guidChar); - } - - /** - * Process all TV related releases which will assign their series/episode/rage data. - * - * @param string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First letter of a release GUID to use to get work. - * @param int|string|null $processTV (Optional) 0 Don't process, 1 process all releases, - * 2 process renamed releases only, '' check site setting - * @param string $mode (Optional) Processing mode: 'pipeline' (default) or 'parallel' - * - * @throws \Exception - */ - public function processTv(string $groupID = '', string $guidChar = '', int|string|null $processTV = '', string $mode = 'pipeline'): void - { - // If no GUID character specified, use parallel-pipeline processing via ForkingService - if ($guidChar === '') { - $forkingService = new \App\Services\ForkingService; - - // Convert processTV setting to renamed-only flag - $processTV = (is_numeric($processTV) ? $processTV : \App\Models\Settings::settingValue('lookuptv')); - $renamedOnly = ($processTV == 2); - - $forkingService->processTv($renamedOnly); - } else { - // Process single GUID bucket with pipeline - $this->tvProcessor->process($groupID, $guidChar, $processTV, $mode); - } - } - - /** - * Lookup xxx if enabled. - * - * @throws \Exception - */ - public function processXXX(): void - { - $this->xxxProcessor->process(); - } - - /** - * Check for passworded releases, RAR/ZIP contents and Sample/Media info. - * - * @note Called externally by tmux/bin/update_per_group and update/postprocess.php - * - * @param int|string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First char of release GUID, can be used to select work. - * - * @throws \Exception - */ - public function processAdditional(int|string $groupID = '', string $guidChar = ''): void - { - app(AdditionalProcessingOrchestrator::class)->start($groupID, $guidChar); - } - - /** - * Attempt to get a better name from a par2 file and categorize the release. - * - * @note Called from NZBContents.php - * - * @param string $messageID MessageID from NZB file. - * @param int $relID ID of the release. - * @param int $groupID Group ID of the release. - * @param NNTP $nntp Class NNTP - * @param int $show Only show result or apply iy. - * - * @throws \Exception - */ - public function parsePAR2(string $messageID, int $relID, int $groupID, NNTP $nntp, int $show): bool - { - return $this->par2Processor->parseFromMessage($messageID, $relID, $groupID, $nntp, $show); - } -} diff --git a/app/Console/Commands/PostProcessGuid.php b/app/Console/Commands/PostProcessGuid.php index a17d4ce66..55c59a556 100644 --- a/app/Console/Commands/PostProcessGuid.php +++ b/app/Console/Commands/PostProcessGuid.php @@ -1,12 +1,14 @@ argument('guid'); $renamed = $this->argument('renamed') ?? ''; - if (! $this->isValidChar($guid)) { + if (!$this->isValidChar($guid)) { $this->error('GUID character must be a-f or 0-9.'); return self::FAILURE; } try { - switch ($type) { - case 'additional': - $nntp = $this->getNntp(); - app(AdditionalProcessingOrchestrator::class)->start('', $guid); - break; - - case 'nfo': - $nntp = $this->getNntp(); - (new Nfo)->processNfoFiles( - $nntp, - '', - $guid, - (int) Settings::settingValue('lookupimdb'), - (int) Settings::settingValue('lookuptv') - ); - break; - - case 'movie': - (new PostProcess)->processMovies('', $guid, $renamed); - break; - - case 'tv': - (new PostProcess)->processTv('', $guid, $renamed); - break; - - case 'anime': - (new PostProcess)->processAnime('', $guid); - break; - - case 'books': - (new PostProcess)->processBooks('', $guid); - break; - - default: - $this->error('Invalid type. Must be: additional, nfo, movie, tv, anime, or books.'); - - return self::FAILURE; - } + match ($type) { + 'additional' => $this->processAdditional($guid), + 'nfo' => $this->processNfo($guid), + 'movie' => $this->postProcessService->processMovies('', $guid, $renamed), + 'tv' => $this->postProcessService->processTv('', $guid, $renamed), + 'anime' => $this->postProcessService->processAnime('', $guid), + 'books' => $this->postProcessService->processBooks('', $guid), + default => throw new \InvalidArgumentException( + 'Invalid type. Must be: additional, nfo, movie, tv, anime, or books.' + ), + }; return self::SUCCESS; } catch (\Throwable $e) { @@ -93,6 +75,29 @@ class PostProcessGuid extends Command } } + /** + * Process additional data for releases. + */ + private function processAdditional(string $guid): void + { + $this->additionalProcessor->start('', $guid); + } + + /** + * Process NFO files for releases. + */ + private function processNfo(string $guid): void + { + $nntp = $this->getNntp(); + (new Nfo())->processNfoFiles( + $nntp, + '', + $guid, + (bool) Settings::settingValue('lookupimdb'), + (bool) Settings::settingValue('lookuptv') + ); + } + /** * Check if the character contains a-f or 0-9. */ @@ -110,12 +115,12 @@ class PostProcessGuid extends Command */ private function getNntp(): NNTP { - $nntp = new NNTP; + $nntp = new NNTP(); if ((config('nntmux_nntp.use_alternate_nntp_server') === true ? $nntp->doConnect(false, true) : $nntp->doConnect()) !== true) { - throw new \Exception('Unable to connect to usenet.'); + throw new \RuntimeException('Unable to connect to usenet.'); } return $nntp; diff --git a/app/Console/Commands/ProcessReleasesCommand.php b/app/Console/Commands/ProcessReleasesCommand.php index e1f4cb5b9..b13493835 100644 --- a/app/Console/Commands/ProcessReleasesCommand.php +++ b/app/Console/Commands/ProcessReleasesCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Console\Commands; -use Blacklight\processing\ProcessReleases; +use App\Services\ReleaseProcessingService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -24,6 +24,12 @@ class ProcessReleasesCommand extends Command */ protected $description = 'Process releases for a specific group or all groups'; + public function __construct( + private readonly ReleaseProcessingService $releaseProcessingService + ) { + parent::__construct(); + } + /** * Execute the console command. */ @@ -32,13 +38,11 @@ class ProcessReleasesCommand extends Command $groupId = $this->argument('groupId'); try { - $releases = new ProcessReleases(); - if (is_numeric($groupId)) { - $this->processReleasesForGroup($releases, (string) $groupId); + $this->processReleasesForGroup((string) $groupId); } else { - $this->processReleasesForGroup($releases, ''); - $this->runPostProcessingTasks($releases); + $this->processReleasesForGroup(''); + $this->runPostProcessingTasks(); } return self::SUCCESS; @@ -52,35 +56,32 @@ class ProcessReleasesCommand extends Command /** * Create / process releases for a groupID. - * - * Uses the ProcessReleases DTO-based workflow for cleaner code. */ - private function processReleasesForGroup(ProcessReleases $releases, string $groupID): void + private function processReleasesForGroup(string $groupID): void { - $limit = $releases->getReleaseCreationLimit(); + $limit = $this->releaseProcessingService->getReleaseCreationLimit(); - $releases->processIncompleteCollections($groupID); - $releases->processCollectionSizes($groupID); - $releases->deleteUnwantedCollections($groupID); + $this->releaseProcessingService->processIncompleteCollections($groupID); + $this->releaseProcessingService->processCollectionSizes($groupID); + $this->releaseProcessingService->deleteUnwantedCollections($groupID); do { - $result = $releases->createReleases($groupID); - $nzbFilesAdded = $releases->createNZBs($groupID); + $result = $this->releaseProcessingService->createReleases($groupID); + $nzbFilesAdded = $this->releaseProcessingService->createNZBs($groupID); - // Continue if we processed up to the limit (more work may be available) $shouldContinue = $result->total() >= $limit || $nzbFilesAdded >= $limit; } while ($shouldContinue); - $releases->deleteCollections($groupID); + $this->releaseProcessingService->deleteCollections($groupID); } /** * Run post-processing tasks after all group releases are processed. */ - private function runPostProcessingTasks(ProcessReleases $releases): void + private function runPostProcessingTasks(): void { - $releases->deletedReleasesByGroup(); - $releases->deleteReleases(); - $releases->categorizeReleases(2); + $this->releaseProcessingService->deletedReleasesByGroup(); + $this->releaseProcessingService->deleteReleases(); + $this->releaseProcessingService->categorizeReleases(2); } } diff --git a/app/Console/Commands/ReleasesFixNamesGroup.php b/app/Console/Commands/ReleasesFixNamesGroup.php index 7e5d01612..e98f41ed2 100644 --- a/app/Console/Commands/ReleasesFixNamesGroup.php +++ b/app/Console/Commands/ReleasesFixNamesGroup.php @@ -1,15 +1,17 @@ proc_par2 === NameFixingService::PROC_PAR2_NONE) { // Initialize NZB contents if needed if (! isset($nzbcontents)) { - $nntp = new NNTP; + $nntp = new NNTP(); $compressedHeaders = config('nntmux_nntp.compressed_headers'); if ((config('nntmux_nntp.use_alternate_nntp_server') === true @@ -217,12 +219,12 @@ class ReleasesFixNamesGroup extends Command : $nntp->doConnect()) !== true) { $this->warn('Unable to connect to usenet for PAR2 processing'); } else { - $Nfo = new Nfo; + $Nfo = new Nfo(); $nzbcontents = new NZBContents([ 'Echo' => false, 'NNTP' => $nntp, 'Nfo' => $Nfo, - 'PostProcess' => new PostProcess(), + 'PostProcess' => app(PostProcessService::class), ]); } } diff --git a/app/Console/Commands/UpdatePerGroup.php b/app/Console/Commands/UpdatePerGroup.php index e0ae046de..86575fe1a 100644 --- a/app/Console/Commands/UpdatePerGroup.php +++ b/app/Console/Commands/UpdatePerGroup.php @@ -9,9 +9,9 @@ use App\Models\UsenetGroup; use App\Services\AdditionalProcessing\AdditionalProcessingOrchestrator; use App\Services\Backfill\BackfillService; use App\Services\Binaries\BinariesService; +use App\Services\ReleaseProcessingService; use Blacklight\Nfo; use Blacklight\NNTP; -use Blacklight\processing\ProcessReleases; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -31,6 +31,12 @@ class UpdatePerGroup extends Command */ protected $description = 'Do a single group (update_binaries/backFill/update_releases/postprocess)'; + public function __construct( + private readonly ReleaseProcessingService $releaseProcessingService + ) { + parent::__construct(); + } + /** * Execute the console command. */ @@ -67,7 +73,7 @@ class UpdatePerGroup extends Command // Create releases $this->info("Processing releases for group: {$groupMySQL['name']}"); - $this->processReleases(new ProcessReleases(), (string) $groupId); + $this->processReleases((string) $groupId); // Post process the releases $this->info("Post-processing additional for group: {$groupMySQL['name']}"); @@ -78,8 +84,8 @@ class UpdatePerGroup extends Command $nntp, $groupId, '', - (bool)Settings::settingValue('lookupimdb'), - (bool)Settings::settingValue('lookuptv') + (bool) Settings::settingValue('lookupimdb'), + (bool) Settings::settingValue('lookuptv') ); $this->info("Completed all processing for group: {$groupMySQL['name']}"); @@ -95,26 +101,23 @@ class UpdatePerGroup extends Command /** * Create / process releases for a groupID. - * - * Uses the ProcessReleases DTO-based workflow for cleaner code. */ - private function processReleases(ProcessReleases $releases, string $groupID): void + private function processReleases(string $groupID): void { - $limit = $releases->getReleaseCreationLimit(); + $limit = $this->releaseProcessingService->getReleaseCreationLimit(); - $releases->processIncompleteCollections($groupID); - $releases->processCollectionSizes($groupID); - $releases->deleteUnwantedCollections($groupID); + $this->releaseProcessingService->processIncompleteCollections($groupID); + $this->releaseProcessingService->processCollectionSizes($groupID); + $this->releaseProcessingService->deleteUnwantedCollections($groupID); do { - $result = $releases->createReleases($groupID); - $nzbFilesAdded = $releases->createNZBs($groupID); + $result = $this->releaseProcessingService->createReleases($groupID); + $nzbFilesAdded = $this->releaseProcessingService->createNZBs($groupID); - // Continue if we processed up to the limit (more work may be available) $shouldContinue = $result->total() >= $limit || $nzbFilesAdded >= $limit; } while ($shouldContinue); - $releases->deleteCollections($groupID); + $this->releaseProcessingService->deleteCollections($groupID); } /** diff --git a/app/Console/Commands/UpdatePostProcess.php b/app/Console/Commands/UpdatePostProcess.php index 120bbde22..c5221e6f0 100644 --- a/app/Console/Commands/UpdatePostProcess.php +++ b/app/Console/Commands/UpdatePostProcess.php @@ -1,9 +1,11 @@ */ - private array $validTypes = [ + private const array VALID_TYPES = [ 'additional' => false, 'all' => true, - 'amazon' => false, // Alias for book, music, console, games, xxx + 'amazon' => false, 'anime' => false, 'book' => false, 'console' => false, @@ -42,16 +46,20 @@ class UpdatePostProcess extends Command 'xxx' => false, ]; + public function __construct( + private readonly PostProcessService $postProcessService + ) { + parent::__construct(); + } + /** * Execute the console command. */ public function handle(): int { $type = $this->argument('type'); - $echoArg = $this->argument('echo'); - $echo = ($echoArg === null || $echoArg === 'true'); - if (! array_key_exists($type, $this->validTypes)) { + if (!array_key_exists($type, self::VALID_TYPES)) { $this->error("Invalid type: {$type}"); $this->showHelp(); @@ -59,22 +67,21 @@ class UpdatePostProcess extends Command } try { - $nntp = $this->validTypes[$type] ? $this->getNntp() : null; - $postProcess = new PostProcess; + $nntp = self::VALID_TYPES[$type] ? $this->getNntp() : null; match ($type) { - 'all' => $postProcess->processAll($nntp), - 'amazon' => $this->processAmazon($postProcess), - 'nfo' => $postProcess->processNfos($nntp), - 'movies' => $postProcess->processMovies(), - 'music' => $postProcess->processMusic(), - 'console' => $postProcess->processConsoles(), - 'games' => $postProcess->processGames(), - 'book' => $postProcess->processBooks(), - 'anime' => $postProcess->processAnime(), - 'tv' => $postProcess->processTv(), - 'xxx' => $postProcess->processXXX(), - 'additional' => $postProcess->processAdditional(), + 'all' => $this->postProcessService->processAll($nntp), + 'amazon' => $this->processAmazon(), + 'nfo' => $this->postProcessService->processNfos($nntp), + 'movies' => $this->postProcessService->processMovies(), + 'music' => $this->postProcessService->processMusic(), + 'console' => $this->postProcessService->processConsoles(), + 'games' => $this->postProcessService->processGames(), + 'book' => $this->postProcessService->processBooks(), + 'anime' => $this->postProcessService->processAnime(), + 'tv' => $this->postProcessService->processTv(), + 'xxx' => $this->postProcessService->processXXX(), + 'additional' => $this->postProcessService->processAdditional(), default => throw new \Exception("Unhandled type: {$type}"), }; @@ -110,13 +117,13 @@ class UpdatePostProcess extends Command /** * Process amazon types (books, music, console, games, xxx). */ - private function processAmazon(PostProcess $postProcess): void + private function processAmazon(): void { - $postProcess->processBooks(); - $postProcess->processMusic(); - $postProcess->processConsoles(); - $postProcess->processGames(); - $postProcess->processXXX(); + $this->postProcessService->processBooks(); + $this->postProcessService->processMusic(); + $this->postProcessService->processConsoles(); + $this->postProcessService->processGames(); + $this->postProcessService->processXXX(); } /** @@ -124,12 +131,12 @@ class UpdatePostProcess extends Command */ private function getNntp(): NNTP { - $nntp = new NNTP; + $nntp = new NNTP(); if ((config('nntmux_nntp.use_alternate_nntp_server') === true ? $nntp->doConnect(false, true) : $nntp->doConnect()) !== true) { - throw new \Exception('Unable to connect to usenet.'); + throw new \RuntimeException('Unable to connect to usenet.'); } return $nntp; diff --git a/app/Providers/ProcessingServiceProvider.php b/app/Providers/ProcessingServiceProvider.php new file mode 100644 index 000000000..8c702b80c --- /dev/null +++ b/app/Providers/ProcessingServiceProvider.php @@ -0,0 +1,35 @@ +app->singleton(PostProcessService::class, function ($app) { + return new PostProcessService(); + }); + + $this->app->singleton(ReleaseProcessingService::class, function ($app) { + return new ReleaseProcessingService(); + }); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} + diff --git a/app/Services/PostProcessService.php b/app/Services/PostProcessService.php new file mode 100644 index 000000000..2c8d6eccd --- /dev/null +++ b/app/Services/PostProcessService.php @@ -0,0 +1,266 @@ +echoOutput = (bool) config('nntmux.echocli'); + $this->addPar2 = (bool) config('nntmux_settings.add_par2'); + $this->alternateNNTP = (bool) config('nntmux_nntp.use_alternate_nntp_server'); + + // Core dependencies + $this->nameFixingService = $nameFixingService ?? new NameFixingService(); + $this->par2Info = $par2Info ?? new Par2Info(); + $this->nfo = $nfo ?? new Nfo(); + + // Processors + $this->par2Processor = $par2Processor ?? new Par2Processor( + $this->nameFixingService, + $this->par2Info, + $this->addPar2, + $this->alternateNNTP + ); + $this->tvProcessor = $tvProcessor ?? new TvProcessor($this->echoOutput); + $this->nfoProcessor = $nfoProcessor ?? new NfoProcessor($this->nfo, $this->echoOutput); + $this->moviesProcessor = $moviesProcessor ?? new MoviesProcessor($this->echoOutput); + $this->musicProcessor = $musicProcessor ?? new MusicProcessor($this->echoOutput); + $this->booksProcessor = $booksProcessor ?? new BooksProcessor($this->echoOutput); + $this->consolesProcessor = $consolesProcessor ?? new ConsolesProcessor($this->echoOutput); + $this->gamesProcessor = $gamesProcessor ?? new GamesProcessor($this->echoOutput); + $this->animeProcessor = $animeProcessor ?? new AnimeProcessor($this->echoOutput); + $this->xxxProcessor = $xxxProcessor ?? new XXXProcessor($this->echoOutput); + } + + /** + * Run all post-processing types. + * + * @throws \Exception + */ + public function processAll(NNTP $nntp): void + { + $this->processAdditional(); + $this->processNfos($nntp); + $this->processMovies(); + $this->processMusic(); + $this->processConsoles(); + $this->processGames(); + $this->processAnime(); + $this->processTv(); + $this->processXXX(); + $this->processBooks(); + } + + /** + * Process anime releases using AniDB. + * + * @param string $groupID Optional group ID filter + * @param string $guidChar Optional GUID character filter + * + * @throws \Exception + */ + public function processAnime(string $groupID = '', string $guidChar = ''): void + { + $this->animeProcessor->process($groupID, $guidChar); + } + + /** + * Process book releases using Amazon. + * + * @param string $groupID Optional group ID filter + * @param string $guidChar Optional GUID character filter + * + * @throws \Exception + */ + public function processBooks(string $groupID = '', string $guidChar = ''): void + { + $this->booksProcessor->process($groupID, $guidChar); + } + + /** + * Process console game releases. + * + * @throws \Exception + */ + public function processConsoles(): void + { + $this->consolesProcessor->process(); + } + + /** + * Process PC game releases. + * + * @throws \Exception + */ + public function processGames(): void + { + $this->gamesProcessor->process(); + } + + /** + * Process movie releases using IMDB/TMDB. + * + * @param string $groupID Optional group ID filter + * @param string $guidChar Optional GUID character filter + * @param int|string|null $processMovies Processing mode (0=skip, 1=all, 2=renamed only, ''=check setting) + * + * @throws \Exception + */ + public function processMovies( + string $groupID = '', + string $guidChar = '', + int|string|null $processMovies = '' + ): void { + $this->moviesProcessor->process($groupID, $guidChar, $processMovies); + } + + /** + * Process music releases. + * + * @throws \Exception + */ + public function processMusic(): void + { + $this->musicProcessor->process(); + } + + /** + * Process NFO files for releases. + * + * @param NNTP $nntp NNTP connection for downloading NFOs + * @param string $groupID Optional group ID filter + * @param string $guidChar Optional GUID character filter + * + * @throws \Exception + */ + public function processNfos(NNTP $nntp, string $groupID = '', string $guidChar = ''): void + { + $this->nfoProcessor->process($nntp, $groupID, $guidChar); + } + + /** + * Process TV releases. + * + * @param string $groupID Optional group ID filter + * @param string $guidChar Optional GUID character filter + * @param int|string|null $processTV Processing mode (0=skip, 1=all, 2=renamed only, ''=check setting) + * @param string $mode Processing mode ('pipeline' or 'parallel') + * + * @throws \Exception + */ + public function processTv( + string $groupID = '', + string $guidChar = '', + int|string|null $processTV = '', + string $mode = 'pipeline' + ): void { + if ($guidChar === '') { + $forkingService = new ForkingService(); + $processTV = is_numeric($processTV) + ? $processTV + : \App\Models\Settings::settingValue('lookuptv'); + $renamedOnly = ((int) $processTV === 2); + + $forkingService->processTv($renamedOnly); + } else { + $this->tvProcessor->process($groupID, $guidChar, $processTV, $mode); + } + } + + /** + * Process XXX releases. + * + * @throws \Exception + */ + public function processXXX(): void + { + $this->xxxProcessor->process(); + } + + /** + * Process additional release data (RAR/ZIP contents, samples, media info). + * + * @param int|string $groupID Optional group ID filter + * @param string $guidChar Optional GUID character filter + * + * @throws \Exception + */ + public function processAdditional(int|string $groupID = '', string $guidChar = ''): void + { + app(AdditionalProcessingOrchestrator::class)->start($groupID, $guidChar); + } + + /** + * Attempt to get a better name from a PAR2 file and re-categorize. + * + * @param string $messageID Message ID from NZB + * @param int $relID Release ID + * @param int $groupID Group ID + * @param NNTP $nntp NNTP connection + * @param int $show Display mode (0=apply, 1=show only) + * + * @throws \Exception + */ + public function parsePAR2( + string $messageID, + int $relID, + int $groupID, + NNTP $nntp, + int $show + ): bool { + return $this->par2Processor->parseFromMessage($messageID, $relID, $groupID, $nntp, $show); + } +} + diff --git a/Blacklight/processing/ProcessReleases.php b/app/Services/ReleaseProcessingService.php old mode 100755 new mode 100644 similarity index 82% rename from Blacklight/processing/ProcessReleases.php rename to app/Services/ReleaseProcessingService.php index b90f96d59..1f455472c --- a/Blacklight/processing/ProcessReleases.php +++ b/app/Services/ReleaseProcessingService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Blacklight\processing; +namespace App\Services; use App\Enums\CollectionFileCheckStatus; use App\Enums\FileCompletionStatus; @@ -13,8 +13,6 @@ use App\Models\Release; use App\Models\Settings; use App\Models\UsenetGroup; use App\Services\Categorization\CategorizationService; -use App\Services\CollectionCleanupService; -use App\Services\ReleaseCreationService; use App\Support\DTOs\ProcessReleasesSettings; use App\Support\DTOs\ReleaseCreationResult; use App\Support\DTOs\ReleaseDeleteStats; @@ -28,67 +26,29 @@ use Blacklight\Releases; use DateTimeInterface; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Str; use Throwable; /** - * Processes collections into releases and creates NZB files. + * Service for processing collections into releases and creating NZB files. * - * This class handles the complete release processing pipeline: + * This service handles the complete release processing pipeline: * - Finding complete collections * - Calculating collection sizes * - Creating releases from collections * - Generating NZB files * - Categorizing releases * - Cleanup of old/unwanted releases - * - * @phpstan-type GroupId int|string|null */ -final class ProcessReleases +final class ReleaseProcessingService { - // Legacy constants for backward compatibility - use enums for new code - /** @deprecated Use CollectionFileCheckStatus::Default instead */ - public const COLLFC_DEFAULT = 0; - /** @deprecated Use CollectionFileCheckStatus::CompleteCollection instead */ - public const COLLFC_COMPCOLL = 1; - /** @deprecated Use CollectionFileCheckStatus::CompleteParts instead */ - public const COLLFC_COMPPART = 2; - /** @deprecated Use CollectionFileCheckStatus::Sized instead */ - public const COLLFC_SIZED = 3; - /** @deprecated Use CollectionFileCheckStatus::Inserted instead */ - public const COLLFC_INSERTED = 4; - /** @deprecated Use CollectionFileCheckStatus::Delete instead */ - public const COLLFC_DELETE = 5; - /** @deprecated Use CollectionFileCheckStatus::TempComplete instead */ - public const COLLFC_TEMPCOMP = 15; - /** @deprecated Use CollectionFileCheckStatus::ZeroPart instead */ - public const COLLFC_ZEROPART = 16; - - /** @deprecated Use FileCompletionStatus::Incomplete instead */ - public const FILE_INCOMPLETE = 0; - /** @deprecated Use FileCompletionStatus::Complete instead */ - public const FILE_COMPLETE = 1; - - /** Batch size for bulk operations */ private const int BATCH_SIZE = 500; - - /** Maximum retry attempts for database operations */ private const int MAX_RETRIES = 5; - - /** Base delay for exponential backoff (microseconds) */ private const int RETRY_BASE_DELAY_US = 20000; - - /** Pause between batch operations (microseconds) */ private const int BATCH_PAUSE_US = 10000; - - /** Chunk size for release categorization */ private const int CATEGORIZE_CHUNK_SIZE = 1000; - - /** Chunk size for NZB creation */ private const int NZB_CHUNK_SIZE = 100; - public bool $echoCLI; - + private bool $echoCLI; private readonly ProcessReleasesSettings $settings; private readonly ColorCLI $colorCLI; private readonly NZB $nzb; @@ -97,16 +57,8 @@ final class ProcessReleases private readonly ReleaseImage $releaseImage; private readonly ReleaseCreationService $releaseCreationService; private readonly CollectionCleanupService $collectionCleanupService; + private readonly ?PostProcessService $postProcessService; - /** - * @param ColorCLI|null $colorCLI Console output handler - * @param NZB|null $nzb NZB file handler - * @param ReleaseCleaning|null $releaseCleaning Release name cleaner - * @param Releases|null $releases Release operations handler - * @param ReleaseImage|null $releaseImage Release image handler - * @param ReleaseCreationService|null $releaseCreationService Service for creating releases - * @param CollectionCleanupService|null $collectionCleanupService Service for cleaning collections - */ public function __construct( ?ColorCLI $colorCLI = null, ?NZB $nzb = null, @@ -115,10 +67,10 @@ final class ProcessReleases ?ReleaseImage $releaseImage = null, ?ReleaseCreationService $releaseCreationService = null, ?CollectionCleanupService $collectionCleanupService = null, + ?PostProcessService $postProcessService = null, ) { $this->echoCLI = (bool) config('nntmux.echocli'); - // Initialize dependencies with defaults $this->colorCLI = $colorCLI ?? new ColorCLI(); $this->nzb = $nzb ?? new NZB(); $this->releaseCleaning = $releaseCleaning ?? new ReleaseCleaning(); @@ -129,6 +81,7 @@ final class ProcessReleases ?? new ReleaseCreationService($this->colorCLI, $this->releaseCleaning); $this->collectionCleanupService = $collectionCleanupService ?? new CollectionCleanupService($this->colorCLI); + $this->postProcessService = $postProcessService; $this->settings = $this->loadSettings(); $this->validateSettings(); @@ -140,19 +93,10 @@ final class ProcessReleases private function loadSettings(): ProcessReleasesSettings { $settingKeys = [ - 'delaytime', - 'crossposttime', - 'maxnzbsprocessed', - 'completionpercent', - 'collection_timeout', - 'maxsizetoformrelease', - 'minsizetoformrelease', - 'minfilestoformrelease', - 'releaseretentiondays', - 'deletepasswordedrelease', - 'miscotherretentionhours', - 'mischashedretentionhours', - 'partretentionhours', + 'delaytime', 'crossposttime', 'maxnzbsprocessed', 'completionpercent', + 'collection_timeout', 'maxsizetoformrelease', 'minsizetoformrelease', + 'minfilestoformrelease', 'releaseretentiondays', 'deletepasswordedrelease', + 'miscotherretentionhours', 'mischashedretentionhours', 'partretentionhours', 'last_run_time', ]; @@ -177,131 +121,7 @@ final class ProcessReleases } // ======================================================================== - // Console Output Helper Methods - // ======================================================================== - - /** - * Output a section header. - */ - private function outputHeader(string $title): void - { - if (!$this->echoCLI) { - return; - } - - echo PHP_EOL; - $this->colorCLI->header(strtoupper($title)); - $this->colorCLI->header(str_repeat('-', strlen($title))); - } - - /** - * Output a sub-section header. - */ - private function outputSubHeader(string $title): void - { - if (!$this->echoCLI) { - return; - } - - $this->colorCLI->notice(" {$title}"); - } - - /** - * Output a success message. - */ - private function outputSuccess(string $message): void - { - if (!$this->echoCLI) { - return; - } - - $this->colorCLI->primary(" {$message}"); - } - - /** - * Output an info message. - */ - private function outputInfo(string $message): void - { - if (!$this->echoCLI) { - return; - } - - $this->colorCLI->info(" {$message}"); - } - - /** - * Output a stat line with label and value. - */ - private function outputStat(string $label, string|int $value, string $suffix = ''): void - { - if (!$this->echoCLI) { - return; - } - - $formattedValue = is_int($value) ? number_format($value) : $value; - $this->colorCLI->primary(" {$label}: {$formattedValue}{$suffix}"); - } - - /** - * Output elapsed time. - */ - private function outputElapsedTime(DateTimeInterface $startTime, string $prefix = 'Time'): void - { - if (!$this->echoCLI) { - return; - } - - $elapsed = now()->diffInSeconds($startTime, true); - $timeStr = $this->formatElapsedTime($elapsed); - $this->colorCLI->info(" {$prefix}: {$timeStr}"); - } - - /** - * Format elapsed time. - */ - private function formatElapsedTime(int|float $seconds): string - { - if ($seconds < 1) { - return sprintf('%dms', (int) ($seconds * 1000)); - } - - if ($seconds < 60) { - return sprintf('%.1fs', $seconds); - } - - $minutes = floor($seconds / 60); - $remainingSeconds = $seconds % 60; - - if ($minutes < 60) { - return sprintf('%dm %ds', $minutes, (int) $remainingSeconds); - } - - $hours = floor($minutes / 60); - $remainingMinutes = $minutes % 60; - - return sprintf('%dh %dm', $hours, $remainingMinutes); - } - - /** - * Output a progress indicator. - */ - private function outputProgress(int $current, int $total, string $action): void - { - if (!$this->echoCLI || $total === 0) { - return; - } - - $percent = min(100, (int) (($current / $total) * 100)); - echo "\r {$action}: " . number_format($current) . '/' . number_format($total) . " ({$percent}%) "; - - if ($current >= $total) { - echo PHP_EOL; - } - } - - // ======================================================================== - // Public Getters + // Public API // ======================================================================== /** @@ -336,20 +156,26 @@ final class ProcessReleases return $this->settings->crossPostTime; } + /** + * Check if CLI echo is enabled. + */ + public function isEchoCLI(): bool + { + return $this->echoCLI; + } + + /** + * Set CLI echo mode. + */ + public function setEchoCLI(bool $echo): self + { + $this->echoCLI = $echo; + return $this; + } + /** * Main method for creating releases/NZB files from collections. * - * This orchestrates the complete release processing pipeline: - * 1. Process incomplete collections to find complete ones - * 2. Calculate collection sizes - * 3. Delete unwanted collections based on size/file count rules - * 4. Create releases from sized collections - * 5. Generate NZB files for new releases - * 6. Categorize new releases - * 7. Run post-processing (if enabled) - * 8. Clean up processed collections - * 9. Delete unwanted releases based on retention/quality rules - * * @param int $categorize Categorization type (1=name, 2=searchname) * @param int $postProcess Whether to run post-processing (1=yes) * @param string $groupName Optional group name to filter processing @@ -358,15 +184,16 @@ final class ProcessReleases * * @throws Throwable */ - public function processReleases(int $categorize, int $postProcess, string $groupName, NNTP $nntp): int - { + public function processReleases( + int $categorize, + int $postProcess, + string $groupName, + NNTP $nntp + ): int { $this->echoCLI = (bool) config('nntmux.echocli'); $overallStartTime = now()->toImmutable(); - if ($this->echoCLI) { - $this->outputBanner(); - } - + $this->outputBanner(); if (!$this->validateNzbPath()) { return 0; } @@ -386,117 +213,60 @@ final class ProcessReleases // Phase 2: Release creation loop $this->outputHeader('Phase 2: Release Creation'); - $totalReleasesAdded = 0; - $totalNzbsCreated = 0; - $totalDupes = 0; - $iterations = 0; - $limit = $this->settings->releaseCreationLimit; - - do { - $iterations++; - $result = $this->createReleases($normalizedGroupId); - $totalReleasesAdded += $result->added; - $totalDupes += $result->dupes; - - $nzbFilesAdded = $this->createNZBs($normalizedGroupId); - $totalNzbsCreated += $nzbFilesAdded; - - $this->categorizeReleases($categorize, $normalizedGroupId); - $this->postProcessReleases($postProcess, $nntp); - $this->deleteCollections($normalizedGroupId); - - // Continue processing if we hit the limit (more work to do) - $shouldContinue = $result->total() >= $limit || $nzbFilesAdded >= $limit; - } while ($shouldContinue); + $totals = $this->runReleaseCreationLoop($normalizedGroupId, $categorize, $postProcess, $nntp); // Phase 3: Cleanup $this->outputHeader('Phase 3: Cleanup'); $this->deleteReleases(); - // Final summary - $this->outputFinalSummary($totalReleasesAdded, $totalNzbsCreated, $totalDupes, $iterations, $overallStartTime); + $this->outputFinalSummary( + $totals['releases'], + $totals['nzbs'], + $totals['dupes'], + $totals['iterations'], + $overallStartTime + ); - return $totalReleasesAdded; + return $totals['releases']; } /** - * Output the application banner. + * Run the release creation loop. + * + * @return array{releases: int, nzbs: int, dupes: int, iterations: int} + * @throws Throwable */ - private function outputBanner(): void - { - if (!$this->echoCLI) { - return; - } + private function runReleaseCreationLoop( + ?int $normalizedGroupId, + int $categorize, + int $postProcess, + NNTP $nntp + ): array { + $totals = ['releases' => 0, 'nzbs' => 0, 'dupes' => 0, 'iterations' => 0]; + $limit = $this->settings->releaseCreationLimit; - echo PHP_EOL; - $this->colorCLI->header('NNTmux Release Processing'); - $this->colorCLI->info('Started: ' . now()->format('Y-m-d H:i:s')); - } + do { + $totals['iterations']++; - /** - * Output the final processing summary. - */ - private function outputFinalSummary( - int $releasesAdded, - int $nzbsCreated, - int $dupes, - int $iterations, - DateTimeInterface $startTime - ): void { - if (!$this->echoCLI) { - return; - } + $result = $this->createReleases($normalizedGroupId); + $totals['releases'] += $result->added; + $totals['dupes'] += $result->dupes; - $elapsed = now()->diffInSeconds($startTime, true); + $nzbFilesAdded = $this->createNZBs($normalizedGroupId); + $totals['nzbs'] += $nzbFilesAdded; - echo PHP_EOL; - $this->colorCLI->header('SUMMARY'); - $this->colorCLI->header('-------'); - $this->colorCLI->primary(' Releases added: ' . number_format($releasesAdded)); - $this->colorCLI->primary(' NZBs created: ' . number_format($nzbsCreated)); - if ($dupes > 0) { - $this->colorCLI->warning(' Duplicates skipped: ' . number_format($dupes)); - } - $this->colorCLI->info(' Processing cycles: ' . number_format($iterations)); - $this->colorCLI->info(' Total time: ' . $this->formatElapsedTime($elapsed)); - echo PHP_EOL; - } + $this->categorizeReleases($categorize, $normalizedGroupId); + $this->postProcessReleases($postProcess, $nntp); + $this->deleteCollections($normalizedGroupId); - /** - * Validate that the NZB storage path exists. - */ - private function validateNzbPath(): bool - { - $nzbPath = config('nntmux_settings.path_to_nzbs'); + $shouldContinue = $result->total() >= $limit || $nzbFilesAdded >= $limit; + } while ($shouldContinue); - if (!file_exists($nzbPath)) { - if ($this->echoCLI) { - $this->colorCLI->error("Bad or missing NZB directory - {$nzbPath}"); - } - return false; - } - - return true; - } - - /** - * Resolve a group name to its database ID. - */ - private function resolveGroupId(string $groupName): string - { - if ($groupName === '') { - return ''; - } - - $groupInfo = UsenetGroup::getByName($groupName); - - return $groupInfo !== null ? (string) $groupInfo['id'] : ''; + return $totals; } /** * Reset all releases to other->misc category. - * - * @param string $where Optional WHERE clause to limit which releases are reset */ public function resetCategorize(string $where = ''): void { @@ -514,11 +284,7 @@ final class ProcessReleases } /** - * Categorize uncategorized releases. - * - * @param string $type Column to use for categorization ('name' or 'searchname') - * @param int|string|null $groupId Optional group ID to filter releases - * @return int Number of releases categorized + * Categorize a release using the specified type. * * @throws \Exception */ @@ -571,19 +337,16 @@ final class ProcessReleases /** * Process incomplete collections to find complete ones. * - * @throws \Exception * @throws Throwable */ public function processIncompleteCollections(int|string|null $groupID): void { $startTime = now()->toImmutable(); - $this->outputSubHeader('Finding Complete Collections'); $normalizedGroupId = $this->normalizeGroupId($groupID); $whereSql = $this->buildGroupWhereSql($normalizedGroupId, 'c'); - // Run all collection check stages $this->processStuckCollections($normalizedGroupId ?? 0); $this->runCollectionFileCheckStage1($normalizedGroupId ?? 0); $this->runCollectionFileCheckStage2($normalizedGroupId ?? 0); @@ -597,31 +360,14 @@ final class ProcessReleases $this->outputElapsedTime($startTime); } - /** - * Count collections marked as complete. - */ - private function countCompleteCollections(?int $groupId): int - { - $query = Collection::query() - ->where('filecheck', CollectionFileCheckStatus::CompleteParts->value); - - if ($groupId !== null) { - $query->where('groups_id', $groupId); - } - - return $query->count('id'); - } - /** * Calculate sizes for complete collections. * - * @throws \Exception * @throws Throwable */ public function processCollectionSizes(int|string|null $groupID): void { $startTime = now()->toImmutable(); - $this->outputSubHeader('Calculating Collection Sizes'); $updated = 0; @@ -656,13 +402,11 @@ final class ProcessReleases /** * Delete collections that don't meet size/file count requirements. * - * @throws \Exception * @throws Throwable */ public function deleteUnwantedCollections(int|string|null $groupID): void { $startTime = now()->toImmutable(); - $this->outputSubHeader('Filtering Collections by Size/File Count'); $normalizedGroupId = $this->normalizeGroupId($groupID); @@ -682,7 +426,6 @@ final class ProcessReleases } DB::transaction(function () use ($groupMinSize, $groupMinFiles, &$stats): void { - // Delete collections smaller than minimum size $effectiveMinSize = max($groupMinSize, $this->settings->minSizeToFormRelease); if ($effectiveMinSize > 0) { $stats['minSize'] += Collection::query() @@ -692,7 +435,6 @@ final class ProcessReleases ->delete(); } - // Delete collections larger than maximum size if ($this->settings->maxSizeToFormRelease > 0) { $stats['maxSize'] += Collection::query() ->where('filecheck', CollectionFileCheckStatus::Sized->value) @@ -700,7 +442,6 @@ final class ProcessReleases ->delete(); } - // Delete collections with fewer files than minimum $effectiveMinFiles = max($groupMinFiles, $this->settings->minFilesToFormRelease); if ($effectiveMinFiles > 0) { $stats['minFiles'] += Collection::query() @@ -715,37 +456,6 @@ final class ProcessReleases $this->outputCollectionDeleteStats($stats, $startTime); } - /** - * Check if there are any sized collections to process. - */ - private function hasSizedCollections(): bool - { - return Collection::query() - ->where('filecheck', CollectionFileCheckStatus::Sized->value) - ->where('filesize', '>', 0) - ->exists(); - } - - /** - * Output collection deletion statistics. - * - * @param array{minSize: int, maxSize: int, minFiles: int} $stats - */ - private function outputCollectionDeleteStats(array $stats, DateTimeInterface $startTime): void - { - $totalDeleted = $stats['minSize'] + $stats['maxSize'] + $stats['minFiles']; - - if ($totalDeleted > 0) { - $this->outputStat('Too small', $stats['minSize']); - $this->outputStat('Too large', $stats['maxSize']); - $this->outputStat('Too few files', $stats['minFiles']); - $this->outputStat('Total removed', $totalDeleted); - } else { - $this->outputInfo('No collections filtered'); - } - $this->outputElapsedTime($startTime); - } - /** * Create releases from complete collections. * @@ -770,7 +480,6 @@ final class ProcessReleases public function createNZBs(int|string|null $groupID): int { $startTime = now()->toImmutable(); - $this->outputSubHeader('Creating NZB Files'); $query = Release::query() @@ -806,9 +515,6 @@ final class ProcessReleases /** * Categorize releases based on the specified field. * - * @param int $categorize Categorization type (1=name, 2=searchname) - * @param int|string|null $groupID Optional group ID filter - * * @throws \Exception */ public function categorizeReleases(int $categorize, int|string|null $groupID = null): void @@ -835,17 +541,19 @@ final class ProcessReleases */ public function postProcessReleases(int $postProcess, NNTP $nntp): void { - if ($postProcess === 1) { - $this->outputSubHeader('Post-Processing Releases'); - (new PostProcess(['Echo' => $this->echoCLI]))->processAll($nntp); + if ($postProcess !== 1) { return; } + + $this->outputSubHeader('Post-Processing Releases'); + + $service = $this->postProcessService ?? new PostProcessService(); + $service->processAll($nntp); } /** * Delete finished and orphaned collections. * - * @throws \Exception * @throws Throwable */ public function deleteCollections(int|string|null $groupID): void @@ -883,10 +591,362 @@ final class ProcessReleases } /** - * Delete releases smaller than minimum size for a group. + * Delete releases based on site-wide settings. * - * @param array{minSize: int, maxSize: int, minFiles: int} $stats + * @throws \Exception */ + public function deleteReleases(): void + { + $startTime = now()->toImmutable(); + $this->outputSubHeader('Removing Unwanted Releases'); + + $stats = new ReleaseDeleteStats(); + + $stats = $this->deleteReleasesOverRetention($stats); + $stats = $this->deletePasswordedReleases($stats); + $stats = $this->deleteCrossPostedReleases($stats); + $stats = $this->deleteIncompleteReleases($stats); + $stats = $this->deleteDisabledCategoryReleases($stats); + $stats = $this->deleteCategoryMinSizeReleases($stats); + $stats = $this->deleteDisabledGenreReleases($stats); + $stats = $this->deleteMiscReleases($stats); + + $this->outputReleaseDeleteStats($stats, $startTime); + } + + // ======================================================================== + // Private Helper Methods + // ======================================================================== + + private function validateNzbPath(): bool + { + $nzbPath = config('nntmux_settings.path_to_nzbs'); + + if (!file_exists($nzbPath)) { + if ($this->echoCLI) { + $this->colorCLI->error("Bad or missing NZB directory - {$nzbPath}"); + } + return false; + } + + return true; + } + + private function resolveGroupId(string $groupName): string + { + if ($groupName === '') { + return ''; + } + + $groupInfo = UsenetGroup::getByName($groupName); + return $groupInfo !== null ? (string) $groupInfo['id'] : ''; + } + + private function countCompleteCollections(?int $groupId): int + { + $query = Collection::query() + ->where('filecheck', CollectionFileCheckStatus::CompleteParts->value); + + if ($groupId !== null) { + $query->where('groups_id', $groupId); + } + + return $query->count('id'); + } + + private function hasSizedCollections(): bool + { + return Collection::query() + ->where('filecheck', CollectionFileCheckStatus::Sized->value) + ->where('filesize', '>', 0) + ->exists(); + } + + private function normalizeGroupId(int|string|null $groupID): ?int + { + if ($groupID === null || $groupID === '') { + return null; + } + + if (is_numeric($groupID)) { + return (int) $groupID; + } + + $groupInfo = UsenetGroup::getByName($groupID); + return $groupInfo !== null ? (int) $groupInfo['id'] : null; + } + + private function buildGroupWhereSql(?int $groupID, string $alias = 'c'): string + { + return $groupID !== null ? " AND {$alias}.groups_id = {$groupID} " : ' '; + } + + // ======================================================================== + // Collection Processing Stages + // ======================================================================== + + /** + * @throws Throwable + */ + private function runCollectionFileCheckStage1(int $groupID): void + { + DB::transaction(static function () use ($groupID): void { + $collectionsQuery = Collection::query() + ->select(['collections.id']) + ->join('binaries', 'binaries.collections_id', '=', 'collections.id') + ->where('collections.totalfiles', '>', 0) + ->where('collections.filecheck', '=', CollectionFileCheckStatus::Default->value) + ->groupBy(['binaries.collections_id', 'collections.totalfiles', 'collections.id']) + ->havingRaw('COUNT(binaries.id) IN (collections.totalfiles, collections.totalfiles + 1)'); + + if ($groupID !== 0) { + $collectionsQuery->where('collections.groups_id', $groupID); + } + + Collection::query() + ->joinSub($collectionsQuery, 'r', static fn($join) => $join->on('collections.id', '=', 'r.id')) + ->update(['collections.filecheck' => CollectionFileCheckStatus::CompleteCollection->value]); + }, 10); + } + + /** + * @throws Throwable + */ + private function runCollectionFileCheckStage2(int $groupID): void + { + DB::transaction(static function () use ($groupID): void { + $collectionsQuery = Collection::query() + ->select(['collections.id']) + ->join('binaries', 'binaries.collections_id', '=', 'collections.id') + ->where('binaries.filenumber', '=', 0) + ->where('collections.totalfiles', '>', 0) + ->where('collections.filecheck', '=', CollectionFileCheckStatus::CompleteCollection->value) + ->groupBy(['collections.id']); + + if ($groupID !== 0) { + $collectionsQuery->where('collections.groups_id', $groupID); + } + + Collection::query() + ->joinSub($collectionsQuery, 'r', static fn($join) => $join->on('collections.id', '=', 'r.id')) + ->update(['collections.filecheck' => CollectionFileCheckStatus::ZeroPart->value]); + }, 10); + + DB::transaction(static function () use ($groupID): void { + $query = Collection::query() + ->where('filecheck', '=', CollectionFileCheckStatus::CompleteCollection->value); + + if ($groupID !== 0) { + $query->where('groups_id', $groupID); + } + + $query->update(['filecheck' => CollectionFileCheckStatus::TempComplete->value]); + }, 10); + } + + /** + * @throws Throwable + */ + private function runCollectionFileCheckStage3(string $whereSql): void + { + DB::transaction(static function () use ($whereSql): void { + $sql = <<value, + FileCompletionStatus::Incomplete->value, + FileCompletionStatus::Complete->value, + ]); + }, 10); + + DB::transaction(static function () use ($whereSql): void { + $sql = <<= (b.totalparts + 1) + GROUP BY b.id, b.totalparts + ) r ON b.id = r.id + SET b.partcheck = ? + SQL; + + DB::update($sql, [ + CollectionFileCheckStatus::ZeroPart->value, + FileCompletionStatus::Incomplete->value, + FileCompletionStatus::Complete->value, + ]); + }, 10); + } + + /** + * @throws Throwable + */ + private function runCollectionFileCheckStage4(string $whereSql): void + { + DB::transaction(static function () use ($whereSql): void { + $sql = <<= c.totalfiles + ) r ON c.id = r.id + SET filecheck = ? + SQL; + + DB::update($sql, [ + FileCompletionStatus::Complete->value, + CollectionFileCheckStatus::TempComplete->value, + CollectionFileCheckStatus::ZeroPart->value, + CollectionFileCheckStatus::CompleteParts->value, + ]); + }, 10); + } + + /** + * @throws Throwable + */ + private function runCollectionFileCheckStage5(int $groupId): void + { + DB::transaction(static function () use ($groupId): void { + $query = Collection::query() + ->whereIn('filecheck', [ + CollectionFileCheckStatus::TempComplete->value, + CollectionFileCheckStatus::ZeroPart->value, + ]); + + if ($groupId !== 0) { + $query->where('groups_id', $groupId); + } + + $query->update(['filecheck' => CollectionFileCheckStatus::CompleteCollection->value]); + }, 10); + } + + /** + * @throws Throwable + */ + private function runCollectionFileCheckStage6(string $whereSql): void + { + DB::transaction(function () use ($whereSql): void { + $sql = <<value, + $this->settings->collectionDelayTime, + CollectionFileCheckStatus::Default->value, + CollectionFileCheckStatus::CompleteCollection->value, + ]); + }, 10); + } + + /** + * @throws Throwable + */ + private function processStuckCollections(int $groupID): void + { + $cutoff = $this->calculateStuckCollectionsCutoff(); + $totalDeleted = 0; + + do { + $affected = $this->deleteStuckCollectionBatch($groupID, $cutoff); + $totalDeleted += $affected; + + if ($affected < self::BATCH_SIZE) { + break; + } + + usleep(self::BATCH_PAUSE_US); + } while (true); + + if ($this->echoCLI && $totalDeleted > 0) { + $this->colorCLI->primary("Deleted {$totalDeleted} broken/stuck collections.", true); + } + } + + private function calculateStuckCollectionsCutoff(): Carbon + { + $lastRun = $this->settings->lastRunTime; + $threshold = null; + + if ($lastRun !== null) { + try { + $threshold = Carbon::createFromFormat('Y-m-d H:i:s', $lastRun); + } catch (Throwable) { + $threshold = null; + } + } + + return ($threshold ?? now())->copy()->subHours($this->settings->collectionTimeout); + } + + private function deleteStuckCollectionBatch(int $groupID, Carbon $cutoff): int + { + $attempt = 0; + $affected = 0; + + do { + try { + $groupCondition = $groupID !== 0 ? 'AND groups_id = ? ' : ''; + $batchLimit = self::BATCH_SIZE; + + $sql = <<= self::MAX_RETRIES) { + if ($this->echoCLI) { + $this->colorCLI->error( + 'Stuck collections delete failed after retries: ' . $e->getMessage() + ); + } + break; + } + usleep(self::RETRY_BASE_DELAY_US * $attempt); + } + } while (true); + + return $affected; + } + + // ======================================================================== + // Release Deletion Methods + // ======================================================================== + private function deleteReleasesUnderMinSize(int|string $groupId, array &$stats): void { $releases = Release::query() @@ -906,11 +966,6 @@ final class ProcessReleases } } - /** - * Delete releases larger than maximum size for a group. - * - * @param array{minSize: int, maxSize: int, minFiles: int} $stats - */ private function deleteReleasesOverMaxSize(int|string $groupId, array &$stats): void { if ($this->settings->maxSizeToFormRelease <= 0) { @@ -929,11 +984,6 @@ final class ProcessReleases } } - /** - * Delete releases with fewer files than minimum for a group. - * - * @param array{minSize: int, maxSize: int, minFiles: int} $stats - */ private function deleteReleasesUnderMinFiles(int|string $groupId, array &$stats): void { if ($this->settings->minFilesToFormRelease <= 0) { @@ -957,52 +1007,6 @@ final class ProcessReleases } } - /** - * Output statistics for group-based release deletion. - * - * @param array{minSize: int, maxSize: int, minFiles: int} $stats - */ - private function outputReleaseDeleteByGroupStats(array $stats, DateTimeInterface $startTime): void - { - $elapsed = now()->diffInSeconds($startTime, true); - $total = $stats['minSize'] + $stats['maxSize'] + $stats['minFiles']; - - if ($total > 0) { - $this->outputStat('Too small', $stats['minSize']); - $this->outputStat('Too large', $stats['maxSize']); - $this->outputStat('Too few files', $stats['minFiles']); - } - $this->outputElapsedTime($startTime); - } - - /** - * Delete releases based on site-wide settings (retention, passwords, etc.). - * - * @throws \Exception - */ - public function deleteReleases(): void - { - $startTime = now()->toImmutable(); - - $this->outputSubHeader('Removing Unwanted Releases'); - - $stats = new ReleaseDeleteStats(); - - $stats = $this->deleteReleasesOverRetention($stats); - $stats = $this->deletePasswordedReleases($stats); - $stats = $this->deleteCrossPostedReleases($stats); - $stats = $this->deleteIncompleteReleases($stats); - $stats = $this->deleteDisabledCategoryReleases($stats); - $stats = $this->deleteCategoryMinSizeReleases($stats); - $stats = $this->deleteDisabledGenreReleases($stats); - $stats = $this->deleteMiscReleases($stats); - - $this->outputReleaseDeleteStats($stats, $startTime); - } - - /** - * Delete releases past the retention period. - */ private function deleteReleasesOverRetention(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (!$this->settings->hasRetentionCleanup()) { @@ -1025,9 +1029,6 @@ final class ProcessReleases return $stats; } - /** - * Delete passworded releases. - */ private function deletePasswordedReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (!$this->settings->deletePasswordedRelease) { @@ -1053,9 +1054,6 @@ final class ProcessReleases return $stats; } - /** - * Delete cross-posted (duplicate) releases. - */ private function deleteCrossPostedReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (!$this->settings->hasCrossPostDetection()) { @@ -1077,9 +1075,6 @@ final class ProcessReleases return $stats; } - /** - * Delete releases under a completion threshold. - */ private function deleteIncompleteReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (!$this->settings->hasCompletionCleanup()) { @@ -1101,9 +1096,6 @@ final class ProcessReleases return $stats; } - /** - * Delete releases from disabled categories. - */ private function deleteDisabledCategoryReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { $disabledCategories = Category::getDisabledIDs(); @@ -1128,9 +1120,6 @@ final class ProcessReleases return $stats; } - /** - * Delete releases smaller than category minimum size. - */ private function deleteCategoryMinSizeReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { $categories = Category::query() @@ -1156,9 +1145,6 @@ final class ProcessReleases return $stats; } - /** - * Delete releases from disabled music genres. - */ private function deleteDisabledGenreReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { $genres = new Genres(); @@ -1192,9 +1178,6 @@ final class ProcessReleases return $stats; } - /** - * Delete misc releases based on retention settings. - */ private function deleteMiscReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if ($this->settings->miscOtherRetentionHours > 0) { @@ -1232,9 +1215,6 @@ final class ProcessReleases return $stats; } - /** - * Delete a single release with all associated data. - */ private function deleteSingleRelease(object $release): void { $this->releases->deleteSingle( @@ -1244,9 +1224,176 @@ final class ProcessReleases ); } + // ======================================================================== + // Output Helper Methods + // ======================================================================== + + private function outputBanner(): void + { + if (!$this->echoCLI) { + return; + } + + echo PHP_EOL; + $this->colorCLI->header('NNTmux Release Processing'); + $this->colorCLI->info('Started: ' . now()->format('Y-m-d H:i:s')); + } + + private function outputHeader(string $title): void + { + if (!$this->echoCLI) { + return; + } + + echo PHP_EOL; + $this->colorCLI->header(strtoupper($title)); + $this->colorCLI->header(str_repeat('-', strlen($title))); + } + + private function outputSubHeader(string $title): void + { + if (!$this->echoCLI) { + return; + } + + $this->colorCLI->notice(" {$title}"); + } + + private function outputSuccess(string $message): void + { + if (!$this->echoCLI) { + return; + } + + $this->colorCLI->primary(" {$message}"); + } + + private function outputInfo(string $message): void + { + if (!$this->echoCLI) { + return; + } + + $this->colorCLI->info(" {$message}"); + } + + private function outputStat(string $label, string|int $value, string $suffix = ''): void + { + if (!$this->echoCLI) { + return; + } + + $formattedValue = is_int($value) ? number_format($value) : $value; + $this->colorCLI->primary(" {$label}: {$formattedValue}{$suffix}"); + } + + private function outputElapsedTime(DateTimeInterface $startTime, string $prefix = 'Time'): void + { + if (!$this->echoCLI) { + return; + } + + $elapsed = now()->diffInSeconds($startTime, true); + $timeStr = $this->formatElapsedTime($elapsed); + $this->colorCLI->info(" {$prefix}: {$timeStr}"); + } + + private function formatElapsedTime(int|float $seconds): string + { + if ($seconds < 1) { + return sprintf('%dms', (int) ($seconds * 1000)); + } + + if ($seconds < 60) { + return sprintf('%.1fs', $seconds); + } + + $minutes = floor($seconds / 60); + $remainingSeconds = $seconds % 60; + + if ($minutes < 60) { + return sprintf('%dm %ds', $minutes, (int) $remainingSeconds); + } + + $hours = floor($minutes / 60); + $remainingMinutes = $minutes % 60; + + return sprintf('%dh %dm', $hours, $remainingMinutes); + } + + private function outputProgress(int $current, int $total, string $action): void + { + if (!$this->echoCLI || $total === 0) { + return; + } + + $percent = min(100, (int) (($current / $total) * 100)); + echo "\r {$action}: " . number_format($current) . '/' . number_format($total) . " ({$percent}%) "; + + if ($current >= $total) { + echo PHP_EOL; + } + } + + private function outputFinalSummary( + int $releasesAdded, + int $nzbsCreated, + int $dupes, + int $iterations, + DateTimeInterface $startTime + ): void { + if (!$this->echoCLI) { + return; + } + + $elapsed = now()->diffInSeconds($startTime, true); + + echo PHP_EOL; + $this->colorCLI->header('SUMMARY'); + $this->colorCLI->header('-------'); + $this->colorCLI->primary(' Releases added: ' . number_format($releasesAdded)); + $this->colorCLI->primary(' NZBs created: ' . number_format($nzbsCreated)); + if ($dupes > 0) { + $this->colorCLI->warning(' Duplicates skipped: ' . number_format($dupes)); + } + $this->colorCLI->info(' Processing cycles: ' . number_format($iterations)); + $this->colorCLI->info(' Total time: ' . $this->formatElapsedTime($elapsed)); + echo PHP_EOL; + } + /** - * Output release deletion statistics. + * @param array{minSize: int, maxSize: int, minFiles: int} $stats */ + private function outputCollectionDeleteStats(array $stats, DateTimeInterface $startTime): void + { + $totalDeleted = $stats['minSize'] + $stats['maxSize'] + $stats['minFiles']; + + if ($totalDeleted > 0) { + $this->outputStat('Too small', $stats['minSize']); + $this->outputStat('Too large', $stats['maxSize']); + $this->outputStat('Too few files', $stats['minFiles']); + $this->outputStat('Total removed', $totalDeleted); + } else { + $this->outputInfo('No collections filtered'); + } + $this->outputElapsedTime($startTime); + } + + /** + * @param array{minSize: int, maxSize: int, minFiles: int} $stats + */ + private function outputReleaseDeleteByGroupStats(array $stats, DateTimeInterface $startTime): void + { + $total = $stats['minSize'] + $stats['maxSize'] + $stats['minFiles']; + + if ($total > 0) { + $this->outputStat('Too small', $stats['minSize']); + $this->outputStat('Too large', $stats['maxSize']); + $this->outputStat('Too few files', $stats['minFiles']); + } + $this->outputElapsedTime($startTime); + } + private function outputReleaseDeleteStats(ReleaseDeleteStats $stats, DateTimeInterface $startTime): void { if (!$this->echoCLI) { @@ -1291,341 +1438,5 @@ final class ProcessReleases $this->outputElapsedTime($startTime); } - - /** - * Collection file check stage 1: Find complete collections. - * - * Marks collections as complete when they have all expected binary files. - * - * @throws Throwable - */ - private function runCollectionFileCheckStage1(int $groupID): void - { - DB::transaction(static function () use ($groupID): void { - $collectionsQuery = Collection::query() - ->select(['collections.id']) - ->join('binaries', 'binaries.collections_id', '=', 'collections.id') - ->where('collections.totalfiles', '>', 0) - ->where('collections.filecheck', '=', CollectionFileCheckStatus::Default->value) - ->groupBy(['binaries.collections_id', 'collections.totalfiles', 'collections.id']) - ->havingRaw('COUNT(binaries.id) IN (collections.totalfiles, collections.totalfiles + 1)'); - - if ($groupID !== 0) { - $collectionsQuery->where('collections.groups_id', $groupID); - } - - Collection::query() - ->joinSub( - $collectionsQuery, - 'r', - static fn($join) => $join->on('collections.id', '=', 'r.id') - ) - ->update(['collections.filecheck' => CollectionFileCheckStatus::CompleteCollection->value]); - }, 10); - } - - /** - * Collection file check stage 2: Handle zero-part collections. - * - * Identifies collections that have binaries with file number 0 (special handling). - * - * @throws Throwable - */ - private function runCollectionFileCheckStage2(int $groupID): void - { - // Mark collections with zero-numbered files - DB::transaction(static function () use ($groupID): void { - $collectionsQuery = Collection::query() - ->select(['collections.id']) - ->join('binaries', 'binaries.collections_id', '=', 'collections.id') - ->where('binaries.filenumber', '=', 0) - ->where('collections.totalfiles', '>', 0) - ->where('collections.filecheck', '=', CollectionFileCheckStatus::CompleteCollection->value) - ->groupBy(['collections.id']); - - if ($groupID !== 0) { - $collectionsQuery->where('collections.groups_id', $groupID); - } - - Collection::query() - ->joinSub( - $collectionsQuery, - 'r', - static fn($join) => $join->on('collections.id', '=', 'r.id') - ) - ->update(['collections.filecheck' => CollectionFileCheckStatus::ZeroPart->value]); - }, 10); - - // Mark remaining complete collections as temporarily complete - DB::transaction(static function () use ($groupID): void { - $query = Collection::query() - ->where('filecheck', '=', CollectionFileCheckStatus::CompleteCollection->value); - - if ($groupID !== 0) { - $query->where('groups_id', $groupID); - } - - $query->update(['filecheck' => CollectionFileCheckStatus::TempComplete->value]); - }, 10); - } - - /** - * Collection file check stage 3: Mark complete binaries. - * - * Updates binary records where all parts have been received. - * - * @throws Throwable - */ - private function runCollectionFileCheckStage3(string $whereSql): void - { - // Mark binaries with exact part count - DB::transaction(static function () use ($whereSql): void { - $sql = <<value, - FileCompletionStatus::Incomplete->value, - FileCompletionStatus::Complete->value, - ]); - }, 10); - - // Mark binaries with extra parts (zero-part handling) - DB::transaction(static function () use ($whereSql): void { - $sql = <<= (b.totalparts + 1) - GROUP BY b.id, b.totalparts - ) r ON b.id = r.id - SET b.partcheck = ? - SQL; - - DB::update($sql, [ - CollectionFileCheckStatus::ZeroPart->value, - FileCompletionStatus::Incomplete->value, - FileCompletionStatus::Complete->value, - ]); - }, 10); - } - - /** - * Collection file check stage 4: Mark complete collections. - * - * Updates collections where all binaries are complete. - * - * @throws Throwable - */ - private function runCollectionFileCheckStage4(string $whereSql): void - { - DB::transaction(static function () use ($whereSql): void { - $sql = <<= c.totalfiles - ) r ON c.id = r.id - SET filecheck = ? - SQL; - - DB::update($sql, [ - FileCompletionStatus::Complete->value, - CollectionFileCheckStatus::TempComplete->value, - CollectionFileCheckStatus::ZeroPart->value, - CollectionFileCheckStatus::CompleteParts->value, - ]); - }, 10); - } - - /** - * Collection file check stage 5: Reset incomplete collections. - * - * Moves collections that didn't complete back to the complete collection status. - * - * @throws Throwable - */ - private function runCollectionFileCheckStage5(int $groupId): void - { - DB::transaction(static function () use ($groupId): void { - $query = Collection::query() - ->whereIn('filecheck', [ - CollectionFileCheckStatus::TempComplete->value, - CollectionFileCheckStatus::ZeroPart->value, - ]); - - if ($groupId !== 0) { - $query->where('groups_id', $groupId); - } - - $query->update(['filecheck' => CollectionFileCheckStatus::CompleteCollection->value]); - }, 10); - } - - /** - * Collection file check stage 6: Force complete delayed collections. - * - * For collections older than the delay time, force them to complete status. - * - * @throws Throwable - */ - private function runCollectionFileCheckStage6(string $whereSql): void - { - DB::transaction(function () use ($whereSql): void { - $sql = <<value, - $this->settings->collectionDelayTime, - CollectionFileCheckStatus::Default->value, - CollectionFileCheckStatus::CompleteCollection->value, - ]); - }, 10); - } - - /** - * Delete stuck/broken collections. - * - * Collections that are older than the timeout threshold and haven't progressed. - * - * @throws \Exception - * @throws Throwable - */ - private function processStuckCollections(int $groupID): void - { - $cutoff = $this->calculateStuckCollectionsCutoff(); - $totalDeleted = 0; - - do { - $affected = $this->deleteStuckCollectionBatch($groupID, $cutoff); - $totalDeleted += $affected; - - if ($affected < self::BATCH_SIZE) { - break; - } - - usleep(self::BATCH_PAUSE_US); - } while (true); - - if ($this->echoCLI && $totalDeleted > 0) { - $this->colorCLI->primary("Deleted {$totalDeleted} broken/stuck collections.", true); - } - } - - /** - * Calculate the cutoff timestamp for stuck collections. - */ - private function calculateStuckCollectionsCutoff(): Carbon - { - $lastRun = $this->settings->lastRunTime; - $threshold = null; - - if ($lastRun !== null) { - try { - $threshold = Carbon::createFromFormat('Y-m-d H:i:s', $lastRun); - } catch (Throwable) { - $threshold = null; - } - } - - return ($threshold ?? now())->copy()->subHours($this->settings->collectionTimeout); - } - - /** - * Delete a batch of stuck collections with retry logic. - */ - private function deleteStuckCollectionBatch(int $groupID, Carbon $cutoff): int - { - $attempt = 0; - $affected = 0; - - do { - try { - $groupCondition = $groupID !== 0 ? 'AND groups_id = ? ' : ''; - $batchLimit = self::BATCH_SIZE; - - $sql = <<= self::MAX_RETRIES) { - if ($this->echoCLI) { - $this->colorCLI->error( - 'Stuck collections delete failed after retries: ' . $e->getMessage() - ); - } - break; - } - usleep(self::RETRY_BASE_DELAY_US * $attempt); - } - } while (true); - - return $affected; - } - - /** - * Normalize a group ID to integer form. - * - * @param int|string|null $groupID Group ID as int, string, or null - * @return int|null Normalized integer ID or null - */ - private function normalizeGroupId(int|string|null $groupID): ?int - { - if ($groupID === null || $groupID === '') { - return null; - } - - if (is_numeric($groupID)) { - return (int) $groupID; - } - - $groupInfo = UsenetGroup::getByName($groupID); - return $groupInfo !== null ? (int) $groupInfo['id'] : null; - } - - /** - * Build a SQL WHERE clause snippet for group ID filtering. - * - * @param int|null $groupID The group ID to filter by - * @param string $alias Table alias to use in the clause - * @return string SQL WHERE clause snippet - */ - private function buildGroupWhereSql(?int $groupID, string $alias = 'c'): string - { - return $groupID !== null ? " AND {$alias}.groups_id = {$groupID} " : ' '; - } } + diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 8787333a7..352359005 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -7,6 +7,7 @@ return [ App\Providers\CategorizationServiceProvider::class, App\Providers\ForumServiceProvider::class, App\Providers\HorizonServiceProvider::class, + App\Providers\ProcessingServiceProvider::class, App\Providers\RouteServiceProvider::class, App\Providers\TelescopeServiceProvider::class, App\Providers\TvProcessingServiceProvider::class,