Fix "Show next" staleness bug; add location/date/share-QR to manage overlay
Build and push server image / build-and-push (push) Successful in 32s

Two changes, bundled since they landed in the same session and touch
overlapping files:

1. Fix: "Show next" sent the browser's full queue snapshot to
   POST /api/queue/reorder, which hard-rejected if the server's queue
   had shifted since the last fetch (e.g. right after a queue-length
   trim). New POST /api/queue/promote moves one photo to the front
   authoritatively, with no dependency on client staleness. /reorder
   itself is now tolerant too -- unrecognized IDs are dropped and
   missing ones appended, instead of rejecting the whole request.

2. Feature: the manage button's overlay now also shows the photo's
   location (top-left, only if Immich reverse-geocoded it from GPS
   EXIF), the date it was taken (bottom-right), and a QR code (bottom-
   left) linking to a 30-minute public Immich share link -- created
   lazily when someone actually scans it, not when the button's
   pressed. New server endpoints GET /frame/photo-info and
   GET /frame/share/{asset_id} (scoped to the frame's current/queued
   photos, not any arbitrary Immich asset). Firmware-side, the overlay
   mechanism generalizes from one spliced region to up to four
   (manage_qr_overlay.c), each its own small buffer, still never
   holding the full frame in RAM.
This commit is contained in:
2026-07-19 01:28:25 -04:00
parent 42d7c09f97
commit a358045cea
8 changed files with 523 additions and 83 deletions
+29 -5
View File
@@ -48,11 +48,32 @@ algorithm itself -- it just streams the response straight to the panel.
button (see `firmware/README.md`).
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
each wake alongside its reachability check
- `GET /frame/photo-info` -- `{"asset_id": ..., "location": ... | null,
"taken_at": ... | null}` for the current photo (same idempotent
current-photo semantics as `/frame/image`). `location` is `city, state`
(or `city, country`, or just `city`) if Immich reverse-geocoded the
photo's GPS EXIF, else `null`; `taken_at` is `MM/DD/YY` from the
photo's EXIF capture date, else `null`. Used by the device's manage
button to build its overlay text
- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only
Immich share link for `asset_id` and redirects (302) to it. Only works
for the photo currently showing or in the upcoming queue on this frame
-- not any arbitrary Immich asset. The link is created on first hit
(i.e. when someone actually scans the manage overlay's share QR), not
when the button's pressed, so the 30-minute window starts when it's
actually used
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each
entry an asset id + thumbnail URL; used by the config UI
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`, must be exactly a permutation of the
current queue
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
changed server-side since the client's last fetch (e.g. a top-up/trim)
-- unrecognized IDs in the body are dropped, and any currently-queued
photo missing from the body is appended rather than lost, instead of
rejecting the whole request
- `POST /api/queue/promote` -- moves one photo to the front of the queue;
body is `{"asset_id": "..."}`. Used by "Show next" in the web UI --
unlike `/reorder`, doesn't depend on the client knowing the queue's
full current order, so it can't fail from staleness
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
the browser never needs the Immich API key directly
- `GET /health` -- liveness check
@@ -72,9 +93,12 @@ algorithm itself -- it just streams the response straight to the panel.
in sequential or shuffle order per the Order setting. Dragging photos
in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album.
- `/frame/image` and `/frame/advance` aren't authenticated yet. That's
fine on a trusted home LAN for now, but worth revisiting once the ESP32
side is wired up to send a shared device token.
- `/frame/image`, `/frame/advance`, `/frame/photo-info`, and
`/frame/share/{asset_id}` aren't authenticated yet. That's fine on a
trusted home LAN for now, but worth revisiting once the ESP32 side is
wired up to send a shared device token. `/frame/share` at least is
scoped to only ever create a link for a photo this frame is actually
showing or has queued, not any Immich asset ID someone might guess.
- The 6-color palette RGB values in `app/image_pipeline.py` are
approximations, not measured values (Waveshare doesn't publish exact
color primaries for this panel) -- tune them once you can compare a
+35
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
@@ -69,3 +71,36 @@ class ImmichClient:
)
resp.raise_for_status()
return resp.content, resp.headers.get("content-type", "image/jpeg")
def get_asset(self, asset_id: str) -> dict:
"""Full asset metadata, including embedded exifInfo (location,
capture date) -- used for the manage-button overlay's location/
date-taken text."""
resp = httpx.get(f"{self.base_url}/api/assets/{asset_id}", headers=self._headers, timeout=10)
resp.raise_for_status()
return resp.json()
def create_share_link(self, asset_id: str, expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link for a single
asset, expiring expires_in_s seconds from now, and returns its
public URL. Used by the manage-button overlay's share QR --
created lazily (only when someone actually scans it), not when
the button's pressed, so the expiry clock starts when it's
actually used."""
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_s)).isoformat()
resp = httpx.post(
f"{self.base_url}/api/shared-links",
headers=self._headers,
json={
"type": "INDIVIDUAL",
"assetIds": [asset_id],
"expiresAt": expires_at,
"allowUpload": False,
"allowDownload": True,
"showMetadata": True,
},
timeout=10,
)
resp.raise_for_status()
key = resp.json()["key"]
return f"{self.base_url}/share/{key}"
+117 -4
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import io
import logging
from datetime import datetime
import httpx
from fastapi import FastAPI, HTTPException, Form, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from PIL import Image
from pydantic import BaseModel
@@ -162,6 +163,92 @@ def frame_advance():
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
LOCATION_MAX_LEN = 14
def _format_location(exif: dict) -> str | None:
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
if state:
location = f"{city}, {state}"
elif country:
location = f"{city}, {country}"
else:
location = city
if len(location) > LOCATION_MAX_LEN:
location = location[: LOCATION_MAX_LEN - 3] + "..."
return location
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@app.get("/frame/photo-info")
def frame_photo_info():
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
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)
if not cfg.current_asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(cfg.current_asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
return {
"asset_id": cfg.current_asset_id,
"location": _format_location(exif),
"taken_at": _format_taken_at(exif),
}
@app.get("/frame/share/{asset_id}")
def frame_share(asset_id: str):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Scoped to the photo currently showing or queued -- not any arbitrary
Immich asset id -- since this is otherwise an unauthenticated
endpoint (see server/README.md)."""
cfg = config.load()
_require_configured(cfg)
if asset_id != cfg.current_asset_id and asset_id not in cfg.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@app.get("/api/queue")
def api_queue():
cfg = config.load()
@@ -191,10 +278,36 @@ class QueueReorderRequest(BaseModel):
@app.post("/api/queue/reorder")
def api_queue_reorder(body: QueueReorderRequest):
"""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."""
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
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
config.save(cfg)
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@app.post("/api/queue/promote")
def api_queue_promote(body: QueuePromoteRequest):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
cfg = config.load()
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]
config.save(cfg)
return {"status": "saved"}
+15 -6
View File
@@ -224,12 +224,21 @@
persistOrder(items);
}
function showNext(index) {
const items = upcomingItems.slice();
const [moved] = items.splice(index, 1);
items.unshift(moved);
renderUpcoming(items);
persistOrder(items);
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch('/api/queue/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function persistOrder(items) {