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