Add job and artisan command for generating featured thumbnails

This commit is contained in:
2026-05-06 18:55:08 +02:00
parent bd8a5c14a0
commit 8fa3adf4df
2 changed files with 245 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
use App\Models\Artwork;
use App\Services\Featured\FeaturedArtworkSelector;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\LazyCollection;
final class GenerateFeaturedArtworkThumbnailsCommand extends Command
{
protected $signature = 'skinbase:featured-thumbnails:generate
{--artwork=* : Restrict generation to one or more artwork IDs}
{--only-featured : Restrict generation to currently selected featured artworks}
{--missing-only : Only generate artworks missing at least one featured variant}
{--all : Process all artworks with a hash and source extension}
{--limit=0 : Cap the number of artworks processed}
{--queue : Dispatch background jobs instead of generating inline}
{--force : Regenerate all featured variants even when they already exist}
{--dry-run : Report planned generation without writing files}';
protected $description = 'Generate dedicated featured artwork CDN thumbnails for the homepage hero';
public function __construct(
private readonly FeaturedArtworkSelector $selector,
private readonly FeaturedArtworkThumbnailGenerator $generator,
) {
parent::__construct();
}
public function handle(): int
{
$artworkIds = collect((array) $this->option('artwork'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$queue = (bool) $this->option('queue');
$limit = max(0, (int) $this->option('limit'));
$all = (bool) $this->option('all');
$explicitOnlyFeatured = (bool) $this->option('only-featured');
$missingOnly = $force ? false : ((bool) $this->option('missing-only') || ($artworkIds === [] && ! $all));
if ($all && $explicitOnlyFeatured) {
$this->error('Use either --all or --only-featured, not both.');
return self::INVALID;
}
if ($queue && $dryRun) {
$this->error('Use either --queue or --dry-run, not both.');
return self::INVALID;
}
$onlyFeatured = $artworkIds === [] && ! $all;
if ($explicitOnlyFeatured) {
$onlyFeatured = true;
}
$processed = 0;
$queued = 0;
$generatedVariants = 0;
$skipped = 0;
$failed = 0;
foreach ($this->candidateArtworks($artworkIds, $onlyFeatured) as $artwork) {
if ($limit > 0 && $processed >= $limit) {
break;
}
$processed++;
$plan = $this->generator->plan($artwork, $force);
$targetVariants = (array) ($plan['target_variants'] ?? []);
if ($missingOnly && ! $force && $targetVariants === []) {
$skipped++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry-run] artwork=%d variants=%s',
(int) $artwork->id,
$targetVariants === [] ? 'none' : implode(',', $targetVariants),
));
if ($targetVariants === []) {
$skipped++;
} else {
$generatedVariants += count($targetVariants);
}
continue;
}
if ($queue) {
GenerateFeaturedArtworkThumbnailsJob::dispatch((int) $artwork->id, $force);
$queued++;
continue;
}
try {
$result = $this->generator->generate($artwork, $force);
$generatedVariants += (int) ($result['generated'] ?? 0);
$skipped += count((array) ($result['target_variants'] ?? [])) === 0 ? 1 : 0;
if (($result['failed'] ?? []) !== []) {
$failed++;
$this->warn(sprintf(
'Artwork %d failed for variants: %s',
(int) $artwork->id,
implode(', ', array_keys((array) $result['failed'])),
));
}
} catch (\Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
}
}
$mode = $dryRun ? 'dry-run' : ($queue ? 'queued' : 'generated');
$this->info(sprintf(
'Featured artwork thumbnail %s complete: processed=%d queued=%d generated_variants=%d skipped=%d failed=%d',
$mode,
$processed,
$queued,
$generatedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param list<int> $artworkIds
* @return LazyCollection<int, Artwork>
*/
private function candidateArtworks(array $artworkIds, bool $onlyFeatured): LazyCollection
{
if ($artworkIds !== []) {
return Artwork::query()
->withTrashed()
->whereIn('id', $artworkIds)
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '')
->orderByDesc('id')
->cursor();
}
$query = $onlyFeatured
? $this->selector->querySelectedArtworks()
: Artwork::query()
->select('artworks.*')
->withTrashed()
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '');
return $this->orderedCursor($query);
}
/**
* @return LazyCollection<int, Artwork>
*/
private function orderedCursor(Builder $query): LazyCollection
{
return $query
->orderByDesc('artworks.id')
->cursor();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
final class GenerateFeaturedArtworkThumbnailsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private readonly int $artworkId,
private readonly bool $force = false,
) {
$queue = (string) config('uploads.featured_thumbnails.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(
FeaturedArtworkThumbnailGenerator $generator,
): void {
$artwork = Artwork::withTrashed()->find($this->artworkId);
if (! $artwork instanceof Artwork) {
return;
}
$result = $generator->generate($artwork, $this->force);
if (($result['failed'] ?? []) !== []) {
Log::warning('Featured artwork thumbnail generation had partial failures', [
'artwork_id' => $artwork->id,
'failed_variants' => array_keys((array) $result['failed']),
]);
}
}
}