Adds two settings exposed in the PWA frame-settings sheet:
- rotationMode (enum: random | least_recently_shown | oldest_upload |
newest_upload). Default oldest_upload preserves the legacy
hard-coded sort, so existing devices behave identically until the
user changes it.
- prioritizeNeverShown (bool). When set, the candidate set is narrowed
to never-shown images first (if any exist) before the mode runs —
useful for "burn through new uploads before re-shuffling the catalog."
RotationService pipeline:
1. Pull approved/ready pool.
2. Drop the last `uniquenessWindow` served (existing).
3. If prioritizeNeverShown AND any candidates have never been served,
narrow to those.
4. Apply the selection mode.
Backend: enum, entity columns + accessors, migration, serializer,
PATCH validator. Frontend: types, stores, settings sheet section
(dropdown + checkbox), test fixtures, save-flow test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ use App\Entity\RenderedAsset;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Enum\RotationMode;
|
||||
use App\Service\DeviceSerializer;
|
||||
use App\Service\MercurePublisher;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -119,6 +120,18 @@ class DeviceApiController extends AbstractController
|
||||
$device->setUniquenessWindow(max(1, (int) $body['uniquenessWindow']));
|
||||
}
|
||||
|
||||
if (isset($body['rotationMode'])) {
|
||||
$mode = RotationMode::tryFrom((string) $body['rotationMode']);
|
||||
if (!$mode) {
|
||||
return $this->json(['error' => 'Invalid rotation mode'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
$device->setRotationMode($mode);
|
||||
}
|
||||
|
||||
if (array_key_exists('prioritizeNeverShown', $body)) {
|
||||
$device->setPrioritizeNeverShown((bool) $body['prioritizeNeverShown']);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$payload = $this->serializer->serialize($device);
|
||||
$this->mercure->publishDevice((int) $device->getId(), $payload);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Entity\Image;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RotationMode;
|
||||
use App\Repository\DeviceRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
@@ -52,6 +53,18 @@ class Device
|
||||
#[ORM\Column]
|
||||
private int $uniquenessWindow = 10;
|
||||
|
||||
/** How RotationService picks the next image once the uniqueness filter has run. */
|
||||
#[ORM\Column(length: 32, enumType: RotationMode::class)]
|
||||
private RotationMode $rotationMode = RotationMode::OldestUpload;
|
||||
|
||||
/**
|
||||
* When true and the candidate set contains any never-shown images, the
|
||||
* selection narrows to those before the rotation mode runs. Lets users
|
||||
* burn through new uploads before the catalog re-shuffles.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $prioritizeNeverShown = false;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'devices')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
@@ -147,6 +160,12 @@ class Device
|
||||
public function getUniquenessWindow(): int { return $this->uniquenessWindow; }
|
||||
public function setUniquenessWindow(int $w): static { $this->uniquenessWindow = $w; return $this; }
|
||||
|
||||
public function getRotationMode(): RotationMode { return $this->rotationMode; }
|
||||
public function setRotationMode(RotationMode $m): static { $this->rotationMode = $m; return $this; }
|
||||
|
||||
public function isPrioritizeNeverShown(): bool { return $this->prioritizeNeverShown; }
|
||||
public function setPrioritizeNeverShown(bool $v): static { $this->prioritizeNeverShown = $v; return $this; }
|
||||
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function setUser(?User $user): static { $this->user = $user; return $this; }
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Selection strategy for the next image to serve to a device. The chosen mode
|
||||
* runs *after* the uniqueness-window filter (don't repeat the last N) and
|
||||
* *after* the optional "prioritize never-shown" narrowing — so it only ever
|
||||
* sees the surviving candidate set.
|
||||
*/
|
||||
enum RotationMode: string
|
||||
{
|
||||
/** Uniform random pick from the candidates. */
|
||||
case Random = 'random';
|
||||
|
||||
/** Image with the oldest most-recent served-at timestamp first; never-shown sorts first. */
|
||||
case LeastRecentlyShown = 'least_recently_shown';
|
||||
|
||||
/** Sort by upload time ascending — oldest photo first. The legacy default. */
|
||||
case OldestUpload = 'oldest_upload';
|
||||
|
||||
/** Sort by upload time descending — newest photo first. */
|
||||
case NewestUpload = 'newest_upload';
|
||||
}
|
||||
@@ -26,6 +26,8 @@ final class DeviceSerializer
|
||||
'wakeTimes' => $d->getWakeTimes(),
|
||||
'timezone' => $d->getTimezone(),
|
||||
'uniquenessWindow' => $d->getUniquenessWindow(),
|
||||
'rotationMode' => $d->getRotationMode()->value,
|
||||
'prioritizeNeverShown' => $d->isPrioritizeNeverShown(),
|
||||
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
|
||||
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
|
||||
'nextPollExpectedAt' => $d->getNextPollExpectedAt()?->format(\DateTimeInterface::ATOM),
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Enum\RotationMode;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -21,6 +22,14 @@ class RotationService
|
||||
/**
|
||||
* Select the next image for the device, record history, update currentImage.
|
||||
* Returns null if no ready images exist in the pool.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Pull the device's ready/approved pool.
|
||||
* 2. Drop the last `uniquenessWindow` served — fall back to the full pool
|
||||
* if that empties the candidate set.
|
||||
* 3. If `prioritizeNeverShown` is on AND any candidates have never been
|
||||
* served on this device, narrow to those.
|
||||
* 4. Apply the selection mode to pick exactly one image.
|
||||
*/
|
||||
public function advance(Device $device): ?Image
|
||||
{
|
||||
@@ -43,24 +52,101 @@ class RotationService
|
||||
$candidates = $pool;
|
||||
}
|
||||
|
||||
usort($candidates, static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt());
|
||||
$neverShownPreferred = false;
|
||||
if ($device->isPrioritizeNeverShown()) {
|
||||
$shownIds = $this->everShownImageIds($device);
|
||||
$neverShown = array_values(array_filter(
|
||||
$candidates,
|
||||
static fn(Image $i) => !in_array($i->getId(), $shownIds, true),
|
||||
));
|
||||
if (!empty($neverShown)) {
|
||||
$candidates = $neverShown;
|
||||
$neverShownPreferred = true;
|
||||
}
|
||||
}
|
||||
|
||||
$image = $candidates[0];
|
||||
$image = $this->pickByMode($device, $candidates);
|
||||
|
||||
$this->em->persist(new DeviceImageHistory($device, $image));
|
||||
$device->setCurrentImage($image);
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->info('rotation.advanced', [
|
||||
'device_id' => $device->getId(),
|
||||
'image_id' => $image->getId(),
|
||||
'pool_size' => count($pool),
|
||||
'recent_ids' => $recentIds,
|
||||
'device_id' => $device->getId(),
|
||||
'image_id' => $image->getId(),
|
||||
'pool_size' => count($pool),
|
||||
'recent_ids' => $recentIds,
|
||||
'mode' => $device->getRotationMode()->value,
|
||||
'never_shown_preferred' => $neverShownPreferred,
|
||||
]);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/** @param Image[] $candidates */
|
||||
private function pickByMode(Device $device, array $candidates): Image
|
||||
{
|
||||
return match ($device->getRotationMode()) {
|
||||
RotationMode::Random => $candidates[array_rand($candidates)],
|
||||
RotationMode::LeastRecentlyShown => $this->pickLeastRecentlyShown($device, $candidates),
|
||||
RotationMode::OldestUpload => $this->sortedByUpload($candidates, ascending: true)[0],
|
||||
RotationMode::NewestUpload => $this->sortedByUpload($candidates, ascending: false)[0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Image[] $candidates
|
||||
* @return Image[]
|
||||
*/
|
||||
private function sortedByUpload(array $candidates, bool $ascending): array
|
||||
{
|
||||
usort(
|
||||
$candidates,
|
||||
$ascending
|
||||
? static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt()
|
||||
: static fn(Image $a, Image $b) => $b->getUploadedAt() <=> $a->getUploadedAt(),
|
||||
);
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the candidate whose most recent serve on this device is oldest.
|
||||
* Never-shown candidates win because they have no history row at all.
|
||||
*
|
||||
* @param Image[] $candidates
|
||||
*/
|
||||
private function pickLeastRecentlyShown(Device $device, array $candidates): Image
|
||||
{
|
||||
$imageIds = array_map(static fn(Image $i) => $i->getId(), $candidates);
|
||||
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select('IDENTITY(h.image) AS image_id', 'MAX(h.servedAt) AS last_served')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->andWhere('h.image IN (:image_ids)')
|
||||
->groupBy('h.image')
|
||||
->setParameter('device', $device)
|
||||
->setParameter('image_ids', $imageIds)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
$lastServed = [];
|
||||
foreach ($rows as $row) {
|
||||
$lastServed[(int) $row['image_id']] = $row['last_served']; // 'Y-m-d H:i:s' or null
|
||||
}
|
||||
|
||||
usort($candidates, static function (Image $a, Image $b) use ($lastServed) {
|
||||
$la = $lastServed[$a->getId()] ?? null;
|
||||
$lb = $lastServed[$b->getId()] ?? null;
|
||||
if ($la === null && $lb === null) return $a->getId() <=> $b->getId();
|
||||
if ($la === null) return -1; // never-shown sorts first
|
||||
if ($lb === null) return 1;
|
||||
return strcmp($la, $lb); // ISO-ish strings sort lexically = chronologically
|
||||
});
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
/** @return Image[] */
|
||||
private function readyPool(Device $device): array
|
||||
{
|
||||
@@ -82,6 +168,25 @@ class RotationService
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* All image ids ever served on this device. Used when the user wants
|
||||
* never-shown images to be picked first.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function everShownImageIds(Device $device): array
|
||||
{
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select('DISTINCT IDENTITY(h.image) AS image_id')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_map('intval', array_column($rows, 'image_id'));
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function recentImageIds(Device $device, int $limit): array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user