Add per-photo-widget lock (freezes current photo until unlocked)
Build and push server image / test (push) Successful in 33s
Build and push server image / build-and-push (push) Successful in 2m32s
Build and push server image / deploy (push) Successful in 57s

A "Lock this photo" button in the photos widget's dialog toggles
PhotoWidgetConfig.locked, which suppresses both the timer-elapsed
auto-advance and the advance/back button actions until unlocked. The
Layout tab canvas shows a lock badge on any locked photo widget's box.
This commit is contained in:
2026-07-27 21:07:54 +00:00
parent 4e8c6e534b
commit 9911151d8d
14 changed files with 274 additions and 8 deletions
+7
View File
@@ -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
+21
View File
@@ -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),
]
+1
View File
@@ -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):
+9 -2
View File
@@ -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)
+35 -3
View File
@@ -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),
+9
View File
@@ -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();
+14
View File
@@ -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;
+34
View File
@@ -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 =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
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.
@@ -46,6 +46,10 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<button type="button" class="secondary" id="lock-photo-btn" style="margin-top: 8px;">Lock this photo</button>
<p class="sub" style="margin-top: 4px;">While locked, this photo stays
on screen -- the refresh timer and the next/back buttons won't
change it until you unlock it.</p>
</section>
<section class="card" style="margin-top: 20px;">
+2 -2
View File
@@ -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)
+2
View File
@@ -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) ---
+1 -1
View File
@@ -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)
+100
View File
@@ -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
+35
View File
@@ -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