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
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:
+29
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user