This commit is contained in:
Home Assistant Version Control
2026-08-26 17:38:07 +00:00
parent da9d4c1551
commit abe821a047
15 changed files with 654 additions and 128 deletions
+15 -1
View File
@@ -10,7 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_PREVIOUS_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .store import SlideshowStore from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -392,6 +392,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if cam: if cam:
await cam.async_force_next() await cam.async_force_next()
async def _previous_slide(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id:
return
data = hass.data.get(DOMAIN, {}).get(entry_id)
if not data:
return
cam = data.get("camera")
if cam:
await cam.async_force_prev()
async def _refresh_album(call) -> None: async def _refresh_album(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID) entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id: if not entry_id:
@@ -406,6 +417,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE): if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide) hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)
if not hass.services.has_service(DOMAIN, SERVICE_PREVIOUS_SLIDE):
hass.services.async_register(DOMAIN, SERVICE_PREVIOUS_SLIDE, _previous_slide)
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM): if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album) hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
+18 -7
View File
@@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID from .const import DOMAIN, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .coordinator import AlbumCoordinator from .coordinator import AlbumCoordinator
@@ -13,6 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities( async_add_entities(
[ [
PreviousSlideButton(hass, entry, coordinator),
NextSlideButton(hass, entry, coordinator), NextSlideButton(hass, entry, coordinator),
RefreshAlbumButton(hass, entry, coordinator), RefreshAlbumButton(hass, entry, coordinator),
] ]
@@ -36,6 +37,19 @@ class _BaseButton(ButtonEntity):
} }
class PreviousSlideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_previous_button"
self._attr_name = "Previous slide"
self._attr_icon = "mdi:skip-previous"
async def async_press(self) -> None:
camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
if camera is not None:
await camera.async_force_prev()
class NextSlideButton(_BaseButton): class NextSlideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator) super().__init__(hass, entry, coordinator)
@@ -44,12 +58,9 @@ class NextSlideButton(_BaseButton):
self._attr_icon = "mdi:skip-next" self._attr_icon = "mdi:skip-next"
async def async_press(self) -> None: async def async_press(self) -> None:
await self.hass.services.async_call( camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
DOMAIN, if camera is not None:
SERVICE_NEXT_SLIDE, await camera.async_force_next()
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
class RefreshAlbumButton(_BaseButton): class RefreshAlbumButton(_BaseButton):
+481 -103
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict from collections import OrderedDict, deque
from dataclasses import dataclass
from datetime import datetime, timezone
import logging import logging
import random import random
from pathlib import Path from pathlib import Path
from typing import Any
import async_timeout import async_timeout
from PIL import Image from PIL import Image
@@ -46,18 +49,44 @@ _ACCEPTED_IMAGE_PREFIX = ("image/",)
_PAIR_SEARCH_LIMIT = 12 _PAIR_SEARCH_LIMIT = 12
_SKIP_SEARCH_LIMIT = 30 _SKIP_SEARCH_LIMIT = 30
_MAX_RENDER_ATTEMPTS = 10
@dataclass(frozen=True, slots=True)
class _NavigationCursor:
"""All mutable ordering state needed to render the following slide."""
index: int
random_order: tuple[int, ...]
random_pos: int
recent_urls: tuple[str, ...]
rng_state: Any
@dataclass(frozen=True, slots=True)
class _RenderedFrame:
"""A complete slide that can be displayed without any further work."""
data: bytes
cursor: _NavigationCursor
meta: dict
def _ts_to_iso(ts_ms: int | None) -> str | None: def _ts_to_iso(ts_ms: int | None) -> str | None:
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None.""" """Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
if not isinstance(ts_ms, int): if not isinstance(ts_ms, int):
return None return None
from datetime import datetime, timezone
try: try:
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat() return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
except (OverflowError, OSError, ValueError): except (OverflowError, OSError, ValueError):
return None return None
def _utc_now_iso() -> str:
"""Return the current time as an ISO-8601 UTC string."""
return datetime.now(timezone.utc).isoformat()
class _DownloadCache: class _DownloadCache:
"""Byte-budget LRU cache for downloaded image data, O(1) per operation.""" """Byte-budget LRU cache for downloaded image data, O(1) per operation."""
@@ -155,22 +184,43 @@ class AlbumSlideshowCamera(Camera):
self._frame_id: int = 0 self._frame_id: int = 0
self._interrupt_event: asyncio.Event = asyncio.Event() self._interrupt_event: asyncio.Event = asyncio.Event()
self._force_next: bool = False # Navigation runs directly in the button/service coroutine. The lock
# serialises rapid presses while buffered swaps remain independent of
# the background timer loop.
self._navigation_lock: asyncio.Lock = asyncio.Lock()
self._navigation_pending: int = 0
# Rendered timeline around the current frame. Previous frames and future
# frames are final encoded JPEGs, so both navigation directions are an
# O(1) deque swap. The future deque is replenished in the background.
self._current_frame: _RenderedFrame | None = None
self._previous_frames: deque[_RenderedFrame] = deque()
self._next_frames: deque[_RenderedFrame] = deque()
self._timeline_generation: int = 0
self._timeline_dirty: bool = False
self._next_ready_event: asyncio.Event = asyncio.Event()
self._preload_task: asyncio.Task | None = None
# Visible navigation diagnostics. These are state attributes rather
# than debug-only log messages so they remain observable when Home
# Assistant's UI filters debug logs.
self._last_nav_direction: str | None = None
self._last_nav_requested_at: str | None = None
self._last_nav_started_at: str | None = None
self._last_nav_committed_at: str | None = None
self._last_nav_outcome: str | None = None
self._last_nav_error: str | None = None
self._consecutive_failures: int = 0 self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None self._render_task: asyncio.Task | None = None
def _on_coordinator_update() -> None: def _on_coordinator_update() -> None:
self._effective_cache = None self._effective_cache = None
self._interrupt_event.set() self._invalidate_timeline()
self.async_write_ha_state()
coordinator.async_add_listener(_on_coordinator_update) coordinator.async_add_listener(_on_coordinator_update)
def _on_store_change() -> None: def _on_store_change() -> None:
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024) self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._effective_cache = None self._effective_cache = None
self._interrupt_event.set() self._invalidate_timeline()
self.async_write_ha_state()
store.add_listener(_on_store_change) store.add_listener(_on_store_change)
@@ -193,12 +243,19 @@ class AlbumSlideshowCamera(Camera):
) )
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
preload_task = self._preload_task
self._cancel_preload()
if self._render_task is not None: if self._render_task is not None:
self._render_task.cancel() self._render_task.cancel()
try: try:
await self._render_task await self._render_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if preload_task is not None:
try:
await preload_task
except asyncio.CancelledError:
pass
@property @property
def device_info(self): def device_info(self):
@@ -258,6 +315,19 @@ class AlbumSlideshowCamera(Camera):
"pair_divider_px": int(self.store.pair_divider_px), "pair_divider_px": int(self.store.pair_divider_px),
"pair_divider_color": self.store.pair_divider_color, "pair_divider_color": self.store.pair_divider_color,
"frame_id": self._frame_id, "frame_id": self._frame_id,
"navigation_buffer_size": self._buffer_depth,
"previous_frames_cached": len(self._previous_frames),
"next_frames_preloaded": len(self._next_frames),
"navigation_preloading": bool(
self._preload_task is not None and not self._preload_task.done()
),
"navigation_queue_size": self._navigation_pending,
"last_navigation_direction": self._last_nav_direction,
"last_navigation_requested_at": self._last_nav_requested_at,
"last_navigation_started_at": self._last_nav_started_at,
"last_navigation_committed_at": self._last_nav_committed_at,
"last_navigation_outcome": self._last_nav_outcome,
"last_navigation_error": self._last_nav_error,
"pagination_debug": data.get("pagination_debug"), "pagination_debug": data.get("pagination_debug"),
} }
@@ -331,10 +401,53 @@ class AlbumSlideshowCamera(Camera):
return ordered return ordered
async def async_force_next(self) -> None: async def async_force_next(self) -> None:
self._force_next = True """Display the next buffered slide immediately."""
await self._async_navigate(1)
async def async_force_prev(self) -> None:
"""Display the previous retained slide immediately."""
await self._async_navigate(-1)
async def _async_navigate(self, direction: int) -> None:
"""Serialise a manual navigation request and execute it directly."""
direction_name = "next" if direction > 0 else "previous"
self._navigation_pending += 1
self._last_nav_direction = direction_name
self._last_nav_requested_at = _utc_now_iso()
self._last_nav_outcome = "pending"
self._last_nav_error = None
# Wake the timer loop so the manual frame starts a fresh interval.
self._interrupt_event.set() self._interrupt_event.set()
self.async_write_ha_state() self.async_write_ha_state()
try:
async with self._navigation_lock:
self._last_nav_started_at = _utc_now_iso()
if self._timeline_dirty:
await self._rebuild_current_frame()
changed = (
await self._show_next_frame()
if direction > 0
else await self._show_previous_frame()
)
if changed:
self._last_nav_committed_at = _utc_now_iso()
self._last_nav_outcome = "displayed"
else:
self._last_nav_outcome = "not_available"
except Exception as err:
self._last_nav_outcome = "error"
self._last_nav_error = str(err)
_LOGGER.warning(
"Album Slideshow %s: manual %s navigation failed: %s",
self.entry.title,
direction_name,
err,
)
finally:
self._navigation_pending -= 1
self.async_write_ha_state()
async def async_force_refresh(self) -> None: async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
@@ -373,123 +486,387 @@ class AlbumSlideshowCamera(Camera):
async def async_handle_async_mjpeg_stream(self, request): async def async_handle_async_mjpeg_stream(self, request):
return await self.handle_async_mjpeg_stream(request) return await self.handle_async_mjpeg_stream(request)
async def _wait_or_interrupt(self, timeout: float) -> bool: @property
"""Wait up to ``timeout`` seconds, returning True if interrupted. def _buffer_depth(self) -> int:
"""Configured number of rendered frames retained in each direction."""
return min(10, max(0, int(self.store.navigation_buffer_size)))
Does NOT clear ``_interrupt_event`` - the render loop clears it once def _capture_cursor(self, source=None) -> _NavigationCursor:
per cycle, before rendering, so a signal that arrives while we're """Snapshot ordering state from this camera or a private renderer."""
rendering (a "next slide" press, a coordinator/store change) survives source = source or self
until we get here instead of being wiped. return _NavigationCursor(
index=int(source._index),
random_order=tuple(source._random_order),
random_pos=int(source._random_pos),
recent_urls=tuple(source._recent_urls),
rng_state=source._rng.getstate(),
)
A force-next that's already pending is honored immediately without def _make_renderer(self, cursor: _NavigationCursor):
sleeping, so a press that landed during the last render (or right at """Create an isolated render context sharing only immutable services/cache.
the loop boundary) isn't held until the full slide interval elapses.
Composition helpers historically operate on ``self._index`` and random
ordering fields. A private camera-shaped context lets background
preloading reuse those mature helpers without ever mutating the live
entity's current frame or navigation cursor.
""" """
if self._force_next: renderer = AlbumSlideshowCamera.__new__(AlbumSlideshowCamera)
renderer.hass = self.hass
renderer.entry = self.entry
renderer.coordinator = self.coordinator
renderer.store = self.store
renderer._download_cache = self._download_cache
renderer._index = cursor.index
renderer._random_order = list(cursor.random_order)
renderer._random_pos = cursor.random_pos
renderer._recent_urls = list(cursor.recent_urls)
renderer._rng = random.Random()
renderer._rng.setstate(cursor.rng_state)
return renderer
async def _render_available_frame(
self,
cursor: _NavigationCursor,
items: list[MediaItem],
*,
advance: bool,
) -> _RenderedFrame:
"""Render the current or next usable slide from ``cursor``.
Broken candidates are skipped, matching the old loop's retry behavior.
All state mutations occur on a private renderer. The returned JPEG and
cursor are therefore safe to place in the future buffer.
"""
if not items:
raise RuntimeError("No media available")
attempts = min(len(items), _MAX_RENDER_ATTEMPTS)
last_error: Exception | None = None
should_advance = advance
for _ in range(attempts):
renderer = self._make_renderer(cursor)
renderer._index %= len(items)
if should_advance:
renderer._do_advance(len(items), items)
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
)
return _RenderedFrame(encoded, cursor, meta or {})
except asyncio.CancelledError:
raise
except Exception as err:
last_error = err
cursor = self._capture_cursor(renderer)
should_advance = True
_LOGGER.debug(
"Album Slideshow %s: skipping unrenderable buffered slide: %s",
self.entry.title,
err,
)
finally:
ip.safe_close(composed)
raise RuntimeError(
f"Could not render a usable slide after {attempts} attempts: {last_error}"
)
def _apply_frame(self, frame: _RenderedFrame) -> None:
"""Make a rendered frame current and publish it to Home Assistant."""
self._current_frame = frame
self._framebuffer = frame.data
self.store.last_frame = frame.data
self._index = frame.cursor.index
self._random_order = list(frame.cursor.random_order)
self._random_pos = frame.cursor.random_pos
self._recent_urls = list(frame.cursor.recent_urls)
self._rng.setstate(frame.cursor.rng_state)
meta = frame.meta
self._last_is_portrait = meta.get("is_portrait")
self._last_captured_at_pair = meta.get("captured_at_pair")
self._last_pair_frames = meta.get("pair_frames")
self._last_pair_orientation = meta.get("pair_orientation")
self._frame_id += 1
_LOGGER.debug(
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
"previous=%d next=%d",
self.entry.title,
self._frame_id,
self._index,
len(self._previous_frames),
len(self._next_frames),
)
self.async_write_ha_state()
def _trim_timeline(self) -> None:
"""Enforce the configured frame count on both sides of current."""
depth = self._buffer_depth
while len(self._previous_frames) > depth:
self._previous_frames.popleft()
while len(self._next_frames) > depth:
self._next_frames.pop()
def _cancel_preload(self) -> None:
task = self._preload_task
self._preload_task = None
if task is not None and not task.done():
task.cancel()
self._next_ready_event.set()
def _invalidate_timeline(self) -> None:
"""Drop frames rendered from stale media/settings and wake the loop."""
self._timeline_generation += 1
self._timeline_dirty = True
self._previous_frames.clear()
self._next_frames.clear()
self._cancel_preload()
self._interrupt_event.set()
self.async_write_ha_state()
def _schedule_preload(self) -> None:
"""Start the single per-camera worker that fills future frames."""
self._trim_timeline()
if (
self._buffer_depth <= 0
or self._current_frame is None
or len(self._next_frames) >= self._buffer_depth
):
return
if self._preload_task is not None and not self._preload_task.done():
return
generation = self._timeline_generation
self._preload_task = self.hass.async_create_background_task(
self._preload_loop(generation),
name="album_slideshow_preload",
)
async def _preload_loop(self, generation: int) -> None:
"""Render future slides sequentially until the configured buffer is full."""
this_task = asyncio.current_task()
try:
while generation == self._timeline_generation:
self._trim_timeline()
if len(self._next_frames) >= self._buffer_depth:
return
base = self._next_frames[-1] if self._next_frames else self._current_frame
if base is None:
return
items = self._effective_items()
if not items:
return
frame = await self._render_available_frame(
base.cursor,
items,
advance=True,
)
if generation != self._timeline_generation:
return
# Navigation may have moved the base from future to current or
# vice versa while rendering. It is still valid if it remains
# the last frame in the known timeline.
current_tail = (
self._next_frames[-1]
if self._next_frames
else self._current_frame
)
if current_tail is not base:
continue
if len(self._next_frames) < self._buffer_depth:
self._next_frames.append(frame)
self._next_ready_event.set()
self.async_write_ha_state()
await asyncio.sleep(0)
except asyncio.CancelledError:
raise
except Exception as err:
_LOGGER.warning(
"Album Slideshow %s: could not fill navigation buffer: %s",
self.entry.title,
err,
)
finally:
if self._preload_task is this_task:
self._preload_task = None
self._next_ready_event.set()
self.async_write_ha_state()
async def _await_preloaded_frame(self) -> bool:
"""Wait for the in-flight worker's first frame, without waiting for all X."""
if self._next_frames:
return True
if self._buffer_depth <= 0:
return False
self._schedule_preload()
while not self._next_frames:
task = self._preload_task
if task is None or task.done():
return False
self._next_ready_event.clear()
if self._next_frames:
return True
waiter = asyncio.create_task(self._next_ready_event.wait())
try:
await asyncio.wait(
{task, waiter},
return_when=asyncio.FIRST_COMPLETED,
)
finally:
waiter.cancel()
try:
await waiter
except asyncio.CancelledError:
pass
return True
async def _show_next_frame(self) -> bool:
"""Display the next buffered frame, rendering on demand only if empty."""
if self._current_frame is None:
return await self._rebuild_current_frame()
await self._await_preloaded_frame()
if self._next_frames:
frame = self._next_frames.popleft()
else:
items = self._effective_items()
frame = await self._render_available_frame(
self._current_frame.cursor,
items,
advance=True,
)
if self._buffer_depth > 0:
self._previous_frames.append(self._current_frame)
self._trim_timeline()
self._apply_frame(frame)
self._schedule_preload()
return True
async def _show_previous_frame(self) -> bool:
"""Restore the most recent retained frame without I/O or composition."""
if not self._previous_frames:
return False
frame = self._previous_frames.pop()
if self._current_frame is not None and self._buffer_depth > 0:
self._next_frames.appendleft(self._current_frame)
self._trim_timeline()
self._apply_frame(frame)
self._schedule_preload()
return True
async def _rebuild_current_frame(self) -> bool:
"""Render the current cursor after startup or playlist/settings changes."""
generation = self._timeline_generation
self._timeline_dirty = False
items = self._effective_items()
if not items:
return False
cursor = (
self._current_frame.cursor
if self._current_frame is not None
else self._capture_cursor()
)
frame = await self._render_available_frame(cursor, items, advance=False)
if generation != self._timeline_generation:
return False
self._previous_frames.clear()
self._next_frames.clear()
self._apply_frame(frame)
self._schedule_preload()
return True
async def _wait_or_interrupt(self, timeout: float) -> bool:
"""Wait for configuration/navigation or for the slide timer to expire."""
if self._timeline_dirty:
return True
self._interrupt_event.clear()
# Close the clear/wait race: callbacks cannot run between these two
# synchronous statements without setting the event again.
if self._timeline_dirty:
return True return True
try: try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout) if self.store.paused:
await self._interrupt_event.wait()
else:
await asyncio.wait_for(
self._interrupt_event.wait(),
timeout=timeout,
)
return True return True
except asyncio.TimeoutError: except asyncio.TimeoutError:
return False return False
async def _render_loop(self, initial_delay: float = 0.0) -> None: async def _render_loop(self, initial_delay: float = 0.0) -> None:
"""Background task: render slides into _framebuffer, advance on timer or interrupt.""" """Display buffered frames on command/timer and refill them in the background."""
if initial_delay > 0: if initial_delay > 0:
await asyncio.sleep(initial_delay)
while self._current_frame is None:
try: try:
await asyncio.sleep(initial_delay) async with self._navigation_lock:
except asyncio.CancelledError: if self._current_frame is None:
raise await self._rebuild_current_frame()
should_advance = False # Don't advance on the very first render if self._current_frame is not None:
while True: self._consecutive_failures = 0
# Clear the wake signal before rendering. Anything that happens break
# from here on (a "next slide" press, a coordinator/store change)
# re-sets it and is picked up after this frame commits, so no
# wake is lost while we're mid-render.
self._interrupt_event.clear()
try:
await self._render_cycle(advance=should_advance)
self._consecutive_failures = 0
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as err: except Exception as err:
self._consecutive_failures += 1 self._consecutive_failures += 1
backoff = min(2 ** self._consecutive_failures, 60) backoff = min(2 ** self._consecutive_failures, 60)
_LOGGER.warning( _LOGGER.warning(
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s", "Album Slideshow: initial render failed (attempt %d), retrying in %ds: %s",
self._consecutive_failures, backoff, err, self._consecutive_failures,
backoff,
err,
) )
try: await asyncio.sleep(backoff)
await asyncio.sleep(backoff) if not self._effective_items():
except asyncio.CancelledError: self._interrupt_event.clear()
raise if not self._effective_items():
should_advance = True # Skip the broken image on retry await self._interrupt_event.wait()
continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval))) while True:
if self._force_next:
# Explicit "next slide" request: always advance.
should_advance = True
self._force_next = False
elif interrupted:
# A coordinator/store change woke us: re-render the current
# frame (new data or settings) without skipping ahead.
should_advance = False
else:
# Paused slideshows hold the current frame until the user
# un-pauses or hits "next slide" explicitly.
should_advance = not bool(self.store.paused)
async def _render_cycle(self, advance: bool) -> None:
"""Render one frame.
The slideshow is just "advance index, compose, encode, broadcast".
Visible transitions are handled by the Lovelace card client-side,
so this path stays minimal: at most one PIL decode + encode per
slide change.
Compose work is serialised across all albums via a domain-wide
semaphore so 4 cameras don't all decode + encode at once.
"""
items: list[MediaItem] = self._effective_items()
if not items:
return
count = len(items)
if advance:
self._do_advance(count, items)
async with self._compose_semaphore:
composed, meta = await self._compose_for_index(items)
if composed is None:
return
try: try:
await self._commit_composed(composed, meta) if self._timeline_dirty:
finally: async with self._navigation_lock:
ip.safe_close(composed) if self._timeline_dirty:
await self._rebuild_current_frame()
continue
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None: interrupted = await self._wait_or_interrupt(
"""Encode the composed slide into the framebuffer and broadcast. float(int(self.store.slide_interval))
)
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more if not interrupted and not self.store.paused:
at 4K) doesn't block HA. async with self._navigation_lock:
""" if not self._timeline_dirty:
encoded = await self.hass.async_add_executor_job( await self._show_next_frame()
ip.encode_image, composed self._consecutive_failures = 0
) except asyncio.CancelledError:
raise
self._framebuffer = encoded except Exception as err:
self.store.last_frame = encoded self._consecutive_failures += 1
self._frame_id += 1 _LOGGER.warning(
if meta: "Album Slideshow: buffered navigation/render failed (attempt %d): %s",
self._last_is_portrait = meta.get("is_portrait") self._consecutive_failures,
else: err,
self._last_is_portrait = None )
self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
self._last_pair_frames = meta.get("pair_frames") if meta else None
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
self.async_write_ha_state()
@property @property
def _compose_semaphore(self) -> asyncio.Semaphore: def _compose_semaphore(self) -> asyncio.Semaphore:
@@ -849,6 +1226,7 @@ class AlbumSlideshowCamera(Camera):
return None return None
async def _http_get(self, url: str) -> bytes | None: async def _http_get(self, url: str) -> bytes | None:
"""Fetch one remote image with validation and a hard timeout."""
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
try: try:
async with async_timeout.timeout(30): async with async_timeout.timeout(30):
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import re import re
from typing import Any from typing import Any
@@ -116,6 +117,15 @@ def _normalize_local_path(hass, path: str) -> str:
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$") ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
_LOGGER = logging.getLogger(__name__)
def _describe_error(err: BaseException) -> str:
"""Render an exception for the log, including an HTTP status when known."""
status = getattr(err, "status", None)
detail = str(err) or type(err).__name__
return f"HTTP {status}: {detail}" if status else f"{type(err).__name__}: {detail}"
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
@@ -318,13 +328,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
from . import immich as immich_api from . import immich as immich_api
client = immich_api.ImmichClient(self.hass, url, key) client = immich_api.ImmichClient(self.hass, url, key)
albums: list[dict[str, Any]] = []
people: list[dict[str, Any]] = []
try: try:
await client.async_validate() await client.async_validate()
albums = await client.async_list_albums() except Exception as err: # noqa: BLE001 - any failure means bad URL/key
people = await client.async_list_people() _LOGGER.warning(
except Exception: # noqa: BLE001 - any failure means bad URL/key "Immich validation failed for %s: %s",
errors["base"] = "immich_cannot_connect" client.base_url,
_describe_error(err),
)
errors["base"] = (
"immich_invalid_auth"
if getattr(err, "status", None) in (401, 403)
else "immich_cannot_connect"
)
else: else:
# A key with limited permissions can read the server but not
# albums or people; keep going with whatever it can see.
for label, call in (
("albums", client.async_list_albums),
("people", client.async_list_people),
):
try:
result = await call()
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Immich %s listing failed for %s: %s",
label,
client.base_url,
_describe_error(err),
)
else:
if label == "albums":
albums = result
else:
people = result
self._immich_url = client.base_url self._immich_url = client.base_url
self._immich_key = key self._immich_key = key
# id -> name maps for the two multi-select pickers. # id -> name maps for the two multi-select pickers.
@@ -254,6 +254,10 @@ DEFAULT_RECURSIVE = True
# path. Users with one album and lots of RAM can bump this via the # path. Users with one album and lots of RAM can bump this via the
# Image cache size number entity. # Image cache size number entity.
DEFAULT_IMAGE_CACHE_MB = 75 DEFAULT_IMAGE_CACHE_MB = 75
# Number of fully rendered slides retained on each side of the current frame.
# Previous/Next can swap these JPEGs immediately without downloading,
# composing, or encoding during the button press.
DEFAULT_NAVIGATION_BUFFER_SIZE = 2
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"] MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
DEFAULT_MAX_RESOLUTION = "1080p" DEFAULT_MAX_RESOLUTION = "1080p"
@@ -269,5 +273,6 @@ MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc" PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
SERVICE_NEXT_SLIDE = "next_slide" SERVICE_NEXT_SLIDE = "next_slide"
SERVICE_PREVIOUS_SLIDE = "previous_slide"
SERVICE_REFRESH_ALBUM = "refresh_album" SERVICE_REFRESH_ALBUM = "refresh_album"
ATTR_ENTRY_ID = "entry_id" ATTR_ENTRY_ID = "entry_id"
@@ -194,6 +194,8 @@ def _extract_first_page_items(html: str) -> list[MediaItem]:
items: list[MediaItem] = [] items: list[MediaItem] = []
seen: set[str] = set() seen: set[str] = set()
for raw in best: for raw in best:
if _is_video_item(raw):
continue
item = _parse_album_item(raw) item = _parse_album_item(raw)
if item is None or item.url in seen: if item is None or item.url in seen:
continue continue
@@ -290,12 +292,39 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
items: list[MediaItem] = [] items: list[MediaItem] = []
for raw in raw_items: for raw in raw_items:
if _is_video_item(raw):
continue
item = _parse_album_item(raw) item = _parse_album_item(raw)
if item is not None: if item is not None:
items.append(item) items.append(item)
return items, next_page return items, next_page
def _is_video_item(raw: Any) -> bool:
"""Return ``True`` when an album item carries a video duration.
Google tags videos with key ``76647426`` (duration in ms) inside a metadata
dict. That dict is usually the item's last element, but its position moves
between responses, so scan every nested container instead of just ``[-1]``.
Live photos carry a duration too and are skipped along with videos, which
is what the photo-only slideshow wants (see #26).
"""
return _contains_key(raw, 0)
def _contains_key(node: Any, depth: int) -> bool:
if depth > 6:
return False
if isinstance(node, dict):
for key in node:
if key == _VIDEO_DURATION_KEY or key == str(_VIDEO_DURATION_KEY):
return True
return any(_contains_key(v, depth + 1) for v in node.values())
if isinstance(node, list):
return any(_contains_key(v, depth + 1) for v in node)
return False
def _parse_album_item(raw: Any) -> MediaItem | None: def _parse_album_item(raw: Any) -> MediaItem | None:
"""Parse a single album item array. """Parse a single album item array.
@@ -315,14 +344,6 @@ def _parse_album_item(raw: Any) -> MediaItem | None:
width = visual[1] if isinstance(visual[1], int) else None width = visual[1] if isinstance(visual[1], int) else None
height = visual[2] if isinstance(visual[2], int) else None height = visual[2] if isinstance(visual[2], int) else None
# Skip videos: their last element is a dict with key 76647426 (duration).
if raw and isinstance(raw[-1], dict):
if _VIDEO_DURATION_KEY in raw[-1] or "76647426" in raw[-1]:
# Note: live photos also carry a duration but are still images;
# we treat the presence of duration as "video". If a user reports
# missing live photos we can revisit by checking 146008172 too.
return None
captured_at = raw[2] if len(raw) > 2 and _looks_like_timestamp_ms(raw[2]) else None captured_at = raw[2] if len(raw) > 2 and _looks_like_timestamp_ms(raw[2]) else None
uploaded_at = raw[5] if len(raw) > 5 and _looks_like_timestamp_ms(raw[5]) else None uploaded_at = raw[5] if len(raw) > 5 and _looks_like_timestamp_ms(raw[5]) else None
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"], "requirements": ["Pillow"],
"version": "1.7.1" "version": "1.8.1"
} }
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
SlideIntervalNumber(entry, store), SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator), RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store), PairDividerWidthNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store), ImageCacheMbNumber(entry, store),
] ]
) )
@@ -143,6 +144,38 @@ class PairDividerWidthNumber(_BaseNumber):
return return
class NavigationBufferSizeNumber(_BaseNumber):
_attr_icon = "mdi:page-previous-outline"
_attr_native_min_value = 0
_attr_native_max_value = 10
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_navigation_buffer_size"
self._attr_name = "Navigation buffer (slides)"
@property
def native_value(self):
return int(self.store.navigation_buffer_size)
async def async_set_native_value(self, value: float) -> None:
self.store.navigation_buffer_size = min(10, max(0, int(value)))
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.navigation_buffer_size = min(
10, max(0, int(float(old.state)))
)
self.store.notify()
except Exception:
return
class ImageCacheMbNumber(_BaseNumber): class ImageCacheMbNumber(_BaseNumber):
_attr_icon = "mdi:database-outline" _attr_icon = "mdi:database-outline"
_attr_native_min_value = 50 _attr_native_min_value = 50
@@ -9,6 +9,17 @@ next_slide:
selector: selector:
text: text:
previous_slide:
name: Previous slide
description: Go back to the previously shown image for a specific config entry.
fields:
entry_id:
name: Entry ID
description: The config entry id for the album slideshow instance.
required: true
selector:
text:
refresh_album: refresh_album:
name: Refresh album name: Refresh album
description: Refresh the album list for a specific config entry. description: Refresh the album list for a specific config entry.
@@ -13,6 +13,7 @@ from .const import (
DEFAULT_PAIR_DIVIDER_PX, DEFAULT_PAIR_DIVIDER_PX,
DEFAULT_PAIR_DIVIDER_COLOR, DEFAULT_PAIR_DIVIDER_COLOR,
DEFAULT_IMAGE_CACHE_MB, DEFAULT_IMAGE_CACHE_MB,
DEFAULT_NAVIGATION_BUFFER_SIZE,
DEFAULT_MAX_RESOLUTION, DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER, DEFAULT_DATE_FILTER,
DEFAULT_MISSING_DATE_MODE, DEFAULT_MISSING_DATE_MODE,
@@ -33,6 +34,7 @@ class SlideshowStore:
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
max_resolution: str = DEFAULT_MAX_RESOLUTION max_resolution: str = DEFAULT_MAX_RESOLUTION
# Date filter mode (preset windows like this_year / on_this_day). # Date filter mode (preset windows like this_year / on_this_day).
@@ -129,6 +129,7 @@
"invalid_path": "Path is empty or invalid.", "invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).", "invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.", "immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_invalid_auth": "Immich rejected the API key. Check that it is valid and that its permissions include server.about, album.read and person.read.",
"immich_no_content": "No albums or named people were found on this Immich server.", "immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.", "immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.", "immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
@@ -129,6 +129,7 @@
"invalid_path": "Path is empty or invalid.", "invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).", "invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.", "immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_invalid_auth": "Immich rejected the API key. Check that it is valid and that its permissions include server.about, album.read and person.read.",
"immich_no_content": "No albums or named people were found on this Immich server.", "immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.", "immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.", "immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info * tap_action: none # none | more-info
*/ */
const VERSION = "1.7.1"; const VERSION = "1.8.1";
const ANIMATED_TRANSITIONS = [ const ANIMATED_TRANSITIONS = [
"fade", "fade",
@@ -974,6 +974,7 @@ const LIVE_SUFFIX = {
slide_interval: "_interval", slide_interval: "_interval",
pair_divider_px: "_pair_divider_px", pair_divider_px: "_pair_divider_px",
pair_divider_color: "_pair_divider_color", pair_divider_color: "_pair_divider_color",
previous_button: "_previous_button",
next_button: "_next_button", next_button: "_next_button",
refresh_button: "_refresh_button", refresh_button: "_refresh_button",
}; };
@@ -1092,7 +1093,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
_hasActions() { _hasActions() {
return !!( return !!(
this._siblings && this._siblings &&
(this._siblings.next_button || this._siblings.refresh_button) (this._siblings.previous_button ||
this._siblings.next_button ||
this._siblings.refresh_button)
); );
} }
@@ -1543,13 +1546,19 @@ function createAlbumSlideshowCardEditorClass(Base) {
wrap.innerHTML = ` wrap.innerHTML = `
<div class="actions-title">Actions</div> <div class="actions-title">Actions</div>
<div class="actions-row"> <div class="actions-row">
${s.previous_button ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button ? `<button class="act" data-act="next">Next slide</button>` : ""} ${s.next_button ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.refresh_button ? `<button class="act" data-act="refresh">Refresh album</button>` : ""} ${s.refresh_button ? `<button class="act" data-act="refresh">Refresh album</button>` : ""}
</div> </div>
`; `;
const actionEntities = {
previous: s.previous_button,
next: s.next_button,
refresh: s.refresh_button,
};
wrap.querySelectorAll("button.act").forEach((b) => { wrap.querySelectorAll("button.act").forEach((b) => {
b.addEventListener("click", () => { b.addEventListener("click", () => {
const id = b.dataset.act === "next" ? s.next_button : s.refresh_button; const id = actionEntities[b.dataset.act];
if (id && this._hass) { if (id && this._hass) {
this._hass.callService("button", "press", { entity_id: id }); this._hass.callService("button", "press", { entity_id: id });
} }
File diff suppressed because one or more lines are too long