Files
Home-Assistant/custom_components/album_slideshow/config_flow.py
T
2026-07-20 22:52:35 -04:00

629 lines
24 KiB
Python

from __future__ import annotations
import json
import re
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
# Sentinel values for the "Select all" options in the multi-selects.
_ALL_PEOPLE = "__all_people__"
_ALL_ALBUMS = "__all_albums__"
from .const import (
DOMAIN,
CONF_PROVIDER,
CONF_ALBUM_NAME,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
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,
)
def _normalize_local_path(hass, path: str) -> str:
p = path.strip()
if p.startswith("/local/"):
p = "/config/www/" + p[len("/local/"):]
elif p == "/local":
p = "/config/www"
elif p.startswith("local/"):
p = "/config/www/" + p[len("local/"):]
elif p.startswith("/media/local/"):
p = "/media/" + p[len("/media/local/"):]
elif p.startswith("media/local/"):
p = "/media/" + p[len("media/local/"):]
elif p.startswith("media/"):
p = "/media/" + p[len("media/"):]
elif p == "media":
p = "/media"
if not p.startswith("/"):
p = hass.config.path(p)
return p
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
self._provider: str | None = None
# Immich flow state carried between steps.
self._immich_url: str | None = None
self._immich_key: str | None = None
# 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
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Return the options flow handler.
Only local-folder entries expose user-tunable options today (the
reverse-geocode toggle); Google entries get a no-op handler so
that the "Configure" button doesn't appear empty in the UI.
Note: do NOT pass ``config_entry`` to the OptionsFlow constructor.
Since Home Assistant 2024.12 the base class manages
``self.config_entry`` as a property and assigning to it in
``__init__`` raises (the symptom is a 500 when the user clicks
Configure).
"""
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
return LocalFolderOptionsFlow()
return _NoOptionsFlow()
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
if user_input is not None:
self._provider = user_input[CONF_PROVIDER]
if self._provider == PROVIDER_LOCAL_FOLDER:
return await self.async_step_local_folder()
if self._provider == PROVIDER_MEDIA_SOURCE:
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(
{
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
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)",
})
}
)
return self.async_show_form(step_id="user", data_schema=schema)
async def async_step_google_shared(self, user_input: dict[str, Any] | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_ALBUM_URL].strip()
name = user_input[CONF_ALBUM_NAME].strip()
if not ALBUM_URL_RE.match(url):
errors[CONF_ALBUM_URL] = "invalid_album_url"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_GOOGLE_SHARED,
CONF_ALBUM_URL: url,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_ALBUM_URL): str,
}
)
return self.async_show_form(step_id="google_shared", data_schema=schema, errors=errors)
async def async_step_local_folder(self, user_input: dict[str, Any] | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
path = _normalize_local_path(self.hass, user_input[CONF_LOCAL_PATH])
name = user_input[CONF_ALBUM_NAME].strip()
recursive = bool(user_input.get(CONF_RECURSIVE, DEFAULT_RECURSIVE))
if not path:
errors[CONF_LOCAL_PATH] = "invalid_path"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_LOCAL_FOLDER,
CONF_LOCAL_PATH: path,
CONF_RECURSIVE: recursive,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_LOCAL_PATH): str,
vol.Optional(CONF_RECURSIVE, default=DEFAULT_RECURSIVE): bool,
}
)
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
async def async_step_media_source(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
content_id = user_input[CONF_MEDIA_CONTENT_ID].strip()
name = user_input[CONF_ALBUM_NAME].strip()
if not content_id.startswith("media-source://"):
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_MEDIA_SOURCE,
CONF_MEDIA_CONTENT_ID: content_id,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_MEDIA_CONTENT_ID): str,
}
)
return self.async_show_form(
step_id="media_source", data_schema=schema, errors=errors
)
async def async_step_immich(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the Immich URL + API key and validate them."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_IMMICH_URL].strip()
key = user_input[CONF_IMMICH_API_KEY].strip()
from . import immich as immich_api
client = immich_api.ImmichClient(self.hass, url, key)
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/key
errors["base"] = "immich_cannot_connect"
else:
self._immich_url = client.base_url
self._immich_key = key
# id -> name maps for the two multi-select pickers.
self._immich_albums = {
a["id"]: (a.get("albumName") or a["id"])
for a in albums
if a.get("id")
}
self._immich_people = {
p["id"]: p["name"]
for p in people
if p.get("id") and (p.get("name") or "").strip()
}
return await self.async_step_immich_select()
schema = vol.Schema(
{
vol.Required(CONF_IMMICH_URL): str,
vol.Required(CONF_IMMICH_API_KEY): str,
}
)
return self.async_show_form(
step_id="immich", data_schema=schema, errors=errors
)
async def async_step_immich_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Build a composite Immich selection and finish the entry.
The user ticks any mix of albums, people and favorites (and may add a
custom JSON filter); the coordinator unions them. Leaving everything
empty means "all photos".
"""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
raw_filter = (user_input.get(CONF_IMMICH_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._immich_albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in self._immich_albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(self._immich_people.keys())
else:
chosen_people = [p for p in chosen_people if p in self._immich_people]
if raw_filter:
try:
parsed = json.loads(raw_filter)
if not isinstance(parsed, dict):
raise ValueError
except ValueError:
errors[CONF_IMMICH_FILTER] = "immich_filter_invalid"
if not errors:
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
f"composite:{sel_id}:{raw_filter}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_IMMICH,
CONF_IMMICH_URL: self._immich_url,
CONF_IMMICH_API_KEY: self._immich_key,
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: sel_id,
CONF_IMMICH_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if raw_filter:
data[CONF_IMMICH_FILTER] = raw_filter
return self.async_create_entry(title=name, data=data)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
if self._immich_albums:
album_options = [
selector.SelectOptionDict(
value=_ALL_ALBUMS, label="Select all albums"
)
] + [
selector.SelectOptionDict(value=aid, label=name)
for aid, name in self._immich_albums.items()
]
fields[vol.Optional("albums")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
if self._immich_people:
people_options = [
selector.SelectOptionDict(
value=_ALL_PEOPLE, label="Select all people"
)
] + [
selector.SelectOptionDict(value=pid, label=name)
for pid, name in self._immich_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_IMMICH_FILTER)] = str
fields[
vol.Optional(CONF_IMMICH_IMAGE_SIZE, default=DEFAULT_IMMICH_IMAGE_SIZE)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
schema = vol.Schema(fields)
return self.async_show_form(
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.
Currently exposes a single toggle: ``reverse_geocode``. Users with
privacy concerns about handing EXIF GPS coordinates to an external
OSM endpoint can turn this off; the GPS coordinates remain available
as ``latitude``/``longitude`` attributes regardless.
``self.config_entry`` is provided by ``OptionsFlow`` as a managed
property (HA 2024.12+); we deliberately do NOT define ``__init__``
or assign to it, since doing so raises in newer cores.
"""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE
)
schema = vol.Schema(
{
vol.Required(
CONF_REVERSE_GEOCODE, default=bool(current)
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
class _NoOptionsFlow(config_entries.OptionsFlow):
"""Fallback options flow for providers that expose nothing tunable."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return self.async_create_entry(title="", data={})