"""PhotoPrism (direct API) client and pure parsing helpers. Talks to a PhotoPrism server using either an app password (used directly as a Bearer token) or a username + password (exchanged for a session access token). HTTP lives in ``PhotoprismClient``; the parsing/URL helpers are pure functions so they can be unit-tested without a live server or aiohttp. API shape (PhotoPrism ``/api/v1``, Bearer auth): - ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}`` (only needed for the username + password auth method). - ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a filter (``s=``, ``q=subject:``, ``q=favorite:true``, or a custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``, ``Lat``/``Lng``, ``PlaceLabel``, ``Title``, ``Description``, ``Portrait``, ``Width``/``Height``), so there is no per-asset enrichment call. The response headers carry ``X-Preview-Token`` (for thumbnail URLs) and ``X-Count`` (number of items returned, for pagination). - ``GET /api/v1/albums`` / ``GET /api/v1/subjects`` -> albums / people. - Image bytes: ``/api/v1/t///`` - the preview token in the URL is enough (cookie-free access by design), so no auth header is needed to fetch thumbnails. """ from __future__ import annotations import json from datetime import datetime, timezone from typing import Any import async_timeout from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import PHOTOPRISM_SELECTION_COMPOSITE _TIMEOUT = 30 _PAGE_SIZE = 1000 _MAX_ASSETS = 20_000 # PhotoPrism media types that render as a still image (videos are skipped). _IMAGE_TYPES = {"image", "raw", "live", "animated"} def normalize_base_url(url: str) -> str: """Strip trailing slashes and a trailing ``/api``/``/api/v1`` from a URL.""" u = (url or "").strip().rstrip("/") for suffix in ("/api/v1", "/api"): if u.endswith(suffix): u = u[: -len(suffix)] return u.rstrip("/") def build_image_url(base_url: str, file_hash: str, token: str, size: str) -> str: """Build the thumbnail URL for a file hash at the requested size. The preview ``token`` is included in the URL on purpose: PhotoPrism serves thumbnails cookie-free and gates them with this rotatable token, so no auth header is required to fetch the bytes. """ base = normalize_base_url(base_url) return f"{base}/api/v1/t/{file_hash}/{token}/{size}" def _to_epoch_ms(value: Any) -> int | None: """Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``.""" if not isinstance(value, str) or not value: return None try: iso = value.replace("Z", "+00:00") dt = datetime.fromisoformat(iso) except ValueError: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) try: return int(dt.timestamp() * 1000) except (OverflowError, OSError, ValueError): return None def location_label(city: Any, state: Any, country: Any) -> str | None: """Build a short ``"City, Country"`` label from place fields. Prefers ``city`` for the locality, falling back to ``state``. Appends the country when present. Used only when PhotoPrism's own ``PlaceLabel`` is unavailable. """ parts: list[str] = [] locality = None for candidate in (city, state): if isinstance(candidate, str) and candidate.strip() and candidate.strip().lower() != "unknown": locality = candidate.strip() break if locality: parts.append(locality) if isinstance(country, str) and country.strip() and country.strip().lower() not in ("unknown", "zz"): parts.append(country.strip()) return ", ".join(parts) if parts else None def _is_image(item: Any) -> bool: """True for photo items that render as a still image (skip videos).""" if not isinstance(item, dict): return False if not item.get("Hash") or not item.get("UID"): return False t = str(item.get("Type", "image")).lower() return t in _IMAGE_TYPES def parse_photo_meta(item: dict[str, Any]) -> dict[str, Any]: """Extract the metadata we surface from a search photo item.""" out: dict[str, Any] = {} captured = _to_epoch_ms(item.get("TakenAt")) or _to_epoch_ms(item.get("TakenAtLocal")) if captured is not None: out["captured_at"] = captured lat = item.get("Lat") lng = item.get("Lng") if isinstance(lat, (int, float)) and isinstance(lng, (int, float)): # 0/0 means "no fix" in PhotoPrism; treat it as no location. if not (abs(lat) < 1e-6 and abs(lng) < 1e-6): out["latitude"] = float(lat) out["longitude"] = float(lng) # PhotoPrism builds a human-readable ``PlaceLabel`` (e.g. "San Diego, # California, USA"); prefer it, falling back to city/country parts. label = item.get("PlaceLabel") if not (isinstance(label, str) and label.strip() and label.strip().lower() != "unknown"): label = location_label( item.get("PlaceCity"), item.get("PlaceState"), item.get("PlaceCountry") ) if isinstance(label, str) and label.strip() and label.strip().lower() != "unknown": out["location"] = label.strip() # Only surface a real caption. PhotoPrism auto-generates ``Title`` from # place + date for every photo, so it would be noise as a caption. desc = item.get("Description") if isinstance(desc, str) and desc.strip(): out["description"] = desc.strip() return out def build_query_params( selection_type: str, selection_id: str | None ) -> dict[str, str]: """Build the search query params for a single (non-composite) member. ``album`` uses the ``s`` scope; ``person`` and ``favorites`` use ``q`` filters; ``search`` passes the user's raw query through; ``all`` adds nothing (whole library). """ if selection_type == "album" and selection_id: return {"s": selection_id} if selection_type == "person" and selection_id: return {"q": f"subject:{selection_id}"} if selection_type == "favorites": return {"q": "favorite:true"} if selection_type == "search" and selection_id: return {"q": selection_id} return {} def parse_composite_selection(selection_id: str | None) -> dict[str, Any]: """Parse a composite selection id into ``{albums, people, favorites}``.""" albums: list[str] = [] people: list[str] = [] favorites = False if selection_id: try: data = json.loads(selection_id) except (ValueError, TypeError): data = None if isinstance(data, dict): albums = [a for a in data.get("albums", []) if isinstance(a, str) and a] people = [p for p in data.get("people", []) if isinstance(p, str) and p] favorites = bool(data.get("favorites")) return {"albums": albums, "people": people, "favorites": favorites} def build_composite_queries( selection_id: str | None, filter_query: str | None = None ) -> list[dict[str, str]]: """Build one search-param dict per composite union member. PhotoPrism has no OR across filters, so each album, person, the favorites flag and any custom query becomes its own request; the caller unions the results. An empty composite yields a single unfiltered query (the whole library). """ sel = parse_composite_selection(selection_id) queries: list[dict[str, str]] = [] for uid in sel["albums"]: queries.append({"s": uid}) for uid in sel["people"]: queries.append({"q": f"subject:{uid}"}) if sel["favorites"]: queries.append({"q": "favorite:true"}) if isinstance(filter_query, str) and filter_query.strip(): queries.append({"q": filter_query.strip()}) if not queries: queries.append({}) return queries class PhotoprismAuthError(Exception): """Raised when PhotoPrism authentication fails.""" class PhotoprismClient: """Thin async wrapper over the PhotoPrism REST API.""" def __init__( self, hass, base_url: str, *, auth_method: str, token: str | None = None, username: str | None = None, password: str | None = None, ) -> None: self.hass = hass self.base_url = normalize_base_url(base_url) self.auth_method = auth_method self._token = token self._username = username self._password = password # Bearer token used for API calls: the app password directly, or the # session access token obtained from username + password. self._bearer: str | None = token if auth_method == "app_password" else None # Preview token captured from the most recent search response, used to # build thumbnail URLs. self.preview_token: str | None = None @property def _headers(self) -> dict[str, str]: h = {"Accept": "application/json"} if self._bearer: h["Authorization"] = "Bearer " + self._bearer return h async def _login(self) -> None: """Exchange username + password for a session access token.""" session = async_get_clientsession(self.hass) async with async_timeout.timeout(_TIMEOUT): async with session.post( self.base_url + "/api/v1/session", json={"username": self._username, "password": self._password}, headers={"Accept": "application/json"}, ) as resp: if resp.status != 200: raise PhotoprismAuthError(f"session login failed: {resp.status}") data = await resp.json() token = data.get("access_token") if isinstance(data, dict) else None if not token: raise PhotoprismAuthError("session login returned no access_token") self._bearer = token async def async_authenticate(self) -> None: """Ensure a usable Bearer token is available.""" if self.auth_method == "user_password": await self._login() elif not self._bearer: raise PhotoprismAuthError("no app password configured") async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]: """GET returning ``(json, headers)``, re-authenticating once on 401.""" if self._bearer is None: await self.async_authenticate() session = async_get_clientsession(self.hass) for attempt in (1, 2): async with async_timeout.timeout(_TIMEOUT): async with session.get( self.base_url + path, params=params, headers=self._headers ) as resp: if resp.status == 401 and attempt == 1 and self.auth_method == "user_password": # Session token likely expired; re-login and retry once. await self._login() continue resp.raise_for_status() return await resp.json(), dict(resp.headers) raise PhotoprismAuthError("unauthorized after re-authentication") async def async_validate(self) -> None: """Authenticate and confirm the search endpoint responds.""" await self.async_authenticate() await self._get("/api/v1/photos", {"count": "1", "public": "false"}) async def async_list_albums(self) -> list[dict[str, Any]]: data, _ = await self._get( "/api/v1/albums", {"count": "1000", "offset": "0", "type": "album"} ) return data if isinstance(data, list) else [] async def async_list_people(self) -> list[dict[str, Any]]: data, _ = await self._get( "/api/v1/subjects", {"count": "1000", "type": "person"} ) return data if isinstance(data, list) else [] async def async_collect_assets( self, selection_type: str, selection_id: str | None = None, filter_query: str | None = None, ) -> list[dict[str, Any]]: """Collect image photo items for a selection. ``composite`` unions albums + people + favorites (+ optional query); everything else is a single filtered search. The preview token from the responses is captured on ``self.preview_token`` for URL building. """ if selection_type == PHOTOPRISM_SELECTION_COMPOSITE: queries = build_composite_queries(selection_id, filter_query) else: queries = [build_query_params(selection_type, selection_id)] return await self._collect_union(queries) async def _collect_union( self, queries: list[dict[str, str]] ) -> list[dict[str, Any]]: """Union several searches (OR), deduped by photo UID.""" seen: set[str] = set() out: list[dict[str, Any]] = [] for query in queries: if len(out) >= _MAX_ASSETS: break for item in await self._search(query): uid = item.get("UID") if uid and uid not in seen: seen.add(uid) out.append(item) return out async def _search(self, query: dict[str, str]) -> list[dict[str, Any]]: """Page through ``/api/v1/photos`` for one filter, image items only.""" collected: list[dict[str, Any]] = [] offset = 0 while len(collected) < _MAX_ASSETS: params = { "count": str(_PAGE_SIZE), "offset": str(offset), "order": "newest", "primary": "true", "public": "false", "merged": "false", } params.update(query) data, headers = await self._get("/api/v1/photos", params) token = headers.get("X-Preview-Token") if token: self.preview_token = token items = data if isinstance(data, list) else [] for it in items: if _is_image(it): collected.append(it) if len(items) < _PAGE_SIZE: break offset += _PAGE_SIZE return collected