48 files
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user