From a33a3a71e4be0b476b6fe24c52641121791ef3b8 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Fri, 24 Jul 2026 14:30:56 -0400
Subject: [PATCH] Widget system Phase 4b: per-widget gear-icon config dialogs
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.
Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.
API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.
UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.
Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
---
server/app/calendar_render.py | 8 +-
server/app/migration.py | 64 +-
server/app/models.py | 35 +-
server/app/routers/api_frames.py | 685 ++----------------
server/app/routers/api_widgets.py | 666 ++++++++++++++++-
server/app/routers/common.py | 35 +-
server/app/routers/frame_pages.py | 211 +++---
server/app/static/device_status_bar.js | 7 +-
server/app/static/frame_calendar.js | 308 --------
server/app/static/frame_config.js | 33 +-
server/app/static/frame_header.js | 14 +-
server/app/static/frame_layout.js | 71 ++
server/app/static/frame_photos.js | 128 ----
server/app/static/frame_whiteboard.js | 183 -----
server/app/static/queue.js | 5 +-
server/app/static/settings.js | 6 +-
server/app/static/theme.css | 34 +-
server/app/static/widget_dialog_calendar.js | 290 ++++++++
server/app/static/widget_dialog_photos.js | 116 +++
server/app/static/widget_dialog_whiteboard.js | 163 +++++
server/app/templates/_frame_tabs.html | 7 +-
.../templates/_widget_dialog_calendar.html | 173 +++++
.../app/templates/_widget_dialog_photos.html | 55 ++
.../templates/_widget_dialog_whiteboard.html | 58 ++
server/app/templates/frame_calendar.html | 206 ------
server/app/templates/frame_config.html | 33 +-
server/app/templates/frame_layout.html | 18 +-
server/app/templates/frame_photos.html | 84 ---
server/app/templates/frame_stats.html | 2 +-
server/app/templates/frame_whiteboard.html | 91 ---
.../tests/test_calendar_preview_endpoint.py | 41 +-
server/tests/test_migrations.py | 95 ++-
server/tests/test_permission_boundaries.py | 122 ++--
.../test_whiteboard_refresh_and_browse.py | 40 +-
.../test_widget_config_and_queue_endpoints.py | 160 ++++
35 files changed, 2335 insertions(+), 1912 deletions(-)
delete mode 100644 server/app/static/frame_calendar.js
delete mode 100644 server/app/static/frame_photos.js
delete mode 100644 server/app/static/frame_whiteboard.js
create mode 100644 server/app/static/widget_dialog_calendar.js
create mode 100644 server/app/static/widget_dialog_photos.js
create mode 100644 server/app/static/widget_dialog_whiteboard.js
create mode 100644 server/app/templates/_widget_dialog_calendar.html
create mode 100644 server/app/templates/_widget_dialog_photos.html
create mode 100644 server/app/templates/_widget_dialog_whiteboard.html
delete mode 100644 server/app/templates/frame_calendar.html
delete mode 100644 server/app/templates/frame_photos.html
delete mode 100644 server/app/templates/frame_whiteboard.html
create mode 100644 server/tests/test_widget_config_and_queue_endpoints.py
diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py
index ea4fe81..e261f8d 100644
--- a/server/app/calendar_render.py
+++ b/server/app/calendar_render.py
@@ -63,8 +63,8 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
_draw_color_bar. Each source's own manually pinned color
- (FrameCalendar.color_index -- see routers/api_frames.py's
- api_calendar_color) resolves against whichever palette this frame
+ (FrameCalendar.color_index -- see routers/api_widgets.py's
+ api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
blue; a source with no color pinned falls back to the old
auto-cycle-by-owner-name behavior. owners_seen is shared across every
@@ -644,7 +644,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
start_offset: int = 0) -> Image.Image:
- """`days` (2-10, see routers/api_frames.py's clamp) side-by-side
+ """`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
_draw_agenda_day the same way _build_today_tomorrow does, just for
@@ -657,7 +657,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weekday, "start on the most recent Monday") exactly like before --
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
- today, see routers/api_frames.py's api_config_save)."""
+ today, see routers/api_widgets.py's api_widget_config_save)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
tier = _size_tier(target_w, target_h)
diff --git a/server/app/migration.py b/server/app/migration.py
index f331611..7c3de2b 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -15,7 +15,7 @@ import secrets
import shutil
import time
-from sqlalchemy import select, text
+from sqlalchemy import inspect, select, text
from . import config, grid
from .db import SessionLocal, engine
@@ -378,6 +378,7 @@ def run_migrations() -> None:
_ensure_frame_one()
_ensure_server_settings()
_ensure_widgets_backfilled()
+ _ensure_frame_calendars_rekeyed()
def new_device_token() -> str:
@@ -604,3 +605,64 @@ def _ensure_widgets_backfilled() -> None:
continue
_backfill_frame_widgets(db, frame)
db.commit()
+
+
+def _ensure_frame_calendars_rekeyed() -> None:
+ """Re-keys frame_calendars from frame_id to widget_id -- a frame can
+ hold more than one independent calendar widget (see the widget
+ system), each with its own included-calendars set, so "included on
+ this frame" no longer means anything unambiguous (see
+ models.FrameCalendar). Existing rows attach to their frame's calendar
+ widget if it has one; rows for a frame with no calendar widget at all
+ are dropped -- they were already-dormant settings for content
+ nothing ever actually displayed (the Calendar tab stayed reachable
+ and savable even while a frame's old `mode` was "photos"), not real
+ live configuration.
+
+ Deliberately NOT a numbered migration: this needs each frame's
+ calendar widget to already exist to know what to re-key against, and
+ those widget rows aren't created by a schema migration at all --
+ they come from _ensure_widgets_backfilled() above, which (like this
+ function) runs unconditionally after every startup rather than being
+ tracked by schema_version. Running this as a numbered migration
+ would execute it *before* that backfill during a real upgrade (the
+ numbered-migration loop runs first, see run_migrations), silently
+ dropping every row -- caught by test_migrations.py actually exercising
+ the raw-SQL upgrade path instead of the fresh-install create_all()
+ shortcut every other test in that file takes.
+
+ Runs unconditionally after every startup, like _ensure_widgets_
+ backfilled; a no-op the moment frame_calendars is already
+ widget_id-shaped (every fresh install, and any existing install
+ after its first run past this code) -- SQLite can't ALTER a column's
+ FK target or drop a column that's part of an index/FK constraint, so
+ when it isn't a no-op this is the standard SQLite "rebuild" pattern:
+ create the new-shape table, copy matching rows across (joining to
+ find each row's calendar widget), drop the old table, rename the new
+ one into place."""
+ inspector = inspect(engine)
+ columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
+ if "widget_id" in columns:
+ return
+ with engine.begin() as conn:
+ conn.execute(text(
+ "CREATE TABLE frame_calendars_new ("
+ "id INTEGER PRIMARY KEY, "
+ "widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
+ "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
+ "calendar_key TEXT NOT NULL, "
+ "calendar_label TEXT NOT NULL DEFAULT '', "
+ "included INTEGER NOT NULL DEFAULT 1, "
+ "color_index INTEGER)"
+ ))
+ conn.execute(text(
+ "INSERT INTO frame_calendars_new (widget_id, user_id, calendar_key, calendar_label, included, color_index) "
+ "SELECT w.id, fc.user_id, fc.calendar_key, fc.calendar_label, fc.included, fc.color_index "
+ "FROM frame_calendars fc "
+ "JOIN widgets w ON w.frame_id = fc.frame_id AND w.widget_type = 'calendar'"
+ ))
+ conn.execute(text("DROP TABLE frame_calendars"))
+ conn.execute(text("ALTER TABLE frame_calendars_new RENAME TO frame_calendars"))
+ conn.execute(text(
+ "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (widget_id, user_id, calendar_key)"
+ ))
diff --git a/server/app/models.py b/server/app/models.py
index 4c22a5c..25640d5 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -80,9 +80,10 @@ class User(Base):
webdav_username: Mapped[str] = mapped_column(String, default="")
webdav_password: Mapped[str] = mapped_column(String, default="")
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
- # Optional starting folder for the file-picker on a frame's Whiteboard
- # tab (see routers/api_frames.py's whiteboard-browse) -- purely a
- # convenience for browsing to a file rather than typing its full URL.
+ # Optional starting folder for the whiteboard dialog's file-picker
+ # (see routers/api_widgets.py's api_widget_whiteboard_browse) --
+ # purely a convenience for browsing to a file rather than typing its
+ # full URL.
# Never used for fetching/rendering itself, which always uses the
# frame's own saved whiteboard_url regardless of whether this is set.
webdav_base_url: Mapped[str] = mapped_column(String, default="")
@@ -241,8 +242,8 @@ class Frame(Base):
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
# something a plain ICS subscription meaningfully has); source is
# one specific linked user's own CalDAV calendar, same
- # owner-controls-their-own-data permission split as FrameCalendar --
- # see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id
+ # owner-controls-their-own-data permission split as FrameCalendar.
+ # calendar_tasks_user_id
# SET NULL on the user's deletion clears the source rather than
# leaving a dangling reference (checked_at isn't reset by that, but
# the next refresh attempt finds no source and just returns []).
@@ -261,9 +262,8 @@ class Frame(Base):
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
- # whiteboard_user_id may point the frame at their own account (see
- # routers/api_frames.py's api_whiteboard_source), since it's their
- # credentials being used to fetch it. --
+ # whiteboard_user_id may point the frame at their own account, since
+ # it's their credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
@@ -355,12 +355,18 @@ class FrameCalendar(Base):
ANY user linked to the frame may flip included back to False, muting
a calendar they'd rather not see on a shared display even though
they don't own it. Only the owner may flip it back to True. See
- routers/api_frames.py's api_calendar_select."""
+ routers/api_widgets.py's api_widget_calendar_select.
+
+ Keyed by widget_id, not frame_id -- a frame can hold more than one
+ independent calendar widget (see Widget), each with its own included-
+ calendars set; "included on this frame" stopped being unambiguous
+ the moment that became possible (see migration.py's _migration_17,
+ which re-keyed this table)."""
__tablename__ = "frame_calendars"
id: Mapped[int] = mapped_column(primary_key=True)
- frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
+ widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
# Snapshot label for display -- so the list still reads sensibly even
@@ -372,11 +378,11 @@ class FrameCalendar(Base):
# text/background) pinning this calendar's events to a specific
# panel color rather than calendar_render.py's old owner-name
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
- # owner may set this -- see routers/api_frames.py's api_calendar_color.
+ # owner may set this -- see routers/api_widgets.py's api_widget_calendar_color.
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
- Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
+ Index("ix_frame_calendars_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
@@ -445,9 +451,8 @@ class CalendarWidgetConfig(Base):
minus calendar_photo_inlay (dropped: arbitrary widget placement
subsumes what a fixed 50/50 inlay split did, so it's not a special
case anymore, just place a photo widget alongside). "Included
- calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed
- to widget_id in a later phase once more than one calendar widget per
- frame is actually supported end to end)."""
+ calendars" is its own table (FrameCalendar), widget_id-keyed so each
+ calendar widget on a frame has its own independent set."""
__tablename__ = "calendar_widget_configs"
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index e60c092..163e11b 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -1,16 +1,17 @@
-"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
+"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/...
+Per-widget settings (album, calendar view/inclusion, whiteboard source,
+etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/
+{widget_id}/... -- split out once a frame could hold more than one
+widget of the same type.
Auth: session-only (require_frame_view for reads, require_frame_control
for mutations -- the "take control" soft lock). The limited manage-QR
surface lives separately under /api/m/ (routers/manage.py), and device
traffic under /frame/* (routers/device.py).
-Config saves are PARTIAL updates: each page's form posts only its own
-fields (the old single Settings form split across the Photos and
-Configuration tabs), so every field is optional and only provided ones
-are touched. Checkboxes are sent explicitly as "true"/"false" strings by
-the page JS -- an absent field means "not this form's field", never
-"unchecked".
+Config saves are PARTIAL updates: only provided fields are touched.
+Checkboxes are sent explicitly as "true"/"false" strings by the page JS
+-- an absent field means "not this form's field", never "unchecked".
"""
from __future__ import annotations
@@ -20,48 +21,16 @@ import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
-from fastapi.responses import Response
-from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
-from .. import calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client
+from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
-from ..db import frame_locked, get_db, widget_locked
-from ..image_pipeline import (
- DEFAULT_DISPLAY_MODE,
- DISPLAY_MODES,
- PALETTE_LABELS,
- hex_to_rgb,
- render_preview_png,
-)
+from ..db import frame_locked, get_db
+from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
-from ..models import (
- BatteryLog,
- CalendarWidgetConfig,
- Frame,
- FrameCalendar,
- PhotoWidgetConfig,
- WhiteboardWidgetConfig,
- Widget,
-)
-from .common import (
- OVERDUE_FACTOR,
- battery_estimate_s,
- calendar_sources_for_frame,
- fetch_source_and_faces,
- get_or_refresh_calendar_events_for_widget,
- get_or_refresh_tasks_for_widget,
- get_or_refresh_weather_for_widget,
- get_or_refresh_whiteboard_for_widget,
- immich_client_for,
- immich_creds,
- list_assets,
- photo_widget_config_or_404,
- valid_http_url,
- webdav_creds_for,
- widget_of_type,
-)
+from ..models import BatteryLog, Frame, Widget
+from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
logger = logging.getLogger(__name__)
@@ -69,8 +38,6 @@ router = APIRouter()
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
-MIN_QUEUE_TARGET_LEN = 5
-MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@@ -116,11 +83,7 @@ def api_albums(frame: Frame = Depends(require_frame_view)):
@router.post("/api/frames/{frame_id}/config")
def api_config_save(
name: str | None = Form(None),
- album_id: str | None = Form(None),
- order: str | None = Form(None),
refresh_interval_s: int | None = Form(None),
- display_mode: str | None = Form(None),
- queue_target_len: int | None = Form(None),
orientation: str | None = Form(None),
quiet_hours_enabled: bool | None = Form(None),
quiet_hours_start: str | None = Form(None),
@@ -134,27 +97,18 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
- calendar_view: str | None = Form(None),
- calendar_week_start: int | None = Form(None),
- calendar_week_days: int | None = Form(None),
- calendar_week_layout: str | None = Form(None),
- calendar_week_start_offset: int | None = Form(None),
- calendar_weather_enabled: bool | None = Form(None),
- calendar_weather_units: str | None = Form(None),
- calendar_tasks_enabled: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
- """Partial update, split across up to three sequential lock spans --
- frame-level settings, the frame's photo widget, the frame's calendar
- widget -- rather than one, now that those settings live on separate
- rows (see models.Widget's per-type extension tables). Never nested
- (see db.widget_locked's own docstring on why that would deadlock).
-
- `mode` and `calendar_photo_inlay` are no longer accepted here: mode
- no longer governs anything (a frame's widgets do), and photo inlay
- has no widget-system equivalent (place an independent photo widget
- alongside instead -- see CalendarWidgetConfig's docstring). Both are
+ """Partial update of frame-wide settings only -- per-widget settings
+ (album, calendar view/inclusion, whiteboard source, etc.) live on
+ routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
+ since a frame can hold more than one widget of the same type and
+ "the frame's calendar settings" stopped being unambiguous the moment
+ that became possible. `mode` and `calendar_photo_inlay` are no
+ longer accepted here either: mode no longer governs anything (a
+ frame's widgets do), and photo inlay has no widget-system equivalent
+ (place an independent photo widget alongside instead). All three are
harmless no-ops if an old cached page still POSTs them -- FastAPI
silently ignores form fields with no matching parameter.
@@ -162,14 +116,7 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
- visually wrong but literally out of bounds on the new grid.
-
- Until the widget-placement UI (a later phase) lets a frame have more
- than one widget of a type, "the photo widget" / "the calendar
- widget" below unambiguously means the frame's single auto-migrated
- one (see widget_of_type) -- these fields are silent no-ops if the
- frame doesn't have one yet, same posture as any other partial update
- whose target doesn't exist."""
+ visually wrong but literally out of bounds on the new grid."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
@@ -219,72 +166,6 @@ def api_config_save(
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
cfg.stats_config_saves += 1
- photo_widget = widget_of_type(db, frame, "photos")
- photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len))
- if photo_widget and photo_fields_present:
- with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg):
- if album_id is not None and album_id != pcfg.album_id:
- # A newly selected album starts clean -- the old current
- # photo and queue don't mean anything in the new album's
- # context.
- pcfg.current_asset_id = ""
- pcfg.current_asset_set_at = 0.0
- pcfg.queue = []
- pcfg.queue_cursor = 0
- pcfg.history = []
- pcfg.excluded_asset_ids = []
- pcfg.album_id = album_id
- if order is not None:
- pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
- if display_mode is not None:
- pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
- if queue_target_len is not None:
- pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
-
- calendar_widget = widget_of_type(db, frame, "calendar")
- calendar_fields_present = any(v is not None for v in (
- calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout,
- calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled,
- ))
- if calendar_widget and calendar_fields_present:
- with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg):
- if calendar_view is not None:
- new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
- if new_view != ccfg.view:
- # A stale offset means something different in a
- # different view's units (days vs. weeks vs. months).
- ccfg.browse_offset = 0
- ccfg.view = new_view
- if calendar_week_start is not None:
- ccfg.week_start = max(0, min(6, calendar_week_start))
- if calendar_week_days is not None:
- new_days = max(2, min(10, calendar_week_days))
- if new_days != ccfg.week_days:
- # A stale offset counts a different-sized page under
- # the old day count.
- ccfg.browse_offset = 0
- ccfg.week_days = new_days
- if calendar_week_layout is not None:
- ccfg.week_layout = (
- calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
- )
- if calendar_week_start_offset is not None:
- new_offset = max(-30, min(30, calendar_week_start_offset))
- if new_offset != ccfg.week_start_offset:
- ccfg.browse_offset = 0
- ccfg.week_start_offset = new_offset
- if calendar_weather_enabled is not None:
- ccfg.weather_enabled = calendar_weather_enabled
- if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
- if calendar_weather_units != ccfg.weather_units:
- # Cached forecasts are in the old unit -- force a
- # refetch rather than showing stale numbers under a
- # new unit label.
- ccfg.weather_checked_at = 0.0
- ccfg.weather_units = calendar_weather_units
- if calendar_tasks_enabled is not None:
- ccfg.tasks_enabled = calendar_tasks_enabled
-
return {"status": "saved"}
@@ -317,62 +198,35 @@ def api_stats(frame: Frame = Depends(require_frame_view)):
}
-@router.get("/api/frames/{frame_id}/queue")
-def api_queue(
+@router.get("/api/frames/{frame_id}/status")
+def api_status(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
+ """Device liveness + control-lock info -- frame-level facts (battery,
+ last-seen, firmware, who has control), not tied to any particular
+ widget. Powers static/device_status_bar.js, shown on every per-frame
+ page regardless of which widgets that frame has. Used to piggyback on
+ the photo queue endpoint (back when a frame had at most one widget,
+ always photos-shaped); split out once that stopped being true, so the
+ status bar isn't blank on a frame with no photo widget."""
user = require_user_api(request, db)
- photo_widget, pcfg = photo_widget_config_or_404(db, frame)
-
- client = immich_client_for(frame)
- assets = list_assets(client, pcfg.album_id)
-
- with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
- photo_queue.get_current(locked_pcfg, assets, locked_frame,
- in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
- photo_queue.sync_queue_length(locked_pcfg, assets)
- snapshot = {
- "current_asset_id": locked_pcfg.current_asset_id,
- "queue": list(locked_pcfg.queue),
- "last_seen": locked_frame.last_seen,
- "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR,
- "firmware_version": locked_frame.device_firmware_version,
- "firmware_available": locked_frame.firmware_available_version,
- "battery_percent": locked_frame.battery_percent,
- "battery_as_of": locked_frame.battery_as_of,
- "battery_estimate_s": battery_estimate_s(locked_frame, db),
- "controller_id": locked_frame.controlled_by_user_id,
- "controller": (
- (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
- if locked_frame.controlled_by
- else None
- ),
- }
-
- def entry(asset_id: str) -> dict:
- return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
-
now = time.time()
+ overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
return {
- "current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
- "upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"control": {
- "controller": snapshot["controller"],
- "you": snapshot["controller_id"] == user.id,
+ "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
+ "you": frame.controlled_by_user_id == user.id,
},
"device": {
- "last_seen": snapshot["last_seen"] or None,
- "overdue": bool(
- snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
- ),
- "firmware_version": snapshot["firmware_version"] or None,
- "firmware_available": snapshot["firmware_available"] or None,
+ "last_seen": frame.last_seen or None,
+ "overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
+ "firmware_version": frame.device_firmware_version or None,
+ "firmware_available": frame.firmware_available_version or None,
"battery": (
- {"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
- if snapshot["battery_percent"] >= 0
- else None
+ {"percent": frame.battery_percent, "as_of": frame.battery_as_of}
+ if frame.battery_percent >= 0 else None
),
- "battery_estimate_s": snapshot["battery_estimate_s"],
+ "battery_estimate_s": battery_estimate_s(frame, db),
},
}
@@ -387,461 +241,6 @@ def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = De
return {"log": [[ts, percent] for ts, percent in rows]}
-class QueueReorderRequest(BaseModel):
- queue: list[str]
-
-
-@router.post("/api/frames/{frame_id}/queue/reorder")
-def api_queue_reorder(
- body: QueueReorderRequest,
- frame: Frame = Depends(require_frame_control),
- db: Session = Depends(get_db),
-):
- """Applies the client's requested order, tolerating drift between the
- browser's last-fetched snapshot and the server's current queue (e.g.
- a top-up/trim landed in between) instead of hard-rejecting: any ID
- the client sent that's no longer actually queued is dropped, and any
- ID the server has that the client didn't know about is appended
- rather than lost."""
- photo_widget = widget_of_type(db, frame, "photos")
- if photo_widget is None:
- raise HTTPException(404, "No photo widget on this frame")
- with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
- current_set = set(cfg.queue)
- reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
- reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
- cfg.queue = reordered
- return {"status": "saved"}
-
-
-class QueuePromoteRequest(BaseModel):
- asset_id: str
-
-
-@router.post("/api/frames/{frame_id}/queue/promote")
-def api_queue_promote(
- body: QueuePromoteRequest,
- frame: Frame = Depends(require_frame_control),
- db: Session = Depends(get_db),
-):
- """Moves a single photo to the front of the queue -- "Show next".
- Unlike reorder, doesn't depend on the client knowing the queue's
- exact current order, so it can't fail from staleness."""
- photo_widget = widget_of_type(db, frame, "photos")
- if photo_widget is None:
- raise HTTPException(404, "No photo widget on this frame")
- with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
- if body.asset_id not in cfg.queue:
- raise HTTPException(400, "That photo is no longer in the upcoming queue")
- cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
- return {"status": "saved"}
-
-
-class QueueRemoveRequest(BaseModel):
- asset_id: str
-
-
-@router.post("/api/frames/{frame_id}/queue/remove")
-def api_queue_remove(
- body: QueueRemoveRequest,
- frame: Frame = Depends(require_frame_control),
- db: Session = Depends(get_db),
-):
- """Permanently removes a photo from this frame's rotation. Does NOT
- touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
- photo_widget, pcfg = photo_widget_config_or_404(db, frame)
- client = immich_client_for(frame)
- assets = list_assets(client, pcfg.album_id)
-
- with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
- photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
- return {"status": "removed"}
-
-
-@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
-def api_thumbnail(
- asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
-):
- """Scoped to what this frame is actually showing/queuing -- a user
- merely linked to view this frame shouldn't be able to pull thumbnails
- for arbitrary asset ids in the owner's Immich library, only the
- frame's own curated album. Same rule device.frame_share and
- manage.manage_thumbnail already enforce."""
- _, pcfg = photo_widget_config_or_404(db, frame)
- if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
- raise HTTPException(404, "Not on this frame")
- client = immich_client_for(frame)
- try:
- content, content_type = client.download_asset_thumbnail(asset_id)
- except httpx.HTTPError as e:
- raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
- return Response(content=content, media_type=content_type)
-
-
-def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]:
- """Same idempotent get_current() dance /api/frames/{id}/queue uses --
- picks a current photo if none is set yet, otherwise just reads it,
- never advances early. Returns the photo widget's own config
- alongside the asset id, since callers (api_preview_rendered) also
- need its display_mode."""
- photo_widget, pcfg = photo_widget_config_or_404(db, frame)
- client = immich_client_for(frame)
- assets = list_assets(client, pcfg.album_id)
- with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
- photo_queue.get_current(locked_pcfg, assets, locked_frame,
- in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
- asset_id = locked_pcfg.current_asset_id
- if not asset_id:
- raise HTTPException(404, "No current photo")
- return asset_id, pcfg
-
-
-@router.get("/api/frames/{frame_id}/preview/original")
-def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
- """The Immich preview image behind the currently-displayed photo,
- unprocessed -- the "now displaying" side of the Configuration tab's
- before/after comparison."""
- asset_id, _ = _current_asset_id(frame, db)
- client = immich_client_for(frame)
- try:
- jpeg_bytes = client.download_asset_preview(asset_id)
- except httpx.HTTPError as e:
- raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
- return Response(content=jpeg_bytes, media_type="image/jpeg")
-
-
-@router.get("/api/frames/{frame_id}/preview/rendered")
-def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
- """The same photo run through this frame's actual saved rendering
- pipeline (display mode, palette, color/contrast/dithering) and
- exported as a PNG -- the "how it will look on the frame" side of the
- comparison. Not a live preview of unsaved slider values; reflects
- whatever's currently saved. display_mode comes from the photo
- widget's own config now (palette/color/contrast/dither stay
- frame-level -- one physical panel, one set of those)."""
- asset_id, pcfg = _current_asset_id(frame, db)
- client = immich_client_for(frame)
- source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id)
- png = render_preview_png(
- source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- display_mode=pcfg.display_mode, color_boost=frame.color_boost,
- contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
- )
- return Response(content=png, media_type="image/png")
-
-
-class CalendarSelectRequest(BaseModel):
- user_id: int
- calendar_key: str
- calendar_label: str = ""
- included: bool
-
-
-@router.post("/api/frames/{frame_id}/calendar-select")
-def api_calendar_select(
- body: CalendarSelectRequest,
- request: Request,
- frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
- db: Session = Depends(get_db),
-):
- """Include/exclude one calendar (calendar_key "ics" or
- "caldav:", see FrameCalendar) on this frame. Deliberately not
- require_frame_control: adding your own calendar, or muting anyone's
- (including your own), is each viewer's own call, not something a
- frame's controller manages on someone else's behalf. The one-sided
- permission split lives here: turning a calendar ON requires being its
- owner (nobody can add someone else's calendar to a shared frame for
- them); turning one OFF only requires being linked to the frame at
- all, so anyone sharing the display can mute a calendar they'd rather
- not see there even if they don't own it."""
- user = require_user_api(request, db)
- if body.included and body.user_id != user.id:
- raise HTTPException(403, "Only a calendar's owner can add it to a frame")
- row = db.execute(
- select(FrameCalendar).where(
- FrameCalendar.frame_id == frame.id,
- FrameCalendar.user_id == body.user_id,
- FrameCalendar.calendar_key == body.calendar_key,
- )
- ).scalar_one_or_none()
- if row is None:
- if not body.included:
- raise HTTPException(404, "Not currently included on this frame")
- row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
- db.add(row)
- row.included = body.included
- if body.calendar_label:
- row.calendar_label = body.calendar_label
- # Force the frame's calendar widget's merged cache to pick up the
- # change promptly rather than waiting out the throttle.
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is not None:
- db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
- db.commit()
- return {"status": "saved", "included": row.included}
-
-
-CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
-
-
-class CalendarColorRequest(BaseModel):
- calendar_key: str
- color_index: int | None # None clears the pin, reverting to auto-cycle
-
-
-@router.post("/api/frames/{frame_id}/calendar-color")
-def api_calendar_color(
- body: CalendarColorRequest,
- request: Request,
- frame: Frame = Depends(require_frame_view),
- db: Session = Depends(get_db),
-):
- """Pins a specific panel color to one of your own included calendars
- (models.FrameCalendar.color_index) -- always owner-only, unlike
- calendar-select's included=False, since recoloring someone else's
- calendar isn't the same kind of "I'd rather not see this" veto as
- muting it. None clears the pin, reverting calendar_render.py to its
- old auto-cycle-by-owner-name behavior for this calendar."""
- user = require_user_api(request, db)
- if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
- raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
- row = db.execute(
- select(FrameCalendar).where(
- FrameCalendar.frame_id == frame.id,
- FrameCalendar.user_id == user.id,
- FrameCalendar.calendar_key == body.calendar_key,
- )
- ).scalar_one_or_none()
- if row is None:
- raise HTTPException(404, "Not included on this frame")
- row.color_index = body.color_index
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is not None:
- db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
- db.commit()
- return {"status": "saved", "color_index": row.color_index}
-
-
-@router.get("/api/frames/{frame_id}/preview/calendar")
-def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
- """The same merged, cached event set a live device render would use
- -- not a live preview of an unsaved calendar_view choice, same
- "reflects what's currently saved" convention as preview/rendered.
- No photo_inlay parameter anymore -- that's not a widget-system
- concept (see CalendarWidgetConfig's docstring); place an independent
- photo widget alongside instead."""
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is None:
- raise HTTPException(400, "No calendar widget on this frame yet")
- if not calendar_sources_for_frame(db, frame):
- raise HTTPException(400, "No calendars included on this frame yet")
- ccfg = db.get(CalendarWidgetConfig, calendar_widget.id)
- events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget)
- weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None
- tasks = (
- get_or_refresh_tasks_for_widget(db, frame, calendar_widget)
- if (ccfg.view == "week" and ccfg.tasks_enabled) else None
- )
- png = calendar_render.render_calendar_preview_png(
- events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
- palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
- week_start=ccfg.week_start,
- weather_cities=weather_cities, weather_units=ccfg.weather_units,
- week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
- week_start_offset=ccfg.week_start_offset,
- )
- return Response(content=png, media_type="image/png")
-
-
-class TasksSourceRequest(BaseModel):
- calendar_key: str | None # None clears the source
-
-
-@router.post("/api/frames/{frame_id}/tasks-source")
-def api_tasks_source(
- body: TasksSourceRequest,
- request: Request,
- frame: Frame = Depends(require_frame_view),
- db: Session = Depends(get_db),
-):
- """Points this frame's week-view task list at one of the calling
- user's own CalDAV calendars -- same owner-controls-their-own-data
- permission split as calendar-select's included=True, since this is
- volunteering personal calendar data, not a frame-wide display
- setting a controller should get to pick on someone else's behalf.
- None clears the source; clearing (unlike setting) isn't
- ownership-gated -- like muting a shared calendar, anyone linked to
- the frame can turn off a task list they'd rather not see, but only
- its owner can point the frame at one of their calendars to begin
- with."""
- user = require_user_api(request, db)
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is None:
- raise HTTPException(404, "No calendar widget on this frame yet")
- with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
- if body.calendar_key is None:
- cfg.tasks_user_id = None
- cfg.tasks_calendar_key = None
- cfg.tasks_cached = None
- else:
- cfg.tasks_user_id = user.id
- cfg.tasks_calendar_key = body.calendar_key
- cfg.tasks_checked_at = 0.0 # pick up the change promptly
- return {"status": "saved", "calendar_key": body.calendar_key}
-
-
-class WhiteboardSourceRequest(BaseModel):
- url: str | None # None clears the source
-
-
-@router.post("/api/frames/{frame_id}/whiteboard-source")
-def api_whiteboard_source(
- body: WhiteboardSourceRequest,
- request: Request,
- frame: Frame = Depends(require_frame_view),
- db: Session = Depends(get_db),
-):
- """Points this frame's whiteboard at one of the calling user's own
- WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds)
- credentials -- same owner-controls-their-own-data permission split
- as api_tasks_source: only the account owner can set the frame to use
- it, but anyone linked to the frame can clear it, same as muting a
- shared calendar."""
- user = require_user_api(request, db)
- whiteboard_widget = widget_of_type(db, frame, "whiteboard")
- if whiteboard_widget is None:
- raise HTTPException(404, "No whiteboard widget on this frame yet")
- with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg):
- if body.url is None:
- cfg.user_id = None
- cfg.url = ""
- cfg.cached_image = None
- else:
- stripped = body.url.strip()
- if not valid_http_url(stripped):
- raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
- cfg.user_id = user.id
- cfg.url = stripped
- cfg.checked_at = 0.0 # pick up the change promptly
- return {"status": "saved", "url": body.url}
-
-
-@router.get("/api/frames/{frame_id}/whiteboard-browse")
-def api_whiteboard_browse(
- request: Request,
- url: str | None = None,
- frame: Frame = Depends(require_frame_view),
- db: Session = Depends(get_db),
-):
- """One level of a WebDAV directory listing, using the calling user's
- own credentials (never the frame's saved whiteboard_user_id -- this
- is "help me find a file in MY account", same person as whoever would
- go on to Save it, before that's even happened) -- powers the file
- picker on the Whiteboard tab as an alternative to pasting a URL.
- `url` omitted/None starts from the user's webdav_base_url (see
- models.py's User docstring); passing back a previous response's
- `entries[].url` (for a folder) descends into it."""
- user = require_user_api(request, db)
- creds = webdav_creds_for(user)
- if creds is None:
- raise HTTPException(400, "Set up WebDAV credentials in Settings first")
- target = url or user.webdav_base_url
- if not target:
- raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
- if not valid_http_url(target):
- raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
- try:
- entries = webdav_client.list_directory(target, creds[0], creds[1])
- except webdav_client.WebDavError as e:
- raise HTTPException(502, f"Could not browse: {e}")
- base = user.webdav_base_url or target
- parent_url = webdav_client.parent_directory_url(base, target)
- return {"current_url": target, "parent_url": parent_url, "entries": entries}
-
-
-@router.get("/api/frames/{frame_id}/preview/whiteboard")
-def api_preview_whiteboard(
- force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
-):
- """The same throttled fetch/render cache a live device request would
- use, run through the same panel composition/quantization pipeline
- (see routers/device.py's _render_whiteboard_mode) -- "how it will
- look on the frame" (dithered, letterboxed), not just the raw
- Excalidraw export, same convention as preview/rendered and
- preview/calendar. force=True (the "Refresh now" button, as opposed
- to just reopening this tab) bypasses the fetch throttle."""
- whiteboard_widget = widget_of_type(db, frame, "whiteboard")
- if whiteboard_widget is None:
- raise HTTPException(400, "No whiteboard widget on this frame yet")
- wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
- png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force)
- if png_bytes is None:
- if not wcfg.url:
- raise HTTPException(400, "No whiteboard configured on this frame yet")
- raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
- import io
-
- from PIL import Image
-
- source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
- png = render_preview_png(
- source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- display_mode="letterbox",
- )
- return Response(content=png, media_type="image/png")
-
-
-class WeatherCityAddRequest(BaseModel):
- name: str
-
-
-@router.post("/api/frames/{frame_id}/weather-cities/add")
-def api_weather_city_add(
- body: WeatherCityAddRequest,
- frame: Frame = Depends(require_frame_control),
- db: Session = Depends(get_db),
-):
- """Geocodes a free-text city name (e.g. "Portland, OR") and adds it
- to this frame's weather strip -- a frame-wide display setting (like
- calendar_view), not personal data, so this is gated the same way as
- api_config_save rather than the calendar-select owner/mute split."""
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is None:
- raise HTTPException(404, "No calendar widget on this frame yet")
- try:
- city = weather.geocode_city(body.name)
- except weather.WeatherFetchError as e:
- raise HTTPException(400, str(e)) from e
- with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
- cities = list(cfg.weather_cities or [])
- if any(c["label"] == city["label"] for c in cities):
- raise HTTPException(400, f"{city['label']} is already on this frame's list")
- cities.append(city)
- cfg.weather_cities = cities
- cfg.weather_checked_at = 0.0 # pick up the new city promptly
- return {"status": "saved", "city": city}
-
-
-class WeatherCityRemoveRequest(BaseModel):
- label: str
-
-
-@router.post("/api/frames/{frame_id}/weather-cities/remove")
-def api_weather_city_remove(
- body: WeatherCityRemoveRequest,
- frame: Frame = Depends(require_frame_control),
- db: Session = Depends(get_db),
-):
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is None:
- raise HTTPException(404, "No calendar widget on this frame yet")
- with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
- cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
- cfg.weather_cities = cities
- cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
- cfg.weather_cached = cached
- return {"status": "saved"}
-
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py
index 0b1733a..1ff548d 100644
--- a/server/app/routers/api_widgets.py
+++ b/server/app/routers/api_widgets.py
@@ -1,33 +1,113 @@
-"""CRUD + grid placement for a frame's widgets (see models.Widget) --
-backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
-Every mutation re-validates bounds/minimum footprint/no-overlap
+"""Everything scoped to one specific widget rather than "the frame":
+placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
+plus every setting/action that used to assume a frame had at most one
+widget of a given type -- photo queue, calendar inclusion/color/tasks,
+whiteboard source, and their preview endpoints. Split out of
+api_frames.py (which keeps frame-wide settings: orientation, quiet
+hours, palette, firmware, stats) once a frame could hold more than one
+widget of the same type, at which point "the frame's calendar settings"
+stopped meaning anything unambiguous.
+
+Placement mutations re-validate bounds/minimum footprint/no-overlap
server-side regardless of what the client already checked -- the
client's own checks are UX, not the source of truth (this project's
usual posture, e.g. api_frames.py's own field clamps)."""
from __future__ import annotations
+import io
import time
-from fastapi import APIRouter, Depends, HTTPException, Request
+import httpx
+from fastapi import APIRouter, Depends, Form, HTTPException, Request
+from fastapi.responses import Response
+from PIL import Image
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
-from .. import grid
+from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
-from ..db import frame_locked, get_db
-from ..models import Frame, WIDGET_CONFIG_MODELS, Widget
+from ..db import frame_locked, get_db, widget_locked
+from ..image_pipeline import DEFAULT_DISPLAY_MODE, DISPLAY_MODES, render_preview_png
+from ..models import (
+ CalendarWidgetConfig,
+ Frame,
+ FrameCalendar,
+ PhotoWidgetConfig,
+ WhiteboardWidgetConfig,
+ WIDGET_CONFIG_MODELS,
+ Widget,
+)
from ..widgets import WIDGET_TYPES
+from .common import (
+ calendar_sources_for_widget,
+ fetch_source_and_faces,
+ get_or_refresh_calendar_events_for_widget,
+ get_or_refresh_tasks_for_widget,
+ get_or_refresh_weather_for_widget,
+ get_or_refresh_whiteboard_for_widget,
+ immich_client_for,
+ immich_creds,
+ list_assets,
+ valid_http_url,
+ webdav_creds_for,
+)
router = APIRouter()
+MIN_QUEUE_TARGET_LEN = 5
+MAX_QUEUE_TARGET_LEN = 5000
+CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
+
def _widget_dict(w: Widget) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
+def require_widget_view(
+ widget_id: int, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
+) -> tuple[Frame, Widget]:
+ """View-only widget dependency -- same 404-not-403 posture as
+ require_frame_view for a widget id that doesn't belong to this
+ frame (or doesn't exist at all)."""
+ widget = db.get(Widget, widget_id)
+ if widget is None or widget.frame_id != frame.id:
+ raise HTTPException(404, "No such widget")
+ return frame, widget
+
+
+def require_widget_control(
+ widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
+) -> tuple[Frame, Widget]:
+ """Same as require_widget_view, but behind the frame's "take control"
+ soft lock -- for endpoints that mutate the widget's own settings."""
+ widget = db.get(Widget, widget_id)
+ if widget is None or widget.frame_id != frame.id:
+ raise HTTPException(404, "No such widget")
+ return frame, widget
+
+
+def _require_widget_type(widget: Widget, expected: str) -> None:
+ if widget.widget_type != expected:
+ raise HTTPException(400, f"This widget is a {widget.widget_type} widget, not {expected}")
+
+
+def _photo_config_or_400(db: Session, frame: Frame, widget: Widget) -> PhotoWidgetConfig:
+ """Same 400 shape routers/common.py's photo_widget_config_or_404 uses
+ for a frame with no configured photo widget at all, here for a widget
+ we already know is a photos widget -- Immich creds are frame/owner-
+ level, album_id is this widget's own."""
+ url, key = immich_creds(frame)
+ if not url or not key:
+ raise HTTPException(400, "Immich URL/API key not configured yet")
+ pcfg = db.get(PhotoWidgetConfig, widget.id)
+ if not pcfg.album_id:
+ raise HTTPException(400, "No album configured yet")
+ return pcfg
+
+
@router.get("/api/frames/{frame_id}/widgets")
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
user = require_user_api(request, db)
@@ -140,3 +220,575 @@ def api_widget_delete(
db.delete(widget)
db.commit()
return {"status": "deleted"}
+
+
+# --- Per-widget-type config save (the gear-icon dialog's Save button) --------
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
+def api_widget_config_save(
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
+ db: Session = Depends(get_db),
+ # photos
+ album_id: str | None = Form(None),
+ order: str | None = Form(None),
+ display_mode: str | None = Form(None),
+ queue_target_len: int | None = Form(None),
+ # calendar
+ calendar_view: str | None = Form(None),
+ calendar_week_start: int | None = Form(None),
+ calendar_week_days: int | None = Form(None),
+ calendar_week_layout: str | None = Form(None),
+ calendar_week_start_offset: int | None = Form(None),
+ calendar_weather_enabled: bool | None = Form(None),
+ calendar_weather_units: str | None = Form(None),
+ calendar_tasks_enabled: bool | None = Form(None),
+):
+ """Every field optional -- same partial-update, form-urlencoded
+ convention as the old frame-level api_config_save, now scoped to one
+ widget instead of "the frame's widget of this type". Fields that
+ don't apply to this widget's own widget_type are simply ignored,
+ same posture as an unrecognized form field always had here."""
+ frame, widget = frame_widget
+ if widget.widget_type == "photos":
+ with widget_locked(db, frame.id, widget.id) as (_, _, pcfg):
+ if album_id is not None and album_id != pcfg.album_id:
+ # A newly selected album starts clean -- the old current
+ # photo and queue don't mean anything in the new album's
+ # context.
+ pcfg.current_asset_id = ""
+ pcfg.current_asset_set_at = 0.0
+ pcfg.queue = []
+ pcfg.queue_cursor = 0
+ pcfg.history = []
+ pcfg.excluded_asset_ids = []
+ pcfg.album_id = album_id
+ if order is not None:
+ pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
+ if display_mode is not None:
+ pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
+ if queue_target_len is not None:
+ pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
+ elif widget.widget_type == "calendar":
+ with widget_locked(db, frame.id, widget.id) as (_, _, ccfg):
+ if calendar_view is not None:
+ new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
+ if new_view != ccfg.view:
+ # A stale offset means something different in a
+ # different view's units (days vs. weeks vs. months).
+ ccfg.browse_offset = 0
+ ccfg.view = new_view
+ if calendar_week_start is not None:
+ ccfg.week_start = max(0, min(6, calendar_week_start))
+ if calendar_week_days is not None:
+ new_days = max(2, min(10, calendar_week_days))
+ if new_days != ccfg.week_days:
+ # A stale offset counts a different-sized page under
+ # the old day count.
+ ccfg.browse_offset = 0
+ ccfg.week_days = new_days
+ if calendar_week_layout is not None:
+ ccfg.week_layout = (
+ calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
+ )
+ if calendar_week_start_offset is not None:
+ new_offset = max(-30, min(30, calendar_week_start_offset))
+ if new_offset != ccfg.week_start_offset:
+ ccfg.browse_offset = 0
+ ccfg.week_start_offset = new_offset
+ if calendar_weather_enabled is not None:
+ ccfg.weather_enabled = calendar_weather_enabled
+ if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
+ if calendar_weather_units != ccfg.weather_units:
+ # Cached forecasts are in the old unit -- force a
+ # refetch rather than showing stale numbers under a
+ # new unit label.
+ ccfg.weather_checked_at = 0.0
+ ccfg.weather_units = calendar_weather_units
+ if calendar_tasks_enabled is not None:
+ ccfg.tasks_enabled = calendar_tasks_enabled
+ with frame_locked(db, frame.id) as cfg:
+ cfg.stats_config_saves += 1
+ return {"status": "saved"}
+
+
+# --- Photos: queue/thumbnail/preview ------------------------------------
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
+def api_widget_queue(
+ request: Request, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ user = require_user_api(request, db)
+ pcfg = _photo_config_or_400(db, frame, widget)
+ client = immich_client_for(frame)
+ assets = list_assets(client, pcfg.album_id)
+
+ with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.get_current(locked_pcfg, assets, locked_frame,
+ in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
+ photo_queue.sync_queue_length(locked_pcfg, assets)
+ current_asset_id = locked_pcfg.current_asset_id
+ queue = list(locked_pcfg.queue)
+ controller_id = locked_frame.controlled_by_user_id
+ controller = (
+ (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
+ if locked_frame.controlled_by else None
+ )
+
+ def entry(asset_id: str) -> dict:
+ return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/widgets/{widget.id}/thumbnail/{asset_id}"}
+
+ return {
+ "current": entry(current_asset_id) if current_asset_id else None,
+ "upcoming": [entry(asset_id) for asset_id in queue],
+ "control": {"controller": controller, "you": controller_id == user.id},
+ }
+
+
+class QueueReorderRequest(BaseModel):
+ queue: list[str]
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/reorder")
+def api_widget_queue_reorder(
+ body: QueueReorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
+ db: Session = Depends(get_db),
+):
+ """Applies the client's requested order, tolerating drift between the
+ browser's last-fetched snapshot and the server's current queue (e.g.
+ a top-up/trim landed in between) instead of hard-rejecting: any ID
+ the client sent that's no longer actually queued is dropped, and any
+ ID the server has that the client didn't know about is appended
+ rather than lost."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
+ current_set = set(cfg.queue)
+ reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
+ reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
+ cfg.queue = reordered
+ return {"status": "saved"}
+
+
+class QueuePromoteRequest(BaseModel):
+ asset_id: str
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/promote")
+def api_widget_queue_promote(
+ body: QueuePromoteRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
+ db: Session = Depends(get_db),
+):
+ """Moves a single photo to the front of the queue -- "Show next"."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
+ if body.asset_id not in cfg.queue:
+ raise HTTPException(400, "That photo is no longer in the upcoming queue")
+ cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
+ return {"status": "saved"}
+
+
+class QueueRemoveRequest(BaseModel):
+ asset_id: str
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/remove")
+def api_widget_queue_remove(
+ body: QueueRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
+ db: Session = Depends(get_db),
+):
+ """Permanently removes a photo from this widget's rotation. Does NOT
+ touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ pcfg = _photo_config_or_400(db, frame, widget)
+ client = immich_client_for(frame)
+ assets = list_assets(client, pcfg.album_id)
+ with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
+ return {"status": "removed"}
+
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
+def api_widget_thumbnail(
+ asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ """Scoped to what this widget is actually showing/queuing -- a user
+ merely linked to view this frame shouldn't be able to pull thumbnails
+ for arbitrary asset ids in the owner's Immich library, only this
+ widget's own curated album. Same rule device.frame_share and
+ manage.manage_thumbnail already enforce."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ pcfg = db.get(PhotoWidgetConfig, widget.id)
+ if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
+ raise HTTPException(404, "Not on this frame")
+ client = immich_client_for(frame)
+ try:
+ content, content_type = client.download_asset_thumbnail(asset_id)
+ except httpx.HTTPError as e:
+ raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
+ return Response(content=content, media_type=content_type)
+
+
+def _current_asset_id(db: Session, frame: Frame, widget: Widget) -> tuple[str, PhotoWidgetConfig]:
+ """Same idempotent get_current() dance the queue endpoint uses --
+ picks a current photo if none is set yet, otherwise just reads it,
+ never advances early."""
+ pcfg = _photo_config_or_400(db, frame, widget)
+ client = immich_client_for(frame)
+ assets = list_assets(client, pcfg.album_id)
+ with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.get_current(locked_pcfg, assets, locked_frame,
+ in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
+ asset_id = locked_pcfg.current_asset_id
+ if not asset_id:
+ raise HTTPException(404, "No current photo")
+ return asset_id, pcfg
+
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/original")
+def api_widget_preview_original(
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
+):
+ """The Immich preview image behind the currently-displayed photo,
+ unprocessed -- the "now displaying" side of the dialog's before/after
+ comparison."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ asset_id, _ = _current_asset_id(db, frame, widget)
+ client = immich_client_for(frame)
+ try:
+ jpeg_bytes = client.download_asset_preview(asset_id)
+ except httpx.HTTPError as e:
+ raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
+ return Response(content=jpeg_bytes, media_type="image/jpeg")
+
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/rendered")
+def api_widget_preview_rendered(
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
+):
+ """The same photo run through this frame's actual saved rendering
+ pipeline (display mode, palette, color/contrast/dithering) and
+ exported as a PNG -- the "how it will look on the frame" side of the
+ comparison. Not a live preview of unsaved slider values; reflects
+ whatever's currently saved. display_mode comes from this widget's own
+ config (palette/color/contrast/dither stay frame-level -- one
+ physical panel, one set of those)."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "photos")
+ asset_id, pcfg = _current_asset_id(db, frame, widget)
+ client = immich_client_for(frame)
+ source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id)
+ png = render_preview_png(
+ source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
+ display_mode=pcfg.display_mode, color_boost=frame.color_boost,
+ contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
+ )
+ return Response(content=png, media_type="image/png")
+
+
+# --- Calendar: inclusion/color/tasks/weather/preview --------------------
+
+class CalendarSelectRequest(BaseModel):
+ user_id: int
+ calendar_key: str
+ calendar_label: str = ""
+ included: bool
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-select")
+def api_widget_calendar_select(
+ body: CalendarSelectRequest, request: Request,
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
+ db: Session = Depends(get_db),
+):
+ """Include/exclude one calendar (calendar_key "ics" or
+ "caldav:", see FrameCalendar) on this calendar widget.
+ Deliberately not require_widget_control: adding your own calendar, or
+ muting anyone's (including your own), is each viewer's own call, not
+ something a frame's controller manages on someone else's behalf. The
+ one-sided permission split lives here: turning a calendar ON requires
+ being its owner (nobody can add someone else's calendar to a shared
+ frame for them); turning one OFF only requires being linked to the
+ frame at all, so anyone sharing the display can mute a calendar
+ they'd rather not see there even if they don't own it."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "calendar")
+ user = require_user_api(request, db)
+ if body.included and body.user_id != user.id:
+ raise HTTPException(403, "Only a calendar's owner can add it to a frame")
+ row = db.execute(
+ select(FrameCalendar).where(
+ FrameCalendar.widget_id == widget.id,
+ FrameCalendar.user_id == body.user_id,
+ FrameCalendar.calendar_key == body.calendar_key,
+ )
+ ).scalar_one_or_none()
+ if row is None:
+ if not body.included:
+ raise HTTPException(404, "Not currently included on this widget")
+ row = FrameCalendar(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
+ db.add(row)
+ row.included = body.included
+ if body.calendar_label:
+ row.calendar_label = body.calendar_label
+ # Force this widget's merged cache to pick up the change promptly
+ # rather than waiting out the throttle.
+ db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
+ db.commit()
+ return {"status": "saved", "included": row.included}
+
+
+class CalendarColorRequest(BaseModel):
+ calendar_key: str
+ color_index: int | None # None clears the pin, reverting to auto-cycle
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-color")
+def api_widget_calendar_color(
+ body: CalendarColorRequest, request: Request,
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ """Pins a specific panel color to one of your own included calendars
+ (models.FrameCalendar.color_index) -- always owner-only, unlike
+ calendar-select's included=False, since recoloring someone else's
+ calendar isn't the same kind of "I'd rather not see this" veto as
+ muting it. None clears the pin, reverting calendar_render.py to its
+ old auto-cycle-by-owner-name behavior for this calendar."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "calendar")
+ user = require_user_api(request, db)
+ if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
+ raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
+ row = db.execute(
+ select(FrameCalendar).where(
+ FrameCalendar.widget_id == widget.id,
+ FrameCalendar.user_id == user.id,
+ FrameCalendar.calendar_key == body.calendar_key,
+ )
+ ).scalar_one_or_none()
+ if row is None:
+ raise HTTPException(404, "Not included on this widget")
+ row.color_index = body.color_index
+ db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
+ db.commit()
+ return {"status": "saved", "color_index": row.color_index}
+
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/calendar")
+def api_widget_preview_calendar(
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
+):
+ """The same merged, cached event set a live device render would use --
+ not a live preview of an unsaved calendar_view choice, same "reflects
+ what's currently saved" convention as preview/rendered."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "calendar")
+ if not calendar_sources_for_widget(db, widget):
+ raise HTTPException(400, "No calendars included on this widget yet")
+ ccfg = db.get(CalendarWidgetConfig, widget.id)
+ events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
+ weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
+ tasks = (
+ get_or_refresh_tasks_for_widget(db, frame, widget)
+ if (ccfg.view == "week" and ccfg.tasks_enabled) else None
+ )
+ png = calendar_render.render_calendar_preview_png(
+ events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
+ week_start=ccfg.week_start,
+ weather_cities=weather_cities, weather_units=ccfg.weather_units,
+ week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
+ week_start_offset=ccfg.week_start_offset,
+ )
+ return Response(content=png, media_type="image/png")
+
+
+class TasksSourceRequest(BaseModel):
+ calendar_key: str | None # None clears the source
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source")
+def api_widget_tasks_source(
+ body: TasksSourceRequest, request: Request,
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ """Points this widget's week-view task list at one of the calling
+ user's own CalDAV calendars -- same owner-controls-their-own-data
+ permission split as calendar-select's included=True, since this is
+ volunteering personal calendar data, not a display setting a
+ controller should get to pick on someone else's behalf. None clears
+ the source; clearing (unlike setting) isn't ownership-gated -- like
+ muting a shared calendar, anyone linked to the frame can turn off a
+ task list they'd rather not see, but only its owner can point the
+ widget at one of their calendars to begin with."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "calendar")
+ user = require_user_api(request, db)
+ with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
+ if body.calendar_key is None:
+ cfg.tasks_user_id = None
+ cfg.tasks_calendar_key = None
+ cfg.tasks_cached = None
+ else:
+ cfg.tasks_user_id = user.id
+ cfg.tasks_calendar_key = body.calendar_key
+ cfg.tasks_checked_at = 0.0 # pick up the change promptly
+ return {"status": "saved", "calendar_key": body.calendar_key}
+
+
+class WeatherCityAddRequest(BaseModel):
+ name: str
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/add")
+def api_widget_weather_city_add(
+ body: WeatherCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
+ db: Session = Depends(get_db),
+):
+ """Geocodes a free-text city name (e.g. "Portland, OR") and adds it to
+ this widget's weather strip -- a widget-wide display setting (like
+ calendar_view), not personal data, so this is gated the same way as
+ the config-save endpoint rather than the calendar-select owner/mute
+ split."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "calendar")
+ try:
+ city = weather.geocode_city(body.name)
+ except weather.WeatherFetchError as e:
+ raise HTTPException(400, str(e)) from e
+ with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
+ cities = list(cfg.weather_cities or [])
+ if any(c["label"] == city["label"] for c in cities):
+ raise HTTPException(400, f"{city['label']} is already on this widget's list")
+ cities.append(city)
+ cfg.weather_cities = cities
+ cfg.weather_checked_at = 0.0 # pick up the new city promptly
+ return {"status": "saved", "city": city}
+
+
+class WeatherCityRemoveRequest(BaseModel):
+ label: str
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/remove")
+def api_widget_weather_city_remove(
+ body: WeatherCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
+ db: Session = Depends(get_db),
+):
+ frame, widget = frame_widget
+ _require_widget_type(widget, "calendar")
+ with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
+ cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
+ cfg.weather_cities = cities
+ cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
+ cfg.weather_cached = cached
+ return {"status": "saved"}
+
+
+# --- Whiteboard: source/preview ------------------------------------------
+
+class WhiteboardSourceRequest(BaseModel):
+ url: str | None # None clears the source
+
+
+@router.post("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-source")
+def api_widget_whiteboard_source(
+ body: WhiteboardSourceRequest, request: Request,
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ """Points this widget at one of the calling user's own WebDAV (or
+ reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
+ same owner-controls-their-own-data permission split as
+ api_widget_tasks_source: only the account owner can set the widget to
+ use it, but anyone linked to the frame can clear it, same as muting a
+ shared calendar."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "whiteboard")
+ user = require_user_api(request, db)
+ with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
+ if body.url is None:
+ cfg.user_id = None
+ cfg.url = ""
+ cfg.cached_image = None
+ else:
+ stripped = body.url.strip()
+ if not valid_http_url(stripped):
+ raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
+ cfg.user_id = user.id
+ cfg.url = stripped
+ cfg.checked_at = 0.0 # pick up the change promptly
+ return {"status": "saved", "url": body.url}
+
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-browse")
+def api_widget_whiteboard_browse(
+ request: Request, url: str | None = None,
+ frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ """One level of a WebDAV directory listing, using the calling user's
+ own credentials (never this widget's saved user_id -- this is "help
+ me find a file in MY account", same person as whoever would go on to
+ Save it, before that's even happened) -- powers the file picker in
+ the whiteboard dialog as an alternative to pasting a URL. Nested
+ under this widget's own path purely so the dialog's JS can keep using
+ one shared window.FRAME_API base for every call it makes -- the
+ lookup itself doesn't touch this (or any) widget's own state. `url`
+ omitted/None starts from the user's webdav_base_url (see models.py's
+ User docstring); passing back a previous response's `entries[].url`
+ (for a folder) descends into it."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "whiteboard")
+ user = require_user_api(request, db)
+ creds = webdav_creds_for(user)
+ if creds is None:
+ raise HTTPException(400, "Set up WebDAV credentials in Settings first")
+ target = url or user.webdav_base_url
+ if not target:
+ raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
+ if not valid_http_url(target):
+ raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
+ try:
+ entries = webdav_client.list_directory(target, creds[0], creds[1])
+ except webdav_client.WebDavError as e:
+ raise HTTPException(502, f"Could not browse: {e}")
+ base = user.webdav_base_url or target
+ parent_url = webdav_client.parent_directory_url(base, target)
+ return {"current_url": target, "parent_url": parent_url, "entries": entries}
+
+
+@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/whiteboard")
+def api_widget_preview_whiteboard(
+ force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
+ db: Session = Depends(get_db),
+):
+ """The same throttled fetch/render cache a live device request would
+ use, run through the same panel composition/quantization pipeline --
+ "how it will look on the frame" (dithered, letterboxed), not just the
+ raw Excalidraw export, same convention as the other preview
+ endpoints. force=True (the "Refresh now" button, as opposed to just
+ reopening the dialog) bypasses the fetch throttle."""
+ frame, widget = frame_widget
+ _require_widget_type(widget, "whiteboard")
+ wcfg = db.get(WhiteboardWidgetConfig, widget.id)
+ png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget, force=force)
+ if png_bytes is None:
+ if not wcfg.url:
+ raise HTTPException(400, "No whiteboard configured on this widget yet")
+ raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
+ source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
+ png = render_preview_png(
+ source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
+ display_mode="letterbox",
+ )
+ return Response(content=png, media_type="image/png")
diff --git a/server/app/routers/common.py b/server/app/routers/common.py
index e06b4e2..ab42d47 100644
--- a/server/app/routers/common.py
+++ b/server/app/routers/common.py
@@ -97,12 +97,11 @@ def fetch_source_and_faces(
client: ImmichClient, display_mode: str, asset_id: str
) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
- and (only if display_mode needs it) its detected faces. Used by both
- render_asset (device-facing) and the web UI's rendered-preview
- endpoint (routers/api_frames.py) so they can't drift apart. Takes
- display_mode directly (a photos widget's own setting, see
- PhotoWidgetConfig) rather than a whole Frame -- this function only
- ever needed that one attribute off it."""
+ and (only if display_mode needs it) its detected faces. Used by the
+ web UI's rendered-preview endpoint (routers/api_widgets.py's
+ api_widget_preview_rendered). Takes display_mode directly (a photos
+ widget's own setting, see PhotoWidgetConfig) rather than a whole
+ Frame -- this function only ever needed that one attribute off it."""
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
@@ -477,16 +476,16 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
return content
-def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
- """Every calendar included on this frame (FrameCalendar.included) --
- the exact set calendar_feed.merge_events needs. A calendar_key of
- "ics" resolves against its owner's calendar_ics_url; "caldav:"
- resolves against the href itself, authenticated with the owner's
- CalDAV account credentials (see caldav_client.py)."""
+def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]:
+ """Every calendar included on this calendar widget (FrameCalendar.
+ included) -- the exact set calendar_feed.merge_events needs. A
+ calendar_key of "ics" resolves against its owner's calendar_ics_url;
+ "caldav:" resolves against the href itself, authenticated with
+ the owner's CalDAV account credentials (see caldav_client.py)."""
rows = db.execute(
select(FrameCalendar, User)
.join(User, User.id == FrameCalendar.user_id)
- .where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
+ .where(FrameCalendar.widget_id == widget.id, FrameCalendar.included == True) # noqa: E712
).all()
sources = []
for fc, u in rows:
@@ -513,19 +512,13 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget:
whole merged result (every included user's events together), not
per-user -- ICS feeds are small and this refetches at most every ~20
minutes regardless of how many are included, so per-user cache
- columns would add bookkeeping for a marginal benefit.
- calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's
- own docstring) until a later phase re-keys it to widget_id, so every
- calendar widget on a frame currently shares the same "included
- calendars" set -- not a real limitation yet since nothing supports
- more than one calendar widget per frame end to end until that phase
- lands."""
+ columns would add bookkeeping for a marginal benefit."""
cfg = db.get(CalendarWidgetConfig, widget.id)
now = time.time()
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_events, cfg.fetch_summary
- sources = calendar_sources_for_frame(db, frame)
+ sources = calendar_sources_for_widget(db, widget)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index 3312c86..9029cb5 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -1,6 +1,12 @@
-"""The per-frame HTML pages: Photos (/frames/{id}), Configuration,
-Calendar, and Stats tabs, all inside the sidebar app shell. Data loading
-happens client-side against /api/frames/{id}/... (routers/api_frames.py);
+"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
+canvas), Configuration, and Stats, all inside the sidebar app shell.
+Each widget's own settings (album, calendar view/inclusion, whiteboard
+source, etc.) no longer have their own tab/page -- they're a dialog
+opened from a gear icon on the widget's box in the Layout canvas (see
+static/frame_layout.js), whose content this module also serves (the
+/widgets/{widget_id}/dialog route) as a small HTML fragment, not a full
+page. Data loading otherwise happens client-side against
+/api/frames/{id}/... (routers/api_frames.py, routers/api_widgets.py);
these routes just authorize and render the scaffold."""
from __future__ import annotations
@@ -20,7 +26,16 @@ from ..image_pipeline import (
PALETTE_LABELS,
palette_to_hex,
)
-from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig
+from ..models import (
+ CalendarWidgetConfig,
+ Frame,
+ FrameCalendar,
+ PhotoWidgetConfig,
+ User,
+ UserFrame,
+ WhiteboardWidgetConfig,
+ Widget,
+)
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context, widget_of_type
@@ -36,37 +51,42 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
ctx = shell_context(request, db, user, active_frame=frame)
- ctx.update({
- "frame": frame, "active_tab": tab,
- "has_calendar_widget": widget_of_type(db, frame, "calendar") is not None,
- "has_whiteboard_widget": widget_of_type(db, frame, "whiteboard") is not None,
- **extra,
- })
+ ctx.update({"frame": frame, "active_tab": tab, **extra})
return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
-def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
- frame = db.get(Frame, frame_id)
- photo_cfg = None
- if frame is not None:
- photo_widget = widget_of_type(db, frame, "photos")
- if photo_widget is not None:
- photo_cfg = db.get(PhotoWidgetConfig, photo_widget.id)
- return _frame_page(
- request, db, frame_id, "frame_photos.html", "photos",
- display_mode_labels=DISPLAY_MODE_LABELS,
- photo_cfg=photo_cfg,
- )
-
-
-@router.get("/frames/{frame_id}/layout", response_class=HTMLResponse)
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
+@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
+def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
+ frame = db.get(Frame, frame_id)
+ photo_widget_id = None
+ if frame is not None:
+ photo_widget = widget_of_type(db, frame, "photos")
+ if photo_widget is not None:
+ photo_widget_id = photo_widget.id
+ return _frame_page(
+ request, db, frame_id, "frame_config.html", "config",
+ timezones=ALL_TIMEZONES,
+ palette_labels=PALETTE_LABELS,
+ default_palette_rgb=DEFAULT_PALETTE_RGB,
+ palette_to_hex=palette_to_hex,
+ photo_widget_id=photo_widget_id,
+ )
+
+
+@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
+def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
+ return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
+
+
+# --- Per-widget config dialog content -------------------------------------
+
def _user_available_calendars(user: User) -> list[dict]:
- """This user's full set of calendars available to add to any frame:
+ """This user's full set of calendars available to add to any widget:
the single ICS subscription (if set) plus every CalDAV calendar last
discovered from Settings' "Discover calendars" button. Doesn't hit
the network -- reads the cached list a user refreshes themselves."""
@@ -78,20 +98,20 @@ def _user_available_calendars(user: User) -> list[dict]:
return calendars
-def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
- """Per-linked-user calendar list for the Calendar tab's "Included
+def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
+ """Per-linked-user calendar list for the calendar dialog's "Included
calendars" section. The viewer's own row lists EVERY calendar they
have available, each with a full add/remove toggle; every other
linked user's row lists ONLY the calendars they've already included
- (mute-only for the viewer -- see api_frames.py's api_calendar_select:
- only a calendar's owner may turn it on, but anyone linked to the
- frame may turn one off)."""
+ (mute-only for the viewer -- see api_widgets.py's
+ api_widget_calendar_select: only a calendar's owner may turn it on,
+ but anyone linked to the frame may turn one off)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameCalendar]] = {}
- for fc in db.execute(select(FrameCalendar).where(FrameCalendar.frame_id == frame_id)).scalars().all():
+ for fc in db.execute(select(FrameCalendar).where(FrameCalendar.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(fc.user_id, []).append(fc)
result = []
@@ -116,12 +136,12 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
return result
-def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None:
- """Whose CalDAV calendar this frame's week-view task list currently
+def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
+ """Whose CalDAV calendar this widget's week-view task list currently
pulls from, and its label -- for showing "using 's Chores
list" to everyone linked, not just whoever set it. None if no
source is configured."""
- if calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
+ if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
return None
user = db.get(User, calendar_cfg.tasks_user_id)
if user is None:
@@ -134,52 +154,11 @@ def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
-@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
-def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
- return _frame_page(
- request, db, frame_id, "frame_config.html", "config",
- timezones=ALL_TIMEZONES,
- palette_labels=PALETTE_LABELS,
- default_palette_rgb=DEFAULT_PALETTE_RGB,
- palette_to_hex=palette_to_hex,
- )
-
-
-WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
- 4: "Friday", 5: "Saturday", 6: "Sunday"}
-
-
-@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
-def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
- viewer = current_user(request, db)
- frame = db.get(Frame, frame_id)
- viewer_task_calendars = []
- calendar_cfg = None
- if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
- viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")]
- if frame is not None:
- calendar_widget = widget_of_type(db, frame, "calendar")
- if calendar_widget is not None:
- calendar_cfg = db.get(CalendarWidgetConfig, calendar_widget.id)
- return _frame_page(
- request, db, frame_id, "frame_calendar.html", "calendar",
- calendar_views=CALENDAR_VIEW_LABELS,
- calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
- week_start_labels=WEEK_START_LABELS,
- calendar_color_labels=PALETTE_LABELS,
- default_palette_rgb=DEFAULT_PALETTE_RGB,
- palette_to_hex=palette_to_hex,
- viewer_task_calendars=viewer_task_calendars,
- calendar_cfg=calendar_cfg,
- tasks_source=_tasks_source_info(db, calendar_cfg),
- )
-
-
-def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig | None) -> dict | None:
- """Whose account this frame's whiteboard currently fetches with, for
- showing "using 's account" to everyone linked, not just
- whoever set it. None if no source is configured."""
- if whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url:
+def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
+ """Whose account this widget currently fetches with, for showing
+ "using 's account" to everyone linked, not just whoever set
+ it. None if no source is configured."""
+ if not whiteboard_cfg.user_id or not whiteboard_cfg.url:
return None
user = db.get(User, whiteboard_cfg.user_id)
if user is None:
@@ -187,27 +166,57 @@ def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
-@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
-def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
- viewer = current_user(request, db)
+WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
+ 4: "Friday", 5: "Saturday", 6: "Sunday"}
+
+
+@router.get("/frames/{frame_id}/widgets/{widget_id}/dialog", response_class=HTMLResponse)
+def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = Depends(get_db)):
+ """The gear-icon dialog's content, dispatched by widget_type -- a
+ small HTML fragment (no app_base shell/tabs), fetched and injected
+ into a
Drag a photo to reorder (on touch, hold briefly first so a
- normal scroll still works), "Show next" to jump it to the front, or
- the × to remove it from rotation entirely.