updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
+64 -92
View File
@@ -148,12 +148,6 @@ class AlbumSlideshowCamera(Camera):
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
@@ -244,6 +238,7 @@ class AlbumSlideshowCamera(Camera):
"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.
@@ -256,6 +251,7 @@ class AlbumSlideshowCamera(Camera):
"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,
@@ -282,6 +278,7 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
}
]
@@ -318,6 +315,7 @@ class AlbumSlideshowCamera(Camera):
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):
@@ -326,6 +324,7 @@ class AlbumSlideshowCamera(Camera):
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)
@@ -343,67 +342,32 @@ class AlbumSlideshowCamera(Camera):
return self._framebuffer
async def handle_async_mjpeg_stream(self, request):
"""Stream the slideshow as multipart MJPEG.
"""Serve the current slide as MJPEG for Home Assistant core surfaces.
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.
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 without installing aiohttp.
from aiohttp import web
# that stub out homeassistant.
from homeassistant.components.camera import async_get_still_stream
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",
},
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
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):
@@ -412,12 +376,17 @@ class AlbumSlideshowCamera(Camera):
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.
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.
"""
self._interrupt_event.clear()
if self._force_next:
return True
try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
return True
@@ -433,6 +402,11 @@ class AlbumSlideshowCamera(Camera):
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
@@ -453,9 +427,14 @@ class AlbumSlideshowCamera(Camera):
continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
if interrupted:
should_advance = self._force_next
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.
@@ -510,7 +489,6 @@ class AlbumSlideshowCamera(Camera):
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
@@ -528,27 +506,6 @@ class AlbumSlideshowCamera(Camera):
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:
@@ -651,12 +608,14 @@ class AlbumSlideshowCamera(Camera):
"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]
@@ -876,11 +835,24 @@ class AlbumSlideshowCamera(Camera):
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) as resp:
async with session.get(url, headers=self._image_request_headers(url)) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")