$query * @return Builder */ public static function applyPredicates( Builder $query, int|string $groupID = '', string $guidChar = '', ?int $minSizeMB = null, ?int $maxSizeGB = null, bool $includeClaimed = false, ): Builder { $min = $minSizeMB ?? self::minSizeMB(); $max = $maxSizeGB ?? self::maxSizeGB(); $query ->where('r.passwordstatus', -1) ->where('r.haspreview', -1) ->where('r.nzbstatus', 1) ->where('c.disablepreview', 0); if ($min > 0) { $query->where('r.size', '>', $min * 1048576); } if ($max > 0) { $query->where('r.size', '<', $max * 1073741824); } if ($groupID !== '' && $groupID !== 0 && $groupID !== '0') { $query->where('r.groups_id', $groupID); } if ($guidChar !== '') { $query->where('r.leftguid', $guidChar); } if (! $includeClaimed) { self::applyClaimWindow($query); } return $query; } /** * Return a fresh Eloquent builder, joined and predicate-applied, ready for * the orchestrator to add selects / order / limit. * * @return Builder */ public static function baseBuilder( int|string $groupID = '', string $guidChar = '', ?int $minSizeMB = null, ?int $maxSizeGB = null, bool $includeClaimed = false, ): Builder { $query = Release::query() ->from('releases as r') ->leftJoin('categories as c', 'c.id', '=', 'r.categories_id'); return self::applyPredicates($query, $groupID, $guidChar, $minSizeMB, $maxSizeGB, $includeClaimed); } /** * Return up to {@see self::BUCKET_LIMIT} distinct GUID first-characters * that have at least one release matching the candidate predicates. * * The fan-out is capped at 16 because `leftguid` is a single hex digit. * Worker concurrency is then capped further by the `postthreads` setting * in {@see PostProcessRunner::runPostProcess()}. * * @return array */ public static function bucketChars(?int $limit = null): array { $effectiveLimit = $limit !== null && $limit > 0 ? min($limit, self::BUCKET_LIMIT) : self::BUCKET_LIMIT; $rows = self::baseBuilder() ->select(DB::raw('DISTINCT r.leftguid AS guid_bucket')) ->limit($effectiveLimit) ->get(); $chars = []; foreach ($rows as $row) { $id = strtolower((string) ($row->guid_bucket ?? '')); if ($id !== '') { $chars[] = substr($id, 0, 1); } } return array_values(array_unique($chars)); } /** * True when there is at least one candidate release anywhere (any char). * Used by the drain command to know when to stop looping. */ public static function hasAnyCandidate(): bool { return self::baseBuilder()->limit(1)->exists(); } /** * Claim a bounded batch of release rows for one worker. * * @param list $columns * @return EloquentCollection */ public static function claimBatch( string $guidChar, int $limit, string $token, int|string $groupID = '', ?int $minSizeMB = null, ?int $maxSizeGB = null, array $columns = ['*'], ): EloquentCollection { $effectiveLimit = max(1, $limit); return DB::transaction(function () use ($guidChar, $effectiveLimit, $token, $groupID, $minSizeMB, $maxSizeGB, $columns): EloquentCollection { $supportsClaims = self::supportsClaims(); $query = self::baseBuilder($groupID, $guidChar, $minSizeMB, $maxSizeGB) ->select('r.id') ->orderByDesc('r.postdate') ->orderBy('r.id') ->limit($effectiveLimit); if (DB::getDriverName() !== 'sqlite') { $query->lockForUpdate(); } $ids = $query ->pluck('r.id') ->map(static fn (mixed $id): int => (int) $id) ->all(); if ($ids === []) { return (new Release)->newCollection(); } if ($supportsClaims) { Release::query() ->whereIn('id', $ids) ->update([ self::CLAIMED_AT_COLUMN => now(), self::CLAIM_TOKEN_COLUMN => $token, ]); } return Release::query() ->whereIn('id', $ids) ->select(self::selectableColumns($columns, $supportsClaims)) ->orderByRaw(self::idOrderExpression($ids)) ->get(); }, 3); } public static function clearClaim(int $releaseId, ?string $token = null): void { if (! self::supportsClaims()) { return; } $query = Release::query()->where('id', $releaseId); if ($token !== null && $token !== '') { $query->where(self::CLAIM_TOKEN_COLUMN, $token); } $query->update([ self::CLAIMED_AT_COLUMN => null, self::CLAIM_TOKEN_COLUMN => null, ]); } /** * @return array */ public static function claimResetValues(): array { if (! self::supportsClaims()) { return []; } return [ self::CLAIMED_AT_COLUMN => null, self::CLAIM_TOKEN_COLUMN => null, ]; } public static function supportsClaims(): bool { if (self::$supportsClaims !== null) { return self::$supportsClaims; } if (! Schema::hasTable('releases')) { return self::$supportsClaims = false; } return self::$supportsClaims = Schema::hasColumn('releases', self::CLAIMED_AT_COLUMN) && Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN); } /** * @param Builder $query */ private static function applyClaimWindow(Builder $query): void { if (! self::supportsClaims()) { return; } $staleBefore = now()->subSeconds(self::claimTtlSeconds()); $query->where(function (Builder $claimQuery) use ($staleBefore): void { $claimQuery ->whereNull('r.'.self::CLAIMED_AT_COLUMN) ->orWhere('r.'.self::CLAIMED_AT_COLUMN, '<', $staleBefore); }); } private static function claimTtlSeconds(): int { $timeout = (int) (Settings::settingValue('releaseprocessingtimeout') ?: 120); return max(300, $timeout * 2); } /** * @param list $columns * @return list */ private static function selectableColumns(array $columns, bool $supportsClaims): array { if ($supportsClaims || $columns === ['*']) { return $columns; } return array_values(array_filter( $columns, static fn (string $column): bool => ! in_array($column, [self::CLAIMED_AT_COLUMN, self::CLAIM_TOKEN_COLUMN], true), )); } /** * @param list $ids */ private static function idOrderExpression(array $ids): string { if (DB::getDriverName() !== 'sqlite') { return 'FIELD(id, '.implode(',', $ids).')'; } $cases = []; foreach ($ids as $position => $id) { $cases[] = 'WHEN '.(int) $id.' THEN '.(int) $position; } return 'CASE id '.implode(' ', $cases).' ELSE '.count($ids).' END'; } }