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