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 # MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG # byte payloads. The render loop pushes the latest still as soon # as it's encoded; if a subscriber falls behind we drop frames # for that subscriber rather than block the whole loop. self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set() # 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), # 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, "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), } ] @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.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, ) 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): """Stream the slideshow as multipart MJPEG. Each open client gets a bounded asyncio.Queue that the render loop pushes JPEG payloads into when a new still is committed. Visible transitions are now handled by the Lovelace card on the client side, so this stream just emits the latest still per slide change. """ # Imported lazily so the module still loads in test environments # that stub out homeassistant without installing aiohttp. from aiohttp import web boundary = "frame" response = web.StreamResponse( status=200, reason="OK", headers={ "Content-Type": f"multipart/x-mixed-replace;boundary={boundary}", "Cache-Control": "no-cache, private", "Pragma": "no-cache", }, ) await response.prepare(request) # Bounded queue: a slow client should fall behind on slide commits # rather than balloon memory. queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4) self._mjpeg_subscribers.add(queue) # Push the held still immediately so the client renders something # before the next slide change. if self._framebuffer is not None: try: queue.put_nowait(self._framebuffer) except asyncio.QueueFull: pass try: while True: payload = await queue.get() try: await response.write( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/jpeg\r\n" b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n" + payload + b"\r\n" ) except (ConnectionResetError, asyncio.CancelledError): raise except Exception as err: _LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err) break except (ConnectionResetError, asyncio.CancelledError): pass finally: self._mjpeg_subscribers.discard(queue) try: await response.write_eof() except Exception: pass return response # 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. Safe wrapper around clear() + wait_for() - callers don't have to worry about the ordering of the two operations. The clear() runs synchronously before the awaitable is created, so no interrupt can be lost on the single-threaded event loop. """ self._interrupt_event.clear() 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: 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 interrupted: should_advance = self._force_next self._force_next = 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._broadcast_frame(encoded) 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 _broadcast_frame(self, payload: bytes) -> None: """Push a frame to every active MJPEG subscriber. Slow subscribers get their frame dropped rather than backing up the queue; the next still emission will catch them up. """ for queue in list(self._mjpeg_subscribers): try: queue.put_nowait(payload) except asyncio.QueueFull: # Drain one and retry once so a wedged client still sees # the latest frame eventually instead of forever stale. try: queue.get_nowait() except asyncio.QueueEmpty: pass try: queue.put_nowait(payload) except asyncio.QueueFull: pass 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), }, { "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), }, ] 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 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) 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