"""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 random import httpx from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates from PIL import Image from . import config 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: cfg.cursor = 0 # restart from the top of a newly selected album 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"} @app.get("/frame/image") def frame_image(): cfg = config.load() 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") client = ImmichClient(cfg.immich_url, cfg.immich_api_key) 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") if cfg.order == "shuffle": asset = random.choice(assets) else: index = cfg.cursor % len(assets) asset = assets[index] cfg.cursor = (index + 1) % len(assets) config.save(cfg) 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)) frame_bytes = render_frame(source, faces=faces) return Response(content=frame_bytes, media_type="application/octet-stream")