Files
espresso_frame/server/app/photo_queue.py
T
tfaour 42d7c09f97
Build and push server image / build-and-push (push) Successful in 42s
Make the upcoming-photos queue length user-configurable
Adds "Upcoming photos to show" to the config UI (queue_target_len, 5-50,
default 20, replacing the hardcoded QUEUE_TARGET_LEN constant). Lowering
it trims the queue immediately on next page load rather than waiting for
enough advances to consume the excess naturally; raising it tops back up
the same way, via a new photo_queue.sync_queue_length() called from
GET /api/queue.
2026-07-19 01:03:05 -04:00

116 lines
4.6 KiB
Python

"""Tracks which photo is currently displayed and what's queued up next.
`current_asset_id` only ever changes two ways: the configured refresh
interval elapsing (`get_current`, called on every `GET /frame/image` --
a no-op otherwise, so an unplanned device reboot just redisplays the same
photo instead of silently skipping ahead) or an explicit forced advance
(`advance_forced`, called from `POST /frame/advance` -- the next-photo
button -- ignoring elapsed time).
`queue` is a small reorderable lookahead the web UI can preview and
rearrange, topped up (or trimmed) automatically to match
`cfg.queue_target_len` (user-configurable from the web UI) as it's
consumed. `queue_cursor` is separate, internal-only bookkeeping for where
sequential top-up resumes in the album -- not shown or reordered in the UI.
"""
from __future__ import annotations
import random
import time
from .config import FrameConfig
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
valid_ids = {a["id"] for a in assets}
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids]
target = cfg.queue_target_len
if len(cfg.queue) > target:
# Target was lowered since this queue was built -- shrink it
# immediately rather than waiting for enough advances to consume
# the excess naturally.
cfg.queue = cfg.queue[:target]
return
needed = target - len(cfg.queue)
if needed <= 0 or not assets:
return
excluded = set(cfg.queue)
if cfg.current_asset_id:
excluded.add(cfg.current_asset_id)
if cfg.order == "shuffle":
candidates = [a["id"] for a in assets if a["id"] not in excluded]
cfg.queue.extend(random.sample(candidates, min(needed, len(candidates))))
return
# Sequential: walk the album starting at queue_cursor, at most one full
# pass, wrapping around. queue_cursor resumes right after wherever this
# pass stopped, whether or not it filled the queue (e.g. a small album
# where everything's already queued/current -- next call is then a
# cheap no-op scan until something's consumed).
n = len(assets)
cfg.queue_cursor %= n
added = 0
i = 0
for i in range(n):
if added >= needed:
break
asset_id = assets[(cfg.queue_cursor + i) % n]["id"]
if asset_id not in excluded:
cfg.queue.append(asset_id)
excluded.add(asset_id)
added += 1
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used only by the explicit
next-photo action (POST /frame/advance) -- always mutates cfg."""
_top_up(cfg, assets)
if cfg.queue:
cfg.current_asset_id = cfg.queue.pop(0)
elif assets:
# Queue still empty after top-up (e.g. a single-photo album whose
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
# Refill back up to queue_target_len now that current_asset_id has
# changed -- otherwise the queue is left one short until the *next*
# advance, since the pop above consumes one of the items _top_up just
# added.
_top_up(cfg, assets)
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
otherwise touching current_asset_id. Used by GET /api/queue so a
change to the "upcoming photos to show" setting takes effect on page
load rather than waiting for the next natural advance."""
_top_up(cfg, assets)
def get_current(cfg: FrameConfig, assets: list[dict]) -> bool:
"""Time-based, idempotent path used by GET /frame/image. Advances only
if the current photo is unset/invalid or refresh_interval_s has
elapsed since it was set. Returns whether it changed anything, so the
caller knows whether to persist. Calling this repeatedly well within
the interval is a no-op both times -- what makes an unplanned device
reboot safe: it just re-reads the current photo instead of skipping
ahead, while a wake that lands after the interval has elapsed still
advances exactly once, even after a long time offline."""
valid_ids = {a["id"] for a in assets}
stale = (
not cfg.current_asset_id
or cfg.current_asset_id not in valid_ids
or (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
)
if not stale:
return False
advance_forced(cfg, assets)
return True