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:
@@ -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