Files
HomeAssistantVS/custom_components/album_slideshow/camera.py
T
2026-07-14 23:57:03 -04:00

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