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)); $dryRun = (bool) $this->option('dry-run'); $variants = $this->resolveVariants(); if ($variants === []) { $this->error('No thumbnail variants are configured. Check uploads.derivatives.'); return self::FAILURE; } if (! $dryRun && ! Schema::hasColumns('artworks', [ 'has_missing_thumbnails', 'missing_thumbnail_variants_json', 'thumbnails_checked_at', ])) { $this->error('Artwork thumbnail audit columns are missing. Run the latest database migrations first.'); return self::FAILURE; } $diskName = $storage->objectDiskName(); $diskConfig = config("filesystems.disks.{$diskName}"); if (! is_array($diskConfig)) { $this->error("Filesystem disk [{$diskName}] is not configured."); return self::FAILURE; } $disk = Storage::disk($diskName); $this->info(sprintf( 'Starting thumbnail audit. disk=%s variants=%s chunk=%d limit=%s dry_run=%s', $diskName, implode(',', $variants), $chunkSize, $limit !== null ? (string) $limit : 'all', $dryRun ? 'yes' : 'no', )); $query = Artwork::query() ->select(['id', 'hash', 'thumb_ext']) ->orderBy('id'); if ($artworkId !== null) { $query->whereKey($artworkId); } $processed = 0; $healthy = 0; $missing = 0; $written = 0; $failed = 0; $query->chunkById($chunkSize, function ($artworks) use ($storage, $disk, $variants, $limit, $dryRun, &$processed, &$healthy, &$missing, &$written, &$failed) { foreach ($artworks as $artwork) { if ($limit !== null && $processed >= $limit) { return false; } try { $missingVariants = $this->resolveMissingVariants($artwork, $variants, $storage, $disk); $hasMissing = $missingVariants !== []; if ($hasMissing) { $missing++; $this->warn(sprintf( 'Artwork %d missing thumbnails: %s', (int) $artwork->id, implode(',', $missingVariants), )); } else { $healthy++; } if (! $dryRun) { $this->persistAuditResult((int) $artwork->id, $hasMissing, $missingVariants); $written++; } } catch (Throwable $exception) { $failed++; $this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage())); } $processed++; } return true; }); $this->info(sprintf( 'Thumbnail audit complete. processed=%d healthy=%d missing=%d written=%d failed=%d', $processed, $healthy, $missing, $written, $failed, )); return $failed > 0 ? self::FAILURE : self::SUCCESS; } /** * @return list */ private function resolveVariants(): array { $configured = array_keys((array) config('uploads.derivatives', [])); $configured = array_values(array_filter(array_map( static fn ($variant): string => strtolower(trim((string) $variant)), $configured, ))); $requested = (array) $this->option('variant'); if ($requested === []) { return $configured; } $normalizedRequested = array_values(array_unique(array_filter(array_map( static fn ($variant): string => strtolower(trim((string) $variant)), $requested, )))); $invalid = array_values(array_diff($normalizedRequested, $configured)); if ($invalid !== []) { $this->error('Unknown thumbnail variants: ' . implode(', ', $invalid)); $this->line('Configured variants: ' . implode(', ', $configured)); 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 ?? ''))); $thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.')); if ($hash === '' || $thumbExt === '') { return $variants; } $filename = $hash . '.' . $thumbExt; $missing = []; foreach ($variants as $variant) { $objectPath = $storage->objectPathForVariant($variant, $hash, $filename); if (! $disk->exists($objectPath)) { $missing[] = $variant; } } return $missing; } /** * @param list $missingVariants */ private function persistAuditResult(int $artworkId, bool $hasMissing, array $missingVariants): void { DB::table('artworks') ->where('id', $artworkId) ->update([ 'has_missing_thumbnails' => $hasMissing, 'missing_thumbnail_variants_json' => $missingVariants === [] ? null : json_encode(array_values($missingVariants), JSON_UNESCAPED_SLASHES), 'thumbnails_checked_at' => now(), ]); } }