15 files
This commit is contained in:
@@ -10,7 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -392,6 +392,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if cam:
|
||||
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:
|
||||
entry_id = call.data.get(ATTR_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):
|
||||
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):
|
||||
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
async_add_entities(
|
||||
[
|
||||
PreviousSlideButton(hass, entry, coordinator),
|
||||
NextSlideButton(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):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
@@ -44,12 +58,9 @@ class NextSlideButton(_BaseButton):
|
||||
self._attr_icon = "mdi:skip-next"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_NEXT_SLIDE,
|
||||
{ATTR_ENTRY_ID: self.entry.entry_id},
|
||||
blocking=False,
|
||||
)
|
||||
camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
|
||||
if camera is not None:
|
||||
await camera.async_force_next()
|
||||
|
||||
|
||||
class RefreshAlbumButton(_BaseButton):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from PIL import Image
|
||||
@@ -46,18 +49,44 @@ _ACCEPTED_IMAGE_PREFIX = ("image/",)
|
||||
_PAIR_SEARCH_LIMIT = 12
|
||||
_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:
|
||||
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
|
||||
if not isinstance(ts_ms, int):
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
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:
|
||||
"""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._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._render_task: asyncio.Task | None = None
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
self._invalidate_timeline()
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
self._invalidate_timeline()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
@@ -193,12 +243,19 @@ class AlbumSlideshowCamera(Camera):
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
preload_task = self._preload_task
|
||||
self._cancel_preload()
|
||||
if self._render_task is not None:
|
||||
self._render_task.cancel()
|
||||
try:
|
||||
await self._render_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if preload_task is not None:
|
||||
try:
|
||||
await preload_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
@@ -258,6 +315,19 @@ class AlbumSlideshowCamera(Camera):
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
"pair_divider_color": self.store.pair_divider_color,
|
||||
"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"),
|
||||
}
|
||||
|
||||
@@ -331,10 +401,53 @@ class AlbumSlideshowCamera(Camera):
|
||||
return ordered
|
||||
|
||||
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.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:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -373,123 +486,387 @@ class AlbumSlideshowCamera(Camera):
|
||||
async def async_handle_async_mjpeg_stream(self, request):
|
||||
return await self.handle_async_mjpeg_stream(request)
|
||||
|
||||
async def _wait_or_interrupt(self, timeout: float) -> bool:
|
||||
"""Wait up to ``timeout`` seconds, returning True if interrupted.
|
||||
@property
|
||||
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
|
||||
per cycle, before rendering, so a signal that arrives while we're
|
||||
rendering (a "next slide" press, a coordinator/store change) survives
|
||||
until we get here instead of being wiped.
|
||||
def _capture_cursor(self, source=None) -> _NavigationCursor:
|
||||
"""Snapshot ordering state from this camera or a private renderer."""
|
||||
source = source or self
|
||||
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
|
||||
sleeping, so a press that landed during the last render (or right at
|
||||
the loop boundary) isn't held until the full slide interval elapses.
|
||||
def _make_renderer(self, cursor: _NavigationCursor):
|
||||
"""Create an isolated render context sharing only immutable services/cache.
|
||||
|
||||
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
|
||||
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
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
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:
|
||||
await asyncio.sleep(initial_delay)
|
||||
|
||||
while self._current_frame is None:
|
||||
try:
|
||||
await asyncio.sleep(initial_delay)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = False # Don't advance on the very first render
|
||||
while True:
|
||||
# Clear the wake signal before rendering. Anything that happens
|
||||
# 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
|
||||
async with self._navigation_lock:
|
||||
if self._current_frame is None:
|
||||
await self._rebuild_current_frame()
|
||||
if self._current_frame is not None:
|
||||
self._consecutive_failures = 0
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._consecutive_failures += 1
|
||||
backoff = min(2 ** self._consecutive_failures, 60)
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
|
||||
self._consecutive_failures, backoff, err,
|
||||
"Album Slideshow: initial render failed (attempt %d), retrying in %ds: %s",
|
||||
self._consecutive_failures,
|
||||
backoff,
|
||||
err,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = True # Skip the broken image on retry
|
||||
continue
|
||||
await asyncio.sleep(backoff)
|
||||
if not self._effective_items():
|
||||
self._interrupt_event.clear()
|
||||
if not self._effective_items():
|
||||
await self._interrupt_event.wait()
|
||||
|
||||
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
|
||||
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
|
||||
while True:
|
||||
try:
|
||||
await self._commit_composed(composed, meta)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
if self._timeline_dirty:
|
||||
async with self._navigation_lock:
|
||||
if self._timeline_dirty:
|
||||
await self._rebuild_current_frame()
|
||||
continue
|
||||
|
||||
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
|
||||
"""Encode the composed slide into the framebuffer and broadcast.
|
||||
|
||||
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
|
||||
at 4K) doesn't block HA.
|
||||
"""
|
||||
encoded = await self.hass.async_add_executor_job(
|
||||
ip.encode_image, composed
|
||||
)
|
||||
|
||||
self._framebuffer = encoded
|
||||
self.store.last_frame = encoded
|
||||
self._frame_id += 1
|
||||
if meta:
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
else:
|
||||
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()
|
||||
interrupted = await self._wait_or_interrupt(
|
||||
float(int(self.store.slide_interval))
|
||||
)
|
||||
if not interrupted and not self.store.paused:
|
||||
async with self._navigation_lock:
|
||||
if not self._timeline_dirty:
|
||||
await self._show_next_frame()
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._consecutive_failures += 1
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: buffered navigation/render failed (attempt %d): %s",
|
||||
self._consecutive_failures,
|
||||
err,
|
||||
)
|
||||
|
||||
@property
|
||||
def _compose_semaphore(self) -> asyncio.Semaphore:
|
||||
@@ -849,6 +1226,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
return 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)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
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/[^/]+/?$")
|
||||
|
||||
_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):
|
||||
VERSION = 1
|
||||
@@ -318,13 +328,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
from . import immich as immich_api
|
||||
|
||||
client = immich_api.ImmichClient(self.hass, url, key)
|
||||
albums: list[dict[str, Any]] = []
|
||||
people: list[dict[str, Any]] = []
|
||||
try:
|
||||
await client.async_validate()
|
||||
albums = await client.async_list_albums()
|
||||
people = await client.async_list_people()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/key
|
||||
errors["base"] = "immich_cannot_connect"
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad URL/key
|
||||
_LOGGER.warning(
|
||||
"Immich validation failed for %s: %s",
|
||||
client.base_url,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = (
|
||||
"immich_invalid_auth"
|
||||
if getattr(err, "status", None) in (401, 403)
|
||||
else "immich_cannot_connect"
|
||||
)
|
||||
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_key = key
|
||||
# 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
|
||||
# Image cache size number entity.
|
||||
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"]
|
||||
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"
|
||||
|
||||
SERVICE_NEXT_SLIDE = "next_slide"
|
||||
SERVICE_PREVIOUS_SLIDE = "previous_slide"
|
||||
SERVICE_REFRESH_ALBUM = "refresh_album"
|
||||
ATTR_ENTRY_ID = "entry_id"
|
||||
|
||||
@@ -194,6 +194,8 @@ def _extract_first_page_items(html: str) -> list[MediaItem]:
|
||||
items: list[MediaItem] = []
|
||||
seen: set[str] = set()
|
||||
for raw in best:
|
||||
if _is_video_item(raw):
|
||||
continue
|
||||
item = _parse_album_item(raw)
|
||||
if item is None or item.url in seen:
|
||||
continue
|
||||
@@ -290,12 +292,39 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for raw in raw_items:
|
||||
if _is_video_item(raw):
|
||||
continue
|
||||
item = _parse_album_item(raw)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
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:
|
||||
"""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
|
||||
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
|
||||
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",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"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),
|
||||
RefreshHoursNumber(entry, store, coordinator),
|
||||
PairDividerWidthNumber(entry, store),
|
||||
NavigationBufferSizeNumber(entry, store),
|
||||
ImageCacheMbNumber(entry, store),
|
||||
]
|
||||
)
|
||||
@@ -143,6 +144,38 @@ class PairDividerWidthNumber(_BaseNumber):
|
||||
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):
|
||||
_attr_icon = "mdi:database-outline"
|
||||
_attr_native_min_value = 50
|
||||
|
||||
@@ -9,6 +9,17 @@ next_slide:
|
||||
selector:
|
||||
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:
|
||||
name: Refresh album
|
||||
description: Refresh the album list for a specific config entry.
|
||||
|
||||
@@ -13,6 +13,7 @@ from .const import (
|
||||
DEFAULT_PAIR_DIVIDER_PX,
|
||||
DEFAULT_PAIR_DIVIDER_COLOR,
|
||||
DEFAULT_IMAGE_CACHE_MB,
|
||||
DEFAULT_NAVIGATION_BUFFER_SIZE,
|
||||
DEFAULT_MAX_RESOLUTION,
|
||||
DEFAULT_DATE_FILTER,
|
||||
DEFAULT_MISSING_DATE_MODE,
|
||||
@@ -33,6 +34,7 @@ class SlideshowStore:
|
||||
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
|
||||
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
|
||||
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
|
||||
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
|
||||
max_resolution: str = DEFAULT_MAX_RESOLUTION
|
||||
|
||||
# Date filter mode (preset windows like this_year / on_this_day).
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"invalid_path": "Path is empty or invalid.",
|
||||
"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_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_filter_required": "Custom search needs a JSON filter.",
|
||||
"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_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_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_filter_required": "Custom search needs a JSON filter.",
|
||||
"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
|
||||
*/
|
||||
|
||||
const VERSION = "1.7.1";
|
||||
const VERSION = "1.8.1";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
@@ -974,6 +974,7 @@ const LIVE_SUFFIX = {
|
||||
slide_interval: "_interval",
|
||||
pair_divider_px: "_pair_divider_px",
|
||||
pair_divider_color: "_pair_divider_color",
|
||||
previous_button: "_previous_button",
|
||||
next_button: "_next_button",
|
||||
refresh_button: "_refresh_button",
|
||||
};
|
||||
@@ -1092,7 +1093,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
_hasActions() {
|
||||
return !!(
|
||||
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 = `
|
||||
<div class="actions-title">Actions</div>
|
||||
<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.refresh_button ? `<button class="act" data-act="refresh">Refresh album</button>` : ""}
|
||||
</div>
|
||||
`;
|
||||
const actionEntities = {
|
||||
previous: s.previous_button,
|
||||
next: s.next_button,
|
||||
refresh: s.refresh_button,
|
||||
};
|
||||
wrap.querySelectorAll("button.act").forEach((b) => {
|
||||
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) {
|
||||
this._hass.callService("button", "press", { entity_id: id });
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Reference in New Issue
Block a user