option('id') !== null ? max(1, (int) $this->option('id')) : null; $limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null; $chunkSize = max(1, min((int) $this->option('chunk'), 1000)); $onlyMissingFlagged = (bool) $this->option('only-missing-flagged'); $csvPath = trim((string) $this->option('csv')); $force = (bool) $this->option('force'); $dryRun = (bool) $this->option('dry-run'); $allVariants = $this->resolveConfiguredVariants(); $selectedVariants = $this->resolveSelectedVariants($allVariants); if ($selectedVariants === []) { return self::FAILURE; } $auditColumnsAvailable = Schema::hasColumns('artworks', [ 'has_missing_thumbnails', 'missing_thumbnail_variants_json', 'thumbnails_checked_at', ]); if ($onlyMissingFlagged && ! $auditColumnsAvailable) { $this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.'); return self::FAILURE; } $diskName = $storage->objectDiskName(); $disk = Storage::disk($diskName); $csvHandle = $this->openCsvHandle($csvPath); $baseQuery = $this->baseQuery($onlyMissingFlagged); $totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit); $progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null; $this->info(sprintf( 'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s', $diskName, implode(',', $selectedVariants), $chunkSize, $limit !== null ? (string) $limit : 'all', $onlyMissingFlagged ? 'yes' : 'no', $force ? 'yes' : 'no', $dryRun ? 'yes' : 'no', $csvPath !== '' ? $csvPath : 'off', )); if ($progressBar !== null) { $progressBar->start(); } $processed = 0; $healthy = 0; $planned = 0; $repaired = 0; $failed = 0; $lastSeenId = null; try { do { $artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId); if ($artworks->isEmpty()) { break; } foreach ($artworks as $artwork) { if ($limit !== null && $processed >= $limit) { break 2; } try { $targetVariants = $force ? $selectedVariants : $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk); if ($targetVariants === []) { $healthy++; $processed++; $this->writeCsvRow($csvHandle, [ 'artwork_id' => (int) $artwork->id, 'status' => 'healthy', 'variants' => '', 'source_file' => '', 'message' => '', ]); $progressBar?->advance(); continue; } $sourcePath = $this->resolveLocalSourcePath($artwork, $locator); if ($sourcePath === '') { throw new \RuntimeException('No local original source file was found in the configured artwork roots.'); } if ($dryRun) { $planned++; $this->line(sprintf( 'Artwork %d would repair thumbnails: %s', (int) $artwork->id, implode(',', $targetVariants), )); $this->line(' source_file: ' . $sourcePath); $this->writeCsvRow($csvHandle, [ 'artwork_id' => (int) $artwork->id, 'status' => 'planned', 'variants' => implode(',', $targetVariants), 'source_file' => $sourcePath, 'message' => '', ]); $processed++; $progressBar?->advance(); continue; } $assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants); if ($assets === []) { throw new \RuntimeException('No thumbnail assets were generated for the requested variants.'); } DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void { foreach ($assets as $variant => $asset) { $artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']); } $update = [ 'thumb_ext' => 'webp', ]; if ($auditColumnsAvailable) { $remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk); $update['has_missing_thumbnails'] = $remainingMissing !== []; $update['missing_thumbnail_variants_json'] = $remainingMissing === [] ? null : json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES); $update['thumbnails_checked_at'] = now(); } Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update); }); $cdnPurge->purgeArtworkObjectPaths(array_map( static fn (array $asset): string => (string) $asset['path'], array_values($assets), ), [ 'artwork_id' => (int) $artwork->id, 'reason' => 'thumbnail_repair', ]); $repaired++; $this->info(sprintf( 'Artwork %d repaired thumbnails: %s', (int) $artwork->id, implode(',', array_keys($assets)), )); $this->writeCsvRow($csvHandle, [ 'artwork_id' => (int) $artwork->id, 'status' => 'repaired', 'variants' => implode(',', array_keys($assets)), 'source_file' => $sourcePath, 'message' => '', ]); } catch (Throwable $exception) { $failed++; $this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage())); $this->writeCsvRow($csvHandle, [ 'artwork_id' => (int) $artwork->id, 'status' => 'failed', 'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '', 'source_file' => isset($sourcePath) ? (string) $sourcePath : '', 'message' => $exception->getMessage(), ]); } $processed++; $progressBar?->advance(); } $lastSeenId = (int) $artworks->last()->id; } while (true); } finally { if ($progressBar !== null) { $progressBar->finish(); $this->newLine(2); } if (is_resource($csvHandle)) { fclose($csvHandle); } } $this->info(sprintf( 'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d', $processed, $healthy, $planned, $repaired, $failed, )); return $failed > 0 ? self::FAILURE : self::SUCCESS; } /** * @return Collection */ private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection { $query = clone $baseQuery; if ($artworkId !== null) { $query->whereKey($artworkId); } elseif ($lastSeenId !== null) { $query->where('id', '<', $lastSeenId); } return $query->limit($chunkSize)->get(); } private function baseQuery(bool $onlyMissingFlagged): mixed { $query = Artwork::query() ->withTrashed() ->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext']) ->whereNotNull('hash') ->where('hash', '!=', '') ->orderByDesc('id'); if ($onlyMissingFlagged) { $query->where('has_missing_thumbnails', true); } return $query; } private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int { $countQuery = clone $baseQuery; if ($artworkId !== null) { $countQuery->whereKey($artworkId); } $count = (int) $countQuery->count(); if ($limit !== null) { return min($count, $limit); } return $count; } /** * @return list */ private function resolveConfiguredVariants(): array { return array_values(array_filter(array_map( static fn ($variant): string => strtolower(trim((string) $variant)), array_keys((array) config('uploads.derivatives', [])), ))); } /** * @param list $configuredVariants * @return list */ private function resolveSelectedVariants(array $configuredVariants): array { if ($configuredVariants === []) { $this->error('No thumbnail variants are configured. Check uploads.derivatives.'); return []; } $requested = (array) $this->option('variant'); if ($requested === []) { return $configuredVariants; } $normalizedRequested = array_values(array_unique(array_filter(array_map( static fn ($variant): string => strtolower(trim((string) $variant)), $requested, )))); $invalid = array_values(array_diff($normalizedRequested, $configuredVariants)); if ($invalid !== []) { $this->error('Unknown thumbnail variants: ' . implode(', ', $invalid)); $this->line('Configured variants: ' . implode(', ', $configuredVariants)); return []; } return $normalizedRequested; } /** * @param list $variants * @return list */ private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array { $hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? ''))); if ($hash === '') { return $variants; } $missing = []; foreach ($variants as $variant) { $objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp'); if (! $disk->exists($objectPath)) { $missing[] = $variant; } } return $missing; } private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string { $hash = strtolower((string) ($artwork->hash ?? '')); if (! $this->isValidHash($hash)) { return ''; } $preferred = $locator->resolveLocalPath($artwork); if ($this->isUsableSourceFile($preferred)) { return $preferred; } foreach ($this->candidateOriginalRoots() as $root) { $candidatePath = $this->findNonZipSourceInRoot($root, $hash); if ($candidatePath !== '') { return $candidatePath; } } return ''; } /** * @return list */ private function candidateOriginalRoots(): array { $roots = [ trim((string) config('uploads.local_originals_root', '')), trim((string) config('uploads.readonly_backup_originals_root', '')), ]; $normalizedRoots = []; foreach ($roots as $root) { if ($root === '') { continue; } $normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR); if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) { continue; } $normalizedRoots[] = $normalizedRoot; } return $normalizedRoots; } private function findNonZipSourceInRoot(string $root, string $hash): string { $directory = $root . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2); if (! File::isDirectory($directory)) { return ''; } $matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*'); if (! is_array($matches)) { return ''; } foreach ($matches as $path) { if ($this->isUsableSourceFile($path)) { return $path; } } return ''; } private function isUsableSourceFile(string $path): bool { if ($path === '' || ! File::isFile($path)) { return false; } $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) { return false; } $mime = strtolower((string) (File::mimeType($path) ?? '')); return str_starts_with($mime, 'image/'); } private function isValidHash(string $hash): bool { return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1; } /** * @return resource|null */ private function openCsvHandle(string $csvPath) { if ($csvPath === '') { return null; } File::ensureDirectoryExists(dirname($csvPath)); $handle = fopen($csvPath, 'wb'); if (! is_resource($handle)) { throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath); } fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']); return $handle; } /** * @param resource|null $csvHandle * @param array $row */ private function writeCsvRow($csvHandle, array $row): void { if (! is_resource($csvHandle)) { return; } fputcsv($csvHandle, [ $row['artwork_id'] ?? '', $row['status'] ?? '', $row['variants'] ?? '', $row['source_file'] ?? '', $row['message'] ?? '', ]); } }