Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions migrations/Version20260328141426.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260328141426 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add image_ratio to puzzle and proposed_image_ratio to puzzle_change_request';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE puzzle ADD image_ratio DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE puzzle_change_request ADD proposed_image_ratio DOUBLE PRECISION DEFAULT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE puzzle DROP image_ratio');
$this->addSql('ALTER TABLE puzzle_change_request DROP proposed_image_ratio');
}
}
120 changes: 120 additions & 0 deletions src/ConsoleCommands/BackfillPuzzleImageRatioConsoleCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\ConsoleCommands;

use Doctrine\DBAL\Connection;
use Imagick;
use League\Flysystem\Filesystem;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'myspeedpuzzling:puzzle:backfill-image-ratio',
description: 'Backfill image_ratio for existing puzzles by reading dimensions from S3 images',
)]
final class BackfillPuzzleImageRatioConsoleCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly Filesystem $filesystem,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}

protected function configure(): void
{
$this->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'Number of puzzles to process per batch', '50');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would be done without updating');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$batchSizeOption = $input->getOption('batch-size');
assert(is_string($batchSizeOption));
$batchSize = (int) $batchSizeOption;
$dryRun = $input->getOption('dry-run');

/** @var array<array{id: string, image: string}> $puzzles */
$puzzles = $this->connection->fetchAllAssociative(
'SELECT id, image FROM puzzle WHERE image IS NOT NULL AND (image_ratio IS NULL OR image_ratio = 0)',
);

$total = count($puzzles);
$io->info(sprintf('Found %d puzzles to backfill', $total));

if ($total === 0) {
$io->success('Nothing to backfill');
return Command::SUCCESS;
}

$progressBar = $io->createProgressBar($total);
$progressBar->start();

$successCount = 0;
$errorCount = 0;
$batched = array_chunk($puzzles, max(1, $batchSize));

foreach ($batched as $batch) {
foreach ($batch as $puzzle) {
try {
$ratio = $this->calculateRatio($puzzle['image']);

if ($ratio !== null && !$dryRun) {
$this->connection->update('puzzle', ['image_ratio' => $ratio], ['id' => $puzzle['id']]);
}

$successCount++;
} catch (\Throwable $e) {
$errorCount++;
$this->logger->warning('Failed to backfill image ratio for puzzle {id}: {error}', [
'id' => $puzzle['id'],
'image' => $puzzle['image'],
'error' => $e->getMessage(),
]);
}

$progressBar->advance();
}
}

$progressBar->finish();
$io->newLine(2);

$prefix = $dryRun ? '[DRY RUN] Would have updated' : 'Updated';
$io->success(sprintf('%s %d puzzles, %d errors', $prefix, $successCount, $errorCount));

return Command::SUCCESS;
}

