Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -32,11 +32,28 @@ from .const import (
DEFAULT_IMMICH_IMAGE_SIZE,
IMMICH_IMAGE_SIZE_OPTIONS,
IMMICH_SELECTION_COMPOSITE,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
CONF_PHOTOPRISM_USERNAME,
CONF_PHOTOPRISM_PASSWORD,
CONF_PHOTOPRISM_SELECTION_TYPE,
CONF_PHOTOPRISM_SELECTION_ID,
CONF_PHOTOPRISM_IMAGE_SIZE,
CONF_PHOTOPRISM_FILTER,
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
PHOTOPRISM_IMAGE_PREVIEW,
PHOTOPRISM_IMAGE_FULLSIZE,
PHOTOPRISM_IMAGE_ORIGINAL,
PHOTOPRISM_AUTH_APP_PASSWORD,
PHOTOPRISM_AUTH_USER_PASSWORD,
PHOTOPRISM_SELECTION_COMPOSITE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
DEFAULT_RECURSIVE,
)
@@ -76,6 +93,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# id -> name maps for the Albums and People multi-selects.
self._immich_albums: dict[str, str] = {}
self._immich_people: dict[str, str] = {}
# PhotoPrism flow state carried between steps.
self._pp_url: str | None = None
self._pp_auth_method: str | None = None
self._pp_token: str | None = None
self._pp_username: str | None = None
self._pp_password: str | None = None
self._pp_albums: dict[str, str] = {}
self._pp_people: dict[str, str] = {}
@staticmethod
@callback
@@ -107,6 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_media_source()
if self._provider == PROVIDER_IMMICH:
return await self.async_step_immich()
if self._provider == PROVIDER_PHOTOPRISM:
return await self.async_step_photoprism()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -115,6 +142,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder",
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -369,6 +397,195 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="immich_select", data_schema=schema, errors=errors
)
async def async_step_photoprism(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the PhotoPrism URL + credentials and validate them."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_PHOTOPRISM_URL].strip()
method = user_input[CONF_PHOTOPRISM_AUTH_METHOD]
token = (user_input.get(CONF_PHOTOPRISM_TOKEN) or "").strip()
username = (user_input.get(CONF_PHOTOPRISM_USERNAME) or "").strip()
password = user_input.get(CONF_PHOTOPRISM_PASSWORD) or ""
if method == PHOTOPRISM_AUTH_APP_PASSWORD and not token:
errors[CONF_PHOTOPRISM_TOKEN] = "photoprism_token_required"
elif method == PHOTOPRISM_AUTH_USER_PASSWORD and not (username and password):
errors[CONF_PHOTOPRISM_USERNAME] = "photoprism_user_required"
if not errors:
from . import photoprism as pp_api
client = pp_api.PhotoprismClient(
self.hass,
url,
auth_method=method,
token=token or None,
username=username or None,
password=password or None,
)
try:
await client.async_validate()
albums = await client.async_list_albums()
people = await client.async_list_people()
except Exception: # noqa: BLE001 - any failure means bad URL/creds
errors["base"] = "photoprism_cannot_connect"
else:
self._pp_url = client.base_url
self._pp_auth_method = method
self._pp_token = token or None
self._pp_username = username or None
self._pp_password = password or None
self._pp_albums = {
a["UID"]: (a.get("Title") or a["UID"])
for a in albums
if a.get("UID")
}
self._pp_people = {
p["UID"]: p["Name"]
for p in people
if p.get("UID") and (p.get("Name") or "").strip()
}
return await self.async_step_photoprism_select()
schema = vol.Schema(
{
vol.Required(CONF_PHOTOPRISM_URL): str,
vol.Required(
CONF_PHOTOPRISM_AUTH_METHOD,
default=PHOTOPRISM_AUTH_APP_PASSWORD,
): vol.In(
{
PHOTOPRISM_AUTH_APP_PASSWORD: "App password (recommended)",
PHOTOPRISM_AUTH_USER_PASSWORD: "Username + password",
}
),
vol.Optional(CONF_PHOTOPRISM_TOKEN): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Optional(CONF_PHOTOPRISM_USERNAME): str,
vol.Optional(CONF_PHOTOPRISM_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}
)
return self.async_show_form(
step_id="photoprism", data_schema=schema, errors=errors
)
async def async_step_photoprism_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Build a composite PhotoPrism selection and finish the entry.
Same combined picker as Immich: tick any mix of albums, people and
favorites (optionally a custom search query); an empty selection means
the whole library.
"""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(
CONF_PHOTOPRISM_IMAGE_SIZE, DEFAULT_PHOTOPRISM_IMAGE_SIZE
)
raw_filter = (user_input.get(CONF_PHOTOPRISM_FILTER) or "").strip()
favorites = bool(user_input.get("favorites"))
chosen_albums = [a for a in user_input.get("albums", []) if a]
if _ALL_ALBUMS in chosen_albums:
chosen_albums = list(self._pp_albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in self._pp_albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(self._pp_people.keys())
else:
chosen_people = [p for p in chosen_people if p in self._pp_people]
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
f"composite:{sel_id}:{raw_filter}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_PHOTOPRISM,
CONF_PHOTOPRISM_URL: self._pp_url,
CONF_PHOTOPRISM_AUTH_METHOD: self._pp_auth_method,
CONF_PHOTOPRISM_SELECTION_TYPE: PHOTOPRISM_SELECTION_COMPOSITE,
CONF_PHOTOPRISM_SELECTION_ID: sel_id,
CONF_PHOTOPRISM_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if self._pp_token:
data[CONF_PHOTOPRISM_TOKEN] = self._pp_token
if self._pp_username:
data[CONF_PHOTOPRISM_USERNAME] = self._pp_username
if self._pp_password:
data[CONF_PHOTOPRISM_PASSWORD] = self._pp_password
if raw_filter:
data[CONF_PHOTOPRISM_FILTER] = raw_filter
return self.async_create_entry(title=name, data=data)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
if self._pp_albums:
album_options = [
selector.SelectOptionDict(value=_ALL_ALBUMS, label="Select all albums")
] + [
selector.SelectOptionDict(value=uid, label=name)
for uid, name in self._pp_albums.items()
]
fields[vol.Optional("albums")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
if self._pp_people:
people_options = [
selector.SelectOptionDict(value=_ALL_PEOPLE, label="Select all people")
] + [
selector.SelectOptionDict(value=uid, label=name)
for uid, name in self._pp_people.items()
]
fields[vol.Optional("people")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=people_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector()
fields[vol.Optional(CONF_PHOTOPRISM_FILTER)] = str
fields[
vol.Optional(
CONF_PHOTOPRISM_IMAGE_SIZE, default=DEFAULT_PHOTOPRISM_IMAGE_SIZE
)
] = vol.In(
{
PHOTOPRISM_IMAGE_PREVIEW: "Preview (1280px)",
PHOTOPRISM_IMAGE_FULLSIZE: "Full size (1920px)",
PHOTOPRISM_IMAGE_ORIGINAL: "High detail (2560px)",
}
)
schema = vol.Schema(fields)
return self.async_show_form(
step_id="photoprism_select", data_schema=schema, errors=errors
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -55,6 +55,40 @@ PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
PROVIDER_PHOTOPRISM = "photoprism"
# PhotoPrism (direct API) provider.
CONF_PHOTOPRISM_URL = "photoprism_url"
CONF_PHOTOPRISM_AUTH_METHOD = "photoprism_auth_method"
CONF_PHOTOPRISM_TOKEN = "photoprism_token"
CONF_PHOTOPRISM_USERNAME = "photoprism_username"
CONF_PHOTOPRISM_PASSWORD = "photoprism_password"
CONF_PHOTOPRISM_SELECTION_TYPE = "photoprism_selection_type"
CONF_PHOTOPRISM_SELECTION_ID = "photoprism_selection_id"
CONF_PHOTOPRISM_IMAGE_SIZE = "photoprism_image_size"
CONF_PHOTOPRISM_FILTER = "photoprism_filter"
PHOTOPRISM_AUTH_APP_PASSWORD = "app_password"
PHOTOPRISM_AUTH_USER_PASSWORD = "user_password"
# PhotoPrism thumbnail sizes (from its Thumbnail Image API). ``preview`` is a
# good slideshow default; the larger sizes trade bandwidth for detail.
PHOTOPRISM_IMAGE_PREVIEW = "fit_1280"
PHOTOPRISM_IMAGE_FULLSIZE = "fit_1920"
PHOTOPRISM_IMAGE_ORIGINAL = "fit_2560"
PHOTOPRISM_IMAGE_SIZE_OPTIONS = [
PHOTOPRISM_IMAGE_PREVIEW,
PHOTOPRISM_IMAGE_FULLSIZE,
PHOTOPRISM_IMAGE_ORIGINAL,
]
DEFAULT_PHOTOPRISM_IMAGE_SIZE = PHOTOPRISM_IMAGE_PREVIEW
# Composite: client-side union of albums + people + favorites (+ optional
# search query). PhotoPrism has no OR across filters, so each member is
# queried separately and merged. Selection id is a JSON object
# ``{"albums": [...], "people": [...], "favorites": bool}``; empty means all.
PHOTOPRISM_SELECTION_COMPOSITE = "composite"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
@@ -31,12 +31,23 @@ from .const import (
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
CONF_PHOTOPRISM_USERNAME,
CONF_PHOTOPRISM_PASSWORD,
CONF_PHOTOPRISM_SELECTION_TYPE,
CONF_PHOTOPRISM_SELECTION_ID,
CONF_PHOTOPRISM_IMAGE_SIZE,
CONF_PHOTOPRISM_FILTER,
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
)
from .store import SlideshowStore
@@ -920,6 +931,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_media_source()
elif self.provider == PROVIDER_IMMICH:
data = await self._update_immich()
elif self.provider == PROVIDER_PHOTOPRISM:
data = await self._update_photoprism()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1359,6 +1372,79 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_photoprism(self) -> dict[str, Any]:
"""Fetch photos from PhotoPrism via its REST API.
Unlike Immich, PhotoPrism returns per-photo metadata inline in the
search response, so every ``MediaItem`` is built fully here - there is
no background enrichment pass. Thumbnails carry a preview token in the
URL, so no auth header is needed to fetch the image bytes.
"""
from . import photoprism as pp_api
url = self.entry.data.get(CONF_PHOTOPRISM_URL)
auth_method = self.entry.data.get(CONF_PHOTOPRISM_AUTH_METHOD)
sel_type = self.entry.data.get(CONF_PHOTOPRISM_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_PHOTOPRISM_SELECTION_ID)
size = self.entry.data.get(
CONF_PHOTOPRISM_IMAGE_SIZE, DEFAULT_PHOTOPRISM_IMAGE_SIZE
)
filter_query = self.entry.data.get(CONF_PHOTOPRISM_FILTER)
if not url or not auth_method or not sel_type:
raise UpdateFailed("PhotoPrism provider is missing URL, auth, or selection")
client = pp_api.PhotoprismClient(
self.hass,
url,
auth_method=auth_method,
token=self.entry.data.get(CONF_PHOTOPRISM_TOKEN),
username=self.entry.data.get(CONF_PHOTOPRISM_USERNAME),
password=self.entry.data.get(CONF_PHOTOPRISM_PASSWORD),
)
try:
photos = await client.async_collect_assets(sel_type, sel_id, filter_query)
except Exception as err:
raise UpdateFailed(f"Error querying PhotoPrism: {err}") from err
if not photos:
raise UpdateFailed("No images found for the selected PhotoPrism source")
token = client.preview_token
if not token:
raise UpdateFailed("PhotoPrism did not return a preview token")
items: list[MediaItem] = []
for p in photos:
uid = p.get("UID")
file_hash = p.get("Hash")
if not uid or not file_hash:
continue
meta = pp_api.parse_photo_meta(p)
w = p.get("Width")
h = p.get("Height")
items.append(
MediaItem(
url=pp_api.build_image_url(client.base_url, file_hash, token, size),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=p.get("FileName") or p.get("Name"),
captured_at=meta.get("captured_at"),
latitude=meta.get("latitude"),
longitude=meta.get("longitude"),
location=meta.get("location"),
description=meta.get("description"),
source_id=uid,
exif_scanned=True,
)
)
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.2.2"
"version": "1.3.0"
}
@@ -0,0 +1,356 @@
"""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
+27 -1
View File
@@ -52,6 +52,29 @@
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
},
"photoprism": {
"title": "PhotoPrism",
"description": "Connect directly to your PhotoPrism server for full photo metadata (date, location, description). Use an app password (Settings > Account > Apps and Devices) or your username and password. The password is kept on the server side and never reaches the browser.",
"data": {
"photoprism_url": "PhotoPrism URL (e.g. http://192.168.1.10:2342)",
"photoprism_auth_method": "Authentication",
"photoprism_token": "App password (for App password)",
"photoprism_username": "Username (for Username + password)",
"photoprism_password": "Password (for Username + password)"
}
},
"photoprism_select": {
"title": "PhotoPrism source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add a PhotoPrism search query to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"photoprism_filter": "Extra search query (optional)",
"photoprism_image_size": "Image quality"
}
}
},
"error": {
@@ -63,7 +86,10 @@
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
"immich_albums_required": "Pick at least one album for the Albums source.",
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password."
}
},
"options": {
@@ -52,6 +52,29 @@
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
},
"photoprism": {
"title": "PhotoPrism",
"description": "Connect directly to your PhotoPrism server for full photo metadata (date, location, description). Use an app password (Settings > Account > Apps and Devices) or your username and password. The password is kept on the server side and never reaches the browser.",
"data": {
"photoprism_url": "PhotoPrism URL (e.g. http://192.168.1.10:2342)",
"photoprism_auth_method": "Authentication",
"photoprism_token": "App password (for App password)",
"photoprism_username": "Username (for Username + password)",
"photoprism_password": "Password (for Username + password)"
}
},
"photoprism_select": {
"title": "PhotoPrism source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add a PhotoPrism search query to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"photoprism_filter": "Extra search query (optional)",
"photoprism_image_size": "Image quality"
}
}
},
"error": {
@@ -63,7 +86,10 @@
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
"immich_albums_required": "Pick at least one album for the Albums source.",
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.2.2";
const VERSION = "1.3.0";
const ANIMATED_TRANSITIONS = [
"fade",