Widget system Phase 1: per-type render/action modules
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s

New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus
the WIDGET_TYPES registry) -- the widget-system analogue of
routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS,
generalized from "one mode owns the whole panel" to "each widget renders
into its own region and responds to named button actions." Each module
exposes render(db, frame, widget, target_w, target_h) -> Image.Image
(never raises -- a widget's own fetch hiccup falls back to a small
placeholder rather than taking the whole panel's render down) and an
ACTIONS registry for NEXT/BACK button assignment.

Supporting changes needed to give the widget modules something to call,
all mechanical/behavior-preserving for every existing caller:

- image_pipeline.py: render_panel(regions, ...) generalizes render_frame's
  tail (paste, enhance once, overlay once, quantize once, pack once) from
  one photo to N regions -- not a restructuring, since calendar mode's
  photo-inlay feature already pastes a second composed image onto the
  canvas before that single shared pipeline runs.
- photo_queue.py: advance_forced/back_forced/remove_from_rotation/
  get_current take an explicit `frame` param now that `cfg` won't always
  be the Frame itself once photo-queue state moves to PhotoWidgetConfig
  -- caught a real latent bug while doing this: get_current was reading
  refresh_interval_s off `cfg`, but that's a frame-level wake-cadence
  setting, not something that becomes per-widget, so it now reads that
  off `frame` explicitly instead.
- routers/common.py: list_assets/fetch_source_and_faces take album_id/
  display_mode directly instead of a whole Frame (both only ever read
  that one attribute off it); new get_or_refresh_*_for_widget siblings
  of the existing calendar/weather/tasks/whiteboard cache helpers, read/
  writing the new per-widget config tables -- the Frame-scoped originals
  are untouched and still what routers/device.py's actual dispatch calls
  until the Phase 2 cutover.

26 new tests (95 total): render_panel size/placement/orientation
coverage, and per-widget-type render/action tests (unconfigured ->
placeholder, a fetch failure -> placeholder not a crash, actions mutate
the right state). Full suite passes; diff-reviewed to confirm
device.py's actual RENDERERS dispatch and the old Frame-scoped
get_or_refresh_* bodies are unchanged, so this is safe to deploy on its
own despite being step 1 of a two-step cutover (see the project plan on
why the *next* step, not this one, has to ship atomically).
This commit is contained in:
2026-07-24 08:44:53 -04:00
parent 8bc0749b42
commit f48daa71c8
15 changed files with 861 additions and 45 deletions
+27 -13
View File
@@ -85,11 +85,21 @@ def _top_up(cfg: Frame, assets: list[dict]) -> None:
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
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."""
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
@@ -105,7 +115,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
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
@@ -113,7 +123,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
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
@@ -122,7 +132,7 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
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)."""
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()
@@ -132,12 +142,12 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
return True
return False
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
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
@@ -146,10 +156,10 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
*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."""
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)
cfg.stats_photos_removed += 1
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]
@@ -167,7 +177,7 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
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()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
_top_up(cfg, assets)
return True
@@ -180,7 +190,7 @@ def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
_top_up(cfg, assets)
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
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
@@ -190,6 +200,10 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
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
@@ -200,9 +214,9 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
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) >= cfg.refresh_interval_s
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)
advance_forced(cfg, assets, frame)
return True