251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
+190 -96
View File
@@ -10,6 +10,7 @@ import math
import os
from pathlib import Path
import re
import time
from typing import Any
import aiohttp
@@ -41,8 +42,13 @@ from .const import (
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_INCLUDE_VIDEOS,
CONF_IMMICH_LIVE_PHOTOS,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_IMMICH_INCLUDE_VIDEOS,
DEFAULT_IMMICH_LIVE_PHOTOS,
IMMICH_IMAGE_PREVIEW,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
@@ -162,11 +168,29 @@ class MediaItem:
# True when ``location`` came from our OpenStreetMap lookup rather than
# the provider, so it can be re-formatted from the place cache.
location_geocoded: bool = False
# Upstream playback URL for a video item (``url`` is then its poster
# still), or for a Live Photo's motion clip (``live_photo``; ``url`` is
# the photo). Fetched server-side by the video proxy view only, so
# provider credentials never reach the browser. ``duration_ms`` is the
# clip length (videos only).
video_url: str | None = None
duration_ms: int | None = None
live_photo: bool = False
# Provider file path (Immich ``originalPath``), shown by the card's
# ``path`` caption.
source_path: str | None = None
# Runtime-only description of the stream a video is served as (see
# ``async_video_info``); not persisted.
video_info: dict[str, Any] | None = None
def __post_init__(self) -> None:
if self.photo_id is None:
self.photo_id = _photo_identifier(self.source_id)
@property
def is_video(self) -> bool:
return bool(self.video_url) and not self.live_photo
def _photo_identifier(source_id: str | None) -> str | None:
if not source_id:
@@ -505,9 +529,12 @@ _NAME_PREFIXES = (
"village of ", "town of ", "city of ",
)
# How many EXIF reads to perform between cache flushes. Bigger batches
# mean fewer Store writes but more rework if HA is killed mid-scan.
# How many EXIF reads to perform between listener updates, and the minimum
# time between items-cache writes during a pass (the cache holds every item,
# so writing it per batch is costly on large libraries; the pass still saves
# when it finishes or is cancelled).
_EXIF_BATCH_SAVE = 25
_ENRICHMENT_SAVE_INTERVAL = 60.0
_GEOCODE_BATCH_SAVE = 10
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
@@ -1145,6 +1172,11 @@ class AlbumCoordinator(DataUpdateCoordinator):
CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID, CONF_IMMICH_FILTER, CONF_IMMICH_IMAGE_SIZE,
)]
# Appended only when enabled so existing caches stay valid.
if entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS):
source.append("videos")
if entry.data.get(CONF_IMMICH_LIVE_PHOTOS):
source.append("live_photos")
self._immich_cache_source = sha256(json.dumps(source).encode()).hexdigest()
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
@@ -1208,6 +1240,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
"geocode_total": 0,
"geocode_done": 0,
}
# Per-item enrichment in flight, shared by the background pass and
# on-demand requests from the camera so an item is fetched once.
self._enrich_inflight: dict[int, asyncio.Task] = {}
self._last_items_save: float | None = None
super().__init__(
hass,
@@ -1526,6 +1562,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
} if isinstance(raw.get("camera_metadata"), dict) else None,
google_location_scanned=bool(raw.get("google_location_scanned", False)),
location_geocoded=bool(raw.get("location_geocoded", False)),
video_url=raw.get("video_url") if isinstance(raw.get("video_url"), str) else None,
duration_ms=raw.get("duration_ms") if isinstance(raw.get("duration_ms"), int) else None,
source_path=raw.get("source_path") if isinstance(raw.get("source_path"), str) else None,
live_photo=bool(raw.get("live_photo", False)),
))
except Exception:
continue
@@ -1570,6 +1610,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
"camera_metadata": it.camera_metadata,
"google_location_scanned": it.google_location_scanned,
"location_geocoded": it.location_geocoded,
"video_url": it.video_url,
"duration_ms": it.duration_ms,
"source_path": it.source_path,
"live_photo": it.live_photo,
}
for it in items
],
@@ -1811,6 +1855,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
sel_type = self.entry.data.get(CONF_IMMICH_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_IMMICH_SELECTION_ID)
size = self.entry.data.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
include_videos = bool(
self.entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS)
)
live_photos = bool(
self.entry.data.get(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS)
)
if not url or not api_key or not sel_type:
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
@@ -1837,7 +1887,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.image_request_headers = dict(client.image_headers)
try:
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
assets = await client.async_collect_assets(
sel_type, sel_id, filter_body, include_videos
)
except Exception as err:
raise UpdateFailed(f"Error querying Immich: {err}") from err
@@ -1854,18 +1906,46 @@ class AlbumCoordinator(DataUpdateCoordinator):
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
w = a.get("width")
h = a.get("height")
is_video = immich_api.is_video_item(a)
motion_id = immich_api.live_photo_video_id(a) if live_photos else None
if is_video:
video_url = immich_api.build_video_url(client.base_url, aid)
elif motion_id:
video_url = immich_api.build_video_url(client.base_url, motion_id)
else:
video_url = None
items.append(
MediaItem(
url=immich_api.build_image_url(client.base_url, aid, size),
# A video's poster still is its preview thumbnail: the
# ``original`` endpoint would return the video file and
# ``fullsize`` is only generated for images.
url=immich_api.build_image_url(
client.base_url, aid, IMMICH_IMAGE_PREVIEW if is_video else size
),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=a.get("originalFileName"),
captured_at=captured,
source_id=aid,
video_url=video_url,
live_photo=bool(motion_id),
duration_ms=(
immich_api.parse_duration_ms(a.get("duration")) if is_video else None
),
source_path=(
a.get("originalPath") if isinstance(a.get("originalPath"), str) else None
),
)
)
_LOGGER.debug(
"Immich %s: %d assets (%d videos, %d Live Photos)",
self.entry.title,
len(items),
sum(1 for item in items if item.is_video),
sum(1 for item in items if item.live_photo),
)
return {
"title": self.entry.title,
"items": items,
@@ -2592,6 +2672,33 @@ class AlbumCoordinator(DataUpdateCoordinator):
return candidate, {}
return None, {}
async def async_video_info(self, item: MediaItem) -> dict[str, Any]:
"""Describe the stream a video slide is served as; cached per item.
Used for the card's ``video_version`` caption: whether Immich serves
its transcoded copy or the original, and the stream's bitrate.
"""
if item.video_info is not None:
return item.video_info
if self.provider != PROVIDER_IMMICH or not item.source_id or not item.is_video:
return {}
from . import immich as immich_api
client = immich_api.ImmichClient(
self.hass,
self.entry.data.get(CONF_IMMICH_URL),
self.entry.data.get(CONF_IMMICH_API_KEY),
)
content_type, served = await client.async_probe_video(item.source_id)
if item.byte_size is None:
# Enrichment may not have reached this item yet.
info = immich_api.parse_asset_exif(await client.async_get_asset(item.source_id))
item.byte_size = info.get("byte_size")
item.video_info = immich_api.describe_video(
content_type, served, item.byte_size, item.duration_ms
)
return item.video_info
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch Immich metadata and the face boxes used for cropping."""
from . import immich as immich_api
@@ -2642,6 +2749,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.location_geocoded = False
if "description" in info:
item.description = info["description"]
if "byte_size" in info:
item.byte_size = info["byte_size"]
item.exif_scanned = True
else:
if asset is None:
@@ -2798,6 +2907,73 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.location_geocoded = False
item.exif_scanned = True
async def async_enrich_item(self, item: MediaItem) -> bool:
"""Enrich one item now, sharing the work with the background pass.
The camera calls this just before rendering a slide so faces (for
cropping) and caption metadata are in place without waiting for the
pass to reach the item. Concurrent callers await the same fetch.
Returns whether metadata was fetched (``False``: nothing to do).
"""
if self.provider == PROVIDER_GOOGLE_SHARED or not self._needs_enrichment(item):
return False
inflight = getattr(self, "_enrich_inflight", None)
if inflight is None:
inflight = self._enrich_inflight = {}
key = id(item)
task = inflight.get(key)
if task is None:
task = self.hass.async_create_background_task(
self._enrich_one(item),
name=f"album_slideshow_enrich_item_{self.entry.entry_id}",
)
inflight[key] = task
task.add_done_callback(lambda _task: inflight.pop(key, None))
# Shielded: a cancelled caller (e.g. a dropped preload) must not
# abandon a fetch another caller is waiting on.
return await asyncio.shield(task)
async def _enrich_one(self, item: MediaItem) -> bool:
"""Fetch one item's metadata from its provider; never raises."""
try:
if self.provider == PROVIDER_IMMICH:
await self._enrich_immich_item(item)
elif self.provider == PROVIDER_NEXTCLOUD:
await self._enrich_nextcloud_item(item)
elif self.provider == PROVIDER_UGREEN:
await self._enrich_ugreen_item(item)
elif item.url.startswith("file://"):
path = Path(item.url[len("file://"):])
info = await self.hass.async_add_executor_job(_read_local_exif, path)
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
else:
# Nothing to read for remote files without a provider API.
item.exif_scanned = True
return False
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("%s enrich error: %s", self.provider, err)
item.exif_scanned = True
self._enrich_progress["exif_done"] = self._enrich_progress.get("exif_done", 0) + 1
return True
async def _flush_enrichment(self, data: dict[str, Any]) -> None:
"""Persist progress (at most once a minute) and notify listeners."""
now = time.monotonic()
last = getattr(self, "_last_items_save", None)
if last is None or now - last >= _ENRICHMENT_SAVE_INTERVAL:
self._last_items_save = now
await self._save_cached_items(data)
self.async_set_updated_data(data)
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -2813,110 +2989,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
if not items:
return
scanned_since_save = 0
pending = 0
try:
for idx, item in enumerate(items):
if not self._needs_enrichment(item):
# Fast path: yield occasionally so we don't starve
# the event loop when (re-)visiting a long list of
# already-processed items.
# already-processed items (including ones the camera
# enriched on demand).
if idx % _ENRICHMENT_FAST_PATH_YIELD_EVERY == 0:
await asyncio.sleep(0)
continue
if self.provider == PROVIDER_IMMICH:
try:
await self._enrich_immich_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Immich 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)
if not await self.async_enrich_item(item):
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
if self.provider == PROVIDER_UGREEN:
try:
await self._enrich_ugreen_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("UGREEN 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
continue
path = Path(url[len("file://"):])
try:
info = await self.hass.async_add_executor_job(
_read_local_exif, path
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("EXIF: executor error for %s: %s", path, err)
info = {}
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
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)
pending += 1
if pending >= _EXIF_BATCH_SAVE:
pending = 0
await self._flush_enrichment(data)
# Flush any tail items before the geocode phase.
if scanned_since_save:
await self._save_cached_items(data)
self.async_set_updated_data(data)
if pending:
await self._flush_enrichment(data)
await self._geocode_items_background(data)
except asyncio.CancelledError: