185 lines
6.2 KiB
PHP
185 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Services\ArtworkOriginalFileLocator;
|
|
use App\Services\Uploads\UploadStorageService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
final class AuditArtworkDownloadFilesCommand extends Command
|
|
{
|
|
protected $signature = 'artworks:audit-download-files
|
|
{--id= : Audit only this artwork ID}
|
|
{--limit= : Stop after processing this many artworks}
|
|
{--chunk=500 : Number of artworks to scan per batch}
|
|
{--restore-missing : Copy missing local originals from object storage when available}';
|
|
|
|
protected $description = 'Scan artworks in descending ID order and report missing local download files with full URLs.';
|
|
|
|
public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
|
|
{
|
|
$artworkId = $this->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<int, Artwork>
|
|
*/
|
|
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';
|
|
}
|
|
}
|