"""Tracks which photo is currently displayed, what's queued up next, and what's already been shown. `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. `history` is the mirror image of `queue`: every time `advance_forced` actually changes `current_asset_id`, the old one is pushed onto `history`. `back_forced` (the back-photo button) is the exact reverse of `advance_forced` -- it pops `history` back into `current_asset_id` and pushes the photo it's replacing onto the *front* of `queue`, so pressing next afterwards lands you right back where you were. `excluded_asset_ids` is a permanent (until explicitly un-excluded, which there's no UI for yet) block list -- `_top_up()` never selects an excluded photo, and `remove_from_rotation()` scrubs one out of `queue`/`history` too, so it can't resurface via "Show next" or the back button either. This doesn't touch Immich at all -- the photo stays in the album, it's just never chosen by this frame again. """ from __future__ import annotations import random import time from .models import Frame HISTORY_MAX_LEN = 20 def _top_up(cfg: Frame, assets: list[dict]) -> None: valid_ids = {a["id"] for a in assets} excluded_ids = set(cfg.excluded_asset_ids) cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_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) | set(cfg.excluded_asset_ids) 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: Frame, assets: list[dict], frame: Frame) -> None: """Unconditionally moves to the next photo, ignoring elapsed time, and resets the interval clock from now. Used by the explicit next-photo action (POST /frame/advance) and by get_current() once the refresh interval has elapsed -- always mutates cfg. `frame` is a separate reference to the owning Frame, for fields that stay frame-level rather than moving onto a photo widget's own config (currently just stats_photos_displayed) -- once a photo widget's queue state lives on its own PhotoWidgetConfig row rather than directly on Frame (see models.py), `cfg` and `frame` stop being the same object; every existing caller today still passes the same Frame for both, which is also why this stays a required (not optional) param -- no implicit "guess which Frame owns this" fallback to get wrong later.""" if cfg.current_asset_id: # Recorded regardless of *why* this advance happened (a manual # next-press or the timer just elapsing) -- back should be able # to undo either kind. cfg.history.append(cfg.current_asset_id) cfg.history = cfg.history[-HISTORY_MAX_LEN:] _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() frame.stats_photos_displayed += 1 # 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 back_forced(cfg: Frame, assets: list[dict], frame: Frame) -> bool: """Unconditionally moves to the previously-current photo, the mirror image of advance_forced() -- pops the most recent entry off history, pushes the photo it's replacing onto the front of queue (so pressing next afterwards returns to it), and resets the interval clock from now. Skips over any history entries no longer in the album (deleted since). Returns whether it actually moved -- False (history empty or entirely stale) is a no-op, callers should still just display whatever's current rather than treating it as an error. Used by the back-photo button (POST /frame/back). See advance_forced() on `frame`.""" valid_ids = {a["id"] for a in assets} while cfg.history: previous_id = cfg.history.pop() if previous_id not in valid_ids: continue if cfg.current_asset_id: cfg.queue.insert(0, cfg.current_asset_id) cfg.current_asset_id = previous_id cfg.current_asset_set_at = time.time() frame.stats_photos_displayed += 1 return True return False def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str, frame: Frame) -> bool: """Permanently excludes asset_id from this frame's rotation (see the module docstring) -- doesn't touch Immich, just this frame's own selection. Scrubs it out of queue and history too, so it can't resurface via "Show next" or the back button either. If it was the current photo, immediately advances to a new one -- deliberately *not* through advance_forced(), since that would record the removed photo in history, and going back to a photo you just explicitly removed doesn't make sense. Returns whether the current photo changed as a result. See advance_forced() on `frame`.""" if asset_id not in cfg.excluded_asset_ids: cfg.excluded_asset_ids.append(asset_id) frame.stats_photos_removed += 1 cfg.queue = [a for a in cfg.queue if a != asset_id] cfg.history = [a for a in cfg.history if a != asset_id] if asset_id != cfg.current_asset_id: return False _top_up(cfg, assets) if cfg.queue: cfg.current_asset_id = cfg.queue.pop(0) else: # Queue empty even after top-up (e.g. everything else is also # excluded, or a tiny album) -- fall back to any remaining # non-excluded asset, or give up and show nothing. excluded_ids = set(cfg.excluded_asset_ids) remaining = [a["id"] for a in assets if a["id"] not in excluded_ids] cfg.current_asset_id = remaining[0] if remaining else "" cfg.current_asset_set_at = time.time() frame.stats_photos_displayed += 1 _top_up(cfg, assets) return True def sync_queue_length(cfg: Frame, 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: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bool = False) -> 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. refresh_interval_s is read off `frame`, not `cfg` -- it's a device wake-cadence setting shared by the whole panel, not something that becomes per-widget (see advance_forced() on the cfg/frame split). in_quiet_hours suppresses *only* the elapsed-time trigger -- an unset/invalid current photo still gets picked regardless, since showing nothing is worse than showing something even at 3am. This check runs independent of the device (also triggered by the web UI's /api/queue), so without this an open browser tab polling overnight would silently advance the current photo on raw elapsed time alone, even though the device itself is correctly asleep through the window (see main.py's _effective_refresh_interval_s).""" valid_ids = {a["id"] for a in assets} needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s stale = needs_pick or (time_elapsed and not in_quiet_hours) if not stale: return False advance_forced(cfg, assets, frame) return True