Build and push server image / build-and-push (push) Failing after 2m33s
GET /api/albums/{id} was assumed to return an "assets" array alongside
the album metadata (that's what the original plan/prior art expected),
but on Immich 3.0.3 AlbumResponseDto only has assetCount -- no assets
field at all. Confirmed against the OpenAPI spec served at
/api/spec.json and by testing directly against a real instance: the
assumption was simply wrong for this API version, not a permissions
issue (the album metadata call succeeds fine with a valid 200).
Assets for an album now come from POST /api/search/metadata with an
albumIds filter, which returns them under assets.items. Verified
end-to-end against the real Immich instance and album -- /frame/image
now returns a proper 200 with exactly 192,000 bytes, spread across all
six panel colors (not a degenerate all-white/black response).
Only fetches the first page of search results; fine for a photo frame
cycling through an album, but would need nextPage handling for anyone
pointing this at a very large album.
98 lines
3.0 KiB
Python
98 lines
3.0 KiB
Python
"""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 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
|
|
|
|
app = FastAPI(title="ESPresso Frame Server")
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@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(
|
|
immich_url: str = Form(""),
|
|
immich_api_key: str = Form(""),
|
|
album_id: str = Form(""),
|
|
order: str = Form("sequential"),
|
|
):
|
|
cfg = config.load()
|
|
cfg.immich_url = immich_url.strip()
|
|
cfg.immich_api_key = immich_api_key.strip()
|
|
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"
|
|
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
|
|
|
|
source = Image.open(io.BytesIO(jpeg_bytes))
|
|
frame_bytes = render_frame(source)
|
|
|
|
return Response(content=frame_bytes, media_type="application/octet-stream")
|