device.py's mode-keyed dispatch is replaced by a real compositor: load a frame's widgets, compute pixel rects via app/grid.py, render each through its widget module, and composite with render_panel. Physical NEXT/BACK buttons now execute each frame's assigned FrameButtonAction rows instead of one hardcoded per-mode action. api_frames.py, manage.py, and common.py's build_manage_content are repointed to read/write the frame's widget config rows instead of the old Frame columns, and every settings page (Photos/Calendar/ Whiteboard tabs) now pre-fills its form from the same widget config the write endpoints actually save to -- previously the read and write sides would have silently diverged. The old mode selector and photo-inlay checkbox are removed along with their now-inert wiring; arbitrary widget placement subsumes what the fixed inlay split did. Ships together with Phase 1 (per-type render/action modules) since splitting the read/write cutover across deploys would have left settings changes with no visible effect.
123 lines
4.9 KiB
Python
123 lines
4.9 KiB
Python
"""The limited no-login manage surface behind the on-frame "scan to
|
|
manage" QR. The QR resolves to /m/<manage_token> (see main.index's
|
|
device-credential redirect); the token grants exactly: view the current
|
|
photo + upcoming queue, promote ("show next"), advance, back, and
|
|
thumbnails. No settings, no removal, no other frames -- full control
|
|
requires logging in."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, Response
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import photo_queue, quiet_hours
|
|
from ..db import get_db, widget_locked
|
|
from ..models import Frame
|
|
from .common import immich_client_for, list_assets, photo_widget_config_or_404
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
|
|
def require_manage(manage_token: str, db: Session = Depends(get_db)) -> Frame:
|
|
frame = db.scalars(select(Frame).where(Frame.manage_token == manage_token)).first()
|
|
if frame is None:
|
|
raise HTTPException(404, "Unknown manage link")
|
|
return frame
|
|
|
|
|
|
@router.get("/m/{manage_token}", response_class=HTMLResponse)
|
|
def manage_page(manage_token: str, request: Request, db: Session = Depends(get_db)):
|
|
frame = require_manage(manage_token, db)
|
|
return templates.TemplateResponse(
|
|
"manage.html",
|
|
{"request": request, "frame": frame, "manage_token": manage_token},
|
|
)
|
|
|
|
|
|
@router.get("/api/m/{manage_token}/queue")
|
|
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_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)
|
|
current = locked_pcfg.current_asset_id
|
|
queue = list(locked_pcfg.queue)
|
|
|
|
def entry(asset_id: str) -> dict:
|
|
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
|
|
|
return {
|
|
"frame_name": frame.name,
|
|
"current": entry(current) if current else None,
|
|
"upcoming": [entry(asset_id) for asset_id in queue],
|
|
}
|
|
|
|
|
|
class ManagePromoteRequest(BaseModel):
|
|
asset_id: str
|
|
|
|
|
|
@router.post("/api/m/{manage_token}/promote")
|
|
def manage_promote(
|
|
body: ManagePromoteRequest,
|
|
frame: Frame = Depends(require_manage),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
photo_widget, _ = photo_widget_config_or_404(db, 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] + [a for a in cfg.queue if a != body.asset_id]
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.post("/api/m/{manage_token}/advance")
|
|
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
|
"""Advances the server-side current photo; the panel itself updates
|
|
on the device's next wake (or its next-photo button)."""
|
|
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.advance_forced(locked_pcfg, assets, locked_frame)
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.post("/api/m/{manage_token}/back")
|
|
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_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.back_forced(locked_pcfg, assets, locked_frame)
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
|
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
|
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
|
the manage token must not become a general Immich proxy."""
|
|
_, 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)
|