Updated apps
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user