diff --git a/docs/widgets.md b/docs/widgets.md index cad7c96..ba36973 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -54,6 +54,13 @@ a button press does. `PhotoWidgetConfig` mirrors `app/photo_queue.py`'s attribute names exactly, so that module's advance/back/queue logic ports across widget instances unchanged. + `PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id` + against both the timer-elapsed auto-advance + (`photo_queue.get_current`) and the advance/back button actions + (`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a + "Lock this photo" button in the widget's own dialog + (`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's + box on the Layout tab canvas. `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto `CalendarWidgetConfig` (a week-view-only, single-list task list); split into its own widget type (migration 17) so a task list can be placed diff --git a/server/app/migration.py b/server/app/migration.py index 15a6c18..2eebf17 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -705,6 +705,26 @@ def _migration_26(conn) -> None: conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0")) +def _migration_27(conn) -> None: + """Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes + current_asset_id against both the timer-elapsed auto-advance + (photo_queue.get_current) and the advance/back button actions + (app/widgets/photos.py's ACTIONS) until unlocked. Defaults to + unlocked so existing widgets keep rotating exactly as before. + + Guarded per-column, same reasoning as migration 26's own comment: + photo_widget_configs isn't touched by test_migrations.py's simulated + pre-widget-system replays (unlike calendar/task/widgets tables those + tests DROP and recreate in an old shape), so it keeps the fresh- + install create_all() copy -- which already has this column -- when + those tests replay migrations 17+ from schema_version 16. Without + the guard, replaying this migration there re-adds a column that's + already there and SQLite raises "duplicate column name".""" + existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")} + if "locked" not in existing: + conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -732,6 +752,7 @@ MIGRATIONS = [ (24, _migration_24), (25, _migration_25), (26, _migration_26), + (27, _migration_27), ] diff --git a/server/app/models.py b/server/app/models.py index 2db8ef7..439a4d6 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -483,6 +483,7 @@ class PhotoWidgetConfig(Base): queue_cursor: Mapped[int] = mapped_column(Integer, default=0) history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) + locked: Mapped[bool] = mapped_column(Boolean, default=False) class CalendarWidgetConfig(Base): diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py index 44c74ae..fd41cd2 100644 --- a/server/app/photo_queue.py +++ b/server/app/photo_queue.py @@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo /api/queue), so without this an open browser tab polling overnight would silently advance the current photo on raw elapsed time alone, even though the device itself is correctly asleep through the - window (see main.py's _effective_refresh_interval_s).""" + window (see main.py's _effective_refresh_interval_s). + + cfg.locked suppresses the elapsed-time trigger the same way + in_quiet_hours does -- a locked widget still needs an initial pick + if it somehow has none (an unconfigured widget just locked, or a + changed album), but once it has a current photo the whole point of + locking is that it stops moving on its own until explicitly + unlocked.""" valid_ids = {a["id"] for a in assets} needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s - stale = needs_pick or (time_elapsed and not in_quiet_hours) + stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked) if not stale: return False advance_forced(cfg, assets, frame) diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 2efde8a..e8c1648 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -86,10 +86,11 @@ CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks -def _widget_dict(w: Widget) -> dict: +def _widget_dict(w: Widget, locked: bool = False) -> 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, "border_style": w.border_style, - "border_thickness": w.border_thickness, "border_color_index": w.border_color_index} + "border_thickness": w.border_thickness, "border_color_index": w.border_color_index, + "locked": locked} def require_widget_view( @@ -141,12 +142,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view widgets = db.scalars( select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) ).all() + # Layout canvas needs to know which photo widgets are locked (to draw + # the lock badge) -- a per-type config field, not on Widget itself, + # so it's a separate lookup rather than something _widget_dict can + # read straight off the row it's given. + photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"] + locked_by_widget_id = dict(db.execute( + select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked) + .where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids)) + ).all()) if photo_widget_ids else {} return { "orientation": frame.orientation, "grid": {"cols": cols, "rows": rows}, "widget_types": list(WIDGET_TYPES.keys()), "min_footprint": grid.MIN_FOOTPRINT, - "widgets": [_widget_dict(w) for w in widgets], + "widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets], "control": { "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, @@ -486,6 +496,7 @@ def api_widget_queue( photo_queue.sync_queue_length(locked_pcfg, assets) current_asset_id = locked_pcfg.current_asset_id queue = list(locked_pcfg.queue) + locked = locked_pcfg.locked controller_id = locked_frame.controlled_by_user_id controller = ( (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) @@ -498,6 +509,7 @@ def api_widget_queue( return { "current": entry(current_asset_id) if current_asset_id else None, "upcoming": [entry(asset_id) for asset_id in queue], + "locked": locked, "control": {"controller": controller, "you": controller_id == user.id}, } @@ -567,6 +579,26 @@ def api_widget_queue_remove( return {"status": "removed"} +class QueueLockRequest(BaseModel): + locked: bool + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock") +def api_widget_queue_lock( + body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked) + -- while locked, neither the timer-elapsed auto-advance + (photo_queue.get_current) nor the advance/back button actions + (app/widgets/photos.py) change which photo is showing.""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + cfg.locked = body.locked + return {"status": "saved", "locked": body.locked} + + @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), diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index 6f47e7b..8ed4ad1 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -210,6 +210,14 @@ function renderCanvas() { label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type; box.appendChild(label); + if (widget.locked) { + const lockBadge = document.createElement('span'); + lockBadge.className = 'widget-box-lock-badge'; + lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock + lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings'; + box.appendChild(lockBadge); + } + const settingsBtn = document.createElement('button'); settingsBtn.type = 'button'; settingsBtn.className = 'widget-box-settings'; @@ -343,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => { openDialogWidgetType = null; window.FRAME_API = window.FRAME_BASE_API; document.getElementById('widget-dialog-body').innerHTML = ''; + loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge) }); loadWidgets(); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 5d0e187..e1dbff6 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -477,6 +477,20 @@ button.secondary:hover { background: var(--surface-alt); } .widget-box-remove { right: 4px; } .widget-box-settings { right: 28px; } .widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); } +.widget-box-lock-badge { + position: absolute; + bottom: 4px; + left: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--overlay); + color: #fff; + font-size: 11px; + line-height: 20px; + text-align: center; + pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */ +} .widget-box-resize-handle { position: absolute; bottom: 0; diff --git a/server/app/static/widget_dialog_photos.js b/server/app/static/widget_dialog_photos.js index db81a0b..72af251 100644 --- a/server/app/static/widget_dialog_photos.js +++ b/server/app/static/widget_dialog_photos.js @@ -8,6 +8,33 @@ // FRAME_API + a global loadQueue()" contract queue.js has always had. let photosPollTimer = null; +let photoLocked = false; +let photoHasCurrent = false; + +function renderLockButton() { + const btn = document.getElementById('lock-photo-btn'); + if (!btn) return; + btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo'; + btn.classList.toggle('active', photoLocked); + btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock +} + +async function toggleLock() { + const next = !photoLocked; + try { + const resp = await fetch(`${window.FRAME_API}/lock`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locked: next }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + photoLocked = next; + renderLockButton(); + showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.'); + } catch (e) { + showStatus(false, e.message); + } +} async function loadQueue() { if (dragState) { @@ -21,9 +48,14 @@ async function loadQueue() { currentEl.innerHTML = '

Not available yet -- the owner needs to connect Immich (Settings) and pick an album.

'; renderUpcoming([]); + photoHasCurrent = false; + renderLockButton(); return; } const data = await resp.json(); + photoLocked = !!data.locked; + photoHasCurrent = !!data.current; + renderLockButton(); currentEl.innerHTML = ''; if (data.current) { const wrap = document.createElement('div'); @@ -109,6 +141,8 @@ function initPhotosDialog() { } }); + document.getElementById('lock-photo-btn').addEventListener('click', toggleLock); + loadQueue(); // Slow poll: picks up real changes (new photo displayed, queue edited // from elsewhere) without a manual refresh. Skipped mid-drag. diff --git a/server/app/templates/_widget_dialog_photos.html b/server/app/templates/_widget_dialog_photos.html index 8296682..5728eba 100644 --- a/server/app/templates/_widget_dialog_photos.html +++ b/server/app/templates/_widget_dialog_photos.html @@ -46,6 +46,10 @@

Now displaying

Loading...

+ +

While locked, this photo stays + on screen -- the refresh timer and the next/back buttons won't + change it until you unlock it.

diff --git a/server/app/widgets/photos.py b/server/app/widgets/photos.py index ee04f33..de8badc 100644 --- a/server/app/widgets/photos.py +++ b/server/app/widgets/photos.py @@ -55,7 +55,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i def _advance(db: Session, frame: Frame, widget: Widget) -> None: cfg = db.get(PhotoWidgetConfig, widget.id) - if not cfg.album_id: + if not cfg.album_id or cfg.locked: return try: client = immich_client_for(frame) @@ -68,7 +68,7 @@ def _advance(db: Session, frame: Frame, widget: Widget) -> None: def _back(db: Session, frame: Frame, widget: Widget) -> None: cfg = db.get(PhotoWidgetConfig, widget.id) - if not cfg.album_id: + if not cfg.album_id or cfg.locked: return try: client = immich_client_for(frame) diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index efc2de4..bf03639 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -87,6 +87,8 @@ def test_expected_columns_exist_on_current_schema(): assert "mode" in battery_widget_columns widget_columns = {c["name"] for c in inspector.get_columns("widgets")} assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26 + photo_widget_columns = {c["name"] for c in inspector.get_columns("photo_widget_configs")} + assert "locked" in photo_widget_columns # migration 27 # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- diff --git a/server/tests/test_widget_border.py b/server/tests/test_widget_border.py index 025c8b3..9d158de 100644 --- a/server/tests/test_widget_border.py +++ b/server/tests/test_widget_border.py @@ -37,7 +37,7 @@ def test_set_border_persists(client, db_session): assert resp.status_code == 200, resp.text assert resp.json() == { "id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0, - "border_style": "dashed", "border_thickness": 5, "border_color_index": 3, + "border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "locked": False, } widget = db_session.get(Widget, widget_id) assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3) diff --git a/server/tests/test_widget_lock.py b/server/tests/test_widget_lock.py new file mode 100644 index 0000000..04db494 --- /dev/null +++ b/server/tests/test_widget_lock.py @@ -0,0 +1,100 @@ +"""routers/api_widgets.py's POST .../lock endpoint (models.PhotoWidgetConfig. +locked) -- same permission shape as test_widget_border.py's own endpoint, +just for a photos-only field instead of a shared Widget-level one.""" + +from __future__ import annotations + +import time + +from app.models import Frame, PhotoWidgetConfig, StaticWidgetConfig, Widget + +from .conftest import csrf_headers, link_user, login, make_user + + +def _widget_id(db_session, widget_type="photos") -> int: + return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id + + +def test_new_widget_defaults_to_unlocked(db_session): + widget_id = _widget_id(db_session) + assert db_session.get(PhotoWidgetConfig, widget_id).locked is False + + +def test_set_lock_persists(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock", + json={"locked": True}, headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert resp.json() == {"status": "saved", "locked": True} + assert db_session.get(PhotoWidgetConfig, widget_id).locked is True + + resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock", + json={"locked": False}, headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert db_session.get(PhotoWidgetConfig, widget_id).locked is False + + +def test_set_lock_404s_for_unknown_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + resp = client.post("/api/frames/1/widgets/999999/lock", + json={"locked": True}, headers=csrf_headers(client)) + assert resp.status_code == 404 + + +def test_set_lock_rejects_non_photos_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + # Built directly rather than through the placement-validated create + # endpoint -- the default frame's photos widget already covers the + # whole grid, and overlap isn't the thing under test here. + static_widget = Widget(frame_id=1, widget_type="static", x=0, y=0, w=1, h=1, + sort_order=99, created_at=time.time()) + db_session.add(static_widget) + db_session.flush() + db_session.add(StaticWidgetConfig(widget_id=static_widget.id)) + db_session.commit() + + resp = client.post(f"/api/frames/1/widgets/{static_widget.id}/lock", + json={"locked": True}, headers=csrf_headers(client)) + assert resp.status_code == 400 + + +def test_set_lock_unrelated_user_404s(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + make_user(db_session, "mallory") + widget_id = _widget_id(db_session) + + client.cookies.clear() + login(client, "mallory") + resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock", + json={"locked": True}, headers=csrf_headers(client)) + assert resp.status_code == 404 + assert db_session.get(PhotoWidgetConfig, widget_id).locked is False + + +def test_set_lock_linked_but_not_controlling_user_409s(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + bob = make_user(db_session, "bob") + frame = db_session.get(Frame, 1) + link_user(db_session, bob, frame) + widget_id = _widget_id(db_session) + + client.cookies.clear() + login(client, "bob") + resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock", + json={"locked": True}, headers=csrf_headers(client)) + assert resp.status_code == 409 + assert resp.json()["detail"]["error"] == "not_controller" + + +def test_widgets_list_reports_locked_state(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + resp = client.get("/api/frames/1/widgets") + assert resp.status_code == 200, resp.text + assert next(w for w in resp.json()["widgets"] if w["id"] == widget_id)["locked"] is False + + client.post(f"/api/frames/1/widgets/{widget_id}/lock", + json={"locked": True}, headers=csrf_headers(client)) + resp = client.get("/api/frames/1/widgets") + assert next(w for w in resp.json()["widgets"] if w["id"] == widget_id)["locked"] is True diff --git a/server/tests/test_widgets_photos.py b/server/tests/test_widgets_photos.py index a81b2f6..37fe2d9 100644 --- a/server/tests/test_widgets_photos.py +++ b/server/tests/test_widgets_photos.py @@ -109,6 +109,41 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session): assert cfg.current_asset_id == "" +def test_render_does_not_advance_when_locked(db_session, monkeypatch): + frame, widget = _make_widget(db_session) + monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) + monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS) + source = Image.new("RGB", (100, 80), (10, 20, 30)) + monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None)) + + with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg): + cfg.current_asset_id = "asset-1" + cfg.current_asset_set_at = 0.0 # long ago -- refresh_interval_s has definitely elapsed + cfg.locked = True + + widgets.photos.render(db_session, frame, widget, 400, 300) + + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.current_asset_id == "asset-1" + + +def test_advance_and_back_actions_are_no_ops_when_locked(db_session, monkeypatch): + frame, widget = _make_widget(db_session) + monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) + monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS) + + with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg): + cfg.current_asset_id = "asset-1" + cfg.locked = True + + widgets.photos.ACTIONS["advance"](db_session, frame, widget) + widgets.photos.ACTIONS["back"](db_session, frame, widget) + + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.current_asset_id == "asset-1" + assert cfg.history == [] + + def test_advance_uses_the_frame_stats_counter_not_the_widget_config(): """advance_forced's frame/stats split (see app/photo_queue.py) means stats_photos_displayed should land on the Frame row, never on