*/ private Collection $releases; private int $totalReleases = 0; private string $mainTmpPath = ''; private string $claimToken = ''; public function __construct( private readonly ProcessingConfiguration $config, private readonly ReleaseProcessor $processor, private readonly TempWorkspaceService $tempWorkspace, private readonly ConsoleOutputService $output ) {} /** * Start the additional processing. * * @throws Exception */ public function start(string $groupID = '', string $guidChar = ''): void { $this->finish(); if (! $this->setupTempPath($guidChar, $groupID)) { return; } $this->fetchReleases($groupID, $guidChar); if ($this->totalReleases > 0) { $this->output->echoDescription($this->totalReleases); $this->processReleases($guidChar); } } /** * Process a single release by GUID. */ public function processSingleGuid(string $guid): bool { try { $this->finish(); $release = Release::where('guid', $guid)->first(); if ($release === null) { $this->output->warning('Release not found for GUID: '.$guid); return false; } $this->releases = collect([$release]); $this->totalReleases = 1; $guidChar = $release->leftguid ?? substr($release->guid, 0, 1); $groupID = ''; if (! $this->setupTempPath($guidChar, $groupID)) { return false; } $this->processReleases($guidChar); return true; } catch (\Throwable $e) { if ($this->config->debugMode) { Log::error('processSingleGuid failed: '.$e->getMessage()); } return false; } } /** * Set up the main temp path. */ private function setupTempPath(string $guidChar, string $groupID): bool { try { $this->mainTmpPath = $this->tempWorkspace->ensureMainTempPath( $this->config->tmpUnrarPath, $guidChar, $groupID ); $this->tempWorkspace->clearDirectory($this->mainTmpPath, true); } catch (\Throwable $e) { $this->output->warning('Additional post-processing skipped: '.$e->getMessage()); Log::error('Additional post-processing temp path is unavailable', [ 'tmp_unrar_path' => $this->config->tmpUnrarPath, 'guid_char' => $guidChar, 'group_id' => $groupID, 'exception' => $e, ]); $this->mainTmpPath = ''; return false; } return true; } /** * Fetch releases for processing. * * The selection predicates (passwordstatus, haspreview, nzbstatus, * disablepreview, size bounds) are owned by AdditionalCandidateQuery so * they stay consistent with the bucket-fanout SQL in * PostProcessRunner::processAdditional(). Do NOT inline new predicates * here; add them to AdditionalCandidateQuery instead. */ private function fetchReleases(int|string $groupID, string $guidChar): void { $this->claimToken = bin2hex(random_bytes(16)); $this->releases = AdditionalCandidateQuery::claimBatch( $guidChar, $this->config->queryLimit > 0 ? $this->config->queryLimit : 25, $this->claimToken, $groupID, $this->config->minSizeMB, $this->config->maxSizeGB, [ 'id', 'guid', 'name', 'size', 'groups_id', 'nfostatus', 'fromname', 'completion', 'categories_id', 'searchname', 'predb_id', 'pp_timeout_count', AdditionalCandidateQuery::CLAIM_TOKEN_COLUMN, ], ); $this->totalReleases = $this->releases->count(); } /** * Process all fetched releases. * * Each release is processed inside its own try/catch so that a single * poison release cannot stall an entire GUID-character bucket. Without * this, an exception from ReleaseProcessor::process() would abort the * foreach for the whole worker, the same release would be re-selected on * every subsequent cycle, and the "needs additional pp" backlog would * grow indefinitely without any visible failure. * * @throws Exception */ private function processReleases(string $guidChar = ''): void { $processed = 0; $failed = 0; foreach ($this->releases as $release) { try { $this->processor->process(new ReleaseProcessingContext($release), $this->mainTmpPath); $processed++; } catch (\Throwable $e) { $failed++; Log::error('Additional postprocessing failed for release '.($release->id ?? '?').': '.$e->getMessage(), [ 'release_id' => $release->id ?? null, 'guid' => $release->guid ?? null, 'guid_char' => $guidChar, 'exception' => $e, ]); // Don't rethrow: keep draining the bucket. The release will be // re-selected on the next cycle, and the pp_timeout_count / // maxpptimeoutcount machinery will eventually drop it. } finally { if ($this->claimToken !== '' && ! empty($release->id)) { AdditionalCandidateQuery::clearClaim((int) $release->id, $this->claimToken); } } } Log::info('Additional postprocessing run finished', [ 'guid_char' => $guidChar, 'picked' => $this->totalReleases, 'processed' => $processed, 'failed' => $failed, ]); $this->output->endOutput(); } public function finish(): void { if ($this->mainTmpPath !== '') { $this->tempWorkspace->clearDirectory($this->mainTmpPath, true); $this->mainTmpPath = ''; } $this->releases = collect(); $this->totalReleases = 0; $this->claimToken = ''; } }