echoCLI = (bool) config('nntmux.echocli'); $this->nzb = $nzb ?? app(NzbService::class); $this->releaseCleaning = $releaseCleaning ?? new ReleaseCleaningService; $this->releaseManagement = $releaseManagement ?? app(ReleaseManagementService::class); $this->releaseImage = $releaseImage ?? new ReleaseImageService; $this->releaseCreationService = $releaseCreationService ?? new ReleaseCreationService($this->releaseCleaning); $this->collectionCleanupService = $collectionCleanupService ?? new CollectionCleanupService; $this->postProcessService = $postProcessService; $this->settings = $this->loadSettings(); $this->validateSettings(); } /** * Load all required settings from database. */ private function loadSettings(): ProcessReleasesSettings { $settingKeys = [ 'delaytime', 'crossposttime', 'maxnzbsprocessed', 'completionpercent', 'collection_timeout', 'maxsizetoformrelease', 'minsizetoformrelease', 'minfilestoformrelease', 'releaseretentiondays', 'deletepasswordedrelease', 'miscotherretentionhours', 'mischashedretentionhours', 'partretentionhours', 'last_run_time', ]; $dbSettings = []; foreach ($settingKeys as $key) { $dbSettings[$key] = Settings::settingValue($key); } return ProcessReleasesSettings::forDatabase($dbSettings); } /** * Validate loaded settings and warn about invalid configurations. */ private function validateSettings(): void { if (! $this->settings->hasValidCompletion()) { cli()->error( PHP_EOL.'Invalid completion setting. Value must be between 0 and 100.' ); } } // ======================================================================== // Public API // ======================================================================== /** * Get the current completion percentage setting. */ public function getCompletion(): int { return $this->settings->completion; } /** * Get the release creation limit. */ public function getReleaseCreationLimit(): int { return $this->settings->releaseCreationLimit; } /** * Get the collection delay time in hours. */ public function getCollectionDelayTime(): int { return $this->settings->collectionDelayTime; } /** * Get the cross-post detection time window in hours. */ public function getCrossPostTime(): int { 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. * * @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 * @param NNTPService $nntp NNTP connection for post-processing * @return int Total number of releases added * * @throws Throwable */ public function processReleases( int $categorize, int $postProcess, string $groupName, NNTPService $nntp ): int { $this->echoCLI = (bool) config('nntmux.echocli'); $overallStartTime = now()->toImmutable(); $this->outputBanner(); if (! $this->validateNzbPath()) { return 0; } $groupID = $this->resolveGroupId($groupName); $normalizedGroupId = $this->normalizeGroupId($groupID); if ($this->echoCLI && $groupName !== '') { $this->outputInfo("Processing group: {$groupName}"); } // Phase 1: Collection processing $this->outputHeader('Phase 1: Collection Processing'); $this->processIncompleteCollections($normalizedGroupId); $this->processCollectionSizes($normalizedGroupId); $this->deleteUnwantedCollections($normalizedGroupId); // Phase 2: Release creation loop $this->outputHeader('Phase 2: Release Creation'); $totals = $this->runReleaseCreationLoop($normalizedGroupId, $categorize, $postProcess, $nntp); // Phase 3: Cleanup $this->outputHeader('Phase 3: Cleanup'); $this->deleteReleases(); $this->outputFinalSummary( $totals['releases'], $totals['nzbs'], $totals['dupes'], $totals['iterations'], $overallStartTime ); return $totals['releases']; } /** * Run the release creation loop. * * @return array{releases: int, nzbs: int, dupes: int, iterations: int} * * @throws Throwable */ private function runReleaseCreationLoop( ?int $normalizedGroupId, int $categorize, int $postProcess, NNTPService $nntp ): array { $totals = ['releases' => 0, 'nzbs' => 0, 'dupes' => 0, 'iterations' => 0]; $limit = $this->settings->releaseCreationLimit; do { $totals['iterations']++; $result = $this->createReleases($normalizedGroupId); $totals['releases'] += $result->added; $totals['dupes'] += $result->dupes; $nzbFilesAdded = $this->createNZBs($normalizedGroupId); $totals['nzbs'] += $nzbFilesAdded; $this->categorizeReleases($categorize, $normalizedGroupId); $this->postProcessReleases($postProcess, $nntp); $this->deleteCollections($normalizedGroupId); $shouldContinue = $result->total() >= $limit || $nzbFilesAdded >= $limit; } while ($shouldContinue); return $totals; } /** * Reset all releases to other->misc category. */ public function resetCategorize(string $where = ''): void { if ($where !== '') { DB::update( 'UPDATE releases SET categories_id = ?, iscategorized = 0 '.$where, [Category::OTHER_MISC] ); } else { Release::query()->update([ 'categories_id' => Category::OTHER_MISC, 'iscategorized' => 0, ]); } ReleaseSearchIndexSync::reindexMatchingWhere($where); } /** * Categorize a release using the specified type. * * @throws \Exception */ public function categorizeRelease(string $type, int|string|null $groupId): int { $categorizer = new CategorizationService; $categorized = 0; $query = Release::query() ->where('categories_id', Category::OTHER_MISC) ->where('iscategorized', 0) ->select(['id', 'fromname', 'groups_id', $type]); if (! empty($groupId)) { $query->where('groups_id', $groupId); } $total = $query->count(); if ($total === 0) { return 0; } $this->outputSubHeader('Categorizing Releases'); $query->chunkById(self::CATEGORIZE_CHUNK_SIZE, function ($releases) use ($categorizer, $type, &$categorized, $total): bool { foreach ($releases as $release) { $categoryResult = $categorizer->determineCategory( $release->groups_id, $release->{$type}, $release->fromname ); Release::query() ->where('id', $release->id) ->update([ 'categories_id' => $categoryResult['categories_id'], 'iscategorized' => 1, ]); ReleaseSearchIndexSync::forIds([(int) $release->id]); $categorized++; $this->outputProgress($categorized, $total, 'Categorizing'); } return true; }); return $categorized; } /** * Process incomplete collections to find complete ones. * * @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'); $this->processStuckCollections($normalizedGroupId ?? 0); $this->runCollectionFileCheckStage1($normalizedGroupId ?? 0); $this->runCollectionFileCheckStage2($normalizedGroupId ?? 0); $this->runCollectionFileCheckStage3($whereSql); $this->runCollectionFileCheckStage4($whereSql); $this->runCollectionFileCheckStage5($normalizedGroupId ?? 0); $this->runCollectionFileCheckStage6($whereSql); $count = $this->countCompleteCollections($normalizedGroupId); $this->outputStat('Complete collections found', $count); $this->outputElapsedTime($startTime); } /** * Calculate sizes for complete collections. * * @throws Throwable */ public function processCollectionSizes(int|string|null $groupID): void { $startTime = now()->toImmutable(); $this->outputSubHeader('Calculating Collection Sizes'); $updated = 0; DB::transaction(function () use ($groupID, &$updated): void { $normalizedGroupId = $this->normalizeGroupId($groupID); $whereSql = $normalizedGroupId !== null ? " AND c.groups_id = {$normalizedGroupId} " : ' '; $sql = <<value, CollectionFileCheckStatus::CompleteParts->value, ]); }, 10); $this->outputStat('Collections sized', $updated); $this->outputElapsedTime($startTime); } /** * Delete collections that don't meet size/file count requirements. * * @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); $groupIDs = $normalizedGroupId === null ? UsenetGroup::getActiveIDs() : [['id' => $normalizedGroupId]]; $stats = ['minSize' => 0, 'maxSize' => 0, 'minFiles' => 0, 'par2Only' => 0]; // Delete collections where ALL binaries are par2 files (no actual content) DB::transaction(static function () use (&$stats): void { $par2OnlyCollectionIds = DB::table('collections as c') ->join('binaries as b', 'c.id', '=', 'b.collections_id') ->where('c.filecheck', CollectionFileCheckStatus::Sized->value) ->where('c.filesize', '>', 0) ->groupBy('c.id') ->havingRaw("COUNT(b.id) = SUM(CASE WHEN b.name REGEXP '\\\\.(vol[0-9]+\\\\+[0-9]+\\\\.par2|par2)' THEN 1 ELSE 0 END)") ->pluck('c.id'); if ($par2OnlyCollectionIds->isNotEmpty()) { $stats['par2Only'] += Collection::query() ->whereIn('id', $par2OnlyCollectionIds) ->delete(); } }, 10); foreach ($groupIDs as $grpID) { $groupSettings = UsenetGroup::getGroupByID($grpID['id']); $groupMinSize = (int) ($groupSettings['minsizetoformrelease'] ?? 0); $groupMinFiles = (int) ($groupSettings['minfilestoformrelease'] ?? 0); if (! $this->hasSizedCollections()) { continue; } DB::transaction(function () use ($groupMinSize, $groupMinFiles, &$stats): void { $effectiveMinSize = max($groupMinSize, $this->settings->minSizeToFormRelease); if ($effectiveMinSize > 0) { $stats['minSize'] += Collection::query() ->where('filecheck', CollectionFileCheckStatus::Sized->value) ->where('filesize', '>', 0) ->where('filesize', '<', $effectiveMinSize) ->delete(); } if ($this->settings->maxSizeToFormRelease > 0) { $stats['maxSize'] += Collection::query() ->where('filecheck', CollectionFileCheckStatus::Sized->value) ->where('filesize', '>', $this->settings->maxSizeToFormRelease) ->delete(); } $effectiveMinFiles = max($groupMinFiles, $this->settings->minFilesToFormRelease); if ($effectiveMinFiles > 0) { $stats['minFiles'] += Collection::query() ->where('filecheck', CollectionFileCheckStatus::Sized->value) ->where('filesize', '>', 0) ->where('totalfiles', '<', $effectiveMinFiles) ->delete(); } }, 10); } $this->outputCollectionDeleteStats($stats, $startTime); } /** * Create releases from complete collections. * * @throws Throwable */ public function createReleases(int|string|null $groupID): ReleaseCreationResult { $result = $this->releaseCreationService->createReleases( $groupID, $this->settings->releaseCreationLimit, $this->echoCLI ); return ReleaseCreationResult::fromArray($result); } /** * Create NZB files from releases that don't have them yet. * * @throws Throwable */ public function createNZBs(int|string|null $groupID): int { $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(); 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'); } } return true; }); } $this->outputStat('NZBs created', $nzbCount); $this->outputElapsedTime($startTime); return $nzbCount; } /** * Categorize releases based on the specified field. * * @throws \Exception */ public function categorizeReleases(int $categorize, int|string|null $groupID = null): void { $startTime = now()->toImmutable(); $type = match ($categorize) { 2 => 'searchname', default => 'name', }; $count = $this->categorizeRelease($type, $groupID); if ($count > 0) { $this->outputStat('Releases categorized', $count); $this->outputElapsedTime($startTime); } } /** * Run post-processing on releases. * * @throws \Exception */ public function postProcessReleases(int $postProcess, NNTPService $nntp): void { if ($postProcess !== 1) { return; } $this->outputSubHeader('Post-Processing Releases'); $service = $this->postProcessService ?? new PostProcessService; $service->processAll($nntp); } /** * Delete finished and orphaned collections. * * @throws Throwable */ public function deleteCollections(int|string|null $groupID): void { $this->collectionCleanupService->deleteFinishedAndOrphans($this->echoCLI); } /** * Delete unwanted releases based on group-specific settings. * * @throws \Exception */ public function deletedReleasesByGroup(int|string $groupID = ''): void { $startTime = now()->toImmutable(); $stats = ['minSize' => 0, 'maxSize' => 0, 'minFiles' => 0]; if ($this->echoCLI) { cli()->header( 'Process Releases -> Delete releases smaller/larger than minimum size/file count from group/site setting.' ); } $groupIDs = $groupID === '' ? UsenetGroup::getActiveIDs() : [['id' => $groupID]]; foreach ($groupIDs as $grpID) { $this->deleteReleasesUnderMinSize($grpID['id'], $stats); $this->deleteReleasesOverMaxSize($grpID['id'], $stats); $this->deleteReleasesUnderMinFiles($grpID['id'], $stats); } $this->outputReleaseDeleteByGroupStats($stats, $startTime); } /** * Delete releases based on site-wide settings. * * @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) { cli()->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); $this->updateCollectionsFilecheckInChunks( $groupID, CollectionFileCheckStatus::CompleteCollection->value, CollectionFileCheckStatus::TempComplete->value ); } /** * Update collections filecheck in small chunks to reduce deadlock risk. * Retries on deadlock (1213) with exponential backoff. * * @throws Throwable */ private function updateCollectionsFilecheckInChunks(int $groupID, int $fromStatus, int $toStatus): void { $attempt = 0; $maxAttempts = self::MAX_RETRIES + 1; while ($attempt < $maxAttempts) { try { $updated = 0; do { $ids = Collection::query() ->where('filecheck', $fromStatus) ->when($groupID !== 0, static fn ($q) => $q->where('groups_id', $groupID)) ->orderBy('id') ->limit(self::BATCH_SIZE) ->pluck('id') ->all(); if ($ids === []) { break; } DB::transaction(static function () use ($ids, $toStatus): void { Collection::query() ->whereIn('id', $ids) ->update(['filecheck' => $toStatus]); }, 10); $updated = \count($ids); } while ($updated === self::BATCH_SIZE); return; } catch (QueryException $e) { $isDeadlock = ($e->errorInfo[1] ?? 0) === 1213 || str_contains($e->getMessage(), 'Deadlock'); if ($isDeadlock && $attempt < $maxAttempts - 1) { $attempt++; usleep(self::RETRY_BASE_DELAY_US * (2 ** $attempt)); } else { throw $e; } } } } /** * @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) { cli()->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) { cli()->error( 'Stuck collections delete failed after retries: '.$e->getMessage() ); } break; } usleep(self::RETRY_BASE_DELAY_US * $attempt); } } while (true); return $affected; } // ======================================================================== // Release Deletion Methods // ======================================================================== /** * @param array $stats */ private function deleteReleasesUnderMinSize(int|string $groupId, array &$stats): void { $releases = Release::query() ->where('releases.groups_id', $groupId) ->join('usenet_groups', 'usenet_groups.id', '=', 'releases.groups_id') ->whereRaw( 'GREATEST(IFNULL(usenet_groups.minsizetoformrelease, 0), ?) > 0 '. 'AND releases.size < GREATEST(IFNULL(usenet_groups.minsizetoformrelease, 0), ?)', [$this->settings->minSizeToFormRelease, $this->settings->minSizeToFormRelease] ) ->select(['releases.id', 'releases.guid']) ->get(); foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats['minSize']++; } } /** * @param array $stats */ private function deleteReleasesOverMaxSize(int|string $groupId, array &$stats): void { if ($this->settings->maxSizeToFormRelease <= 0) { return; } $releases = Release::query() ->where('groups_id', $groupId) ->where('size', '>', $this->settings->maxSizeToFormRelease) ->select(['id', 'guid']) ->get(); foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats['maxSize']++; } } /** * @param array $stats */ private function deleteReleasesUnderMinFiles(int|string $groupId, array &$stats): void { if ($this->settings->minFilesToFormRelease <= 0) { return; } $releases = Release::query() ->where('releases.groups_id', $groupId) ->join('usenet_groups', 'usenet_groups.id', '=', 'releases.groups_id') ->whereRaw( 'GREATEST(IFNULL(usenet_groups.minfilestoformrelease, 0), ?) > 0 '. 'AND releases.totalpart < GREATEST(IFNULL(usenet_groups.minfilestoformrelease, 0), ?)', [$this->settings->minFilesToFormRelease, $this->settings->minFilesToFormRelease] ) ->select(['releases.id', 'releases.guid']) ->get(); foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats['minFiles']++; } } private function deleteReleasesOverRetention(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (! $this->settings->hasRetentionCleanup()) { return $stats; } $cutoff = now()->subDays($this->settings->releaseRetentionDays); Release::query() ->where('postdate', '<', $cutoff) ->select(['id', 'guid']) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('retention'); } return true; }); return $stats; } private function deletePasswordedReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (! $this->settings->deletePasswordedRelease) { return $stats; } Release::query() ->select(['id', 'guid']) ->where('passwordstatus', '=', ReleaseBrowseService::PASSWD_RAR) ->orWhereIn('id', function ($query): void { $query->select('releases_id') ->from('release_files') ->where('passworded', '=', ReleaseBrowseService::PASSWD_RAR); }) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('password'); } return true; }); return $stats; } private function deleteCrossPostedReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (! $this->settings->hasCrossPostDetection()) { return $stats; } $releases = Release::query() ->where('adddate', '>', now()->subHours($this->settings->crossPostTime)) ->groupBy(['name', 'fromname']) ->havingRaw('COUNT(name) > 1 AND COUNT(fromname) > 1') ->select(['id', 'guid']) ->get(); foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('duplicate'); } return $stats; } private function deleteIncompleteReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if (! $this->settings->hasCompletionCleanup()) { return $stats; } Release::query() ->where('completion', '<', $this->settings->completion) ->where('completion', '>', 0) ->select(['id', 'guid']) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('completion'); } return true; }); return $stats; } private function deleteDisabledCategoryReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { $disabledCategories = Category::getDisabledIDs(); if ($disabledCategories->isEmpty()) { return $stats; } $categoryIds = $disabledCategories->pluck('id')->toArray(); Release::query() ->whereIn('categories_id', $categoryIds) ->select(['id', 'guid']) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('disabledCategory'); } return true; }); return $stats; } private function deleteCategoryMinSizeReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { $categories = Category::query() ->where('minsizetoformrelease', '>', 0) ->select(['id', 'minsizetoformrelease as minsize']) ->get(); foreach ($categories as $category) { Release::query() ->where('categories_id', (int) $category->id) ->where('size', '<', (int) $category->minsize) // @phpstan-ignore property.notFound ->select(['id', 'guid']) ->limit(1000) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('categoryMinSize'); } return true; }); } return $stats; } private function deleteDisabledGenreReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { $genres = new GenreService; $genreList = $genres->getDisabledIDs(); if ($genreList->isEmpty()) { return $stats; } foreach ($genreList as $genre) { $musicInfoQuery = MusicInfo::query() ->where('genre_id', (int) $genre->id) // @phpstan-ignore property.notFound ->select(['id']); Release::query() ->joinSub( $musicInfoQuery, 'mi', static fn ($join) => $join->on('releases.musicinfo_id', '=', 'mi.id') ) ->select(['releases.id', 'releases.guid']) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('disabledGenre'); } return true; }, 'releases.id'); } return $stats; } private function deleteMiscReleases(ReleaseDeleteStats $stats): ReleaseDeleteStats { if ($this->settings->miscOtherRetentionHours > 0) { $cutoff = now()->subHours($this->settings->miscOtherRetentionHours); Release::query() ->where('categories_id', Category::OTHER_MISC) ->where('adddate', '<=', $cutoff) ->select(['id', 'guid']) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('miscOther'); } return true; }); } if ($this->settings->miscHashedRetentionHours > 0) { $cutoff = now()->subHours($this->settings->miscHashedRetentionHours); Release::query() ->where('categories_id', Category::OTHER_HASHED) ->where('adddate', '<=', $cutoff) ->select(['id', 'guid']) ->chunkById(self::BATCH_SIZE, function ($releases) use (&$stats): bool { foreach ($releases as $release) { $this->deleteSingleRelease($release); $stats = $stats->increment('miscHashed'); } return true; }); } return $stats; } private function deleteSingleRelease(object $release): void { $this->releaseManagement->deleteSingle( ['g' => $release->guid, 'i' => $release->id], $this->nzb, $this->releaseImage ); } // ======================================================================== // Output Helper Methods // ======================================================================== private function outputBanner(): void { if (! $this->echoCLI) { return; } echo PHP_EOL; cli()->header('NNTmux Release Processing'); cli()->info('Started: '.now()->format('Y-m-d H:i:s')); } private function outputHeader(string $title): void { if (! $this->echoCLI) { return; } echo PHP_EOL; cli()->header(strtoupper($title)); cli()->header(str_repeat('-', strlen($title))); } private function outputSubHeader(string $title): void { if (! $this->echoCLI) { return; } cli()->notice(" {$title}"); } /** @phpstan-ignore method.unused */ private function outputSuccess(string $message): void { if (! $this->echoCLI) { return; } cli()->primary(" {$message}"); } private function outputInfo(string $message): void { if (! $this->echoCLI) { return; } cli()->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; cli()->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); cli()->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; cli()->header('SUMMARY'); cli()->header('-------'); cli()->primary(' Releases added: '.number_format($releasesAdded)); cli()->primary(' NZBs created: '.number_format($nzbsCreated)); if ($dupes > 0) { cli()->warning(' Duplicates skipped: '.number_format($dupes)); } cli()->info(' Processing cycles: '.number_format($iterations)); cli()->info(' Total time: '.$this->formatElapsedTime($elapsed)); echo PHP_EOL; } /** * @param array{minSize: int, maxSize: int, minFiles: int} $stats */ private function outputCollectionDeleteStats(array $stats, DateTimeInterface $startTime): void { $totalDeleted = $stats['minSize'] + $stats['maxSize'] + $stats['minFiles'] + ($stats['par2Only'] ?? 0); if ($totalDeleted > 0) { $this->outputStat('Too small', $stats['minSize']); $this->outputStat('Too large', $stats['maxSize']); $this->outputStat('Too few files', $stats['minFiles']); if (($stats['par2Only'] ?? 0) > 0) { $this->outputStat('Par2 only', $stats['par2Only']); } $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) { return; } $total = $stats->total(); if ($total > 0) { if ($stats->retention > 0) { $this->outputStat('Past retention', $stats->retention); } if ($stats->password > 0) { $this->outputStat('Passworded', $stats->password); } if ($stats->duplicate > 0) { $this->outputStat('Cross-posted', $stats->duplicate); } if ($stats->completion > 0) { $this->outputStat("Under {$this->settings->completion}% complete", $stats->completion); } if ($stats->disabledCategory > 0) { $this->outputStat('Disabled categories', $stats->disabledCategory); } if ($stats->categoryMinSize > 0) { $this->outputStat('Under category min size', $stats->categoryMinSize); } if ($stats->disabledGenre > 0) { $this->outputStat('Disabled genres', $stats->disabledGenre); } if ($stats->miscOther > 0) { $this->outputStat('Misc->Other expired', $stats->miscOther); } if ($stats->miscHashed > 0) { $this->outputStat('Misc->Hashed expired', $stats->miscHashed); } $this->outputStat('Total releases removed', $total); } else { $this->outputInfo('No releases removed'); } $this->outputElapsedTime($startTime); } }