Files
HomeAssistantVS/custom_components/album_slideshow/immich.py
T

583 lines
23 KiB
Python

"""Immich (direct API) client and pure parsing helpers.
Talks to an Immich server using an API key. HTTP lives in ``ImmichClient``;
the parsing/URL helpers are pure functions so they can be unit-tested without
a live server or aiohttp.
API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
- ``GET /api/server/about`` -> ``{version, ...}`` (used to validate URL + key)
- ``GET /api/albums`` -> ``[{id, albumName, assetCount}]``
- ``GET /api/people`` -> ``{people: [{id, name}]}``
- ``POST /api/search/metadata`` ``{albumIds|personIds, type, size, page}``
-> ``{assets: {items: [...], total, nextPage}}``. List items carry
``id``/``type``/``localDateTime``/``fileCreatedAt``/``width``/``height``/
``originalFileName`` but NOT ``exifInfo``.
- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city,
country, description) - used to enrich location/description per asset.
- ``GET /api/faces?id={id}`` -> recognised faces and bounding boxes - used to
keep faces whole in cover-mode crops.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
"""
from __future__ import annotations
import json
import math
from datetime import datetime, timezone
from typing import Any
import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_TIMEOUT = 30
_PAGE_SIZE = 1000
_MAX_ASSETS = 20_000
def normalize_base_url(url: str) -> str:
"""Strip trailing slashes and a trailing ``/api`` from a base URL."""
u = (url or "").strip().rstrip("/")
if u.endswith("/api"):
u = u[: -len("/api")]
return u
def build_image_url(base_url: str, asset_id: str, size: str) -> str:
"""Build the image URL for an asset at the requested size.
``preview`` / ``fullsize`` map to the thumbnail endpoint; ``original``
fetches the untouched original file. The API key is NOT included here - it
is sent as a request header so it never leaks into logs or the camera's
``current_url`` attribute.
"""
base = normalize_base_url(base_url)
if size == "original":
return f"{base}/api/assets/{asset_id}/original"
thumb_size = "fullsize" if size == "fullsize" else "preview"
return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_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"`` style label from EXIF place fields.
Prefers ``city`` for the locality, falling back to ``state``. Appends the
country when present. Returns ``None`` when nothing usable is available.
"""
parts: list[str] = []
locality = None
for candidate in (city, state):
if isinstance(candidate, str) and candidate.strip():
locality = candidate.strip()
break
if locality:
parts.append(locality)
if isinstance(country, str) and country.strip():
parts.append(country.strip())
return ", ".join(parts) if parts else None
def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
"""Return ``(image_items, next_page)`` from a search/metadata response.
Filters out non-image assets and anything trashed/archived. ``next_page``
is the page number to request next, or ``None`` when done.
"""
assets = (payload or {}).get("assets") if isinstance(payload, dict) else None
if not isinstance(assets, dict):
return [], None
items = assets.get("items")
out = _filter_image_items(items)
next_page = assets.get("nextPage")
if isinstance(next_page, str) and next_page.isdigit():
next_page = int(next_page)
if not isinstance(next_page, int):
next_page = None
return out, next_page
def parse_random(payload: Any) -> list[dict[str, Any]]:
"""Return image items from a ``/api/search/random`` response.
``search/random`` returns a plain list of assets (no pagination wrapper).
"""
if isinstance(payload, list):
return _filter_image_items(payload)
# Some cores wrap it like search/metadata; handle that too.
if isinstance(payload, dict):
assets = payload.get("assets")
if isinstance(assets, dict):
return _filter_image_items(assets.get("items"))
return []
def _filter_image_items(items: Any) -> list[dict[str, Any]]:
"""Keep only non-trashed, non-archived image assets with an id."""
out: list[dict[str, Any]] = []
if isinstance(items, list):
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("type", "")).upper() != "IMAGE":
continue
if it.get("isTrashed") or it.get("isArchived"):
continue
if not it.get("id"):
continue
out.append(it)
return out
def build_search_body(
selection_type: str, selection_id: str | None, filter_body: dict | None
) -> dict[str, Any]:
"""Build the ``search/metadata`` request body for a selection.
Always constrains to images. For ``search`` the user-supplied filter is
used as a base (with ``type`` forced to IMAGE). ``album``/``person`` add
the id filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
"""
body: dict[str, Any] = {"type": "IMAGE"}
if selection_type == "search" and isinstance(filter_body, dict):
body = dict(filter_body)
body["type"] = "IMAGE"
elif selection_type == "album" and selection_id:
body["albumIds"] = [selection_id]
elif selection_type == "person" and selection_id:
body["personIds"] = [selection_id]
elif selection_type == "favorites":
body["isFavorite"] = True
# ``all`` -> no extra filter (whole library).
return body
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
"""Parse a composite selection id into ``{albums, people, favorites}``.
The id is a JSON object; anything malformed degrades to an empty
composite (which means "all photos").
"""
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_bodies(
selection_id: str | None, filter_body: dict | None = None
) -> list[dict[str, Any]]:
"""Build one ``search/metadata`` body per composite union member.
Immich has no OR, so each album, person, the favorites flag and any
custom filter becomes its own image query; the caller unions the
results. An empty composite yields a single unfiltered query -> the
whole library ("all photos").
"""
sel = parse_composite_selection(selection_id)
bodies: list[dict[str, Any]] = []
for aid in sel["albums"]:
bodies.append({"type": "IMAGE", "albumIds": [aid]})
for pid in sel["people"]:
bodies.append({"type": "IMAGE", "personIds": [pid]})
if sel["favorites"]:
bodies.append({"type": "IMAGE", "isFavorite": True})
if isinstance(filter_body, dict) and filter_body:
member = dict(filter_body)
member["type"] = "IMAGE"
bodies.append(member)
if not bodies:
bodies.append({"type": "IMAGE"})
return bodies
def parse_asset_exif(asset: Any) -> dict[str, Any]:
"""Extract the metadata we surface from a full asset detail response."""
out: dict[str, Any] = {}
if not isinstance(asset, dict):
return out
exif = asset.get("exifInfo")
if not isinstance(exif, dict):
return out
captured = _to_epoch_ms(exif.get("dateTimeOriginal")) or _to_epoch_ms(
asset.get("localDateTime")
)
if captured is not None:
out["captured_at"] = captured
lat = exif.get("latitude")
lon = exif.get("longitude")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
# Immich returns 0/0 or null when there is no fix; treat 0,0 as none.
if not (abs(lat) < 1e-6 and abs(lon) < 1e-6):
out["latitude"] = float(lat)
out["longitude"] = float(lon)
label = location_label(exif.get("city"), exif.get("state"), exif.get("country"))
if label:
out["location"] = label
desc = exif.get("description")
if isinstance(desc, str) and desc.strip():
out["description"] = desc.strip()
return out
def selected_person_ids(
selection_type: str | None, selection_id: str | None
) -> set[str]:
"""Return the people explicitly selected for an Immich source.
Current entries use a composite JSON selection, while older entries can
still carry the legacy ``person``/``people`` shapes. Album-only, favorite,
random and custom-search sources intentionally return an empty set: there
is no user-selected face to prioritise for those sources.
"""
if selection_type == "person":
return {selection_id} if selection_id else set()
if selection_type == "people":
return {person_id for person_id in (selection_id or "").split(",") if person_id}
if selection_type == "composite":
return set(parse_composite_selection(selection_id)["people"])
return set()
def _normalised_face_box(
face: Any,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> tuple[float, float, float, float] | None:
"""Return a face's bounding box normalised to 0..1, or None if invalid."""
if not isinstance(face, dict):
return None
width = face.get("imageWidth")
height = face.get("imageHeight")
coords = (
face.get("boundingBoxX1"),
face.get("boundingBoxY1"),
face.get("boundingBoxX2"),
face.get("boundingBoxY2"),
)
if (
any(isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) for value in (width, height, *coords))
or width <= 0
or height <= 0
):
return None
x1, y1, x2, y2 = coords
if x2 <= x1 or y2 <= y1:
return None
box = _original_face_box(
(x1 / width, y1 / height, x2 / width, y2 / height),
edits or [], original_size,
)
box = tuple(max(0.0, min(1.0, value)) for value in box)
return box if box[2] > box[0] and box[3] > box[1] else None
def parse_face_boxes(
faces: Any,
person_ids: set[str] | None = None,
*,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> list[list[float | bool]]:
"""Return normalised ``[x1, y1, x2, y2, area, selected]`` face boxes.
Immich reports face boxes in the coordinate space described by each
face's ``imageWidth``/``imageHeight``. Normalising each box makes it
independent of whether Album Slideshow downloads a preview, full-size
derivative or original. Faces Immich has not linked to a named person
are included. Explicit person selection is a separate priority from area.
"""
if not isinstance(faces, list):
return []
boxes: list[list[float | bool]] = []
for face in faces:
box = _normalised_face_box(face, edits, original_size)
if box is None:
continue
x1, y1, x2, y2 = box
weight = (x2 - x1) * (y2 - y1)
person = face.get("person")
selected = bool(
person_ids and isinstance(person, dict) and person.get("id") in person_ids
)
boxes.append([x1, y1, x2, y2, weight, selected])
return boxes
def _original_face_box(
box: tuple[float, float, float, float],
edits: list[dict[str, Any]],
original_size: tuple[float, float] | None,
) -> tuple[float, float, float, float]:
points = [(box[0], box[1]), (box[2], box[3])]
crop = None
for edit in reversed(edits):
if not isinstance(edit, dict) or not isinstance(edit.get("parameters"), dict):
raise ValueError("Invalid Immich edit metadata")
params = edit["parameters"]
action = edit.get("action")
if action == "rotate":
angle = params.get("angle")
if angle == 90:
points = [(vertical, 1 - horizontal) for horizontal, vertical in points]
elif angle == 180:
points = [(1 - horizontal, 1 - vertical) for horizontal, vertical in points]
elif angle == 270:
points = [(1 - vertical, horizontal) for horizontal, vertical in points]
elif angle != 0:
raise ValueError("Unsupported Immich rotation")
elif action == "mirror":
axis = params.get("axis")
if axis == "horizontal":
points = [(horizontal, 1 - vertical) for horizontal, vertical in points]
elif axis == "vertical":
points = [(1 - horizontal, vertical) for horizontal, vertical in points]
else:
raise ValueError("Unsupported Immich mirror axis")
elif action == "crop" and crop is None:
crop = params
else:
raise ValueError("Unsupported Immich edit")
if crop is not None:
values = [crop.get(name) for name in ("x", "y", "width", "height")]
if original_size is None or any(
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) for value in values
):
raise ValueError("Missing Immich crop dimensions")
crop_left, crop_top, crop_width, crop_height = values
original_width, original_height = original_size
if (
crop_left < 0 or crop_top < 0 or crop_width <= 0 or crop_height <= 0
or crop_left + crop_width > original_width
or crop_top + crop_height > original_height
):
raise ValueError("Invalid Immich crop dimensions")
points = [
((horizontal * crop_width + crop_left) / original_width,
(vertical * crop_height + crop_top) / original_height)
for horizontal, vertical in points
]
return (
min(point[0] for point in points), min(point[1] for point in points),
max(point[0] for point in points), max(point[1] for point in points),
)
def original_image_size(asset: Any) -> tuple[float, float] | None:
info = asset.get("exifInfo") if isinstance(asset, dict) else None
if not isinstance(info, dict):
return None
width, height = info.get("exifImageWidth"), info.get("exifImageHeight")
if any(
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) or value <= 0 for value in (width, height)
):
return None
if str(info.get("orientation")) in {"5", "6", "7", "8", "-90", "90"}:
return (float(height), float(width))
return (float(width), float(height))
def parse_face_focus(
faces: Any,
person_ids: set[str],
*,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> tuple[float, float] | None:
"""Return a normalised crop focus for the selected people in an asset.
Immich reports face boxes in the coordinate space described by each
face's ``imageWidth``/``imageHeight``. Normalising each box makes the
focus independent of resolution. Edited coordinates are mapped back to the
unedited image before clamping. When several selected people appear in the
same photo, focus on the centre of their combined region.
"""
if not isinstance(faces, list) or not person_ids:
return None
selected_faces = [
face for face in faces
if isinstance(face, dict) and isinstance(face.get("person"), dict)
and face["person"].get("id") in person_ids
]
boxes = parse_face_boxes(selected_faces, edits=edits, original_size=original_size)
if not boxes:
return None
left = min(box[0] for box in boxes)
top = min(box[1] for box in boxes)
right = max(box[2] for box in boxes)
bottom = max(box[3] for box in boxes)
return ((left + right) / 2, (top + bottom) / 2)
class ImmichClient:
"""Thin async wrapper over the Immich REST API."""
def __init__(self, hass, base_url: str, api_key: str) -> None:
self.hass = hass
self.base_url = normalize_base_url(base_url)
self.api_key = api_key
@property
def headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key, "Accept": "application/json"}
@property
def image_headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key}
async def _get(self, path: str) -> Any:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.get(self.base_url + path, headers=self.headers) as resp:
resp.raise_for_status()
return await resp.json()
async def _post(self, path: str, body: dict[str, Any]) -> Any:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.post(
self.base_url + path, headers=self.headers, json=body
) as resp:
resp.raise_for_status()
return await resp.json()
async def async_validate(self) -> str | None:
"""Return the server version if the URL + key work, else raise."""
data = await self._get("/api/server/about")
return data.get("version") if isinstance(data, dict) else None
async def async_list_albums(self) -> list[dict[str, Any]]:
data = await self._get("/api/albums")
return data if isinstance(data, list) else []
async def async_list_people(self) -> list[dict[str, Any]]:
data = await self._get("/api/people")
if isinstance(data, dict):
people = data.get("people")
return people if isinstance(people, list) else []
return data if isinstance(data, list) else []
async def async_collect_assets(
self,
selection_type: str,
selection_id: str | None = None,
filter_body: dict | None = None,
) -> list[dict[str, Any]]:
"""Collect image assets for a selection.
``random`` uses ``/api/search/random`` (a single, unpaginated batch).
Everything else pages through ``/api/search/metadata`` with a body
built from the selection.
"""
if selection_type == "random":
body = {"size": min(_PAGE_SIZE, 250), "type": "IMAGE"}
if isinstance(filter_body, dict):
merged = dict(filter_body)
merged.update(body)
body = merged
payload = await self._post("/api/search/random", body)
return parse_random(payload)
if selection_type == "people":
# Immich treats multiple personIds in one query as AND (only photos
# where everyone appears together). To get OR (any of the people),
# query each person separately and union by asset id. See #19.
ids = [p for p in (selection_id or "").split(",") if p]
bodies = [{"type": "IMAGE", "personIds": [p]} for p in ids]
return await self._collect_union(bodies)
if selection_type == "albums":
# Same OR behavior for a set of albums: query each album on its own
# and union the results, deduped by asset id.
ids = [a for a in (selection_id or "").split(",") if a]
bodies = [{"type": "IMAGE", "albumIds": [a]} for a in ids]
return await self._collect_union(bodies)
if selection_type == "composite":
# A mix of albums, people, favorites and/or a custom filter. Each
# is queried on its own and unioned; an empty composite means the
# whole library. See #19.
bodies = build_composite_bodies(selection_id, filter_body)
return await self._collect_union(bodies)
base = build_search_body(selection_type, selection_id, filter_body)
return await self._collect_metadata(base)
async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
"""Page through ``search/metadata`` for a prebuilt body."""
collected: list[dict[str, Any]] = []
page: int | None = 1
while page is not None and len(collected) < _MAX_ASSETS:
body = dict(base)
body["size"] = _PAGE_SIZE
body["page"] = page
payload = await self._post("/api/search/metadata", body)
items, next_page = parse_search_page(payload)
collected.extend(items)
page = next_page
return collected
async def _collect_union(
self, bodies: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Union several ``search/metadata`` queries (OR), deduped by asset id.
Each body is queried on its own so the results are a union (any of),
not Immich's default AND (only assets that match every filter at
once). See #19.
"""
seen: set[str] = set()
out: list[dict[str, Any]] = []
for body in bodies:
if len(out) >= _MAX_ASSETS:
break
items = await self._collect_metadata(body)
for it in items:
aid = it.get("id")
if aid and aid not in seen:
seen.add(aid)
out.append(it)
return out
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
return await self._get(f"/api/assets/{asset_id}")
async def async_get_faces(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/faces?id={asset_id}")
if not isinstance(data, list):
raise ValueError("Invalid Immich face response")
return data
async def async_get_asset_edits(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/assets/{asset_id}/edits")
if not isinstance(data, dict) or not isinstance(data.get("edits"), list):
raise ValueError("Invalid Immich edit response")
return data["edits"]