Widget system Phase 2: full cutover to widget-based rendering
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.
This commit is contained in:
+216
-174
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, gitea_releases, 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 ..db import frame_locked, get_db, widget_locked
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
@@ -36,23 +36,23 @@ from ..image_pipeline import (
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame, FrameCalendar
|
||||
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
|
||||
from .common import (
|
||||
FRAME_MODES,
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
calendar_sources_for_frame,
|
||||
fetch_source_and_faces,
|
||||
get_or_refresh_calendar_events,
|
||||
get_or_refresh_tasks,
|
||||
get_or_refresh_weather,
|
||||
get_or_refresh_whiteboard,
|
||||
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,
|
||||
require_configured,
|
||||
photo_widget_config_or_404,
|
||||
valid_http_url,
|
||||
webdav_creds_for,
|
||||
widget_of_type,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -100,9 +100,7 @@ def api_config_save(
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
mode: str | None = Form(None),
|
||||
calendar_view: str | None = Form(None),
|
||||
calendar_photo_inlay: bool | None = Form(None),
|
||||
calendar_week_start: int | None = Form(None),
|
||||
calendar_week_days: int | None = Form(None),
|
||||
calendar_week_layout: str | None = Form(None),
|
||||
@@ -113,29 +111,32 @@ def api_config_save(
|
||||
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
|
||||
harmless no-ops if an old cached page still POSTs them -- FastAPI
|
||||
silently ignores form fields with no matching parameter.
|
||||
|
||||
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."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if name is not None:
|
||||
cfg.name = name.strip()[:64] or cfg.name
|
||||
if album_id is not None and album_id != cfg.album_id:
|
||||
# A newly selected album starts clean -- the old current photo
|
||||
# and queue don't mean anything in the new album's context.
|
||||
cfg.current_asset_id = ""
|
||||
cfg.current_asset_set_at = 0.0
|
||||
cfg.queue = []
|
||||
cfg.queue_cursor = 0
|
||||
cfg.history = []
|
||||
cfg.excluded_asset_ids = []
|
||||
cfg.album_id = album_id
|
||||
if order is not None:
|
||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||
if refresh_interval_s is not None:
|
||||
cfg.refresh_interval_s = max(
|
||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
||||
)
|
||||
if display_mode is not None:
|
||||
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
||||
if queue_target_len is not None:
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
if orientation is not None:
|
||||
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
if quiet_hours_enabled is not None:
|
||||
@@ -173,46 +174,74 @@ def api_config_save(
|
||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||
if dither_strength is not None:
|
||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||
if mode is not None:
|
||||
cfg.mode = mode if mode in FRAME_MODES else "photos"
|
||||
if calendar_view is not None:
|
||||
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
if new_view != cfg.calendar_view:
|
||||
# A stale offset means something different in a different
|
||||
# view's units (days vs. weeks vs. months) -- same
|
||||
# reasoning as album_id's reset above.
|
||||
cfg.calendar_browse_offset = 0
|
||||
cfg.calendar_view = new_view
|
||||
if calendar_photo_inlay is not None:
|
||||
cfg.calendar_photo_inlay = calendar_photo_inlay
|
||||
if calendar_week_start is not None:
|
||||
cfg.calendar_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 != cfg.calendar_week_days:
|
||||
# A stale offset counts a different-sized page under the
|
||||
# old day count -- same reasoning as calendar_view's own
|
||||
# reset below.
|
||||
cfg.calendar_browse_offset = 0
|
||||
cfg.calendar_week_days = new_days
|
||||
if calendar_week_layout is not None:
|
||||
cfg.calendar_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 != cfg.calendar_week_start_offset:
|
||||
cfg.calendar_browse_offset = 0
|
||||
cfg.calendar_week_start_offset = new_offset
|
||||
if calendar_weather_enabled is not None:
|
||||
cfg.calendar_weather_enabled = calendar_weather_enabled
|
||||
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
|
||||
if calendar_weather_units != cfg.calendar_weather_units:
|
||||
# Cached forecasts are in the old unit -- force a refetch
|
||||
# rather than showing stale numbers under a new unit label.
|
||||
cfg.calendar_weather_checked_at = 0.0
|
||||
cfg.calendar_weather_units = calendar_weather_units
|
||||
if calendar_tasks_enabled is not None:
|
||||
cfg.calendar_tasks_enabled = calendar_tasks_enabled
|
||||
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"}
|
||||
|
||||
|
||||
@@ -250,28 +279,29 @@ def api_queue(
|
||||
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
):
|
||||
user = require_user_api(request, db)
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
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": cfg.current_asset_id,
|
||||
"queue": list(cfg.queue),
|
||||
"last_seen": cfg.last_seen,
|
||||
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
|
||||
"firmware_version": cfg.device_firmware_version,
|
||||
"firmware_available": cfg.firmware_available_version,
|
||||
"battery_percent": cfg.battery_percent,
|
||||
"battery_as_of": cfg.battery_as_of,
|
||||
"battery_estimate_s": battery_estimate_s(cfg, db),
|
||||
"controller_id": cfg.controlled_by_user_id,
|
||||
"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": (
|
||||
(cfg.controlled_by.display_name or cfg.controlled_by.username)
|
||||
if cfg.controlled_by
|
||||
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
||||
if locked_frame.controlled_by
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -330,7 +360,10 @@ def api_queue_reorder(
|
||||
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."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
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)]
|
||||
@@ -351,7 +384,10 @@ def api_queue_promote(
|
||||
"""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."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
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]
|
||||
@@ -370,24 +406,26 @@ def api_queue_remove(
|
||||
):
|
||||
"""Permanently removes a photo from this frame's rotation. Does NOT
|
||||
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg)
|
||||
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)):
|
||||
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."""
|
||||
require_configured(frame)
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
_, 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:
|
||||
@@ -397,19 +435,22 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
def _current_asset_id(frame: Frame, db: Session) -> str:
|
||||
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."""
|
||||
require_configured(frame)
|
||||
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, frame.album_id)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
asset_id = cfg.current_asset_id
|
||||
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
|
||||
return asset_id, pcfg
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/original")
|
||||
@@ -417,7 +458,7 @@ def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session
|
||||
"""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)
|
||||
asset_id, _ = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
@@ -432,13 +473,15 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
|
||||
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."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
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, frame.display_mode, asset_id)
|
||||
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=frame.display_mode, color_boost=frame.color_boost,
|
||||
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")
|
||||
@@ -486,9 +529,11 @@ def api_calendar_select(
|
||||
row.included = body.included
|
||||
if body.calendar_label:
|
||||
row.calendar_label = body.calendar_label
|
||||
# Force this frame's merged cache to pick up the change promptly
|
||||
# rather than waiting out the throttle.
|
||||
frame.calendar_checked_at = 0.0
|
||||
# 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}
|
||||
|
||||
@@ -527,59 +572,40 @@ def api_calendar_color(
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not included on this frame")
|
||||
row.color_index = body.color_index
|
||||
frame.calendar_checked_at = 0.0
|
||||
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}
|
||||
|
||||
|
||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||
"""The photo-inlay's source image (any view now, not just agenda), or
|
||||
None if inlay is off or the frame's photos-mode album isn't
|
||||
configured. Shared shape between the live render (routers/device.py's
|
||||
_render_calendar_mode) and this preview endpoint; small enough that
|
||||
duplicating rather than factoring out is fine, since the two call
|
||||
sites differ slightly in error handling."""
|
||||
if not frame.calendar_photo_inlay:
|
||||
return None
|
||||
url, key = immich_creds(frame)
|
||||
if not (url and key and frame.album_id):
|
||||
return None
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
if not asset_id:
|
||||
return None
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
@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."""
|
||||
"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")
|
||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||
photo_inlay = _calendar_photo_inlay(frame, db)
|
||||
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
weather_cities = get_or_refresh_weather(db, frame)
|
||||
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
|
||||
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=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
||||
week_start=frame.calendar_week_start,
|
||||
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
|
||||
week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks,
|
||||
week_start_offset=frame.calendar_week_start_offset,
|
||||
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=None, 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")
|
||||
|
||||
@@ -606,15 +632,18 @@ def api_tasks_source(
|
||||
its owner can point the frame at one of their calendars to begin
|
||||
with."""
|
||||
user = require_user_api(request, db)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
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.calendar_tasks_user_id = None
|
||||
cfg.calendar_tasks_calendar_key = None
|
||||
cfg.calendar_tasks_cached = None
|
||||
cfg.tasks_user_id = None
|
||||
cfg.tasks_calendar_key = None
|
||||
cfg.tasks_cached = None
|
||||
else:
|
||||
cfg.calendar_tasks_user_id = user.id
|
||||
cfg.calendar_tasks_calendar_key = body.calendar_key
|
||||
cfg.calendar_tasks_checked_at = 0.0 # pick up the change promptly
|
||||
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}
|
||||
|
||||
|
||||
@@ -636,18 +665,21 @@ def api_whiteboard_source(
|
||||
it, but anyone linked to the frame can clear it, same as muting a
|
||||
shared calendar."""
|
||||
user = require_user_api(request, db)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
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.whiteboard_user_id = None
|
||||
cfg.whiteboard_url = ""
|
||||
cfg.whiteboard_cached_image = 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.whiteboard_user_id = user.id
|
||||
cfg.whiteboard_url = stripped
|
||||
cfg.whiteboard_checked_at = 0.0 # pick up the change promptly
|
||||
cfg.user_id = user.id
|
||||
cfg.url = stripped
|
||||
cfg.checked_at = 0.0 # pick up the change promptly
|
||||
return {"status": "saved", "url": body.url}
|
||||
|
||||
|
||||
@@ -695,9 +727,13 @@ def api_preview_whiteboard(
|
||||
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."""
|
||||
png_bytes = get_or_refresh_whiteboard(db, frame, force=force)
|
||||
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 frame.whiteboard_url:
|
||||
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
|
||||
@@ -726,17 +762,20 @@ def api_weather_city_add(
|
||||
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 frame_locked(db, frame.id) as cfg:
|
||||
cities = list(cfg.calendar_weather_cities or [])
|
||||
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.calendar_weather_cities = cities
|
||||
cfg.calendar_weather_checked_at = 0.0 # pick up the new city promptly
|
||||
cfg.weather_cities = cities
|
||||
cfg.weather_checked_at = 0.0 # pick up the new city promptly
|
||||
return {"status": "saved", "city": city}
|
||||
|
||||
|
||||
@@ -750,11 +789,14 @@ def api_weather_city_remove(
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label]
|
||||
cfg.calendar_weather_cities = cities
|
||||
cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label]
|
||||
cfg.calendar_weather_cached = cached
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user