Files
SkinbaseNova/app/Console/Commands/AuditArtworkDownloadFilesCommand.php
T
test 455b514d50 Track original artwork file availability more accurately.
Resolve local and object-storage originals, persist download status, and audit missing files without guessing from a single path.
2026-09-20 14:49:19 +02:00

237 lines
8.4 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}
{--status= : Only inspect records with this persisted download status}
{--missing-only : Only report unresolved records}
{--mark-status : Persist the resolved availability status (explicit write)}
{--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');
$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<int, Artwork>
*/
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';
}
}