124 files

This commit is contained in:
Home Assistant Version Control
2026-08-31 15:02:40 +00:00
parent 33de328a33
commit 9742f385a3
124 changed files with 13907 additions and 1376 deletions
@@ -1204,6 +1204,12 @@ class AlbumSlideshowCamera(Camera):
url, len(data), _MAX_DOWNLOAD_BYTES,
)
return None
elif not url.startswith("http"):
# End-to-end encrypted providers (Ente) can't be fetched by URL:
# the coordinator downloads and decrypts the bytes for us.
data = await self._provider_bytes(url)
if data is None:
return None
else:
data = await self._http_get(url)
if data is None:
@@ -1212,6 +1218,27 @@ class AlbumSlideshowCamera(Camera):
self._download_cache.put(url, data)
return data
async def _provider_bytes(self, url: str) -> bytes | None:
"""Ask the coordinator for decrypted bytes behind a provider URL."""
fetcher = getattr(self.coordinator, "async_fetch_image_bytes", None)
if fetcher is None:
_LOGGER.warning("Album Slideshow: no handler for image url %s", url)
return None
try:
data = await fetcher(url)
except Exception as err: # noqa: BLE001
_LOGGER.warning("Album Slideshow: failed to fetch %s: %s", url, err)
return None
if data is None:
return None
if len(data) > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: %s is %d bytes, exceeds %d byte limit; skipping",
url, len(data), _MAX_DOWNLOAD_BYTES,
)
return None
return data
def _image_request_headers(self, url: str) -> dict[str, str] | None:
"""Auth headers required to fetch image bytes for some providers.
@@ -78,9 +78,22 @@ from .const import (
CONF_NEXTCLOUD_FOLDER,
CONF_NEXTCLOUD_RECURSIVE,
CONF_NEXTCLOUD_IMAGE_SIZE,
CONF_NEXTCLOUD_SHARE_TOKEN,
CONF_NEXTCLOUD_AUTH_MODE,
NEXTCLOUD_AUTH_MODE_FOLDER,
NEXTCLOUD_AUTH_MODE_PUBLIC,
DEFAULT_NEXTCLOUD_AUTH_MODE,
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
NEXTCLOUD_IMAGE_PREVIEW,
NEXTCLOUD_IMAGE_ORIGINAL,
CONF_ENTE_URL,
CONF_ENTE_ACCESS_TOKEN,
CONF_ENTE_COLLECTION_KEY,
CONF_ENTE_API_ORIGIN,
CONF_ENTE_IMAGE_SIZE,
DEFAULT_ENTE_IMAGE_SIZE,
ENTE_IMAGE_FULL,
ENTE_IMAGE_PREVIEW,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
@@ -90,6 +103,7 @@ from .const import (
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
DEFAULT_RECURSIVE,
)
@@ -181,6 +195,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if config_entry.data.get(CONF_PROVIDER) in (
PROVIDER_LOCAL_FOLDER,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
):
return LocalFolderOptionsFlow()
return _NoOptionsFlow()
@@ -202,6 +217,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_synology()
if self._provider == PROVIDER_NEXTCLOUD:
return await self.async_step_nextcloud()
if self._provider == PROVIDER_ENTE:
return await self.async_step_ente()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -213,7 +230,8 @@ 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_NEXTCLOUD: "Nextcloud (WebDAV folder or public album link)",
PROVIDER_ENTE: "Ente Photos (public album link)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -976,6 +994,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_nextcloud(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Pick between an authenticated WebDAV folder and a public album link."""
if user_input is not None:
if user_input[CONF_NEXTCLOUD_AUTH_MODE] == NEXTCLOUD_AUTH_MODE_PUBLIC:
return await self.async_step_nextcloud_public()
return await self.async_step_nextcloud_folder()
schema = vol.Schema(
{
vol.Required(
CONF_NEXTCLOUD_AUTH_MODE, default=DEFAULT_NEXTCLOUD_AUTH_MODE
): vol.In(
{
NEXTCLOUD_AUTH_MODE_FOLDER: "Authenticated WebDAV folder",
NEXTCLOUD_AUTH_MODE_PUBLIC: "Public album link",
}
),
}
)
return self.async_show_form(step_id="nextcloud", data_schema=schema)
async def async_step_nextcloud_folder(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect and validate a Nextcloud WebDAV folder + app password."""
errors: dict[str, str] = {}
@@ -1010,6 +1051,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
title=name,
data={
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
CONF_NEXTCLOUD_AUTH_MODE: NEXTCLOUD_AUTH_MODE_FOLDER,
CONF_NEXTCLOUD_URL: client.base_url,
CONF_NEXTCLOUD_USERNAME: username,
CONF_NEXTCLOUD_PASSWORD: password,
@@ -1043,7 +1085,138 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
)
return self.async_show_form(
step_id="nextcloud", data_schema=schema, errors=errors
step_id="nextcloud_folder", data_schema=schema, errors=errors
)
async def async_step_nextcloud_public(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect and validate a Nextcloud Photos public album link."""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
raw_url = user_input[CONF_NEXTCLOUD_URL].strip()
size = user_input.get(
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
)
from . import nextcloud as nc_api
parsed = nc_api.parse_share_link(raw_url)
if parsed is None:
errors[CONF_NEXTCLOUD_URL] = "invalid_nextcloud_url"
else:
base_url, token = parsed
client = nc_api.NextcloudPublicClient(self.hass, base_url, token)
try:
await client.async_validate()
except Exception: # noqa: BLE001 - any failure means bad/expired link
errors["base"] = "nextcloud_public_cannot_connect"
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{base_url}:{token}:{name}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
CONF_NEXTCLOUD_AUTH_MODE: NEXTCLOUD_AUTH_MODE_PUBLIC,
CONF_NEXTCLOUD_URL: base_url,
CONF_NEXTCLOUD_SHARE_TOKEN: token,
CONF_NEXTCLOUD_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_NEXTCLOUD_URL): str,
vol.Optional(
CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE
): vol.In(
{
NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)",
NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)",
}
),
}
)
return self.async_show_form(
step_id="nextcloud_public", data_schema=schema, errors=errors
)
async def async_step_ente(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect and validate an Ente Photos public album link."""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
raw_url = user_input[CONF_ENTE_URL].strip()
api_origin = (user_input.get(CONF_ENTE_API_ORIGIN) or "").strip()
size = user_input.get(CONF_ENTE_IMAGE_SIZE, DEFAULT_ENTE_IMAGE_SIZE)
from . import ente as ente_api
share = ente_api.parse_share_link(raw_url)
if share is None:
errors[CONF_ENTE_URL] = "invalid_ente_url"
else:
client = ente_api.EnteClient(self.hass, share, api_origin)
try:
await client.async_validate()
except Exception as err: # noqa: BLE001 - bad, expired or private link
_LOGGER.warning(
"Ente validation failed for %s: %s",
share.album_origin,
_describe_error(err),
)
errors["base"] = "ente_cannot_connect"
else:
from base64 import b64encode
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_ENTE}:{share.access_token}:{name}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_ENTE,
CONF_ENTE_URL: share.album_origin,
CONF_ENTE_ACCESS_TOKEN: share.access_token,
CONF_ENTE_COLLECTION_KEY: b64encode(
share.collection_key
).decode("ascii"),
CONF_ENTE_API_ORIGIN: ente_api.normalize_api_origin(
api_origin
),
CONF_ENTE_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_ENTE_URL): str,
vol.Optional(
CONF_ENTE_IMAGE_SIZE, default=DEFAULT_ENTE_IMAGE_SIZE
): vol.In(
{
ENTE_IMAGE_FULL: "Full quality (original file)",
ENTE_IMAGE_PREVIEW: "Preview (faster, softer)",
}
),
vol.Optional(CONF_ENTE_API_ORIGIN): str,
}
)
return self.async_show_form(
step_id="ente", data_schema=schema, errors=errors
)
+37 -5
View File
@@ -59,18 +59,31 @@ PROVIDER_PHOTOPRISM = "photoprism"
PROVIDER_ICLOUD = "icloud"
PROVIDER_SYNOLOGY = "synology"
PROVIDER_NEXTCLOUD = "nextcloud"
PROVIDER_ENTE = "ente"
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
#
# - "folder": authenticated WebDAV folder. Points at any folder in a user's
# files and lists it over WebDAV. Auth is HTTP Basic with a username + app
# password (Settings > Security > Devices & sessions); the app password is
# stored so the coordinator can re-list on each refresh and is sent
# server-side only, never reaching the browser.
# - "public_link": a public Nextcloud Photos album share link. No credentials
# involved - the share token embedded in the URL path is the only thing the
# server checks against the unauthenticated ``photospublic`` WebDAV
# collection.
CONF_NEXTCLOUD_AUTH_MODE = "nextcloud_auth_mode"
NEXTCLOUD_AUTH_MODE_FOLDER = "folder"
NEXTCLOUD_AUTH_MODE_PUBLIC = "public_link"
DEFAULT_NEXTCLOUD_AUTH_MODE = NEXTCLOUD_AUTH_MODE_FOLDER
# Nextcloud (authenticated WebDAV folder) provider. Points at any folder in a
# user's files and lists it over WebDAV. Auth is HTTP Basic with a username +
# app password (Settings > Security > Devices & sessions); the app password is
# stored so the coordinator can re-list on each refresh and is sent server-side
# only, never reaching the browser.
CONF_NEXTCLOUD_URL = "nextcloud_url"
CONF_NEXTCLOUD_USERNAME = "nextcloud_username"
CONF_NEXTCLOUD_PASSWORD = "nextcloud_password"
CONF_NEXTCLOUD_FOLDER = "nextcloud_folder"
CONF_NEXTCLOUD_RECURSIVE = "nextcloud_recursive"
CONF_NEXTCLOUD_IMAGE_SIZE = "nextcloud_image_size"
CONF_NEXTCLOUD_SHARE_TOKEN = "nextcloud_share_token"
# ``preview`` uses the core/preview thumbnail endpoint (smoother, smaller);
# ``original`` fetches the real file straight off the WebDAV collection.
@@ -81,6 +94,25 @@ DEFAULT_NEXTCLOUD_IMAGE_SIZE = NEXTCLOUD_IMAGE_PREVIEW
# Long edge (px) requested from the core/preview endpoint for preview quality.
NEXTCLOUD_PREVIEW_PX = 1920
# Ente Photos (public album link) provider. Ente is end-to-end encrypted: the
# access token in the link's ``?t=`` query authenticates to the API, while the
# collection key lives in the link's URL *fragment* and never reaches the
# server. Both are stored so the coordinator can re-list and decrypt on each
# refresh; neither is ever put in an image URL, because Ente images are
# decrypted inside Home Assistant and served from memory.
CONF_ENTE_URL = "ente_url"
CONF_ENTE_ACCESS_TOKEN = "ente_access_token"
CONF_ENTE_COLLECTION_KEY = "ente_collection_key"
CONF_ENTE_API_ORIGIN = "ente_api_origin"
CONF_ENTE_IMAGE_SIZE = "ente_image_size"
# ``full`` downloads the original file; ``preview`` uses Ente's smaller
# pre-generated thumbnail (much faster, noticeably softer on a big screen).
ENTE_IMAGE_FULL = "full"
ENTE_IMAGE_PREVIEW = "preview"
ENTE_IMAGE_SIZE_OPTIONS = [ENTE_IMAGE_FULL, ENTE_IMAGE_PREVIEW]
DEFAULT_ENTE_IMAGE_SIZE = ENTE_IMAGE_FULL
# 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
+304 -25
View File
@@ -65,9 +65,20 @@ from .const import (
CONF_NEXTCLOUD_FOLDER,
CONF_NEXTCLOUD_RECURSIVE,
CONF_NEXTCLOUD_IMAGE_SIZE,
CONF_NEXTCLOUD_SHARE_TOKEN,
CONF_NEXTCLOUD_AUTH_MODE,
NEXTCLOUD_AUTH_MODE_PUBLIC,
DEFAULT_NEXTCLOUD_AUTH_MODE,
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
NEXTCLOUD_IMAGE_ORIGINAL,
NEXTCLOUD_PREVIEW_PX,
CONF_ENTE_URL,
CONF_ENTE_ACCESS_TOKEN,
CONF_ENTE_COLLECTION_KEY,
CONF_ENTE_API_ORIGIN,
CONF_ENTE_IMAGE_SIZE,
DEFAULT_ENTE_IMAGE_SIZE,
ENTE_IMAGE_PREVIEW,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
@@ -78,6 +89,7 @@ from .const import (
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
)
from .store import SlideshowStore
@@ -883,6 +895,55 @@ def _read_manifest_version(integration_dir: Path) -> str:
return ""
def _build_ente_item(
raw: dict[str, Any], collection_key: bytes, want_preview: bool
) -> tuple[MediaItem, dict[str, Any]] | None:
"""Decrypt one Ente file into a MediaItem plus its fetch material.
Runs in an executor: unsealing a key and opening two small secretstreams
is CPU work, and an album can hold thousands of files. Returns ``None``
for anything that is not a still image.
"""
from . import ente as ente_api
file_key = ente_api.decrypt_file_key(raw, collection_key)
metadata = ente_api.decrypt_metadata(raw, file_key)
if not ente_api.is_image(metadata):
return None
magic = ente_api.decrypt_magic_metadata(raw, file_key)
fields = ente_api.build_media_fields(metadata, magic)
attrs = raw.get("thumbnail" if want_preview else "file")
if not isinstance(attrs, dict) or not attrs.get("decryptionHeader"):
return None
info = raw.get("info") if isinstance(raw.get("info"), dict) else {}
item = MediaItem(
url=ente_api.build_item_url(raw["id"]),
width=fields["width"],
height=fields["height"],
mime_type=None,
filename=fields["filename"],
captured_at=fields["captured_at"],
uploaded_at=fields["uploaded_at"],
byte_size=info.get("thumbSize" if want_preview else "fileSize"),
latitude=fields["latitude"],
longitude=fields["longitude"],
description=fields["description"],
source_id=str(raw["id"]),
# Ente hands over full metadata up front, so there is nothing for the
# per-photo enrichment download to add.
exif_scanned=True,
)
meta = {
"key": file_key,
"header": attrs["decryptionHeader"],
"thumbnail": want_preview,
}
return item, meta
def _merge_prior_enrichment(
new_items: list[MediaItem], prior_items: list[MediaItem]
) -> None:
@@ -938,6 +999,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
# Extra headers the camera must send when fetching image bytes
# (Immich API key). Empty for providers that need no auth.
self.image_request_headers: dict[str, str] = {}
# Ente only: file id -> {key, header, thumbnail}, the material needed
# to decrypt each image. Rebuilt on every album refresh.
self._ente_fetch_meta: dict[str, dict[str, Any]] = {}
# Persist the most recent successful album fetch so that a transient
# network/Google failure doesn't blank the slideshow on restart.
@@ -1011,6 +1075,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_synology()
elif self.provider == PROVIDER_NEXTCLOUD:
data = await self._update_nextcloud()
elif self.provider == PROVIDER_ENTE:
data = await self._update_ente()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1024,7 +1090,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) and items:
if self.provider in (
PROVIDER_LOCAL_FOLDER,
PROVIDER_IMMICH,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
) and items:
# Carry forward EXIF/geocode metadata for items we've already
# scanned this session; new items get filled in by the
# background worker below.
@@ -1066,7 +1137,13 @@ class AlbumCoordinator(DataUpdateCoordinator):
"""Kick off the background EXIF + geocode worker if there's work."""
items: list[MediaItem] = data.get("items") or []
unscanned = [it for it in items if not it.exif_scanned]
if not unscanned:
# Providers that decrypt/return GPS inline (Ente) have nothing to scan
# but still need the coordinates turned into a place label.
needs_geocode = any(
it.latitude is not None and it.longitude is not None and not it.location
for it in items
)
if not unscanned and not needs_geocode:
# Even with nothing to do, mark the phase as ``done`` so the
# diagnostic sensor stops reporting an in-progress run from
# the previous refresh.
@@ -1736,6 +1813,118 @@ class AlbumCoordinator(DataUpdateCoordinator):
}
async def _update_nextcloud(self) -> dict[str, Any]:
"""List photos from Nextcloud, dispatching on the configured auth mode."""
mode = self.entry.data.get(
CONF_NEXTCLOUD_AUTH_MODE, DEFAULT_NEXTCLOUD_AUTH_MODE
)
if mode == NEXTCLOUD_AUTH_MODE_PUBLIC:
return await self._update_nextcloud_public()
return await self._update_nextcloud_folder()
def _ente_share(self):
"""Rebuild the parsed share (token + collection key) from entry data."""
from base64 import b64decode
from . import ente as ente_api
token = self.entry.data.get(CONF_ENTE_ACCESS_TOKEN)
raw_key = self.entry.data.get(CONF_ENTE_COLLECTION_KEY)
if not token or not raw_key:
raise UpdateFailed("Ente entry is missing its link credentials")
return ente_api.EnteShare(
token,
b64decode(raw_key),
self.entry.data.get(CONF_ENTE_URL) or "",
)
async def _update_ente(self) -> dict[str, Any]:
"""List and decrypt an Ente public album.
Ente returns everything encrypted, so each file's key is unsealed from
the collection key and its metadata decrypted here. That metadata is
complete (capture date, GPS, caption), so unlike the folder-style
providers there is no per-photo enrichment download; items come back
already scanned and only need reverse-geocoding.
"""
from . import ente as ente_api
share = self._ente_share()
client = ente_api.EnteClient(
self.hass, share, self.entry.data.get(CONF_ENTE_API_ORIGIN)
)
try:
files = await client.async_list_files()
except Exception as err:
raise UpdateFailed(f"Error listing Ente album: {err}") from err
want_preview = (
self.entry.data.get(CONF_ENTE_IMAGE_SIZE, DEFAULT_ENTE_IMAGE_SIZE)
== ENTE_IMAGE_PREVIEW
)
items: list[MediaItem] = []
fetch_meta: dict[str, dict[str, Any]] = {}
for f in files:
try:
built = await self.hass.async_add_executor_job(
_build_ente_item, f, share.collection_key, want_preview
)
except Exception as err: # noqa: BLE001 - one bad file must not fail the album
_LOGGER.debug("Ente: skipping file %s (%s)", f.get("id"), err)
continue
if built is None:
continue
item, meta = built
items.append(item)
fetch_meta[str(f["id"])] = meta
if not items:
raise UpdateFailed("No images found in the Ente album")
# Per-file keys + stream headers, so the camera can decrypt on demand
# without re-listing the album for every slide.
self._ente_fetch_meta = fetch_meta
return {
"title": self.entry.title,
"items": items,
}
async def async_fetch_image_bytes(self, url: str) -> bytes | None:
"""Fetch and decrypt bytes for a provider-internal URL.
Only Ente uses this today: its images cannot be served by URL because
the bytes are end-to-end encrypted, so the camera hands the synthetic
``ente://<fileID>`` id back here to be downloaded and decrypted.
Returns ``None`` for anything this coordinator does not own.
"""
from . import ente as ente_api
if self.provider != PROVIDER_ENTE:
return None
file_id = ente_api.file_id_from_url(url)
if not file_id:
return None
meta = (self._ente_fetch_meta or {}).get(str(file_id))
if not meta:
_LOGGER.debug("Ente: no decryption material cached for %s", file_id)
return None
share = self._ente_share()
client = ente_api.EnteClient(
self.hass, share, self.entry.data.get(CONF_ENTE_API_ORIGIN)
)
try:
return await client.async_fetch_image(
file_id, meta["key"], meta["header"], meta["thumbnail"]
)
except Exception as err: # noqa: BLE001
_LOGGER.warning("Ente: failed to fetch image %s: %s", file_id, err)
return None
async def _update_nextcloud_folder(self) -> dict[str, Any]:
"""List photos from an authenticated Nextcloud WebDAV folder.
The PROPFIND listing carries filename/size/content-type/mtime but no
@@ -1799,39 +1988,85 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_nextcloud_public(self) -> dict[str, Any]:
"""List photos from a Nextcloud Photos public collaborative album.
The PROPFIND listing carries filename/size/content-type/mtime but no
EXIF, so capture date, GPS and description are filled in afterwards
by the background enrichment worker (one original-file download per
photo - Nextcloud has no metadata-only endpoint the way Immich does).
No credentials are involved - the share token is embedded in the URL.
"""
from . import nextcloud as nc_api
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
token = self.entry.data.get(CONF_NEXTCLOUD_SHARE_TOKEN)
size = self.entry.data.get(
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
)
if not url or not token:
raise UpdateFailed("Nextcloud provider is missing the URL or share token")
client = nc_api.NextcloudPublicClient(self.hass, url, token)
try:
photos = await client.async_list_photos()
except Exception as err:
raise UpdateFailed(f"Error listing Nextcloud album: {err}") from err
if not photos:
raise UpdateFailed("No images found in the Nextcloud album")
items: list[MediaItem] = []
for p in photos:
href = p.get("href")
if not href:
continue
if size != NEXTCLOUD_IMAGE_ORIGINAL and p.get("file_id"):
display_url = nc_api.build_preview_url_public(
client.base_url, token, p["file_id"], NEXTCLOUD_PREVIEW_PX
)
else:
display_url = href
items.append(
MediaItem(
url=display_url,
width=None,
height=None,
mime_type=p.get("content_type"),
filename=p.get("display_name") or p.get("filename"),
uploaded_at=p.get("mtime_ms"),
byte_size=p.get("size"),
# The DAV href, so enrichment can fetch the original bytes
# even when the display URL is a preview.
source_id=href,
)
)
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_nextcloud_item(self, item: MediaItem) -> None:
"""Download one Nextcloud photo's original bytes and read its EXIF.
Nextcloud's WebDAV folder has no metadata-only endpoint (unlike
Nextcloud has no metadata-only endpoint for either auth mode (unlike
Immich's per-asset detail call), so enrichment costs one full-file
download per photo regardless of the display quality configured.
Dispatches on the configured auth mode to build the right download
URL/headers; the download-and-parse tail is identical either way.
"""
from . import nextcloud as nc_api
from urllib.parse import quote
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
if not username or not password or not url:
item.exif_scanned = True
return
# Reconstruct the original-file URL: for preview items the display url
# is the preview endpoint, so fall back to the folder href by filename.
original_url = None
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
original_url = item.url
elif item.filename:
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
original_url = client.dav_root + quote(item.filename)
mode = self.entry.data.get(
CONF_NEXTCLOUD_AUTH_MODE, DEFAULT_NEXTCLOUD_AUTH_MODE
)
if mode == NEXTCLOUD_AUTH_MODE_PUBLIC:
original_url, headers = self._nextcloud_public_original(item)
else:
original_url, headers = self._nextcloud_folder_original(item)
if not original_url:
item.exif_scanned = True
return
headers = {
"Authorization": nc_api.basic_auth_header(username, password)
}
session = async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(30):
@@ -1872,6 +2107,50 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.longitude = info["longitude"]
item.exif_scanned = True
def _nextcloud_folder_original(
self, item: MediaItem
) -> tuple[str | None, dict[str, str]]:
"""Return ``(original_url, headers)`` for a folder-mode item, or (None, {})."""
from . import nextcloud as nc_api
from urllib.parse import quote
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
if not username or not password or not url:
return None, {}
# Reconstruct the original-file URL: for preview items the display url
# is the preview endpoint, so fall back to the folder href by filename.
original_url = None
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
original_url = item.url
elif item.filename:
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
original_url = client.dav_root + quote(item.filename)
if not original_url:
return None, {}
return original_url, {"Authorization": nc_api.basic_auth_header(username, password)}
def _nextcloud_public_original(
self, item: MediaItem
) -> tuple[str | None, dict[str, str]]:
"""Return ``(original_url, headers)`` for a public-link item, or (None, {})."""
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
token = self.entry.data.get(CONF_NEXTCLOUD_SHARE_TOKEN)
if not url or not token:
return None, {}
# For preview items the display url is the preview endpoint, so fall
# back to the DAV href stashed on the item at listing time.
for candidate in (item.url, item.source_id):
if isinstance(candidate, str) and (
"/remote.php/dav/photospublic/" in candidate
):
return candidate, {}
return None, {}
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
@@ -7,6 +7,6 @@
"documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.8.1"
"requirements": ["Pillow", "PyNaCl>=1.5.0"],
"version": "1.9.0"
}
+31 -1
View File
@@ -111,6 +111,13 @@
}
},
"nextcloud": {
"title": "Nextcloud",
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
"data": {
"nextcloud_auth_mode": "Connection type"
}
},
"nextcloud_folder": {
"title": "Nextcloud folder",
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
"data": {
@@ -122,6 +129,25 @@
"nextcloud_recursive": "Include subfolders",
"nextcloud_image_size": "Image quality"
}
},
"ente": {
"title": "Ente Photos public album",
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
"data": {
"album_name": "Album name",
"ente_url": "Album share link",
"ente_image_size": "Image quality",
"ente_api_origin": "API endpoint (self-hosted only)"
}
},
"nextcloud_public": {
"title": "Nextcloud public album link",
"description": "Paste a public Nextcloud Photos album share link (from the Photos app's Share menu). No login is needed - the link itself grants access.",
"data": {
"album_name": "Album name",
"nextcloud_url": "Album share link",
"nextcloud_image_size": "Image quality"
}
}
},
"error": {
@@ -143,7 +169,11 @@
"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.",
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
}
},
"options": {
@@ -111,6 +111,13 @@
}
},
"nextcloud": {
"title": "Nextcloud",
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
"data": {
"nextcloud_auth_mode": "Connection type"
}
},
"nextcloud_folder": {
"title": "Nextcloud folder",
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
"data": {
@@ -122,6 +129,25 @@
"nextcloud_recursive": "Include subfolders",
"nextcloud_image_size": "Image quality"
}
},
"ente": {
"title": "Ente Photos public album",
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
"data": {
"album_name": "Album name",
"ente_url": "Album share link",
"ente_image_size": "Image quality",
"ente_api_origin": "API endpoint (self-hosted only)"
}
},
"nextcloud_public": {
"title": "Nextcloud public album link",
"description": "Paste a public Nextcloud Photos album share link (from the Photos app's Share menu). No login is needed - the link itself grants access.",
"data": {
"album_name": "Album name",
"nextcloud_url": "Album share link",
"nextcloud_image_size": "Image quality"
}
}
},
"error": {
@@ -143,7 +169,11 @@
"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.",
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.8.1";
const VERSION = "1.9.0";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -810,9 +810,11 @@ function createAlbumSlideshowCardClass(Base) {
ddd: part({ weekday: "short" }),
HH: pad(d.getHours()),
mm: pad(d.getMinutes()),
// Lets relative time be mixed into a format string, e.g. "DD MMMM YYYY - REL".
REL: this._relativeTime(d),
};
return fmt.replace(
/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|mm/g,
/REL|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|mm/g,
(t) => map[t],
);
}
@@ -925,6 +927,10 @@ const CAPTION_DATE_FORMAT_OPTIONS = [
{ value: "numeric", label: "Numeric (8/16/2014)" },
{ value: "weekday", label: "Weekday (Saturday, August 16, 2014)" },
{ value: "relative", label: "Relative (3 years ago)" },
{
value: "D MMMM YYYY - REL",
label: "Date + relative (16 August 2014 - 3 years ago)",
},
];
const DEFAULTS = {
@@ -1440,7 +1446,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
tap_pause_seconds:
"How long the card freezes its slide after a tap. 0 disables it.",
caption_date_format:
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D).",
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D, REL for relative time).",
caption_show:
"Description comes from the photo's EXIF/IPTC/XMP caption and is only available with the local-folder provider.",
caption_per_image: