This commit is contained in:
Home Assistant Version Control
2026-07-24 15:19:02 +00:00
parent 8abf4efaec
commit 2d21a40fd6
48 changed files with 4181 additions and 232 deletions
+284 -44
View File
@@ -51,8 +51,20 @@ from .const import (
CONF_SYNOLOGY_SPACE,
CONF_SYNOLOGY_ALBUM_ID,
CONF_SYNOLOGY_IMAGE_SIZE,
CONF_SYNOLOGY_PASSPHRASE,
CONF_SYNOLOGY_FAVORITE,
CONF_SYNOLOGY_SELECTION,
DEFAULT_SYNOLOGY_IMAGE_SIZE,
SYNOLOGY_SPACE_PERSONAL,
CONF_NEXTCLOUD_URL,
CONF_NEXTCLOUD_USERNAME,
CONF_NEXTCLOUD_PASSWORD,
CONF_NEXTCLOUD_FOLDER,
CONF_NEXTCLOUD_RECURSIVE,
CONF_NEXTCLOUD_IMAGE_SIZE,
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
NEXTCLOUD_IMAGE_ORIGINAL,
NEXTCLOUD_PREVIEW_PX,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
@@ -62,6 +74,7 @@ from .const import (
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
PROVIDER_NEXTCLOUD,
)
from .store import SlideshowStore
@@ -420,6 +433,11 @@ _NOMINATIM_TIMEOUT_S = 20
_EXIF_BATCH_SAVE = 25
_GEOCODE_BATCH_SAVE = 10
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
# blocks of the file, but we read the whole thing since Pillow needs a complete
# image; this bounds memory for pathological files. 64 MB matches the camera.
_NEXTCLOUD_ENRICH_MAX_BYTES = 64 * 1024 * 1024
# Inserted between background-enrichment iterations so the event loop
# stays responsive on the fast path (items that are already scanned and
# need zero work).
@@ -674,53 +692,90 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
try:
with Image.open(path) as img:
exif = img.getexif()
if exif:
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
# Description can come from IPTC / XMP even when the file has no
# EXIF IFD, so this runs regardless of ``exif`` being present.
description = _read_photo_description(img, exif)
if description:
out["description"] = description
if not exif:
return out
gps = None
try:
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
except Exception:
gps = None
if gps:
lat = _gps_to_decimal(
gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF)
)
lon = _gps_to_decimal(
gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF)
)
if lat is not None and lon is not None:
# Null Island guard: GPS chips and some editors stamp
# ``(0, 0)`` when the fix is invalid. Treat that as no
# location rather than dropping every such photo onto
# the equator off the African coast.
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
return out
out["latitude"] = lat
out["longitude"] = lon
_read_exif_from_image(img, out)
except Exception as err:
_LOGGER.debug("EXIF: failed to read %s: %s", path, err)
return out
def _read_exif_from_image(img: Any, out: dict[str, Any]) -> None:
"""Fill ``out`` with capture date / description / GPS from an open image.
Shared by ``_read_local_exif`` (opens from a filesystem path) and
``_read_exif_from_bytes`` (opens from downloaded bytes, e.g. the
Nextcloud provider) - both hand this an already-``Image.open``'d image
plus a dict pre-seeded with a fallback ``captured_at``.
"""
exif = img.getexif()
if exif:
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
# Description can come from IPTC / XMP even when the file has no EXIF
# IFD, so this runs regardless of ``exif`` being present.
description = _read_photo_description(img, exif)
if description:
out["description"] = description
if not exif:
return
gps = None
try:
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
except Exception:
gps = None
if gps:
lat = _gps_to_decimal(gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF))
lon = _gps_to_decimal(gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF))
if lat is not None and lon is not None:
# Null Island guard: GPS chips and some editors stamp ``(0, 0)``
# when the fix is invalid. Treat that as no location rather than
# dropping every such photo onto the equator off the African coast.
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
return
out["latitude"] = lat
out["longitude"] = lon
def _read_exif_from_bytes(
data: bytes, mtime_fallback_ms: int | None
) -> dict[str, Any]:
"""Read EXIF metadata from already-downloaded image bytes.
Same return shape as ``_read_local_exif``, for providers (Nextcloud)
whose files live on a remote server rather than the local filesystem -
the caller downloads the file once for enrichment, regardless of which
quality is used for display. ``mtime_fallback_ms`` takes the place of
the filesystem mtime fallback (e.g. the WebDAV ``Last-Modified`` date).
"""
out: dict[str, Any] = {}
if isinstance(mtime_fallback_ms, int):
out["captured_at"] = mtime_fallback_ms
try:
from PIL import Image
except Exception: # pragma: no cover - Pillow ships with HA core
return out
try:
import io
with Image.open(io.BytesIO(data)) as img:
_read_exif_from_image(img, out)
except Exception as err:
_LOGGER.debug("EXIF: failed to read image bytes: %s", err)
return out
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
"""Turn a Nominatim reverse-geocode response into a short label.
@@ -951,6 +1006,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_icloud()
elif self.provider == PROVIDER_SYNOLOGY:
data = await self._update_synology()
elif self.provider == PROVIDER_NEXTCLOUD:
data = await self._update_nextcloud()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -964,7 +1021,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items:
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) 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.
@@ -1541,6 +1598,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
passphrase = self.entry.data.get(CONF_SYNOLOGY_PASSPHRASE)
favorite_only = bool(self.entry.data.get(CONF_SYNOLOGY_FAVORITE))
selection_raw = self.entry.data.get(CONF_SYNOLOGY_SELECTION)
size = self.entry.data.get(
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
)
@@ -1557,7 +1617,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
)
try:
await client.async_login()
photos = await client.async_collect_assets(album_id or None)
if selection_raw:
# Composite selection (albums + people + places + tags +
# subjects + favorites), merged client-side.
try:
selection = json.loads(selection_raw)
except (TypeError, ValueError):
selection = {}
photos = await client.async_collect_composite(selection)
else:
# Legacy single-source entries (favorites / one album / all).
photos = await client.async_collect_assets(
album_id or None,
passphrase=passphrase or None,
favorite_only=favorite_only,
)
except syn_api.SynologyPermissionError as err:
raise UpdateFailed(str(err)) from err
except Exception as err:
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
@@ -1577,10 +1653,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
continue
unit_id, cache_key = ref
meta = syn_api.parse_photo_meta(p)
# Items pulled from a shared-with-me album carry their own
# passphrase (composite path); fall back to the single-album
# passphrase for legacy entries.
item_pp = p.get("_passphrase") or passphrase or None
items.append(
MediaItem(
url=syn_api.build_thumbnail_url(
client.base_url, unit_id, cache_key, size, space
client.base_url,
unit_id,
cache_key,
size,
space,
passphrase=item_pp,
),
width=meta.get("width"),
height=meta.get("height"),
@@ -1605,6 +1690,143 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_nextcloud(self) -> dict[str, Any]:
"""List photos from an authenticated Nextcloud WebDAV folder.
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). The app
password is sent server-side only via the coordinator's image headers.
"""
from . import nextcloud as nc_api
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 ""
recursive = bool(self.entry.data.get(CONF_NEXTCLOUD_RECURSIVE, False))
size = self.entry.data.get(
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
)
if not url or not username or not password:
raise UpdateFailed("Nextcloud provider is missing URL or credentials")
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
try:
photos = await client.async_list_photos(recursive=recursive)
except Exception as err:
raise UpdateFailed(f"Error listing Nextcloud folder: {err}") from err
if not photos:
raise UpdateFailed("No images found in the Nextcloud folder")
# The camera fetches image bytes server-side with this Basic-auth
# header, so the app password never appears in the browser URL.
self.image_request_headers = dict(client.image_headers)
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(
client.base_url, 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("filename"),
uploaded_at=p.get("mtime_ms"),
byte_size=p.get("size"),
source_id=p.get("file_id") or 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
Immich's per-asset detail call), so enrichment costs one full-file
download per photo regardless of the display quality configured.
"""
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)
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):
async with session.get(original_url, headers=headers) as resp:
resp.raise_for_status()
chunks: list[bytes] = []
total = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
total += len(chunk)
if total > _NEXTCLOUD_ENRICH_MAX_BYTES:
_LOGGER.debug(
"Nextcloud: %s exceeded %d byte enrichment cap; skipping",
item.filename, _NEXTCLOUD_ENRICH_MAX_BYTES,
)
item.exif_scanned = True
return
chunks.append(chunk)
data = b"".join(chunks)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug(
"Nextcloud: failed to download %s for enrichment: %s",
item.filename, err,
)
item.exif_scanned = True
return
info = await self.hass.async_add_executor_job(
_read_exif_from_bytes, data, item.uploaded_at
)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
item.exif_scanned = True
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
@@ -1677,6 +1899,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.async_set_updated_data(data)
continue
if self.provider == PROVIDER_NEXTCLOUD:
try:
await self._enrich_nextcloud_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Nextcloud enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True