This commit is contained in:
Home Assistant Version Control
2026-09-25 04:10:30 +00:00
parent 56c80f896e
commit ad634c1b84
60 changed files with 2895 additions and 794 deletions
+204 -20
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict, deque
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
import logging
import random
@@ -303,6 +303,25 @@ class AlbumSlideshowCamera(Camera):
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
captured_at_pair = self._last_captured_at_pair
return {
"entry_id": self.entry.entry_id,
"displayed_photo_ids": (
self._current_frame.meta.get("photo_ids", [])
if self._current_frame is not None and not any(
photo_id in self.store.hidden_photo_ids
for photo_id in self._current_frame.meta.get("photo_ids", [])
) else []
),
"hidden_photo_count": len(self.store.hidden_photo_ids),
"hidden_revision": self.store.hidden_revision,
"undo_hide_available": bool(self.store.last_hidden_photo_ids),
"empty_reason": (
None if items else (
"all_hidden" if data.get("items") and all(
item.photo_id in self.store.hidden_photo_ids
for item in data["items"]
) else "no_matching_photos"
)
),
"album_title": data.get("title"),
"media_count": len(items),
"media_count_total": len(data.get("items", []) or []),
@@ -412,12 +431,14 @@ class AlbumSlideshowCamera(Camera):
self.store.date_filter,
self.store.missing_date_mode,
self.store.order_mode,
self.store.hidden_revision,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
return self._effective_cache[1]
filtered = playlist.filter_items(
raw,
[item for item in raw if item.photo_id and item.photo_id not in self.store.hidden_photo_ids]
if self.store.hidden_photo_ids else raw,
mode=self.store.date_filter,
missing_date=self.store.missing_date_mode,
)
@@ -476,6 +497,161 @@ class AlbumSlideshowCamera(Camera):
async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh()
async def async_hide_photo(
self,
*,
photo_ids: list[str] | None = None,
position: str | None = None,
frame_id: int | None = None,
) -> None:
async with self._navigation_lock:
if frame_id is not None and frame_id != self._frame_id:
raise ValueError("The displayed slide changed; try again")
if photo_ids is not None and position is not None:
raise ValueError("Choose photo IDs or a position, not both")
if photo_ids is None:
current_ids = (
self._current_frame.meta.get("photo_ids", [])
if self._current_frame is not None else []
)
if not current_ids:
raise ValueError("There is no photo to hide")
if len(current_ids) > 1 and position is None:
raise ValueError("This slide is paired; choose first, second, or both")
if position == "second" and len(current_ids) < 2:
raise ValueError("This slide has no second photo")
if position not in (None, "first", "second", "both"):
raise ValueError("Invalid photo position")
photo_ids = (
current_ids if position == "both"
else [current_ids[1 if position == "second" else 0]]
)
known_ids = {
item.photo_id for item in (self.coordinator.data or {}).get("items", [])
if item.photo_id
}
for frame in [self._current_frame, *self._previous_frames, *self._next_frames]:
if frame is not None:
known_ids.update(photo_id for photo_id in frame.meta.get("photo_ids", []) if photo_id)
if not photo_ids or any(not photo_id or photo_id not in known_ids for photo_id in photo_ids):
raise ValueError("Photo identifier unavailable; refresh the album and try again")
await self._async_change_exclusions(photo_ids, hidden=True)
async def async_restore_photos(
self, photo_ids: list[str] | None = None, *, undo: bool = False
) -> None:
async with self._navigation_lock:
selected = (
self.store.last_hidden_photo_ids if undo
else self.store.hidden_photo_ids if photo_ids is None
else photo_ids
)
await self._async_change_exclusions(selected, hidden=False)
async def _async_change_exclusions(self, photo_ids, *, hidden: bool) -> None:
generation = self._timeline_generation
buffered_frames = (
() if self._timeline_dirty
else tuple(frame for frame in (self._current_frame, *self._next_frames) if frame is not None)
)
if await self.store.async_set_photos_hidden(photo_ids, hidden=hidden):
if self._timeline_generation != generation + 1:
buffered_frames = ()
await self._rebuild_after_exclusion_change(buffered_frames)
async def _rebuild_after_exclusion_change(
self, buffered_frames: tuple[_RenderedFrame, ...] = ()
) -> None:
self._effective_cache = None
self._invalidate_timeline()
self._random_order = []
self._random_pos = 0
self._recent_urls = []
items = self._effective_items()
positions = {item.photo_id: index for index, item in enumerate(items) if item.photo_id}
reusable = []
for frame in buffered_frames:
photo_ids = frame.meta.get("photo_ids", [])
if not photo_ids or any(photo_id not in positions for photo_id in photo_ids):
continue
cursor = replace(
frame.cursor,
index=positions[photo_ids[0]],
random_order=(),
random_pos=0,
recent_urls=(),
)
reusable.append(replace(frame, cursor=cursor))
if reusable:
self._timeline_dirty = False
self._next_frames.extend(reusable[1:])
self._trim_timeline()
self._apply_frame(reusable[0])
self._schedule_preload()
return
generation = self._timeline_generation
await self._clear_current_frame()
for frame in buffered_frames:
if generation != self._timeline_generation:
break
photo_ids = frame.meta.get("photo_ids", [])
orientation = frame.meta.get("pair_orientation")
if len(photo_ids) != 2 or orientation not in ("horizontal", "vertical"):
continue
remaining = [
(half, photo_id) for half, photo_id in enumerate(photo_ids)
if photo_id in positions
]
if len(remaining) != 1:
continue
half, photo_id = remaining[0]
try:
encoded = await self._async_image_job(
ip.render_pair_photo, frame.data, half, orientation == "vertical",
max(0, int(self.store.pair_divider_px)), self.store.fill_mode,
)
except Exception as err:
_LOGGER.debug("Album Slideshow: could not reuse paired photo: %s", err)
continue
if generation != self._timeline_generation:
break
cursor = replace(
frame.cursor, index=positions[photo_id],
random_order=(), random_pos=0, recent_urls=(),
)
meta = {
**frame.meta,
"photo_ids": [photo_id],
"captured_at_pair": None,
"pair_frames": None,
"pair_orientation": None,
}
self._timeline_dirty = False
self._apply_frame(_RenderedFrame(encoded, cursor, meta))
self._schedule_preload()
return
try:
await self._rebuild_current_frame()
except Exception as err:
_LOGGER.warning("Album Slideshow: photos updated but replacement render failed: %s", err)
async def _clear_current_frame(self) -> None:
self._current_frame = None
self._framebuffer = None
self.store.last_frame = None
self._last_is_portrait = None
self._last_captured_at_pair = None
self._last_pair_frames = None
self._last_pair_orientation = None
def blank_frame():
with Image.new("RGB", (16, 9), "black") as image:
return ip.encode_image(image)
self._framebuffer = await self.hass.async_add_executor_job(blank_frame)
self._frame_id += 1
self.async_write_ha_state()
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
return self._framebuffer
@@ -577,14 +753,13 @@ class AlbumSlideshowCamera(Camera):
composed: Image.Image | None = None
try:
async with self._compose_semaphore:
composed, meta = await renderer._compose_for_index(items)
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
encoded = await self.hass.async_add_executor_job(
ip.encode_image, composed
)
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
encoded = await self._async_image_job(ip.encode_image, composed)
return _RenderedFrame(encoded, cursor, meta or {})
except asyncio.CancelledError:
raise
@@ -800,6 +975,7 @@ class AlbumSlideshowCamera(Camera):
self._timeline_dirty = False
items = self._effective_items()
if not items:
await self._clear_current_frame()
return False
cursor = (
@@ -908,6 +1084,10 @@ class AlbumSlideshowCamera(Camera):
domain_data["compose_semaphore"] = sem
return sem
async def _async_image_job(self, function, *args):
async with self._compose_semaphore:
return await self.hass.async_add_executor_job(function, *args)
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
@@ -978,7 +1158,7 @@ class AlbumSlideshowCamera(Camera):
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
img = await self.hass.async_add_executor_job(
img = await self._async_image_job(
ip.open_image, cur_bytes, (width, height)
)
try:
@@ -1000,7 +1180,7 @@ class AlbumSlideshowCamera(Camera):
pair_frames: list[dict] | None = None
try:
if other_img is not None:
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.pair_images, img, other_img, width, height, fill_mode,
is_portrait_canvas, divider, divider_fill, transparent_divider,
)
@@ -1022,12 +1202,16 @@ class AlbumSlideshowCamera(Camera):
]
pair_meta = [f["captured_at"] for f in pair_frames]
else:
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height,
)
finally:
ip.safe_close(other_img)
meta = {
"photo_ids": (
[getattr(cur, "photo_id", None), getattr(other_item, "photo_id", None)]
if pair_frames else [getattr(cur, "photo_id", None)]
),
"is_portrait": cur_is_portrait,
"captured_at_pair": pair_meta,
"pair_frames": pair_frames,
@@ -1041,7 +1225,7 @@ class AlbumSlideshowCamera(Camera):
}
return composed, meta
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1086,14 +1270,14 @@ class AlbumSlideshowCamera(Camera):
if not b:
self._peek_advance(count, items)
continue
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
try:
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1116,10 +1300,10 @@ class AlbumSlideshowCamera(Camera):
b = await self._fetch_bytes(item.url)
if not b:
return None, None
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
try:
cur_is_portrait = ip.is_portrait_item(item, img)
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1139,7 +1323,7 @@ class AlbumSlideshowCamera(Camera):
if composed is None:
return None
try:
return await self.hass.async_add_executor_job(ip.encode_image, composed)
return await self._async_image_job(ip.encode_image, composed)
finally:
ip.safe_close(composed)
@@ -1213,7 +1397,7 @@ class AlbumSlideshowCamera(Camera):
continue
try:
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
except Exception:
continue