feat(rotation): per-device image-selection preferences
CI / test (push) Has been cancelled

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:
2026-05-07 16:37:14 -04:00
parent ba9625d45d
commit cf6623de67
26 changed files with 320 additions and 31 deletions
+2
View File
@@ -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),
+111 -6
View File
@@ -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
{