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'); $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; $lastSeenId = null; do { $artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId); if ($artworks->isEmpty()) { break; } foreach ($artworks as $artwork) { if ($limit !== null && $processed >= $limit) { break 2; } $localPath = $locator->resolveLocalPath($artwork); $missingReason = null; if ($localPath === '') { $missingReason = 'unresolved_local_path'; $unresolved++; } elseif (! File::isFile($localPath)) { $missingReason = 'missing_local_file'; } if ($missingReason !== null) { $objectPath = $locator->resolveObjectPath($artwork); $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_local_file' && $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, )); return self::SUCCESS; } /** * @return Collection */ private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection { $query = Artwork::query() ->withTrashed() ->select(['id', 'slug', 'file_path', 'hash', 'file_ext']) ->orderByDesc('id'); if ($artworkId !== null) { $query->whereKey($artworkId); } elseif ($lastSeenId !== null) { $query->where('id', '<', $lastSeenId); } return $query->limit($chunkSize)->get(); } 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'; } }