Skip to content

fix: fetch camera and image sources from their still endpoint - #115

Merged
g4bri3lDev merged 1 commit into
OpenDisplay:mainfrom
parkghost:fix/media-source-still-frame
Aug 23, 2026
Merged

fix: fetch camera and image sources from their still endpoint#115
g4bri3lDev merged 1 commit into
OpenDisplay:mainfrom
parkghost:fix/media-source-still-frame

Conversation

@parkghost

@parkghost parkghost commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #114.

What it does

Both domains serve the frame we want from the endpoint next to the stream, so
rewrite the media-source id to that path and let the existing download path
sign and fetch it:

_STILL_ENDPOINTS = {
    "camera": "/api/camera_proxy/{}",
    "image": "/api/image_proxy/{}",
}

Plus naming TimeoutError in the except, so a timeout reports as
media_download_error rather than a raw traceback.

This is not a shortcut around the entity API: CameraImageView is a thin
wrapper over the same _async_get_image() that camera.async_get_image()
calls, which also means cameras with no still of their own work, via
use_stream_for_stills.

Calling async_get_image() directly was built first and dropped -- same
behaviour, but importing those components costs a cold-import guard, two
branches (the two functions differ in their optional parameters), and
PyTurboJPEG in the test groups.

Verified

Live, HA 2026.8.3, Waveshare 7.3" BWGBRY over BLE:

Source Before After
media-source://camera/..., no stream support 30 s hang, TimeoutError delivered
media-source://camera/..., stream only (RTSP/H264) resolves to .m3u8, UnidentifiedImageError delivered
media-source://image/... 30 s hang, TimeoutError delivered
media-source://media_source/local/*.jpg delivered delivered
media-source://ai_task/... delivered delivered

The stream-only case is a generic camera over rtsp://.../h264.sdp, H264
1920x1080, no still_image_url, so use_stream_for_stills is what produces the
frame. /api/camera_proxy/ on it returns a real 1920x1080 JPEG keyframe.

Worth noting for that row: because its media source declares
application/vnd.apple.mpegurl, an accept: image/* picker filters it out, so
it is only reachable by writing the media_content_id by hand.

Not verified: image_upload (none present, unaffected either way).

Notes

  • Only _async_upload_image() reaches the changed code; drawcustom does not.
  • No manifest, pyproject.toml, uv.lock or translation change.
  • Nothing that used to succeed changes behaviour.

media_source.async_resolve_media() hands the camera and image domains a
*_proxy_stream endpoint, because it resolves on behalf of media players,
which want something to keep playing. Those endpoints serve an endless
multipart/x-mixed-replace body while declaring a still mime type, so the
accept: image/* selector offers every camera and image entity and every one
of them stalled _async_download_image() for the full 30 s timeout. Measured:
5.19 MB from a camera and 652 KB from an image entity in ten seconds,
neither response ending.

The failure was also illegible. TimeoutError is not an aiohttp.ClientError,
so the handler below the read never caught it and the user got a raw
traceback instead of media_download_error. Naming it in the except is the
second half of this change; the parametrized download_error test pins it,
since no line-coverage metric can.

Both domains already serve the frame we want from the endpoint sitting next
to the stream -- /api/camera_proxy/ and /api/image_proxy/, the same ones
their own media-source thumbnails point at. Rewriting the id to that path
and letting the existing download path sign and fetch it keeps one code path
for every remote source, and brings that path's relative-URL signing branch
under test for the first time.

Calling camera.async_get_image()/image.async_get_image() instead was built
first and rejected. It reaches the same code -- CameraImageView is a thin
wrapper over the same _async_get_image() -- but importing those components
costs a guard so a cold import never lands on the event loop, two branches
because the two functions differ in their optional parameters, and
PyTurboJPEG in the test groups, since components/camera imports it at module
scope and no HA is present to install its requirements.

Going through _async_get_image() either way is also what makes this work for
cameras that have no still of their own: it pulls a keyframe from the stream
when use_stream_for_stills is set. Untested here -- no such camera on hand.

Special-casing only camera was the original scope. Measuring the other
sources showed image fails identically, and unconditionally -- its media
source has no branch at all -- and it is the domain listing this
integration's own image entities.
@parkghost
parkghost requested a review from g4bri3lDev as a code owner August 23, 2026 07:34
@g4bri3lDev
g4bri3lDev merged commit b0568f3 into OpenDisplay:main Aug 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

upload_image fails for every camera and image entity offered by its own media picker

2 participants