Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps.
This commit is contained in:
@@ -17,7 +17,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
@@ -26,7 +25,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, photo_queue, quiet_hours
|
||||
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..image_pipeline import (
|
||||
@@ -37,15 +36,19 @@ from ..image_pipeline import (
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame
|
||||
from ..models import BatteryLog, Frame, UserFrame
|
||||
from .common import (
|
||||
FRAME_MODES,
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
calendar_sources_for_frame,
|
||||
fetch_source_and_faces,
|
||||
get_or_refresh_calendar_events,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
require_configured,
|
||||
valid_http_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,17 +63,6 @@ MAX_QUEUE_TARGET_LEN = 5000
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
|
||||
def _valid_repo_url(url: str) -> bool:
|
||||
"""The frame will periodically fetch from this URL on its own (see
|
||||
gitea_releases.py) and, with auto-update on, install whatever it
|
||||
finds -- unlike a one-off manual firmware upload, that's a standing
|
||||
trust relationship, so it's worth rejecting obviously-wrong input at
|
||||
save time rather than only failing later at fetch time. http(s) only
|
||||
-- no file://, no other schemes."""
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
@@ -104,6 +96,9 @@ 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),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -142,7 +137,7 @@ def api_config_save(
|
||||
cfg.timezone = timezone
|
||||
if firmware_update_repo_url is not None:
|
||||
stripped = firmware_update_repo_url.strip()
|
||||
if stripped and not _valid_repo_url(stripped):
|
||||
if stripped and not valid_http_url(stripped):
|
||||
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
||||
cfg.firmware_update_repo_url = stripped
|
||||
if firmware_auto_update is not None:
|
||||
@@ -167,6 +162,18 @@ 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
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
@@ -399,6 +406,81 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class CalendarIncludedRequest(BaseModel):
|
||||
included: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/calendar-included")
|
||||
def api_calendar_included(
|
||||
body: CalendarIncludedRequest,
|
||||
request: Request,
|
||||
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""A user's own opt-in into this frame's merged calendar (see
|
||||
UserFrame.calendar_included). Deliberately not require_frame_control:
|
||||
this is the toggling user's own data-sharing preference about their
|
||||
own calendar, not a frame setting its controller manages on someone
|
||||
else's behalf -- there's no target user_id in the request body by
|
||||
design, it always toggles the calling session's own row."""
|
||||
user = require_user_api(request, db)
|
||||
row = db.get(UserFrame, (user.id, frame.id))
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not linked to this frame")
|
||||
row.calendar_included = body.included
|
||||
# Force this frame's merged cache to pick up the change promptly
|
||||
# rather than waiting out the throttle.
|
||||
frame.calendar_checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "included": row.calendar_included}
|
||||
|
||||
|
||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||
"""The agenda view's optional photo-inlay source image, or None if
|
||||
inlay is off, not agenda view, 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_view == "agenda" and 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)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, 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."""
|
||||
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"
|
||||
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,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware")
|
||||
def api_firmware_upload(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
Reference in New Issue
Block a user