895 lines
36 KiB
Python
895 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import OrderedDict
|
|
import logging
|
|
import random
|
|
from pathlib import Path
|
|
|
|
import async_timeout
|
|
from PIL import Image
|
|
|
|
from homeassistant.components.camera import Camera
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .const import (
|
|
DOMAIN,
|
|
MAX_RESOLUTION_SHORT_EDGE,
|
|
ORIENTATION_MISMATCH_PAIR,
|
|
ORIENTATION_MISMATCH_AVOID,
|
|
ORDER_ALBUM,
|
|
ORDER_RANDOM,
|
|
PROVIDER_GOOGLE_SHARED,
|
|
)
|
|
from . import image_processing as ip
|
|
from . import playlist
|
|
from .coordinator import AlbumCoordinator, MediaItem
|
|
from .store import SlideshowStore
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Cap a single download at 64 MB. Larger images are rejected before decode
|
|
# to protect low-memory devices. This is well above any realistic camera
|
|
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
|
|
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
|
|
|
|
# Only these content types are accepted as image bodies. If a server returns
|
|
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
|
|
_ACCEPTED_IMAGE_PREFIX = ("image/",)
|
|
|
|
# Max candidates we'll scan when searching for a mismatched-orientation
|
|
# pairing partner. Metadata-only checks are nearly free; decode-only checks
|
|
# (no metadata available) are expensive.
|
|
_PAIR_SEARCH_LIMIT = 12
|
|
_SKIP_SEARCH_LIMIT = 30
|
|
|
|
|
|
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
|
|
|
|
|
|
class _DownloadCache:
|
|
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
|
|
|
|
def __init__(self, max_bytes: int) -> None:
|
|
self._cache: "OrderedDict[str, bytes]" = OrderedDict()
|
|
self._total_bytes: int = 0
|
|
self._max_bytes: int = max(max_bytes, 1)
|
|
|
|
@property
|
|
def total_bytes(self) -> int:
|
|
return self._total_bytes
|
|
|
|
def get(self, url: str) -> bytes | None:
|
|
data = self._cache.get(url)
|
|
if data is None:
|
|
return None
|
|
self._cache.move_to_end(url)
|
|
return data
|
|
|
|
def put(self, url: str, data: bytes) -> None:
|
|
if len(data) > self._max_bytes:
|
|
# Item exceeds the entire cache budget; skip caching but don't raise.
|
|
return
|
|
if url in self._cache:
|
|
self._total_bytes -= len(self._cache[url])
|
|
del self._cache[url]
|
|
self._cache[url] = data
|
|
self._total_bytes += len(data)
|
|
self._evict()
|
|
|
|
def resize(self, max_bytes: int) -> None:
|
|
self._max_bytes = max(max_bytes, 1)
|
|
self._evict()
|
|
|
|
def _evict(self) -> None:
|
|
while self._total_bytes > self._max_bytes and self._cache:
|
|
_, data = self._cache.popitem(last=False)
|
|
self._total_bytes -= len(data)
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
|
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
|
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
|
|
|
cam = AlbumSlideshowCamera(hass, entry, coordinator, store)
|
|
hass.data[DOMAIN][entry.entry_id]["camera"] = cam
|
|
|
|
async_add_entities([cam])
|
|
|
|
|
|
class AlbumSlideshowCamera(Camera):
|
|
_attr_should_poll = False
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
|
|
super().__init__()
|
|
self.hass = hass
|
|
self.entry = entry
|
|
self.coordinator = coordinator
|
|
self.store = store
|
|
|
|
self._attr_name = f"Album Slideshow {entry.title}"
|
|
self._attr_unique_id = f"{entry.entry_id}_camera"
|
|
|
|
self._rng = random.Random()
|
|
self._index = 0
|
|
self._random_order: list[int] = []
|
|
self._random_pos = 0
|
|
|
|
self._download_cache = _DownloadCache(
|
|
max_bytes=store.image_cache_mb * 1024 * 1024
|
|
)
|
|
self._recent_urls: list[str] = []
|
|
self._last_is_portrait: bool | None = None
|
|
# When the current frame is a paired image, this is [taken_a, taken_b]
|
|
# ISO strings (top/left first); None for single frames.
|
|
self._last_captured_at_pair: list[str | None] | None = None
|
|
# Full per-half caption metadata for a paired frame: a list of two
|
|
# dicts (top/left first) each carrying captured_at / location /
|
|
# latitude / longitude. None for single frames. Lets the Lovelace
|
|
# card overlay an accurate caption on each half of a pair.
|
|
self._last_pair_frames: list[dict] | None = None
|
|
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
|
|
# top/bottom) for a paired frame; None for single frames.
|
|
self._last_pair_orientation: str | None = None
|
|
# Cached effective playlist (after date filter + ordering). Invalidated
|
|
# by any store change or coordinator update.
|
|
self._effective_cache: tuple[int, list[MediaItem]] | None = None
|
|
|
|
self._framebuffer: bytes | None = None
|
|
|
|
# Monotonic counter incremented every time a new still is committed.
|
|
# Exposed as the ``frame_id`` state attribute so the Lovelace card
|
|
# has an unambiguous "new frame ready" signal even when other
|
|
# attributes happen not to change between slides.
|
|
self._frame_id: int = 0
|
|
|
|
self._interrupt_event: asyncio.Event = asyncio.Event()
|
|
self._force_next: bool = False
|
|
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()
|
|
|
|
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()
|
|
|
|
store.add_listener(_on_store_change)
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
await super().async_added_to_hass()
|
|
# Restore last framebuffer (if the store kept one) so the camera has
|
|
# something to show immediately after a restart, rather than a broken
|
|
# image placeholder while the first render completes.
|
|
restored = getattr(self.store, "last_frame", None)
|
|
if isinstance(restored, (bytes, bytearray)) and restored:
|
|
self._framebuffer = bytes(restored)
|
|
# Stagger the first render across multiple albums so they don't all
|
|
# decode + encode at the same instant on HA startup. Deterministic
|
|
# offset based on entry_id keeps the pattern stable across
|
|
# restarts. Up to ~3 s spread across albums.
|
|
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
|
|
self._render_task = self.hass.async_create_background_task(
|
|
self._render_loop(initial_delay=startup_delay),
|
|
name="album_slideshow_render_loop",
|
|
)
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
if self._render_task is not None:
|
|
self._render_task.cancel()
|
|
try:
|
|
await self._render_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
@property
|
|
def device_info(self):
|
|
return {
|
|
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
|
"name": f"Album Slideshow {self.entry.title}",
|
|
"manufacturer": "Album Slideshow",
|
|
}
|
|
|
|
@property
|
|
def icon(self) -> str:
|
|
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
|
|
return "mdi:google-photos"
|
|
return "mdi:folder-multiple-image"
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
data = self.coordinator.data or {}
|
|
items: list[MediaItem] = self._effective_items()
|
|
cur = items[self._index] if items and 0 <= self._index < len(items) else None
|
|
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
|
|
captured_at_pair = self._last_captured_at_pair
|
|
return {
|
|
"album_title": data.get("title"),
|
|
"media_count": len(items),
|
|
"media_count_total": len(data.get("items", []) or []),
|
|
"current_index": self._index,
|
|
"current_filename": getattr(cur, "filename", None),
|
|
"current_url": getattr(cur, "url", None),
|
|
"current_is_portrait": self._last_is_portrait,
|
|
"captured_at": captured_at_pair if captured_at_pair else captured_at,
|
|
"captured_at_primary": captured_at,
|
|
"uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
|
|
"byte_size": getattr(cur, "byte_size", None),
|
|
# GPS + reverse-geocoded label come from EXIF for local-folder
|
|
# entries; Google albums leave these as ``None``.
|
|
"latitude": getattr(cur, "latitude", None),
|
|
"longitude": getattr(cur, "longitude", None),
|
|
"location": getattr(cur, "location", None),
|
|
"description": getattr(cur, "description", None),
|
|
# Structured per-image caption metadata. A single-element list for
|
|
# normal slides; two elements (top/left first) for paired slides,
|
|
# so the card can overlay an accurate date/location on each half.
|
|
# ``pair_orientation`` tells the card how the two halves are laid
|
|
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
|
|
"caption_frames": self._caption_frames(cur, captured_at),
|
|
"pair_orientation": self._last_pair_orientation,
|
|
"slide_interval": int(self.store.slide_interval),
|
|
"fill_mode": self.store.fill_mode,
|
|
"portrait_mode": self.store.portrait_mode,
|
|
"order_mode": self.store.order_mode,
|
|
"date_filter": self.store.date_filter,
|
|
"missing_date_mode": self.store.missing_date_mode,
|
|
"paused": bool(self.store.paused),
|
|
"refresh_hours": int(self.store.refresh_hours),
|
|
"aspect_ratio": self.store.aspect_ratio,
|
|
"pair_divider_px": int(self.store.pair_divider_px),
|
|
"pair_divider_color": self.store.pair_divider_color,
|
|
"frame_id": self._frame_id,
|
|
"pagination_debug": data.get("pagination_debug"),
|
|
}
|
|
|
|
def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
|
|
"""Per-image caption metadata for the current slide.
|
|
|
|
Returns a list with one dict for a normal slide, or two (top/left
|
|
first) for a paired slide. Each dict carries ``captured_at`` (ISO
|
|
string or ``None``), ``location`` (human label or ``None``), and
|
|
``latitude`` / ``longitude``. The card reads this to overlay an
|
|
accurate caption on each image, including each half of a pair.
|
|
"""
|
|
if self._last_pair_frames:
|
|
return self._last_pair_frames
|
|
return [
|
|
{
|
|
"captured_at": captured_at,
|
|
"location": getattr(cur, "location", None),
|
|
"latitude": getattr(cur, "latitude", None),
|
|
"longitude": getattr(cur, "longitude", None),
|
|
"description": getattr(cur, "description", None),
|
|
}
|
|
]
|
|
|
|
@property
|
|
def entity_picture(self) -> str | None:
|
|
"""Return the camera proxy URL with a per-frame cache-buster.
|
|
|
|
HA core's default ``entity_picture`` only changes when the access
|
|
token rotates (about every five minutes). Browsers happily serve
|
|
the cached image to the more-info dialog and other surfaces in
|
|
between rotations, so they end up showing the previous slide
|
|
while a fresh slide is already in the framebuffer. Appending the
|
|
``frame_id`` invalidates that cache as soon as a new slide is
|
|
committed, no matter where in HA the picture is rendered.
|
|
"""
|
|
base = super().entity_picture
|
|
if not base:
|
|
return base
|
|
sep = "&" if "?" in base else "?"
|
|
return f"{base}{sep}frame={self._frame_id}"
|
|
|
|
@property
|
|
def cache_usage_mb(self) -> float:
|
|
return round(self._download_cache.total_bytes / (1024 * 1024), 1)
|
|
|
|
def _effective_items(self) -> list[MediaItem]:
|
|
"""Return the playlist after applying the date filter and order mode.
|
|
|
|
Cached until the coordinator or store changes (see invalidations
|
|
wired up in __init__).
|
|
"""
|
|
data = self.coordinator.data or {}
|
|
raw: list[MediaItem] = data.get("items", []) or []
|
|
cache_key = (
|
|
id(raw),
|
|
self.store.date_filter,
|
|
self.store.missing_date_mode,
|
|
self.store.order_mode,
|
|
)
|
|
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
|
|
return self._effective_cache[1]
|
|
|
|
filtered = playlist.filter_items(
|
|
raw,
|
|
mode=self.store.date_filter,
|
|
missing_date=self.store.missing_date_mode,
|
|
)
|
|
ordered = playlist.order_items(filtered, self.store.order_mode)
|
|
self._effective_cache = (hash(cache_key), ordered)
|
|
return ordered
|
|
|
|
async def async_force_next(self) -> None:
|
|
self._force_next = True
|
|
self._interrupt_event.set()
|
|
self.async_write_ha_state()
|
|
|
|
async def async_force_refresh(self) -> None:
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
|
|
return self._framebuffer
|
|
|
|
async def handle_async_mjpeg_stream(self, request):
|
|
"""Serve the current slide as MJPEG for Home Assistant core surfaces.
|
|
|
|
This is what the more-info dialog and picture-glance live view use
|
|
(the camera advertises no live stream, so the frontend falls back to
|
|
``/api/camera_proxy_stream``). We delegate to HA's still-stream
|
|
helper, which polls ``async_camera_image`` at ``frame_interval`` and
|
|
writes a correct multipart response.
|
|
|
|
Crucially it emits frames *continuously* rather than only on slide
|
|
change. A browser parsing ``multipart/x-mixed-replace`` holds the
|
|
current part until the next boundary arrives, so a stream that sent
|
|
one frame and then went quiet until the next slide (potentially many
|
|
seconds away, or never while paused) left the more-info view blank.
|
|
Polling keeps a boundary coming right away, so the current frame
|
|
renders immediately.
|
|
"""
|
|
# Imported lazily so the module still loads in test environments
|
|
# that stub out homeassistant.
|
|
from homeassistant.components.camera import async_get_still_stream
|
|
|
|
return await async_get_still_stream(
|
|
request,
|
|
self.async_camera_image,
|
|
self.content_type,
|
|
self.frame_interval,
|
|
)
|
|
|
|
# Older HA cores may dispatch via the alt name; alias for compatibility.
|
|
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.
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
if self._force_next:
|
|
return True
|
|
try:
|
|
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."""
|
|
if initial_delay > 0:
|
|
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
|
|
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,
|
|
)
|
|
try:
|
|
await asyncio.sleep(backoff)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
should_advance = True # Skip the broken image on retry
|
|
continue
|
|
|
|
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
|
|
try:
|
|
await self._commit_composed(composed, meta)
|
|
finally:
|
|
ip.safe_close(composed)
|
|
|
|
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()
|
|
|
|
@property
|
|
def _compose_semaphore(self) -> asyncio.Semaphore:
|
|
"""Return the domain-wide compose semaphore, creating it on demand.
|
|
|
|
``__init__.py`` populates it during setup, but defensive
|
|
initialisation here means a partially-loaded integration can
|
|
still render without crashing.
|
|
"""
|
|
domain_data = self.hass.data.setdefault(DOMAIN, {})
|
|
sem = domain_data.get("compose_semaphore")
|
|
if sem is None:
|
|
sem = asyncio.Semaphore(1)
|
|
domain_data["compose_semaphore"] = sem
|
|
return sem
|
|
|
|
def _do_advance(self, count: int, items: list) -> None:
|
|
"""Advance _index to the next slide and commit random-order position."""
|
|
if count <= 0:
|
|
self._index = 0
|
|
return
|
|
|
|
self._index %= count
|
|
order_mode = self.store.order_mode
|
|
|
|
# Sequential modes (album order + sorted-by-time orderings) walk in
|
|
# order. The list is already pre-sorted by ``order_items``, so we
|
|
# only need to step forward.
|
|
if order_mode != ORDER_RANDOM:
|
|
self._index = (self._index + 1) % count
|
|
return
|
|
|
|
self._index = self._next_random_index(count)
|
|
cur_url = items[self._index].url
|
|
self._recent_urls.append(cur_url)
|
|
keep = min(20, max(1, count - 1))
|
|
if len(self._recent_urls) > keep:
|
|
self._recent_urls = self._recent_urls[-keep:]
|
|
|
|
def _peek_advance(self, count: int, items: list) -> None:
|
|
"""Advance _index without committing to random-order bookkeeping.
|
|
|
|
Used by the orientation-avoid search so that rejected candidates
|
|
don't burn through the random cycle and cause premature repeats.
|
|
"""
|
|
if count <= 0:
|
|
self._index = 0
|
|
return
|
|
self._index = (self._index + 1) % count
|
|
|
|
async def _compose_for_index(
|
|
self, items: list[MediaItem]
|
|
) -> tuple[Image.Image | None, dict | None]:
|
|
"""Compose the slide at ``self._index`` into a PIL image.
|
|
|
|
Returns ``(composed, meta)`` where ``meta`` carries the orientation
|
|
and paired-capture metadata that ``_commit_composed`` will publish
|
|
as state attributes. Returns ``(None, None)`` if compose failed.
|
|
|
|
Pure compose - does NOT mutate ``self._framebuffer`` or
|
|
``self._last_*`` state. The caller commits via ``_commit_composed``.
|
|
"""
|
|
fill_mode = self.store.fill_mode
|
|
portrait_mode = self.store.portrait_mode
|
|
divider = max(0, int(self.store.pair_divider_px))
|
|
divider_fill, transparent_divider = ip.parse_divider_color(self.store.pair_divider_color)
|
|
max_short_edge = MAX_RESOLUTION_SHORT_EDGE.get(self.store.max_resolution)
|
|
width, height = ip.resolve_output_size(None, None, self.store.aspect_ratio, max_short_edge)
|
|
|
|
cur = items[self._index]
|
|
is_portrait_canvas = height > width
|
|
|
|
# Metadata fast path: if we can resolve orientation without downloading,
|
|
# we may short-circuit the mismatch handling before any bytes are read.
|
|
meta_portrait = ip.is_portrait_item_by_metadata(cur)
|
|
if (
|
|
meta_portrait is not None
|
|
and meta_portrait != is_portrait_canvas
|
|
and portrait_mode == ORIENTATION_MISMATCH_AVOID
|
|
):
|
|
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
|
|
|
cur_bytes = await self._fetch_bytes(cur.url)
|
|
if not cur_bytes:
|
|
raise RuntimeError(f"Failed to fetch image: {cur.url}")
|
|
|
|
img = await self.hass.async_add_executor_job(
|
|
ip.open_image, cur_bytes, (width, height)
|
|
)
|
|
try:
|
|
cur_is_portrait = ip.is_portrait_item(cur, img)
|
|
orientation_mismatch = cur_is_portrait != is_portrait_canvas
|
|
|
|
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_AVOID:
|
|
ip.safe_close(img)
|
|
img = None
|
|
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
|
|
|
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
|
|
pair = await self._find_next_mismatch_image(
|
|
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
|
|
)
|
|
other_img = pair[0] if pair else None
|
|
other_item = pair[1] if pair else None
|
|
pair_meta: list[str | None] | None = None
|
|
pair_frames: list[dict] | None = None
|
|
try:
|
|
if other_img is not None:
|
|
composed = await self.hass.async_add_executor_job(
|
|
ip.pair_images, img, other_img, width, height, fill_mode,
|
|
is_portrait_canvas, divider, divider_fill, transparent_divider,
|
|
)
|
|
pair_frames = [
|
|
{
|
|
"captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
|
|
"location": getattr(cur, "location", None),
|
|
"latitude": getattr(cur, "latitude", None),
|
|
"longitude": getattr(cur, "longitude", None),
|
|
"description": getattr(cur, "description", None),
|
|
},
|
|
{
|
|
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
|
|
"location": getattr(other_item, "location", None),
|
|
"latitude": getattr(other_item, "latitude", None),
|
|
"longitude": getattr(other_item, "longitude", None),
|
|
"description": getattr(other_item, "description", None),
|
|
},
|
|
]
|
|
pair_meta = [f["captured_at"] for f in pair_frames]
|
|
else:
|
|
composed = await self.hass.async_add_executor_job(
|
|
ip.render_image, img, fill_mode, width, height,
|
|
)
|
|
finally:
|
|
ip.safe_close(other_img)
|
|
meta = {
|
|
"is_portrait": cur_is_portrait,
|
|
"captured_at_pair": pair_meta,
|
|
"pair_frames": pair_frames,
|
|
# ``pair_images`` stacks images top/bottom on a portrait
|
|
# canvas and places them left/right on a landscape canvas.
|
|
"pair_orientation": (
|
|
("vertical" if is_portrait_canvas else "horizontal")
|
|
if pair_frames
|
|
else None
|
|
),
|
|
}
|
|
return composed, meta
|
|
|
|
composed = await self.hass.async_add_executor_job(
|
|
ip.render_image, img, fill_mode, width, height
|
|
)
|
|
return composed, {
|
|
"is_portrait": cur_is_portrait,
|
|
"captured_at_pair": None,
|
|
}
|
|
finally:
|
|
ip.safe_close(img)
|
|
|
|
async def _compose_skip_mismatch(
|
|
self,
|
|
items: list[MediaItem],
|
|
width: int,
|
|
height: int,
|
|
fill_mode: str,
|
|
is_portrait_canvas: bool,
|
|
) -> tuple[Image.Image | None, dict | None]:
|
|
"""Skip-mismatch variant of ``_compose_for_index``.
|
|
|
|
Walks forward (peek-advancing for non-matches) until it finds an
|
|
image whose orientation matches the canvas, then composes it.
|
|
"""
|
|
count = len(items)
|
|
if count <= 0:
|
|
return None, None
|
|
|
|
start = self._index
|
|
|
|
for _ in range(min(count, _SKIP_SEARCH_LIMIT)):
|
|
cur = items[self._index]
|
|
|
|
meta_portrait = ip.is_portrait_item_by_metadata(cur)
|
|
if meta_portrait is not None:
|
|
if meta_portrait != is_portrait_canvas:
|
|
self._peek_advance(count, items)
|
|
continue
|
|
if self._index != start:
|
|
self._do_advance(count, items)
|
|
return await self._compose_single(cur, width, height, fill_mode)
|
|
|
|
b = await self._fetch_bytes(cur.url)
|
|
if not b:
|
|
self._peek_advance(count, items)
|
|
continue
|
|
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
|
try:
|
|
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
|
|
self._peek_advance(count, items)
|
|
continue
|
|
if self._index != start:
|
|
self._do_advance(count, items)
|
|
composed = await self.hass.async_add_executor_job(
|
|
ip.render_image, img, fill_mode, width, height
|
|
)
|
|
return composed, {
|
|
"is_portrait": is_portrait_canvas,
|
|
"captured_at_pair": None,
|
|
}
|
|
finally:
|
|
ip.safe_close(img)
|
|
|
|
self._index = start
|
|
return await self._compose_single(items[self._index], width, height, fill_mode)
|
|
|
|
async def _compose_single(
|
|
self,
|
|
item: MediaItem,
|
|
width: int,
|
|
height: int,
|
|
fill_mode: str,
|
|
) -> tuple[Image.Image | None, dict | None]:
|
|
b = await self._fetch_bytes(item.url)
|
|
if not b:
|
|
return None, None
|
|
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
|
try:
|
|
cur_is_portrait = ip.is_portrait_item(item, img)
|
|
composed = await self.hass.async_add_executor_job(
|
|
ip.render_image, img, fill_mode, width, height
|
|
)
|
|
return composed, {
|
|
"is_portrait": cur_is_portrait,
|
|
"captured_at_pair": None,
|
|
}
|
|
finally:
|
|
ip.safe_close(img)
|
|
|
|
async def _render_current(self, items: list[MediaItem]) -> bytes | None:
|
|
"""Compatibility wrapper: compose + encode the current slide.
|
|
|
|
Kept as a thin wrapper because external code paths (e.g., tests)
|
|
may still call it. ``_render_cycle`` no longer does.
|
|
"""
|
|
composed, _ = await self._compose_for_index(items)
|
|
if composed is None:
|
|
return None
|
|
try:
|
|
return await self.hass.async_add_executor_job(ip.encode_image, composed)
|
|
finally:
|
|
ip.safe_close(composed)
|
|
|
|
async def _find_next_mismatch_image(
|
|
self,
|
|
items: list[MediaItem],
|
|
is_portrait_canvas: bool,
|
|
width: int,
|
|
height: int,
|
|
limit: int = _PAIR_SEARCH_LIMIT,
|
|
) -> tuple[Image.Image, MediaItem] | None:
|
|
"""Find an image with the opposite orientation of the canvas.
|
|
|
|
Uses metadata wherever possible - only candidates without width/height
|
|
metadata are downloaded and decoded for their orientation. The returned
|
|
PIL image is the caller's to close. The matching ``MediaItem`` is
|
|
returned alongside so the caller can attribute timestamps etc.
|
|
"""
|
|
if not items:
|
|
return None
|
|
n = len(items)
|
|
tries = 0
|
|
offset = 1
|
|
while tries < limit and offset < n:
|
|
idx = (self._index + offset) % n
|
|
it = items[idx]
|
|
offset += 1
|
|
tries += 1
|
|
|
|
if it.url in self._recent_urls:
|
|
continue
|
|
|
|
meta_portrait = ip.is_portrait_item_by_metadata(it)
|
|
if meta_portrait is not None and meta_portrait == is_portrait_canvas:
|
|
# Metadata says this one is the wrong orientation for pairing; skip.
|
|
continue
|
|
|
|
b = await self._fetch_bytes(it.url)
|
|
if not b:
|
|
continue
|
|
|
|
try:
|
|
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
|
except Exception:
|
|
continue
|
|
|
|
if ip.is_portrait_item(it, img) != is_portrait_canvas:
|
|
return img, it
|
|
ip.safe_close(img)
|
|
|
|
return None
|
|
|
|
def _next_random_index(self, count: int) -> int:
|
|
if count <= 1:
|
|
self._random_order = [0]
|
|
self._random_pos = 0
|
|
return 0
|
|
|
|
needs_new_cycle = len(self._random_order) != count or self._random_pos >= len(self._random_order)
|
|
if needs_new_cycle:
|
|
self._random_order = list(range(count))
|
|
self._rng.shuffle(self._random_order)
|
|
self._random_pos = 0
|
|
|
|
if self._random_order and self._random_order[0] == self._index:
|
|
self._random_order.append(self._random_order.pop(0))
|
|
|
|
idx = self._random_order[self._random_pos]
|
|
self._random_pos += 1
|
|
return idx
|
|
|
|
async def _fetch_bytes(self, url: str) -> bytes | None:
|
|
cached = self._download_cache.get(url)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
if url.startswith("file://"):
|
|
try:
|
|
p = Path(url[7:])
|
|
data = await self.hass.async_add_executor_job(p.read_bytes)
|
|
except Exception as err:
|
|
_LOGGER.warning("Album Slideshow: failed to read local image: %s", err)
|
|
return None
|
|
if len(data) > _MAX_DOWNLOAD_BYTES:
|
|
_LOGGER.warning(
|
|
"Album Slideshow: local image %s is %d bytes, exceeds %d byte limit; skipping",
|
|
url, len(data), _MAX_DOWNLOAD_BYTES,
|
|
)
|
|
return None
|
|
else:
|
|
data = await self._http_get(url)
|
|
if data is None:
|
|
return None
|
|
|
|
self._download_cache.put(url, data)
|
|
return data
|
|
|
|
def _image_request_headers(self, url: str) -> dict[str, str] | None:
|
|
"""Auth headers required to fetch image bytes for some providers.
|
|
|
|
The Immich provider stores an ``x-api-key`` header on the coordinator;
|
|
it is sent server-side only, so the key never reaches the browser or
|
|
the camera's ``current_url`` attribute. Returns ``None`` when no extra
|
|
headers are needed (Google, local folder, media source).
|
|
"""
|
|
headers = getattr(self.coordinator, "image_request_headers", None)
|
|
if headers and isinstance(url, str) and url.startswith("http"):
|
|
return dict(headers)
|
|
return None
|
|
|
|
async def _http_get(self, url: str) -> bytes | None:
|
|
session = async_get_clientsession(self.hass)
|
|
try:
|
|
async with async_timeout.timeout(30):
|
|
async with session.get(url, headers=self._image_request_headers(url)) as resp:
|
|
resp.raise_for_status()
|
|
|
|
content_type = resp.headers.get("Content-Type", "")
|
|
primary = content_type.split(";", 1)[0].strip().lower()
|
|
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
|
|
_LOGGER.debug(
|
|
"Album Slideshow: rejecting %s, content-type %r is not an image",
|
|
url, primary,
|
|
)
|
|
return None
|
|
|
|
content_length = resp.headers.get("Content-Length")
|
|
if content_length is not None:
|
|
try:
|
|
declared = int(content_length)
|
|
except ValueError:
|
|
declared = -1
|
|
if declared > _MAX_DOWNLOAD_BYTES:
|
|
_LOGGER.warning(
|
|
"Album Slideshow: %s advertises %d bytes, exceeds %d byte limit; skipping",
|
|
url, declared, _MAX_DOWNLOAD_BYTES,
|
|
)
|
|
return None
|
|
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
async for chunk in resp.content.iter_chunked(64 * 1024):
|
|
total += len(chunk)
|
|
if total > _MAX_DOWNLOAD_BYTES:
|
|
_LOGGER.warning(
|
|
"Album Slideshow: %s exceeded %d byte limit mid-download; aborting",
|
|
url, _MAX_DOWNLOAD_BYTES,
|
|
)
|
|
return None
|
|
chunks.append(chunk)
|
|
return b"".join(chunks)
|
|
except Exception as err:
|
|
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
|
|
return None
|