357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""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=<album_uid>``, ``q=subject:<uid>``, ``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/<sha1_hash>/<preview_token>/<size>`` - 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
|