private function calculateRatio(string $imagePath): null|float
{
$content = $this->filesystem->read($imagePath);

$imagick = new Imagick();

try {
$imagick->readImageBlob($content);

$width = $imagick->getImageWidth();
$height = $imagick->getImageHeight();

if ($height === 0 || $width === 0) {
return null;
}

return $width / $height;
} finally {
$imagick->clear();
$imagick->destroy();
}
}
}
2 changes: 2 additions & 0 deletions src/Entity/Puzzle.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public function __construct(
public bool $approved,
#[Column(nullable: true)]
public null|string $image = null,
#[Column(nullable: true)]
public null|float $imageRatio = null,
#[ManyToOne]
public null|Manufacturer $manufacturer = null,
#[Column(nullable: true)]
Expand Down
3 changes: 3 additions & 0 deletions src/Entity/PuzzleChangeRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ public function __construct(
#[Immutable]
#[Column(nullable: true)]
public null|string $proposedImage = null,
#[Immutable]
#[Column(nullable: true)]
public null|float $proposedImageRatio = null,
// Original values (snapshot at time of request for audit trail)
#[Immutable]
#[Column]
Expand Down
3 changes: 3 additions & 0 deletions src/MessageHandler/AddPuzzleHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ public function __invoke(AddPuzzle $message): void
}

$puzzlePhotoPath = null;
$puzzleImageRatio = null;
if ($message->puzzlePhoto !== null) {
$extension = $message->puzzlePhoto->guessExtension();
$timestamp = $this->clock->now()->getTimestamp();
$puzzlePhotoPath = "$message->puzzleId-$timestamp.$extension";

$this->imageOptimizer->optimize($message->puzzlePhoto->getPathname());
$puzzleImageRatio = $this->imageOptimizer->getImageRatio($message->puzzlePhoto->getPathname());

// Stream is better because it is memory safe
$stream = fopen($message->puzzlePhoto->getPathname(), 'rb');
Expand All @@ -76,6 +78,7 @@ public function __invoke(AddPuzzle $message): void
$message->puzzleName,
approved: false,
image: $puzzlePhotoPath,
imageRatio: $puzzleImageRatio,
manufacturer: $manufacturer,
addedByUser: $player,
addedAt: $now,
Expand Down
1 change: 1 addition & 0 deletions src/MessageHandler/ApprovePuzzleChangeRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public function __invoke(ApprovePuzzleChangeRequest $message): void
$this->filesystem->delete($changeRequest->proposedImage);

$puzzle->image = $newImagePath;
$puzzle->imageRatio = $changeRequest->proposedImageRatio;
}

// Mark request as approved
Expand Down
1 change: 1 addition & 0 deletions src/MessageHandler/ApprovePuzzleMergeRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public function __invoke(ApprovePuzzleMergeRequest $message): void
$imagePuzzle = $this->puzzleRepository->get($message->selectedImagePuzzleId);
if ($imagePuzzle->image !== null) {
$survivorPuzzle->image = $imagePuzzle->image;
$survivorPuzzle->imageRatio = $imagePuzzle->imageRatio;
}
} catch (PuzzleNotFound) {
$this->logger->debug('Image puzzle {puzzleId} not found, keeping survivor image', [
Expand Down
3 changes: 3 additions & 0 deletions src/MessageHandler/SubmitPuzzleChangeRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ public function __invoke(SubmitPuzzleChangeRequest $message): void

// Store proposed image with temporary name - proper SEO name is assigned on approval
$proposedImagePath = null;
$proposedImageRatio = null;
if ($message->proposedPhoto !== null) {
$extension = $message->proposedPhoto->guessExtension() ?? 'jpg';
$proposedImagePath = "proposal-{$message->changeRequestId}.{$extension}";

$this->imageOptimizer->optimize($message->proposedPhoto->getPathname());
$proposedImageRatio = $this->imageOptimizer->getImageRatio($message->proposedPhoto->getPathname());

$stream = fopen($message->proposedPhoto->getPathname(), 'rb');
$this->filesystem->writeStream($proposedImagePath, $stream);
Expand All @@ -68,6 +70,7 @@ public function __invoke(SubmitPuzzleChangeRequest $message): void
proposedEan: $message->proposedEan,
proposedIdentificationNumber: $message->proposedIdentificationNumber,
proposedImage: $proposedImagePath,
proposedImageRatio: $proposedImageRatio,
originalName: $puzzle->name,
originalManufacturerId: $puzzle->manufacturer?->id,
originalPiecesCount: $puzzle->piecesCount,
Expand Down
6 changes: 6 additions & 0 deletions src/Query/GetBorrowedPuzzles.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function byHolderId(string $holderId): array
p.alternative_name as puzzle_alternative_name,
p.pieces_count,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS image_ratio,
m.name as manufacturer_name,
owner.id as owner_id,
owner.name as owner_name,
Expand Down Expand Up @@ -60,6 +61,7 @@ public function byHolderId(string $holderId): array
* puzzle_alternative_name: string|null,
* pieces_count: int,
* image: string|null,
* image_ratio: string|null,
* manufacturer_name: string|null,
* owner_id: string|null,
* owner_name: string|null,
Expand All @@ -75,6 +77,7 @@ public function byHolderId(string $holderId): array
piecesCount: $row['pieces_count'],
manufacturerName: $row['manufacturer_name'],
image: $row['image'],
imageRatio: $row['image_ratio'] !== null ? (float) $row['image_ratio'] : null,
ownerId: $row['owner_id'],
ownerName: $row['owner_name'] ?? $row['owner_text_name'] ?? '',
ownerAvatar: $row['owner_avatar'],
Expand Down Expand Up @@ -131,6 +134,7 @@ public function unsolvedByHolderId(string $holderId): array
p.ean,
p.pieces_count,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS image_ratio,
m.name as manufacturer_name,
lp.lent_at as added_at,
lp.owner_name as owner_text_name,
Expand Down Expand Up @@ -163,6 +167,7 @@ public function unsolvedByHolderId(string $holderId): array
* ean: string|null,
* pieces_count: int,
* image: string|null,
* image_ratio: string|null,
* manufacturer_name: string|null,
* added_at: string,
* owner_text_name: string|null,
Expand All @@ -180,6 +185,7 @@ public function unsolvedByHolderId(string $holderId): array
piecesCount: $row['pieces_count'],
manufacturerName: $row['manufacturer_name'],
image: $row['image'],
imageRatio: $row['image_ratio'] !== null ? (float) $row['image_ratio'] : null,
addedAt: new DateTimeImmutable($row['added_at']),
isBorrowed: true,
borrowedFromPlayerId: $row['owner_id'],
Expand Down
6 changes: 6 additions & 0 deletions src/Query/GetCollectionItems.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public function byCollectionAndPlayer(null|string $collectionId, string $playerI
p.ean,
p.pieces_count,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS image_ratio,
m.name as manufacturer_name
FROM collection_item ci
JOIN puzzle p ON ci.puzzle_id = p.id
Expand All @@ -65,6 +66,7 @@ public function byCollectionAndPlayer(null|string $collectionId, string $playerI
* ean: string|null,
* pieces_count: int,
* image: string|null,
* image_ratio: string|null,
* manufacturer_name: string|null,
* } $row
*/
Expand All @@ -79,6 +81,7 @@ public function byCollectionAndPlayer(null|string $collectionId, string $playerI
piecesCount: $row['pieces_count'],
manufacturerName: $row['manufacturer_name'],
image: $row['image'],
imageRatio: $row['image_ratio'] !== null ? (float) $row['image_ratio'] : null,
comment: $row['comment'],
addedAt: new DateTimeImmutable($row['added_at']),
);
Expand Down Expand Up @@ -132,6 +135,7 @@ public function getByPuzzleIdAndPlayerId(string $puzzleId, string $playerId, nul
p.ean,
p.pieces_count,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS image_ratio,
m.name as manufacturer_name
FROM collection_item ci
JOIN puzzle p ON ci.puzzle_id = p.id
Expand All @@ -150,6 +154,7 @@ public function getByPuzzleIdAndPlayerId(string $puzzleId, string $playerId, nul
* ean: string|null,
* pieces_count: int,
* image: string|null,
* image_ratio: string|null,
* manufacturer_name: string|null,
* }|false $row
*/
Expand All @@ -171,6 +176,7 @@ public function getByPuzzleIdAndPlayerId(string $puzzleId, string $playerId, nul
piecesCount: $row['pieces_count'],
manufacturerName: $row['manufacturer_name'],
image: $row['image'],
imageRatio: $row['image_ratio'] !== null ? (float) $row['image_ratio'] : null,
comment: $row['comment'],
addedAt: new DateTimeImmutable($row['added_at']),
);
Expand Down
9 changes: 9 additions & 0 deletions src/Query/GetConversations.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public function forPlayer(string $playerId, null|ConversationStatus $status = nu
p.id AS puzzle_id,
p.name AS puzzle_name,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS puzzle_image_ratio,
sli.listing_type AS listing_type,
sli.price AS listing_price
FROM conversation c
Expand Down Expand Up @@ -125,6 +126,7 @@ public function forPlayer(string $playerId, null|ConversationStatus $status = nu
* puzzle_id: null|string,
* puzzle_name: null|string,
* puzzle_image: null|string,
* puzzle_image_ratio: null|string,
* listing_type: null|string,
* listing_price: null|string,
* } $row
Expand All @@ -145,6 +147,7 @@ public function forPlayer(string $playerId, null|ConversationStatus $status = nu
puzzleId: $row['puzzle_id'],
sellSwapListItemId: $row['sell_swap_list_item_id'],
puzzleImage: $row['puzzle_image'],
puzzleImageRatio: $row['puzzle_image_ratio'] !== null ? (float) $row['puzzle_image_ratio'] : null,
listingType: $row['listing_type'],
listingPrice: $row['listing_price'] !== null ? (float) $row['listing_price'] : null,
lastMessageSentByMe: (bool) ($row['last_message_sent_by_me'] ?? false),
Expand Down Expand Up @@ -173,6 +176,7 @@ public function pendingRequestsForPlayer(string $playerId): array
p.id AS puzzle_id,
p.name AS puzzle_name,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS puzzle_image_ratio,
sli.listing_type AS listing_type,
sli.price AS listing_price
FROM conversation c
Expand Down Expand Up @@ -211,6 +215,7 @@ public function pendingRequestsForPlayer(string $playerId): array
* puzzle_id: null|string,
* puzzle_name: null|string,
* puzzle_image: null|string,
* puzzle_image_ratio: null|string,
* listing_type: null|string,
* listing_price: null|string,
* } $row
Expand All @@ -231,6 +236,7 @@ public function pendingRequestsForPlayer(string $playerId): array
puzzleId: $row['puzzle_id'],
sellSwapListItemId: $row['sell_swap_list_item_id'],
puzzleImage: $row['puzzle_image'],
puzzleImageRatio: $row['puzzle_image_ratio'] !== null ? (float) $row['puzzle_image_ratio'] : null,
listingType: $row['listing_type'],
listingPrice: $row['listing_price'] !== null ? (float) $row['listing_price'] : null,
);
Expand Down Expand Up @@ -278,6 +284,7 @@ public function ignoredForPlayer(string $playerId): array
p.id AS puzzle_id,
p.name AS puzzle_name,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image,
CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image_ratio END AS puzzle_image_ratio,
sli.listing_type AS listing_type,
sli.price AS listing_price
FROM conversation c
Expand Down Expand Up @@ -319,6 +326,7 @@ public function ignoredForPlayer(string $playerId): array
* puzzle_id: null|string,
* puzzle_name: null|string,
* puzzle_image: null|string,
* puzzle_image_ratio: null|string,
* listing_type: null|string,
* listing_price: null|string,
* } $row
Expand All @@ -339,6 +347,7 @@ public function ignoredForPlayer(string $playerId): array
puzzleId: $row['puzzle_id'],
sellSwapListItemId: $row['sell_swap_list_item_id'],
puzzleImage: $row['puzzle_image'],
puzzleImageRatio: $row['puzzle_image_ratio'] !== null ? (float) $row['puzzle_image_ratio'] : null,
listingType: $row['listing_type'],
listingPrice: $row['listing_price'] !== null ? (float) $row['listing_price'] : null,
lastMessageSentByMe: (bool) ($row['last_message_sent_by_me'] ?? false),
Expand Down
Loading
Loading