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
+251 -17
View File
@@ -36,8 +36,10 @@ from .const import (
)
from . import image_processing as ip
from . import playlist
from . import video
from .coordinator import AlbumCoordinator, MediaItem
from .store import SlideshowStore
from .viewers import async_get_tracker
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +61,13 @@ _SKIP_SEARCH_LIMIT = 30
_MAX_RENDER_ATTEMPTS = 10
# How long a render waits for an item's metadata (faces for the crop,
# caption fields) to be fetched on demand before rendering without it.
# Upcoming slides render ahead of time, so this rarely delays a slide.
_ENRICH_WAIT_SECONDS = 8.0
_MB = 1024 * 1024
# Fixed floor (in items) for the pairing cooldown exclusion zone, applied on
# each side of the current index in addition to the percentage-based radius.
# Keeps tiny albums from pairing consecutive/near-consecutive shots even when
@@ -161,9 +170,34 @@ def _caption_frame(item: MediaItem | None) -> dict[str, Any]:
"longitude": getattr(item, "longitude", None),
"description": getattr(item, "description", None),
**_camera_metadata_attributes(item),
**_file_attributes(item),
}
def _file_attributes(item: MediaItem | None) -> dict[str, Any]:
return {
"filename": getattr(item, "filename", None),
"path": getattr(item, "source_path", None),
}
# Describe the stream a video slide is served as (see
# ``AlbumCoordinator.async_video_info``); ``None`` for photos.
VIDEO_INFO_FIELDS = (
"video_version", "video_bitrate_mbps", "video_size_bytes", "video_content_type",
)
def _is_video(item: Any) -> bool:
"""A video slide (played alone). Live Photos are photos with motion."""
return bool(getattr(item, "video_url", None)) and not getattr(item, "live_photo", False)
def _has_motion(item: Any) -> bool:
"""A video, or a Live Photo whose motion clip can play over its still."""
return bool(getattr(item, "video_url", None))
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
"""Return the validated face boxes stored on a media item.
@@ -213,6 +247,10 @@ class _DownloadCache:
def put(self, url: str, data: bytes) -> None:
if len(data) > self._max_bytes:
# Item exceeds the entire cache budget; skip caching but don't raise.
_LOGGER.debug(
"Image cache: not caching %.1f MB image, larger than the %.1f MB budget",
len(data) / _MB, self._max_bytes / _MB,
)
return
if url in self._cache:
self._total_bytes -= len(self._cache[url])
@@ -222,13 +260,28 @@ class _DownloadCache:
self._evict()
def resize(self, max_bytes: int) -> None:
self._max_bytes = max(max_bytes, 1)
max_bytes = max(max_bytes, 1)
if max_bytes != self._max_bytes:
_LOGGER.debug(
"Image cache: budget %.1f MB -> %.1f MB (%.1f MB in use)",
self._max_bytes / _MB, max_bytes / _MB, self._total_bytes / _MB,
)
self._max_bytes = max_bytes
self._evict()
def _evict(self) -> None:
count = freed = 0
while self._total_bytes > self._max_bytes and self._cache:
_, data = self._cache.popitem(last=False)
self._total_bytes -= len(data)
count += 1
freed += len(data)
if count:
_LOGGER.debug(
"Image cache: evicted %d image(s) (%.1f MB), now %.1f/%.1f MB, %d cached",
count, freed / _MB, self._total_bytes / _MB, self._max_bytes / _MB,
len(self._cache),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
@@ -243,6 +296,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
class AlbumSlideshowCamera(Camera):
_attr_should_poll = False
# The signed stream URL and the server's file paths stay out of history.
_unrecorded_attributes = frozenset({"video_url", "current_path"})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
super().__init__()
@@ -272,6 +327,10 @@ class AlbumSlideshowCamera(Camera):
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
# top/bottom) for a paired frame; None for single frames.
self._last_pair_orientation: str | None = None
# ``{"photo_id", "duration_ms"}`` when the current slide is a video
# (always unpaired); ``_video_url`` is its signed proxy URL.
self._last_video: dict | None = None
self._video_url: str | None = None
# Cached effective playlist (after date filter + ordering). Invalidated
# by any store change or coordinator update.
self._effective_cache: tuple[int, list[MediaItem]] | None = None
@@ -315,6 +374,8 @@ class AlbumSlideshowCamera(Camera):
self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None
self._render_settings = self._current_render_settings()
self._viewers = async_get_tracker(hass)
self._unsub_viewers = None
def _on_coordinator_update() -> None:
previous_items = self._effective_cache[1] if self._effective_cache else None
@@ -326,7 +387,14 @@ class AlbumSlideshowCamera(Camera):
coordinator.async_add_listener(_on_coordinator_update)
self._pause_when_unwatched = bool(store.pause_when_unwatched)
def _on_store_change() -> None:
enabled = bool(self.store.pause_when_unwatched)
if enabled and not self._pause_when_unwatched and self._unsub_viewers is not None:
# Cards stay quiet while the setting is off; let them check in.
self._viewers.async_restart_grace(self.entry.entry_id)
self._pause_when_unwatched = enabled
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._slide_deadline = None
settings = self._current_render_settings()
@@ -355,12 +423,18 @@ class AlbumSlideshowCamera(Camera):
# offset based on entry_id keeps the pattern stable across
# restarts. Up to ~3 s spread across albums.
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
self._unsub_viewers = self._viewers.async_track(
self.entry.entry_id, self._on_viewers_change
)
self._render_task = self.hass.async_create_background_task(
self._render_loop(initial_delay=startup_delay),
name="album_slideshow_render_loop",
)
async def async_will_remove_from_hass(self) -> None:
if self._unsub_viewers is not None:
self._unsub_viewers()
self._unsub_viewers = None
preload_task = self._preload_task
self._cancel_preload()
if self._render_task is not None:
@@ -396,6 +470,12 @@ class AlbumSlideshowCamera(Camera):
cur = items[self._index] if items and 0 <= self._index < len(items) else None
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
caption_frames = self._caption_frames(cur)
# Paths go out separately as ``current_path``, which isn't recorded.
paths = [frame.get("path") for frame in caption_frames]
caption_frames = [
{key: value for key, value in frame.items() if key != "path"}
for frame in caption_frames
]
captured_at_pair = (
[frame["captured_at"] for frame in caption_frames]
if self._last_pair_frames else None
@@ -425,6 +505,7 @@ class AlbumSlideshowCamera(Camera):
"media_count_total": len(data.get("items", []) or []),
"current_index": self._index,
"current_filename": getattr(cur, "filename", None),
"current_path": paths if self._last_pair_frames else getattr(cur, "source_path", None),
"current_url": getattr(cur, "url", None),
"current_is_portrait": self._last_is_portrait,
"captured_at": captured_at_pair if captured_at_pair else captured_at,
@@ -445,6 +526,17 @@ class AlbumSlideshowCamera(Camera):
# ``pair_orientation`` tells the card how the two halves are laid
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
"caption_frames": caption_frames,
# Video slides: the camera image is the poster frame; the card
# plays ``video_url`` over it for ``video_hold_seconds``.
"media_kind": (
("live_photo" if self._last_video.get("live") else "video")
if self._last_video else "image"
),
"video_url": self._video_url if self._last_video else None,
"video_hold_seconds": (
self._slide_hold_seconds() if self._last_video else None
),
**self._video_info_attributes(),
"pair_orientation": self._last_pair_orientation,
"slide_interval": int(self.store.slide_interval),
"fill_mode": self.store.fill_mode,
@@ -455,6 +547,8 @@ class AlbumSlideshowCamera(Camera):
"shuffle_age_bias": int(self.store.shuffle_age_bias),
"missing_date_mode": self.store.missing_date_mode,
"paused": bool(self.store.paused),
"pause_when_unwatched": bool(self.store.pause_when_unwatched),
"paused_unwatched": self._paused_unwatched,
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
"pair_divider_px": int(self.store.pair_divider_px),
@@ -489,7 +583,7 @@ class AlbumSlideshowCamera(Camera):
the slide was rendered still shows.
"""
if not self._last_pair_frames:
return [_caption_frame(cur)]
return [{**_caption_frame(cur), **self._video_info_attributes()}]
photo_ids = self._current_frame.meta.get("photo_ids", []) if self._current_frame else []
if len(photo_ids) != len(self._last_pair_frames):
return self._last_pair_frames
@@ -762,6 +856,8 @@ class AlbumSlideshowCamera(Camera):
self._last_is_portrait = None
self._last_pair_frames = None
self._last_pair_orientation = None
self._last_video = None
self._video_url = None
def blank_frame():
with Image.new("RGB", (16, 9), "black") as image:
@@ -795,12 +891,17 @@ class AlbumSlideshowCamera(Camera):
# that stub out homeassistant.
from homeassistant.components.camera import async_get_still_stream
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
# Someone is watching the stream, so "Pause when not viewed" holds off.
close = self._viewers.async_add_stream(self.entry.entry_id)
try:
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
finally:
close()
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
@@ -874,9 +975,20 @@ class AlbumSlideshowCamera(Camera):
try:
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
shown = items[renderer._index]
meta.setdefault("photo_ids", [getattr(shown, "photo_id", None)])
# Faces used for the crop, so later face data can be detected.
meta.setdefault("faces", (_item_faces(items[renderer._index]),))
meta.setdefault("faces", (_item_faces(shown),))
# A paired Live Photo shows only its still.
if _has_motion(shown) and len(meta["photo_ids"]) == 1 and shown.photo_id:
if _is_video(shown):
meta["video"] = {
"photo_id": shown.photo_id,
"duration_ms": shown.duration_ms,
**await self._video_info(shown),
}
else:
meta["video"] = {"photo_id": shown.photo_id, "live": True}
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
@@ -902,6 +1014,7 @@ class AlbumSlideshowCamera(Camera):
def _apply_frame(self, frame: _RenderedFrame) -> None:
"""Make a rendered frame current and publish it to Home Assistant."""
previous_frame = getattr(self, "_current_frame", None)
self._current_frame = frame
self._framebuffer = frame.data
self.store.last_frame = frame.data
@@ -915,19 +1028,41 @@ class AlbumSlideshowCamera(Camera):
self._last_is_portrait = meta.get("is_portrait")
self._last_pair_frames = meta.get("pair_frames")
self._last_pair_orientation = meta.get("pair_orientation")
previous_video = getattr(self, "_last_video", None)
self._last_video = meta.get("video")
if not self._last_video:
self._video_url = None
elif not (
previous_video
and previous_video.get("photo_id") == self._last_video["photo_id"]
and getattr(self, "_video_url", None)
):
# Re-rendering the same clip (metadata refreshes do this) keeps its
# URL, so the card lets it play on instead of restarting it.
self._video_url = video.sign_video_path(
self.hass, video.video_path(self.entry.entry_id, self._last_video["photo_id"])
)
self._frame_id += 1
self._frame_serials[self._frame_id] = frame.serial
while len(self._frame_serials) > _FRAME_ID_MEMORY:
del self._frame_serials[next(iter(self._frame_serials))]
if previous_frame is None or previous_frame.meta.get("photo_ids") != meta.get("photo_ids"):
# A different slide is up: restart the clock with its own hold
# (a video's length), whichever path put it here. Re-renders and
# exclusion rebuilds can swap the slide without going through the
# timer; a re-render of the same slide keeps the running clock.
self._slide_deadline = None
self._interrupt_event.set()
_LOGGER.debug(
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
"previous=%d next=%d",
"previous=%d next=%d hold=%.1fs",
self.entry.title,
self._frame_id,
self._index,
len(self._previous_frames),
len(self._next_frames),
self._slide_hold_seconds(),
)
self.async_write_ha_state()
@@ -1203,7 +1338,7 @@ class AlbumSlideshowCamera(Camera):
if self._timeline_dirty:
return True
try:
if self.store.paused:
if self._held:
await self._interrupt_event.wait()
else:
await asyncio.wait_for(
@@ -1214,6 +1349,33 @@ class AlbumSlideshowCamera(Camera):
except asyncio.TimeoutError:
return False
@property
def _paused_unwatched(self) -> bool:
"""Held by "Pause when not viewed": no card or stream is showing us."""
return bool(self.store.pause_when_unwatched) and self._viewers.is_unwatched(
self.entry.entry_id
)
@property
def _held(self) -> bool:
"""Whether the slide timer is stopped (manual navigation still works)."""
return bool(self.store.paused) or self._paused_unwatched
def _on_viewers_change(self) -> None:
if not self.store.pause_when_unwatched:
return
_LOGGER.debug(
"Album Slideshow %s: %s",
self.entry.title,
"paused, no one is viewing it" if self._paused_unwatched
else "resumed, a viewer is back",
)
# Wake the render loop; a returning viewer gets a full interval on
# the slide they find.
self._slide_deadline = None
self._interrupt_event.set()
self.async_write_ha_state()
async def _render_loop(self, initial_delay: float = 0.0) -> None:
"""Display buffered frames on command/timer and refill them in the background."""
loop = asyncio.get_running_loop()
@@ -1248,7 +1410,7 @@ class AlbumSlideshowCamera(Camera):
while True:
try:
if self._slide_deadline is None:
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
self._slide_deadline = loop.time() + self._slide_hold_seconds()
if self._timeline_dirty:
async with self._navigation_lock:
if self._timeline_dirty:
@@ -1258,7 +1420,7 @@ class AlbumSlideshowCamera(Camera):
interrupted = await self._wait_or_interrupt(
max(0.0, self._slide_deadline - loop.time())
)
if not interrupted and not self.store.paused:
if not interrupted and not self._held:
async with self._navigation_lock:
if (
not self._timeline_dirty
@@ -1266,7 +1428,7 @@ class AlbumSlideshowCamera(Camera):
and loop.time() >= self._slide_deadline
):
await self._show_next_frame()
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
self._slide_deadline = loop.time() + self._slide_hold_seconds()
self._consecutive_failures = 0
except asyncio.CancelledError:
raise
@@ -1279,6 +1441,68 @@ class AlbumSlideshowCamera(Camera):
err,
)
async def _ensure_enriched(self, item: MediaItem) -> None:
"""Fetch an item's metadata before rendering it, if still missing.
Faces steer the crop and caption fields are baked into paired slides,
so a slide rendered before the background pass reaches its photo
would otherwise be missing both. Bounded so a slow server only
delays the slide briefly; the fetch carries on in the background.
"""
enrich = getattr(getattr(self, "coordinator", None), "async_enrich_item", None)
if enrich is None:
return
loop = asyncio.get_running_loop()
started = loop.time()
try:
fetched = await asyncio.wait_for(asyncio.shield(enrich(item)), _ENRICH_WAIT_SECONDS)
if fetched:
_LOGGER.debug(
"Album Slideshow: fetched metadata for %s in %d ms before display",
getattr(item, "filename", None), (loop.time() - started) * 1000,
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - render without metadata
_LOGGER.debug(
"Album Slideshow: metadata for %s not ready: %r",
getattr(item, "filename", None), err,
)
async def _video_info(self, item: MediaItem) -> dict[str, Any]:
"""Which stream a video is served as; empty when it can't be told."""
fetch = getattr(self.coordinator, "async_video_info", None)
if fetch is None:
return {}
try:
info = await fetch(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - a caption must not cost the slide
_LOGGER.debug("Album Slideshow: could not describe video %s: %s", item.filename, err)
return {}
return {field: info.get(field) for field in VIDEO_INFO_FIELDS if field in info}
def _video_info_attributes(self) -> dict[str, Any]:
video_meta = getattr(self, "_last_video", None) or {}
duration_ms = video_meta.get("duration_ms")
return {
"video_duration": duration_ms / 1000 if duration_ms else None,
**{field: video_meta.get(field) for field in VIDEO_INFO_FIELDS},
}
def _slide_hold_seconds(self) -> float:
"""Seconds the current slide stays up: the interval, or the clip's length."""
interval = int(self.store.slide_interval)
if not self._last_video or self._last_video.get("live"):
# Photos, including Live Photos, keep the slide interval.
return float(interval)
return video.video_hold_seconds(
self._last_video.get("duration_ms"),
int(getattr(self.store, "video_max_seconds", interval)),
interval,
)
@property
def _compose_semaphore(self) -> asyncio.Semaphore:
"""Return the domain-wide compose semaphore, creating it on demand.
@@ -1372,6 +1596,7 @@ class AlbumSlideshowCamera(Camera):
):
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
await self._ensure_enriched(cur)
cur_bytes = await self._fetch_bytes(cur.url)
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
@@ -1388,7 +1613,12 @@ class AlbumSlideshowCamera(Camera):
img = None
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
# A video plays alone, so it is never split into a pair.
if (
orientation_mismatch
and portrait_mode == ORIENTATION_MISMATCH_PAIR
and not _is_video(cur)
):
pair = await self._find_next_mismatch_image(
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
)
@@ -1485,6 +1715,7 @@ class AlbumSlideshowCamera(Camera):
continue
if self._index != start:
self._do_advance(count, items)
await self._ensure_enriched(cur)
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
)
@@ -1505,6 +1736,7 @@ class AlbumSlideshowCamera(Camera):
height: int,
fill_mode: str,
) -> tuple[Image.Image | None, dict | None]:
await self._ensure_enriched(item)
b = await self._fetch_bytes(item.url)
if not b:
return None, None
@@ -1592,7 +1824,7 @@ class AlbumSlideshowCamera(Camera):
it = items[idx]
tries += 1
if it.url in self._recent_urls:
if it.url in self._recent_urls or _is_video(it):
continue
meta_portrait = ip.is_portrait_item_by_metadata(it)
@@ -1610,6 +1842,8 @@ class AlbumSlideshowCamera(Camera):
continue
if ip.is_portrait_item(it, img) != is_portrait_canvas:
# Only the chosen partner needs faces and caption fields.
await self._ensure_enriched(it)
return img, it
ip.safe_close(img)