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
+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