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'), 2000)); $restoreMissing = (bool) $this->option('restore-missing'); $markStatus = (bool) $this->option('mark-status'); $statusFilter = $this->option('status'); $this->info(sprintf( 'Starting download file audit. order=desc include_trashed=yes chunk=%d limit=%s restore_missing=%s', $chunkSize, $limit !== null ? (string) $limit : 'all', $restoreMissing ? 'yes' : 'no', )); $processed = 0; $missing = 0; $unresolved = 0; $restored = 0; $restoreFailed = 0; $classifications = []; $lastSeenId = null; do { $artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId, is_string($statusFilter) ? $statusFilter : null); if ($artworks->isEmpty()) { break; } foreach ($artworks as $artwork) { if ($limit !== null && $processed >= $limit) { break 2; } $resolution = $locator->resolve($artwork); $classification = match ($resolution['status']) { 'available' => strtoupper((string) $resolution['source']).'_OK', 'pending' => 'PENDING', 'unknown' => 'AMBIGUOUS', default => 'MISSING_EVERYWHERE', }; $classifications[$classification] = ($classifications[$classification] ?? 0) + 1; $localPath = (string) $resolution['path']; $missingReason = $resolution['status'] === 'missing' ? 'missing_everywhere' : null; if ($missingReason !== null) { $unresolved++; } if ($markStatus) { $this->persistStatus($artwork, $resolution); } if ($missingReason === null && (bool) $this->option('missing-only')) { $processed++; continue; } if ($missingReason === null) { $this->line(sprintf('Artwork %d %s source=%s', (int) $artwork->id, $classification, (string) $resolution['source'])); } if ($missingReason !== null) { $objectPath = (string) $resolution['object_key']; $objectUrl = $locator->resolveObjectUrl($artwork); $missing++; $this->warn(sprintf('Artwork %d %s', (int) $artwork->id, $missingReason)); $this->line(' artwork_url: '.route('art.show', [ 'id' => (int) $artwork->id, 'slug' => (string) ($artwork->slug ?? ''), ])); $this->line(' download_url: '.route('art.download', ['id' => (int) $artwork->id])); if ($objectPath !== '') { $this->line(' object_path: '.$objectPath); } if ($objectUrl !== null && $objectUrl !== '') { $this->line(' object_url: '.$objectUrl); } if ($localPath !== '') { $this->line(' local_path: '.$localPath); } if ($restoreMissing && $missingReason === 'missing_everywhere' && $localPath !== '') { $restoreResult = $this->restoreLocalFile($storage, $objectPath, $localPath); if ($restoreResult === 'restored') { $restored++; $this->info(' restore: restored from object storage'); } elseif ($restoreResult === 'object_missing') { $restoreFailed++; $this->warn(' restore: object storage file not found'); } else { $restoreFailed++; $this->warn(' restore: failed to copy object to local path'); } } $this->line(''); } $processed++; } $lastSeenId = (int) $artworks->last()->id; } while (true); $this->info(sprintf( 'Download file audit complete. processed=%d missing=%d unresolved=%d restored=%d restore_failed=%d', $processed, $missing, $unresolved, $restored, $restoreFailed, )); foreach ($classifications as $classification => $count) { $this->line(sprintf('classification=%s count=%d', $classification, $count)); } return self::SUCCESS; } /** * @return Collection */ private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId, ?string $status): Collection { if ($artworkId !== null && $lastSeenId !== null) { return collect(); } $query = Artwork::query() ->withTrashed() ->select(['id', 'slug', 'file_name', 'file_path', 'hash', 'file_ext']) ->orderByDesc('id'); if ($artworkId !== null) { $query->whereKey($artworkId); } elseif ($lastSeenId !== null) { $query->where('id', '<', $lastSeenId); } if ($status !== null && $status !== '') { $query->where('download_status', $status); } return $query->limit($chunkSize)->get(); } /** @param array{status:string,source:?string} $resolution */ private function persistStatus(Artwork $artwork, array $resolution): void { $status = match ($resolution['status']) { 'available' => 'available', 'pending' => 'pending', 'unknown' => 'unknown', default => 'missing', }; $artwork->forceFill([ 'download_status' => $status, 'download_source' => $resolution['source'], 'download_checked_at' => now(), ])->saveQuietly(); } private function restoreLocalFile(UploadStorageService $storage, string $objectPath, string $localPath): string { if ($objectPath === '') { return 'object_missing'; } $disk = Storage::disk($storage->objectDiskName()); if (! $disk->exists($objectPath)) { return 'object_missing'; } $stream = $disk->readStream($objectPath); if (! is_resource($stream)) { return 'failed'; } File::ensureDirectoryExists(dirname($localPath)); $target = fopen($localPath, 'wb'); if (! is_resource($target)) { fclose($stream); return 'failed'; } try { $copied = stream_copy_to_stream($stream, $target); } finally { fclose($stream); fclose($target); } if ($copied === false || $copied <= 0 || ! File::isFile($localPath)) { return 'failed'; } return 'restored'; } }