Featured artworks thumbnails
This commit is contained in:
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\News\NewsCoverImageService;
|
||||
use App\Support\News\NewsCoverImage;
|
||||
use Illuminate\Console\Command;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class GenerateNewsCoverThumbnailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
|
||||
|
||||
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
|
||||
|
||||
public function __construct(
|
||||
private readonly NewsCoverImageService $covers,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = collect((array) $this->option('id'))
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->filter(static fn (int $id): bool => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
$query = NewsArticle::query()
|
||||
->select(['id', 'title', 'cover_image'])
|
||||
->whereNotNull('cover_image')
|
||||
->where('cover_image', '!=', '');
|
||||
|
||||
if ($ids !== []) {
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$purged = 0;
|
||||
|
||||
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
|
||||
foreach ($articles as $article) {
|
||||
$path = trim((string) $article->cover_image);
|
||||
|
||||
if (! NewsCoverImage::isManagedPath($path)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->covers->ensureVariants($path, $force);
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($result['generated'] ?? 0) > 0) {
|
||||
$generated++;
|
||||
|
||||
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
|
||||
$purged++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipped++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function purgeVariantCache(string $path, int $articleId): bool
|
||||
{
|
||||
$variantPaths = array_values(array_map(
|
||||
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
|
||||
array_keys(NewsCoverImage::VARIANTS),
|
||||
));
|
||||
|
||||
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
|
||||
'article_id' => $articleId,
|
||||
'reason' => 'news_cover_thumbnails_regenerated',
|
||||
]);
|
||||
}
|
||||
}
|
||||
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
@@ -0,0 +1,502 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
final class RepairArtworkThumbnailsCommand extends Command
|
||||
{
|
||||
private const SOURCE_IMAGE_EXTENSIONS = [
|
||||
'avif',
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'tif',
|
||||
'tiff',
|
||||
'webp',
|
||||
];
|
||||
|
||||
protected $signature = 'artworks:repair-missing-thumbnails
|
||||
{--id= : Repair only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=200 : Number of artworks to scan per batch}
|
||||
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
|
||||
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
|
||||
{--csv= : Optional path to write a CSV report}
|
||||
{--force : Regenerate the selected variants even when they already exist}
|
||||
{--dry-run : Report repairs without writing files}';
|
||||
|
||||
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
|
||||
|
||||
public function handle(
|
||||
UploadStorageService $storage,
|
||||
UploadDerivativesService $derivatives,
|
||||
ArtworkFileRepository $artworkFiles,
|
||||
ArtworkOriginalFileLocator $locator,
|
||||
ArtworkCdnPurgeService $cdnPurge,
|
||||
): 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'), 1000));
|
||||
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
|
||||
$csvPath = trim((string) $this->option('csv'));
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$allVariants = $this->resolveConfiguredVariants();
|
||||
$selectedVariants = $this->resolveSelectedVariants($allVariants);
|
||||
if ($selectedVariants === []) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$auditColumnsAvailable = Schema::hasColumns('artworks', [
|
||||
'has_missing_thumbnails',
|
||||
'missing_thumbnail_variants_json',
|
||||
'thumbnails_checked_at',
|
||||
]);
|
||||
|
||||
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
|
||||
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$diskName = $storage->objectDiskName();
|
||||
$disk = Storage::disk($diskName);
|
||||
$csvHandle = $this->openCsvHandle($csvPath);
|
||||
|
||||
$baseQuery = $this->baseQuery($onlyMissingFlagged);
|
||||
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
|
||||
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
|
||||
$diskName,
|
||||
implode(',', $selectedVariants),
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$onlyMissingFlagged ? 'yes' : 'no',
|
||||
$force ? 'yes' : 'no',
|
||||
$dryRun ? 'yes' : 'no',
|
||||
$csvPath !== '' ? $csvPath : 'off',
|
||||
));
|
||||
|
||||
if ($progressBar !== null) {
|
||||
$progressBar->start();
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$healthy = 0;
|
||||
$planned = 0;
|
||||
$repaired = 0;
|
||||
$failed = 0;
|
||||
$lastSeenId = null;
|
||||
|
||||
try {
|
||||
do {
|
||||
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
|
||||
if ($artworks->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
try {
|
||||
$targetVariants = $force
|
||||
? $selectedVariants
|
||||
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
|
||||
|
||||
if ($targetVariants === []) {
|
||||
$healthy++;
|
||||
$processed++;
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'healthy',
|
||||
'variants' => '',
|
||||
'source_file' => '',
|
||||
'message' => '',
|
||||
]);
|
||||
$progressBar?->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
|
||||
if ($sourcePath === '') {
|
||||
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$planned++;
|
||||
$this->line(sprintf(
|
||||
'Artwork %d would repair thumbnails: %s',
|
||||
(int) $artwork->id,
|
||||
implode(',', $targetVariants),
|
||||
));
|
||||
$this->line(' source_file: ' . $sourcePath);
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'planned',
|
||||
'variants' => implode(',', $targetVariants),
|
||||
'source_file' => $sourcePath,
|
||||
'message' => '',
|
||||
]);
|
||||
$processed++;
|
||||
$progressBar?->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
|
||||
if ($assets === []) {
|
||||
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
|
||||
foreach ($assets as $variant => $asset) {
|
||||
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
|
||||
}
|
||||
|
||||
$update = [
|
||||
'thumb_ext' => 'webp',
|
||||
];
|
||||
|
||||
if ($auditColumnsAvailable) {
|
||||
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
|
||||
$update['has_missing_thumbnails'] = $remainingMissing !== [];
|
||||
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
|
||||
? null
|
||||
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
|
||||
$update['thumbnails_checked_at'] = now();
|
||||
}
|
||||
|
||||
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
|
||||
});
|
||||
|
||||
$cdnPurge->purgeArtworkObjectPaths(array_map(
|
||||
static fn (array $asset): string => (string) $asset['path'],
|
||||
array_values($assets),
|
||||
), [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'reason' => 'thumbnail_repair',
|
||||
]);
|
||||
|
||||
$repaired++;
|
||||
$this->info(sprintf(
|
||||
'Artwork %d repaired thumbnails: %s',
|
||||
(int) $artwork->id,
|
||||
implode(',', array_keys($assets)),
|
||||
));
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'repaired',
|
||||
'variants' => implode(',', array_keys($assets)),
|
||||
'source_file' => $sourcePath,
|
||||
'message' => '',
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'failed',
|
||||
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
|
||||
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$progressBar?->advance();
|
||||
}
|
||||
|
||||
$lastSeenId = (int) $artworks->last()->id;
|
||||
} while (true);
|
||||
} finally {
|
||||
if ($progressBar !== null) {
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
}
|
||||
|
||||
if (is_resource($csvHandle)) {
|
||||
fclose($csvHandle);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
|
||||
$processed,
|
||||
$healthy,
|
||||
$planned,
|
||||
$repaired,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
|
||||
{
|
||||
$query = clone $baseQuery;
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
} elseif ($lastSeenId !== null) {
|
||||
$query->where('id', '<', $lastSeenId);
|
||||
}
|
||||
|
||||
return $query->limit($chunkSize)->get();
|
||||
}
|
||||
|
||||
private function baseQuery(bool $onlyMissingFlagged): mixed
|
||||
{
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
|
||||
->whereNotNull('hash')
|
||||
->where('hash', '!=', '')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($onlyMissingFlagged) {
|
||||
$query->where('has_missing_thumbnails', true);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
|
||||
{
|
||||
$countQuery = clone $baseQuery;
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$countQuery->whereKey($artworkId);
|
||||
}
|
||||
|
||||
$count = (int) $countQuery->count();
|
||||
if ($limit !== null) {
|
||||
return min($count, $limit);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveConfiguredVariants(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||
array_keys((array) config('uploads.derivatives', [])),
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $configuredVariants
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveSelectedVariants(array $configuredVariants): array
|
||||
{
|
||||
if ($configuredVariants === []) {
|
||||
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$requested = (array) $this->option('variant');
|
||||
if ($requested === []) {
|
||||
return $configuredVariants;
|
||||
}
|
||||
|
||||
$normalizedRequested = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||
$requested,
|
||||
))));
|
||||
|
||||
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
|
||||
if ($invalid !== []) {
|
||||
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
|
||||
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $normalizedRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $variants
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
|
||||
{
|
||||
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||
if ($hash === '') {
|
||||
return $variants;
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach ($variants as $variant) {
|
||||
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
|
||||
if (! $disk->exists($objectPath)) {
|
||||
$missing[] = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
if (! $this->isValidHash($hash)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$preferred = $locator->resolveLocalPath($artwork);
|
||||
if ($this->isUsableSourceFile($preferred)) {
|
||||
return $preferred;
|
||||
}
|
||||
|
||||
foreach ($this->candidateOriginalRoots() as $root) {
|
||||
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
|
||||
if ($candidatePath !== '') {
|
||||
return $candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function candidateOriginalRoots(): array
|
||||
{
|
||||
$roots = [
|
||||
trim((string) config('uploads.local_originals_root', '')),
|
||||
trim((string) config('uploads.readonly_backup_originals_root', '')),
|
||||
];
|
||||
|
||||
$normalizedRoots = [];
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if ($root === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoots[] = $normalizedRoot;
|
||||
}
|
||||
|
||||
return $normalizedRoots;
|
||||
}
|
||||
|
||||
private function findNonZipSourceInRoot(string $root, string $hash): string
|
||||
{
|
||||
$directory = $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
|
||||
|
||||
if (! File::isDirectory($directory)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
|
||||
if (! is_array($matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if ($this->isUsableSourceFile($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function isUsableSourceFile(string $path): bool
|
||||
{
|
||||
if ($path === '' || ! File::isFile($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mime = strtolower((string) (File::mimeType($path) ?? ''));
|
||||
|
||||
return str_starts_with($mime, 'image/');
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource|null
|
||||
*/
|
||||
private function openCsvHandle(string $csvPath)
|
||||
{
|
||||
if ($csvPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($csvPath));
|
||||
$handle = fopen($csvPath, 'wb');
|
||||
if (! is_resource($handle)) {
|
||||
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
|
||||
}
|
||||
|
||||
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource|null $csvHandle
|
||||
* @param array<string, scalar|null> $row
|
||||
*/
|
||||
private function writeCsvRow($csvHandle, array $row): void
|
||||
{
|
||||
if (! is_resource($csvHandle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fputcsv($csvHandle, [
|
||||
$row['artwork_id'] ?? '',
|
||||
$row['status'] ?? '',
|
||||
$row['variants'] ?? '',
|
||||
$row['source_file'] ?? '',
|
||||
$row['message'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user