Files
espresso_frame/server/app/immich_client.py
T
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00

107 lines
4.2 KiB
Python

"""Thin wrapper around the bits of the Immich API this project needs."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
class ImmichClient:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip("/")
self._headers = {"x-api-key": api_key}
def list_albums(self) -> list[dict]:
resp = httpx.get(f"{self.base_url}/api/albums", headers=self._headers, timeout=10)
resp.raise_for_status()
return resp.json()
def list_album_assets(self, album_id: str) -> list[dict]:
"""GET /api/albums/{id} doesn't embed assets in this Immich version
(AlbumResponseDto only has assetCount) -- assets live behind the
general search API instead, filtered by albumIds.
Only returns the first page. Fine for a photo frame cycling
through an album; worth adding nextPage pagination if someone
points this at an album large enough to need it.
"""
resp = httpx.post(
f"{self.base_url}/api/search/metadata",
headers=self._headers,
json={"albumIds": [album_id]},
timeout=15,
)
resp.raise_for_status()
return resp.json().get("assets", {}).get("items", [])
def get_asset_faces(self, asset_id: str) -> list[dict]:
"""Face bounding boxes Immich already computed for its own People
feature -- reused here to bias cropping toward keeping faces on
screen instead of running our own detection."""
resp = httpx.get(
f"{self.base_url}/api/faces",
params={"id": asset_id},
headers=self._headers,
timeout=10,
)
resp.raise_for_status()
return resp.json()
def download_asset_preview(self, asset_id: str) -> bytes:
resp = httpx.get(
f"{self.base_url}/api/assets/{asset_id}/thumbnail",
params={"size": "preview"},
headers=self._headers,
timeout=30,
)
resp.raise_for_status()
return resp.content
def download_asset_thumbnail(self, asset_id: str) -> tuple[bytes, str]:
"""Smaller than download_asset_preview -- used for the web UI's
upcoming-photos list, not the actual rendered frame. Returns
(content, content_type) since this one gets proxied straight to a
browser <img> tag and needs a correct Content-Type header."""
resp = httpx.get(
f"{self.base_url}/api/assets/{asset_id}/thumbnail",
params={"size": "thumbnail"},
headers=self._headers,
timeout=30,
)
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_ids: list[str], expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link covering one or
more assets, 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": list(asset_ids),
"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}"