"""ESPresso Frame server: pulls photos from Immich, pre-processes them for the panel, and serves the ESP32 a ready-to-display frame.""" from __future__ import annotations import io import logging import httpx from fastapi import FastAPI, HTTPException, Form, Request from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates from PIL import Image from pydantic import BaseModel from . import config, photo_queue from .image_pipeline import render_frame from .immich_client import ImmichClient logger = logging.getLogger(__name__) app = FastAPI(title="ESPresso Frame Server") templates = Jinja2Templates(directory="app/templates") MIN_REFRESH_INTERVAL_S = 60 MAX_REFRESH_INTERVAL_S = 86400 @app.get("/health") def health() -> dict: return {"status": "ok"} @app.get("/frame/config") def frame_config(): """Device-facing settings, polled by the frame alongside its reachability check. Always returns 200 with current settings (defaults if nothing's been saved yet) -- no Immich-configured gate, since this doubles as the "is the server up" signal.""" cfg = config.load() return {"refresh_interval_s": cfg.refresh_interval_s} @app.get("/", response_class=HTMLResponse) def index(request: Request): cfg = config.load() return templates.TemplateResponse("index.html", {"request": request, "cfg": cfg}) @app.get("/api/albums") def api_albums(): cfg = config.load() if not cfg.immich_url or not cfg.immich_api_key: raise HTTPException(400, "Immich URL/API key not configured yet") try: albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums() except httpx.HTTPError as e: raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums] @app.post("/api/config") def api_config_save( album_id: str = Form(""), order: str = Form("sequential"), refresh_interval_s: int = Form(3600), smart_crop_faces: bool = Form(True), ): # Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see # docker-compose.yml.example) -- config.load() already applies them, # and this handler doesn't touch cfg.immich_url/immich_api_key at all, # so there's nothing here that could overwrite or clear them. cfg = config.load() if 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.album_id = album_id cfg.order = order if order in ("sequential", "shuffle") else "sequential" cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)) cfg.smart_crop_faces = smart_crop_faces config.save(cfg) return {"status": "saved"} def _require_configured(cfg: config.FrameConfig) -> None: if not cfg.immich_url or not cfg.immich_api_key: raise HTTPException(400, "Immich URL/API key not configured yet") if not cfg.album_id: raise HTTPException(400, "No album configured yet") def _list_assets(client: ImmichClient, cfg: config.FrameConfig) -> list[dict]: try: assets = client.list_album_assets(cfg.album_id) except httpx.HTTPError as e: raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e if not assets: raise HTTPException(404, "Album has no photos") return assets def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes: 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 faces = None if cfg.smart_crop_faces: try: faces = client.get_asset_faces(asset_id) except httpx.HTTPError as e: # A faces lookup hiccup shouldn't block showing a photo at # all -- just fall back to a plain center-crop this cycle. logger.warning("Could not fetch faces for asset %s: %s", asset_id, e) source = Image.open(io.BytesIO(jpeg_bytes)) return render_frame(source, faces=faces) @app.get("/frame/image") def frame_image(): """Returns the current photo. Idempotent: only actually advances to the next photo once refresh_interval_s has elapsed since the current one was set (see app/photo_queue.py) -- safe to call as often as the device wants, including after an unplanned reboot, without skipping ahead in the album.""" cfg = config.load() _require_configured(cfg) client = ImmichClient(cfg.immich_url, cfg.immich_api_key) assets = _list_assets(client, cfg) if photo_queue.get_current(cfg, assets): config.save(cfg) return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") @app.post("/frame/advance") def frame_advance(): """Forces an immediate advance to the next photo, ignoring refresh_interval_s, and resets the interval clock from now. Used by the device's next-photo button.""" cfg = config.load() _require_configured(cfg) client = ImmichClient(cfg.immich_url, cfg.immich_api_key) assets = _list_assets(client, cfg) photo_queue.advance_forced(cfg, assets) config.save(cfg) return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") @app.get("/api/queue") def api_queue(): cfg = config.load() _require_configured(cfg) client = ImmichClient(cfg.immich_url, cfg.immich_api_key) assets = _list_assets(client, cfg) if photo_queue.get_current(cfg, assets): config.save(cfg) def entry(asset_id: str) -> dict: return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"} return { "current": entry(cfg.current_asset_id) if cfg.current_asset_id else None, "upcoming": [entry(asset_id) for asset_id in cfg.queue], } class QueueReorderRequest(BaseModel): queue: list[str] @app.post("/api/queue/reorder") def api_queue_reorder(body: QueueReorderRequest): cfg = config.load() if set(body.queue) != set(cfg.queue) or len(body.queue) != len(cfg.queue): raise HTTPException(400, "Reordered queue must contain exactly the current queue's photos") cfg.queue = body.queue config.save(cfg) return {"status": "saved"} @app.get("/api/photo-thumbnail/{asset_id}") def api_photo_thumbnail(asset_id: str): cfg = config.load() _require_configured(cfg) client = ImmichClient(cfg.immich_url, cfg.immich_api_key) 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)