Shows Frame.battery_percent/battery_as_of, already set by every device wake-on-battery report, plus routers/common.py's existing battery_estimate_s time-remaining estimate -- nothing new to fetch or cache. Compact (icon + percent) or detailed (+ estimate, last report age) display mode. No button actions.
1186 lines
53 KiB
Python
1186 lines
53 KiB
Python
"""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
|
|
inclusion/color, 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
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
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 calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
|
|
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,
|
|
DEFAULT_STATIC_DISPLAY_MODE,
|
|
DISPLAY_MODES,
|
|
hex_to_rgb,
|
|
STATIC_DISPLAY_MODES,
|
|
render_preview_png,
|
|
)
|
|
from ..image_upload import decode_upload
|
|
from ..models import (
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
StaticWidgetConfig,
|
|
TaskWidgetConfig,
|
|
TextWidgetConfig,
|
|
WeatherWidgetConfig,
|
|
WhiteboardWidgetConfig,
|
|
WIDGET_CONFIG_MODELS,
|
|
Widget,
|
|
)
|
|
from ..text_content import has_text, parse_rich_text
|
|
from ..widgets import WIDGET_TYPES
|
|
from ..widgets import battery as battery_widget
|
|
from ..widgets import text as text_widget
|
|
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_weather_widget_data,
|
|
get_or_refresh_whiteboard_for_widget,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
task_sources_for_widget,
|
|
valid_http_url,
|
|
webdav_creds_for,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
MIN_QUEUE_TARGET_LEN = 5
|
|
MAX_QUEUE_TARGET_LEN = 5000
|
|
MIN_TEXT_FONT_SIZE = 10
|
|
MAX_TEXT_FONT_SIZE = 96
|
|
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
|
|
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
|
|
|
|
|
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)
|
|
cols, rows = grid.grid_dims(frame.orientation)
|
|
widgets = db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
|
).all()
|
|
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],
|
|
"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,
|
|
},
|
|
}
|
|
|
|
|
|
def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]:
|
|
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
|
|
return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id]
|
|
|
|
|
|
def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect,
|
|
exclude_widget_id: int | None = None) -> None:
|
|
if not grid.in_bounds(frame.orientation, rect):
|
|
raise HTTPException(400, "Placement is out of bounds for this frame's grid")
|
|
if not grid.meets_minimum(widget_type, rect):
|
|
min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1))
|
|
raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells")
|
|
for other_rect in _other_rects(db, frame.id, exclude_widget_id):
|
|
if grid.overlaps(rect, other_rect):
|
|
raise HTTPException(400, "Overlaps another widget")
|
|
|
|
|
|
class WidgetCreateRequest(BaseModel):
|
|
widget_type: str
|
|
x: int | None = None
|
|
y: int | None = None
|
|
w: int | None = None
|
|
h: int | None = None
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets")
|
|
def api_widget_create(
|
|
body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
if body.widget_type not in WIDGET_TYPES:
|
|
raise HTTPException(400, f"Unknown widget type: {body.widget_type}")
|
|
min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1))
|
|
w, h = body.w or min_w, body.h or min_h
|
|
|
|
if body.x is None or body.y is None:
|
|
rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h)
|
|
if rect is None:
|
|
raise HTTPException(400, "No open space left for a widget this size")
|
|
else:
|
|
rect = (body.x, body.y, w, h)
|
|
_validate_placement(db, frame, body.widget_type, rect)
|
|
|
|
with frame_locked(db, frame.id):
|
|
max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id))
|
|
x, y, w, h = rect
|
|
widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h,
|
|
sort_order=(max_sort or 0) + 1, created_at=time.time())
|
|
db.add(widget)
|
|
db.flush()
|
|
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
|
|
db.commit()
|
|
return _widget_dict(widget)
|
|
|
|
|
|
class WidgetPlacementRequest(BaseModel):
|
|
x: int
|
|
y: int
|
|
w: int
|
|
h: int
|
|
|
|
|
|
@router.patch("/api/frames/{frame_id}/widgets/{widget_id}")
|
|
def api_widget_move(
|
|
widget_id: int, body: WidgetPlacementRequest,
|
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
|
):
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
rect = (body.x, body.y, body.w, body.h)
|
|
_validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id)
|
|
with frame_locked(db, frame.id):
|
|
widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h
|
|
db.commit()
|
|
return _widget_dict(widget)
|
|
|
|
|
|
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
|
def api_widget_delete(
|
|
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
"""Cascades to the widget's own config row and any FrameButtonAction
|
|
bindings that pointed at it (both ondelete="CASCADE" FKs, see
|
|
models.py) -- nothing left pointing at a widget id that no longer
|
|
exists."""
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
with frame_locked(db, frame.id):
|
|
db.delete(widget)
|
|
db.commit()
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.delete("/api/frames/{frame_id}/widgets")
|
|
def api_widgets_clear(frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)):
|
|
"""The Layout tab's "Clear all" button -- same per-widget cascade as
|
|
api_widget_delete, just every widget on this frame in one locked
|
|
transaction instead of one request per widget."""
|
|
with frame_locked(db, frame.id):
|
|
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
|
for widget in widgets:
|
|
db.delete(widget)
|
|
db.commit()
|
|
return {"status": "cleared", "count": len(widgets)}
|
|
|
|
|
|
# --- 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 (display_mode is also reused by the static branch below --
|
|
# each widget's own dialog only ever posts its own fields, so the two
|
|
# Form(None) uses of the same name never collide)
|
|
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),
|
|
# tasks
|
|
tasks_name: str | None = Form(None),
|
|
tasks_show_completed: bool | None = Form(None),
|
|
# text
|
|
text_html: str | None = Form(None),
|
|
text_font_size: int | None = Form(None),
|
|
text_font_family: str | None = Form(None),
|
|
text_align: str | None = Form(None),
|
|
text_background_color: str | None = Form(None),
|
|
# weather
|
|
weather_mode: str | None = Form(None),
|
|
weather_provider: str | None = Form(None),
|
|
weather_units: str | None = Form(None),
|
|
weather_hourly_interval_hours: int | None = Form(None),
|
|
weather_daily_days: int | None = Form(None),
|
|
# battery
|
|
battery_mode: str | 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
|
|
elif widget.widget_type == "tasks":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
|
if tasks_name is not None:
|
|
# Truncated, not rejected -- MAX_TASKS_NAME_LEN is a
|
|
# sane on-panel-header length, not a validation rule the
|
|
# user needs an error for.
|
|
tcfg.name = tasks_name.strip()[:MAX_TASKS_NAME_LEN]
|
|
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
|
tcfg.show_completed = tasks_show_completed
|
|
tcfg.checked_at = 0.0 # pick up the change promptly
|
|
elif widget.widget_type == "static":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
|
if display_mode is not None:
|
|
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
|
elif widget.widget_type == "text":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
|
if text_html is not None:
|
|
# The one save path that touches app/text_content.py --
|
|
# see its module docstring for why parsing (not storing
|
|
# raw HTML) is the actual sanitization boundary here.
|
|
xcfg.content = parse_rich_text(text_html)
|
|
if text_font_size is not None:
|
|
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
|
|
if text_font_family is not None:
|
|
xcfg.font_family = (
|
|
text_font_family if text_font_family in text_widget.FONT_FAMILIES
|
|
else text_widget.DEFAULT_FONT_FAMILY
|
|
)
|
|
if text_align is not None:
|
|
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
|
|
if text_background_color is not None:
|
|
xcfg.background_color = (
|
|
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
|
)
|
|
elif widget.widget_type == "weather":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
|
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
|
if weather_mode != wcfg.mode:
|
|
# A stale cache is a different shape under a
|
|
# different mode (a single-temp dict vs. an hourly
|
|
# list vs. a daily dict vs. a city list) -- clear it
|
|
# outright (not just force a refetch attempt) so a
|
|
# get_or_refresh_weather_widget_data call that happens
|
|
# to fail on the very first fetch under the new mode
|
|
# doesn't fall back to the old mode's incompatible
|
|
# cached shape.
|
|
wcfg.checked_at = 0.0
|
|
wcfg.cached = None
|
|
wcfg.mode = weather_mode
|
|
if weather_provider is not None and weather_provider in weather.PROVIDERS:
|
|
if weather_provider != wcfg.provider:
|
|
wcfg.checked_at = 0.0
|
|
wcfg.provider = weather_provider
|
|
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
|
|
if weather_units != wcfg.units:
|
|
# Cached temps are in the old unit -- force a refetch
|
|
# rather than showing stale numbers under a new unit
|
|
# label (same idiom as calendar_weather_units above).
|
|
wcfg.checked_at = 0.0
|
|
wcfg.units = weather_units
|
|
if weather_hourly_interval_hours is not None:
|
|
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
|
if weather_daily_days is not None:
|
|
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
|
elif widget.widget_type == "battery":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
|
if battery_mode is not None:
|
|
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
|
|
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 manage.manage_thumbnail
|
|
already enforces."""
|
|
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/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:<href>", 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
|
|
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,
|
|
week_start_offset=ccfg.week_start_offset,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Tasks: inclusion/color/preview ---------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
|
|
def api_widget_preview_tasks(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The same cached merged task list a live device render would use,
|
|
same "reflects what's currently saved" convention as the other
|
|
preview endpoints."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "tasks")
|
|
if not task_sources_for_widget(db, widget):
|
|
raise HTTPException(400, "No task lists included on this widget yet")
|
|
tcfg = db.get(TaskWidgetConfig, widget.id)
|
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
|
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks")
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
class TaskListSelectRequest(BaseModel):
|
|
user_id: int
|
|
calendar_key: str
|
|
calendar_label: str = ""
|
|
included: bool
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select")
|
|
def api_widget_task_list_select(
|
|
body: TaskListSelectRequest, request: Request,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Include/exclude one CalDAV task list (calendar_key "caldav:<href>",
|
|
see FrameTaskList) on this tasks widget -- same one-sided permission
|
|
split as api_widget_calendar_select: turning a list ON requires being
|
|
its owner (nobody can add someone else's task list 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 list they'd rather not
|
|
see there even if they don't own it. Deliberately not
|
|
require_widget_control for the same reason."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "tasks")
|
|
user = require_user_api(request, db)
|
|
if body.included and body.user_id != user.id:
|
|
raise HTTPException(403, "Only a task list's owner can add it to a frame")
|
|
row = db.execute(
|
|
select(FrameTaskList).where(
|
|
FrameTaskList.widget_id == widget.id,
|
|
FrameTaskList.user_id == body.user_id,
|
|
FrameTaskList.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 = FrameTaskList(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(TaskWidgetConfig, widget.id).checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "included": row.included}
|
|
|
|
|
|
class TaskListColorRequest(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}/task-list-color")
|
|
def api_widget_task_list_color(
|
|
body: TaskListColorRequest, 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 task
|
|
lists (models.FrameTaskList.color_index) -- always owner-only, same
|
|
as api_widget_calendar_color. None clears the pin, reverting
|
|
calendar_render.py to its auto-cycle-by-owner-name behavior for this
|
|
list."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "tasks")
|
|
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(FrameTaskList).where(
|
|
FrameTaskList.widget_id == widget.id,
|
|
FrameTaskList.user_id == user.id,
|
|
FrameTaskList.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(TaskWidgetConfig, widget.id).checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "color_index": row.color_index}
|
|
|
|
|
|
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"}
|
|
|
|
|
|
# --- Weather widget: location/cities/preview -------------------------------
|
|
#
|
|
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
|
|
# "weather-cities") specifically to avoid colliding with the calendar
|
|
# widget's own /weather-cities/add|remove route *patterns* above -- both
|
|
# are registered against the same {widget_id}-parameterized path shape,
|
|
# so a literal name clash there would silently shadow one of them
|
|
# regardless of each handler's own _require_widget_type check.
|
|
|
|
class WeatherLocationRequest(BaseModel):
|
|
name: str | None # None clears the location; else a free-text city name to geocode
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
|
|
def api_widget_weather_location(
|
|
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Sets (or clears) this weather widget's single configured location
|
|
-- the current/hourly/daily modes' one city. A widget-wide display
|
|
setting like calendar_view/weather_units, not personal data, hence
|
|
require_widget_control rather than the calendar/tasks owner-adds/
|
|
anyone-mutes split (there's only ever one location and no per-person
|
|
ownership of it)."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
if body.name is None:
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cfg.city_label = None
|
|
cfg.city_latitude = None
|
|
cfg.city_longitude = None
|
|
cfg.cached = None
|
|
cfg.checked_at = 0.0
|
|
return {"status": "saved", "city": None}
|
|
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):
|
|
cfg.city_label = city["label"]
|
|
cfg.city_latitude = city["latitude"]
|
|
cfg.city_longitude = city["longitude"]
|
|
cfg.cached = None
|
|
cfg.checked_at = 0.0 # pick up the new location promptly
|
|
return {"status": "saved", "city": city}
|
|
|
|
|
|
class WeatherWidgetCityAddRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
|
|
def api_widget_weather_widget_city_add(
|
|
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""multi_city mode's city list -- same shape/gating as the calendar
|
|
widget's own weather-cities/add above, just scoped to this widget's
|
|
own WeatherWidgetConfig.cities."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
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.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.cities = cities
|
|
cfg.checked_at = 0.0 # pick up the new city promptly
|
|
return {"status": "saved", "city": city}
|
|
|
|
|
|
class WeatherWidgetCityRemoveRequest(BaseModel):
|
|
label: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
|
|
def api_widget_weather_widget_city_remove(
|
|
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
|
|
cfg.cities = cities
|
|
if cfg.cached:
|
|
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
|
|
def api_widget_preview_weather(
|
|
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""The same throttled fetch cache a live device render would use, run
|
|
through the panel composition/quantization pipeline -- "how it will
|
|
look on the frame", same convention as the other preview endpoints.
|
|
force=True (the "Refresh now" button) bypasses the fetch throttle."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
wcfg = db.get(WeatherWidgetConfig, widget.id)
|
|
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
|
|
if data is None:
|
|
if wcfg.mode == "multi_city":
|
|
raise HTTPException(400, "No cities added to this widget yet")
|
|
raise HTTPException(400, "No location set on this widget yet")
|
|
png = weather_render.render_weather_preview_png(
|
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
|
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Static image: upload/preview -----------------------------------------
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
|
async def api_widget_static_upload(
|
|
file: UploadFile = File(...),
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Decodes an uploaded PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF (see
|
|
app/image_upload.py) into plain RGB PNG bytes and stores it as this
|
|
widget's whole content -- a widget-wide setting like a photos
|
|
widget's album, hence require_widget_control (the frame's "take
|
|
control" gate) rather than the calendar/tasks owner-adds/anyone-mutes
|
|
split, since there's only ever one image and no per-person data."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "static")
|
|
data = await file.read()
|
|
if not data:
|
|
raise HTTPException(400, "No file uploaded")
|
|
image = decode_upload(data)
|
|
buf = io.BytesIO()
|
|
image.save(buf, format="PNG")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
|
scfg.image = buf.getvalue()
|
|
scfg.original_filename = (file.filename or "")[:255]
|
|
scfg.uploaded_at = time.time()
|
|
return {"status": "saved", "filename": scfg.original_filename}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/static")
|
|
def api_widget_preview_static(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The uploaded image run through this frame's actual saved
|
|
rendering pipeline (display mode, palette, color/contrast/dithering)
|
|
-- "how it will look on the frame", same convention as the photos/
|
|
whiteboard preview endpoints."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "static")
|
|
scfg = db.get(StaticWidgetConfig, widget.id)
|
|
if not scfg.image:
|
|
raise HTTPException(400, "No image uploaded to this widget yet")
|
|
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
|
|
png = render_preview_png(
|
|
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
display_mode=scfg.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")
|
|
|
|
|
|
# --- Text: preview ----------------------------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
|
|
def api_widget_preview_text(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The saved rich text run through the same word-wrap/shrink-to-fit
|
|
layout and quantize pass a live device render would use -- same
|
|
"reflects what's currently saved" convention as the other preview
|
|
endpoints."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "text")
|
|
xcfg = db.get(TextWidgetConfig, widget.id)
|
|
if not has_text(xcfg.content):
|
|
raise HTTPException(400, "No text authored on this widget yet")
|
|
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Battery: preview --------------------------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery")
|
|
def api_widget_preview_battery(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Unlike every other preview endpoint, there's no "not configured
|
|
yet" 400 case -- the content is frame-level state (battery_percent)
|
|
that either exists or doesn't, and render() already degrades to a
|
|
"No reports yet" placeholder either way, same as a live device
|
|
render would."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "battery")
|
|
png = battery_widget.render_preview_png(
|
|
db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- 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_task_list_select: 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")
|