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