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.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)
+18 -7
View File
@@ -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):
+481 -103
View File
@@ -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