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.
107 lines
4.2 KiB
Python
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}"
|