348 files
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.
@@ -54,6 +54,19 @@ from .const import (
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
ICLOUD_IMAGE_FULL,
|
||||
ICLOUD_IMAGE_PREVIEW,
|
||||
CONF_SYNOLOGY_URL,
|
||||
CONF_SYNOLOGY_USERNAME,
|
||||
CONF_SYNOLOGY_PASSWORD,
|
||||
CONF_SYNOLOGY_DEVICE_ID,
|
||||
CONF_SYNOLOGY_SPACE,
|
||||
CONF_SYNOLOGY_ALBUM_ID,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||
SYNOLOGY_SPACE_PERSONAL,
|
||||
SYNOLOGY_SPACE_SHARED,
|
||||
SYNOLOGY_IMAGE_SMALL,
|
||||
SYNOLOGY_IMAGE_MEDIUM,
|
||||
SYNOLOGY_IMAGE_LARGE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
@@ -61,6 +74,7 @@ from .const import (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -108,6 +122,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._pp_password: str | None = None
|
||||
self._pp_albums: dict[str, str] = {}
|
||||
self._pp_people: dict[str, str] = {}
|
||||
# Synology flow state carried between steps.
|
||||
self._syn_url: str | None = None
|
||||
self._syn_username: str | None = None
|
||||
self._syn_password: str | None = None
|
||||
self._syn_device_id: str | None = None
|
||||
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
|
||||
self._syn_albums: dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -143,6 +164,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_photoprism()
|
||||
if self._provider == PROVIDER_ICLOUD:
|
||||
return await self.async_step_icloud()
|
||||
if self._provider == PROVIDER_SYNOLOGY:
|
||||
return await self.async_step_synology()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -153,6 +176,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -651,6 +675,140 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="icloud", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_synology(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect the Synology URL + credentials (and optional 2FA code)."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_SYNOLOGY_URL].strip()
|
||||
username = user_input[CONF_SYNOLOGY_USERNAME].strip()
|
||||
password = user_input.get(CONF_SYNOLOGY_PASSWORD) or ""
|
||||
space = user_input.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
||||
otp = (user_input.get("otp_code") or "").strip()
|
||||
|
||||
from . import synology as syn_api
|
||||
|
||||
client = syn_api.SynologyClient(
|
||||
self.hass,
|
||||
url,
|
||||
username=username,
|
||||
password=password,
|
||||
space=space,
|
||||
)
|
||||
try:
|
||||
await client.async_login(otp_code=otp or None)
|
||||
albums = await client.async_list_albums()
|
||||
except syn_api.SynologyOtpRequired:
|
||||
errors["otp_code"] = "synology_otp_required"
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||
errors["base"] = "synology_cannot_connect"
|
||||
else:
|
||||
self._syn_url = client.base_url
|
||||
self._syn_username = username
|
||||
self._syn_password = password
|
||||
self._syn_space = space
|
||||
# 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
|
||||
}
|
||||
await client.async_logout()
|
||||
return await self.async_step_synology_select()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SYNOLOGY_URL): str,
|
||||
vol.Required(CONF_SYNOLOGY_USERNAME): str,
|
||||
vol.Required(CONF_SYNOLOGY_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_SYNOLOGY_SPACE, default=SYNOLOGY_SPACE_PERSONAL
|
||||
): vol.In(
|
||||
{
|
||||
SYNOLOGY_SPACE_PERSONAL: "Personal (My Photos)",
|
||||
SYNOLOGY_SPACE_SHARED: "Shared Space",
|
||||
}
|
||||
),
|
||||
vol.Optional("otp_code"): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="synology", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
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."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
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 = ""
|
||||
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||
f"{self._syn_space}:{album_id or 'all'}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
data = {
|
||||
CONF_PROVIDER: PROVIDER_SYNOLOGY,
|
||||
CONF_SYNOLOGY_URL: self._syn_url,
|
||||
CONF_SYNOLOGY_USERNAME: self._syn_username,
|
||||
CONF_SYNOLOGY_PASSWORD: self._syn_password,
|
||||
CONF_SYNOLOGY_SPACE: self._syn_space,
|
||||
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()
|
||||
]
|
||||
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.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=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
@@ -57,6 +57,34 @@ PROVIDER_MEDIA_SOURCE = "media_source"
|
||||
PROVIDER_IMMICH = "immich"
|
||||
PROVIDER_PHOTOPRISM = "photoprism"
|
||||
PROVIDER_ICLOUD = "icloud"
|
||||
PROVIDER_SYNOLOGY = "synology"
|
||||
|
||||
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
|
||||
# entry.cgi web API. The account password is stored so the coordinator can
|
||||
# re-authenticate when the session id expires; accounts with 2FA are handled by
|
||||
# capturing a trusted-device token during setup (see synology.py).
|
||||
CONF_SYNOLOGY_URL = "synology_url"
|
||||
CONF_SYNOLOGY_USERNAME = "synology_username"
|
||||
CONF_SYNOLOGY_PASSWORD = "synology_password"
|
||||
CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
|
||||
CONF_SYNOLOGY_SPACE = "synology_space"
|
||||
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
|
||||
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
|
||||
|
||||
# Personal ("My Photos") vs shared ("Shared Space") library.
|
||||
SYNOLOGY_SPACE_PERSONAL = "personal"
|
||||
SYNOLOGY_SPACE_SHARED = "shared"
|
||||
|
||||
# Native Synology thumbnail sizes. ``xl`` is the largest (best for a slideshow).
|
||||
SYNOLOGY_IMAGE_SMALL = "sm"
|
||||
SYNOLOGY_IMAGE_MEDIUM = "m"
|
||||
SYNOLOGY_IMAGE_LARGE = "xl"
|
||||
SYNOLOGY_IMAGE_SIZE_OPTIONS = [
|
||||
SYNOLOGY_IMAGE_SMALL,
|
||||
SYNOLOGY_IMAGE_MEDIUM,
|
||||
SYNOLOGY_IMAGE_LARGE,
|
||||
]
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE = SYNOLOGY_IMAGE_LARGE
|
||||
|
||||
# iCloud Shared Album provider. The share token in the pasted link is the only
|
||||
# credential; no account or password is involved.
|
||||
|
||||
@@ -44,6 +44,15 @@ from .const import (
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
CONF_SYNOLOGY_URL,
|
||||
CONF_SYNOLOGY_USERNAME,
|
||||
CONF_SYNOLOGY_PASSWORD,
|
||||
CONF_SYNOLOGY_DEVICE_ID,
|
||||
CONF_SYNOLOGY_SPACE,
|
||||
CONF_SYNOLOGY_ALBUM_ID,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||
SYNOLOGY_SPACE_PERSONAL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
@@ -52,6 +61,7 @@ from .const import (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -939,6 +949,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_photoprism()
|
||||
elif self.provider == PROVIDER_ICLOUD:
|
||||
data = await self._update_icloud()
|
||||
elif self.provider == PROVIDER_SYNOLOGY:
|
||||
data = await self._update_synology()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1512,6 +1524,87 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_synology(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a Synology Photos library via its web API.
|
||||
|
||||
Metadata (capture date, GPS, address, description) is returned inline
|
||||
with each item, so every ``MediaItem`` is built fully here - there is
|
||||
no background enrichment pass. Thumbnail URLs carry no SID; the session
|
||||
cookie is stored on the coordinator and sent server-side by the camera
|
||||
(like the Immich x-api-key), so the SID never reaches the browser.
|
||||
"""
|
||||
from . import synology as syn_api
|
||||
|
||||
url = self.entry.data.get(CONF_SYNOLOGY_URL)
|
||||
username = self.entry.data.get(CONF_SYNOLOGY_USERNAME)
|
||||
password = self.entry.data.get(CONF_SYNOLOGY_PASSWORD)
|
||||
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
|
||||
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
||||
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
|
||||
size = self.entry.data.get(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
if not url or not username or not password:
|
||||
raise UpdateFailed("Synology provider is missing URL or credentials")
|
||||
|
||||
client = syn_api.SynologyClient(
|
||||
self.hass,
|
||||
url,
|
||||
username=username,
|
||||
password=password,
|
||||
device_id=device_id,
|
||||
space=space,
|
||||
)
|
||||
try:
|
||||
await client.async_login()
|
||||
photos = await client.async_collect_assets(album_id or None)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
|
||||
|
||||
if not photos:
|
||||
await client.async_logout()
|
||||
raise UpdateFailed("No images found for the selected Synology source")
|
||||
|
||||
# Store the session cookie so the camera can fetch thumbnail bytes
|
||||
# server-side. Do not log out: the SID must stay valid until the next
|
||||
# refresh re-authenticates.
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
ref = syn_api.thumbnail_ref(p)
|
||||
if not ref:
|
||||
continue
|
||||
unit_id, cache_key = ref
|
||||
meta = syn_api.parse_photo_meta(p)
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=syn_api.build_thumbnail_url(
|
||||
client.base_url, unit_id, cache_key, size, space
|
||||
),
|
||||
width=meta.get("width"),
|
||||
height=meta.get("height"),
|
||||
mime_type=None,
|
||||
filename=p.get("filename"),
|
||||
captured_at=meta.get("captured_at"),
|
||||
byte_size=meta.get("byte_size"),
|
||||
latitude=meta.get("latitude"),
|
||||
longitude=meta.get("longitude"),
|
||||
location=meta.get("location"),
|
||||
description=meta.get("description"),
|
||||
source_id=str(p.get("id")) if p.get("id") is not None else None,
|
||||
exif_scanned=True,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any Synology images")
|
||||
|
||||
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.4.0"
|
||||
"version": "1.5.0"
|
||||
}
|
||||
|
||||
@@ -84,6 +84,26 @@
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"synology": {
|
||||
"title": "Synology Photos",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
|
||||
"data": {
|
||||
"synology_url": "DSM URL",
|
||||
"synology_username": "Username",
|
||||
"synology_password": "Password",
|
||||
"synology_space": "Library",
|
||||
"otp_code": "2FA code (only if enabled)"
|
||||
}
|
||||
},
|
||||
"synology_select": {
|
||||
"title": "Synology source",
|
||||
"description": "Pick an album or show the whole library, and choose the image quality.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album": "Album",
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -100,7 +120,9 @@
|
||||
"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.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -84,6 +84,26 @@
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"synology": {
|
||||
"title": "Synology Photos",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
|
||||
"data": {
|
||||
"synology_url": "DSM URL",
|
||||
"synology_username": "Username",
|
||||
"synology_password": "Password",
|
||||
"synology_space": "Library",
|
||||
"otp_code": "2FA code (only if enabled)"
|
||||
}
|
||||
},
|
||||
"synology_select": {
|
||||
"title": "Synology source",
|
||||
"description": "Pick an album or show the whole library, and choose the image quality.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album": "Album",
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -100,7 +120,9 @@
|
||||
"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.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.4.0";
|
||||
const VERSION = "1.5.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
Reference in New Issue
Block a user