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