updated apps
This commit is contained in:
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user