This commit is contained in:
Home Assistant Version Control
2026-07-24 15:19:02 +00:00
parent 8abf4efaec
commit 2d21a40fd6
48 changed files with 4181 additions and 232 deletions
+212 -30
View File
@@ -61,12 +61,24 @@ from .const import (
CONF_SYNOLOGY_SPACE,
CONF_SYNOLOGY_ALBUM_ID,
CONF_SYNOLOGY_IMAGE_SIZE,
CONF_SYNOLOGY_PASSPHRASE,
CONF_SYNOLOGY_FAVORITE,
CONF_SYNOLOGY_SELECTION,
DEFAULT_SYNOLOGY_IMAGE_SIZE,
SYNOLOGY_SPACE_PERSONAL,
SYNOLOGY_SPACE_SHARED,
SYNOLOGY_IMAGE_SMALL,
SYNOLOGY_IMAGE_MEDIUM,
SYNOLOGY_IMAGE_LARGE,
CONF_NEXTCLOUD_URL,
CONF_NEXTCLOUD_USERNAME,
CONF_NEXTCLOUD_PASSWORD,
CONF_NEXTCLOUD_FOLDER,
CONF_NEXTCLOUD_RECURSIVE,
CONF_NEXTCLOUD_IMAGE_SIZE,
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
NEXTCLOUD_IMAGE_PREVIEW,
NEXTCLOUD_IMAGE_ORIGINAL,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
@@ -75,6 +87,7 @@ from .const import (
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
PROVIDER_NEXTCLOUD,
DEFAULT_RECURSIVE,
)
@@ -129,6 +142,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._syn_device_id: str | None = None
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
self._syn_albums: dict[str, str] = {}
# option key -> {"album_id": id|None, "passphrase": str|None}
self._syn_album_meta: dict[str, dict[str, Any]] = {}
# id(str) -> name maps for the composite category multi-selects.
self._syn_people: dict[str, str] = {}
self._syn_places: dict[str, str] = {}
self._syn_tags: dict[str, str] = {}
self._syn_subjects: dict[str, str] = {}
@staticmethod
@callback
@@ -147,7 +167,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
``__init__`` raises (the symptom is a 500 when the user clicks
Configure).
"""
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
if config_entry.data.get(CONF_PROVIDER) in (
PROVIDER_LOCAL_FOLDER,
PROVIDER_NEXTCLOUD,
):
return LocalFolderOptionsFlow()
return _NoOptionsFlow()
@@ -166,6 +189,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_icloud()
if self._provider == PROVIDER_SYNOLOGY:
return await self.async_step_synology()
if self._provider == PROVIDER_NEXTCLOUD:
return await self.async_step_nextcloud()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -177,6 +202,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
PROVIDER_ICLOUD: "iCloud Shared Album",
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -699,9 +725,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
try:
await client.async_login(otp_code=otp or None)
albums = await client.async_list_albums()
# Albums and category browsing live only in the Personal space
# (there is no Shared Space album/category API). For the Shared
# Space, validate access up front so a permission problem
# surfaces here, not later.
if space == SYNOLOGY_SPACE_SHARED:
albums = people = places = tags = subjects = []
await client.async_collect_assets(None)
else:
albums = await client.async_list_albums()
people = await client.async_list_people()
places = await client.async_list_places()
tags = await client.async_list_tags()
subjects = await client.async_list_subjects()
except syn_api.SynologyOtpRequired:
errors["otp_code"] = "synology_otp_required"
except syn_api.SynologyPermissionError:
errors["base"] = "synology_shared_unavailable"
except Exception: # noqa: BLE001 - any failure means bad URL/creds
errors["base"] = "synology_cannot_connect"
else:
@@ -712,10 +752,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# A trusted-device token is captured only on the OTP login;
# store it so future logins skip the 2FA prompt.
self._syn_device_id = client.captured_device_id
self._syn_albums = {
str(a["id"]): (a.get("name") or str(a["id"]))
for a in albums
if a.get("id") is not None
# Key each album by a synthetic value so an own album and a
# shared-with-me album that happen to share a numeric id don't
# collide. Track album_id + passphrase per option.
self._syn_albums = {}
self._syn_album_meta = {}
for a in albums:
if a.get("id") is None:
continue
shared = bool(a.get("shared"))
key = f"{'shared' if shared else 'own'}:{a['id']}"
label = a.get("name") or str(a["id"])
self._syn_albums[key] = f"{label} (shared)" if shared else label
self._syn_album_meta[key] = {
"album_id": None if shared else a["id"],
"passphrase": a.get("passphrase") if shared else None,
}
self._syn_people = {
str(p["id"]): p["name"] for p in people if p.get("id") is not None
}
self._syn_places = {
str(p["id"]): p["name"] for p in places if p.get("id") is not None
}
self._syn_tags = {
str(t["id"]): t["name"] for t in tags if t.get("id") is not None
}
self._syn_subjects = {
str(s["id"]): s["name"] for s in subjects if s.get("id") is not None
}
await client.async_logout()
return await self.async_step_synology_select()
@@ -745,7 +808,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_synology_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Pick a Synology album (or the whole space) and image size."""
"""Build a composite Synology selection and finish the entry.
Like the Immich/PhotoPrism providers: tick any mix of favorites,
albums, people, places, tags and subjects. Synology has no OR across
categories, so each member is queried on its own and merged. An empty
selection means the whole space.
"""
errors: dict[str, str] = {}
if user_input is not None:
@@ -753,13 +822,42 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
size = user_input.get(
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
)
album_id = user_input.get("album")
if album_id in (None, "", "__all__") or album_id not in self._syn_albums:
album_id = ""
favorites = bool(user_input.get("favorites"))
album_ids: list[Any] = []
passphrases: list[str] = []
for key in user_input.get("albums", []) or []:
meta = self._syn_album_meta.get(key)
if not meta:
continue
if meta.get("passphrase"):
passphrases.append(meta["passphrase"])
elif meta.get("album_id") is not None:
album_ids.append(meta["album_id"])
def _ids(field: str, valid: dict[str, str]) -> list[int]:
out: list[int] = []
for v in user_input.get(field, []) or []:
if v in valid:
try:
out.append(int(v))
except (TypeError, ValueError):
pass
return out
selection = {
"favorites": favorites,
"album_ids": album_ids,
"passphrases": passphrases,
"person_ids": _ids("people", self._syn_people),
"geocoding_ids": _ids("places", self._syn_places),
"tag_ids": _ids("tags", self._syn_tags),
"concept_ids": _ids("subjects", self._syn_subjects),
}
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
f"{self._syn_space}:{album_id or 'all'}"
f"{self._syn_space}:{sel_id}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
@@ -769,44 +867,128 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_SYNOLOGY_USERNAME: self._syn_username,
CONF_SYNOLOGY_PASSWORD: self._syn_password,
CONF_SYNOLOGY_SPACE: self._syn_space,
CONF_SYNOLOGY_SELECTION: sel_id,
CONF_SYNOLOGY_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if album_id:
data[CONF_SYNOLOGY_ALBUM_ID] = album_id
if self._syn_device_id:
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
return self.async_create_entry(title=name, data=data)
album_options = [
selector.SelectOptionDict(value="__all__", label="All photos in this space")
] + [
selector.SelectOptionDict(value=aid, label=aname)
for aid, aname in self._syn_albums.items()
]
def _multi(options: dict[str, str]):
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value=v, label=l)
for v, l in options.items()
],
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
# Favorites, albums and subjects are Personal-space concepts.
if self._syn_space == SYNOLOGY_SPACE_PERSONAL:
fields[vol.Optional("favorites", default=False)] = (
selector.BooleanSelector()
)
if self._syn_albums:
fields[vol.Optional("albums")] = _multi(self._syn_albums)
if self._syn_people:
fields[vol.Optional("people")] = _multi(self._syn_people)
if self._syn_places:
fields[vol.Optional("places")] = _multi(self._syn_places)
if self._syn_tags:
fields[vol.Optional("tags")] = _multi(self._syn_tags)
if self._syn_subjects:
fields[vol.Optional("subjects")] = _multi(self._syn_subjects)
fields[
vol.Optional(
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
)
] = vol.In(
{
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
SYNOLOGY_IMAGE_MEDIUM: "Medium",
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
}
)
return self.async_show_form(
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
)
async def async_step_nextcloud(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect and validate a Nextcloud WebDAV folder + app password."""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
url = user_input[CONF_NEXTCLOUD_URL].strip()
username = user_input[CONF_NEXTCLOUD_USERNAME].strip()
password = user_input.get(CONF_NEXTCLOUD_PASSWORD) or ""
folder = (user_input.get(CONF_NEXTCLOUD_FOLDER) or "").strip()
recursive = bool(user_input.get(CONF_NEXTCLOUD_RECURSIVE, False))
size = user_input.get(
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
)
from . import nextcloud as nc_api
client = nc_api.NextcloudClient(
self.hass, url, username, password, folder
)
try:
await client.async_validate()
except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder
errors["base"] = "nextcloud_cannot_connect"
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
f"{username}:{client.folder}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
CONF_NEXTCLOUD_URL: client.base_url,
CONF_NEXTCLOUD_USERNAME: username,
CONF_NEXTCLOUD_PASSWORD: password,
CONF_NEXTCLOUD_FOLDER: client.folder,
CONF_NEXTCLOUD_RECURSIVE: recursive,
CONF_NEXTCLOUD_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Optional("album", default="__all__"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
vol.Required(CONF_NEXTCLOUD_URL): str,
vol.Required(CONF_NEXTCLOUD_USERNAME): str,
vol.Required(CONF_NEXTCLOUD_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Optional(CONF_NEXTCLOUD_FOLDER, default=""): str,
vol.Optional(CONF_NEXTCLOUD_RECURSIVE, default=False): (
selector.BooleanSelector()
),
vol.Optional(
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE
): vol.In(
{
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
SYNOLOGY_IMAGE_MEDIUM: "Medium",
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)",
NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)",
}
),
}
)
return self.async_show_form(
step_id="synology_select", data_schema=schema, errors=errors
step_id="nextcloud", data_schema=schema, errors=errors
)