Skip to content

Add VOD file download over Baichuan - #186

Open
1eft0ver wants to merge 2 commits into
starkillerOG:mainfrom
1eft0ver:baichuan-vod-download
Open

Add VOD file download over Baichuan#186
1eft0ver wants to merge 2 commits into
starkillerOG:mainfrom
1eft0ver:baichuan-vod-download

Conversation

@1eft0ver

@1eft0ver 1eft0ver commented Jul 28, 2026

Copy link
Copy Markdown

Description

Adds get_vod_file_info() and download_vod() to the Baichuan client, so a
recording can be fetched over the Baichuan connection instead of the HTTP
cmd=Playback / cmd=Download handler. The sequence follows what Reolink's own
CLI does against my cameras: a file search for the recording, then the download.
download_vod() is an async iterator:

info = await host.baichuan.get_vod_file_info(channel, file_id, stream)
async with aclosing(host.baichuan.download_vod(channel, file_id, info=info)) as chunks:
    async for chunk in chunks:
        ...

It works on both of my cameras.

Why

On the Lumus Pro cmd=Playback answers with video/x-flv, so the integration
falls back to cmd=Download to get a real mp4. That fallback is the problem.

cmd=Download is fragile. It, and cmd=Playback with output=, make the
camera prepare a temporary file on the SD card. On my Lumus Pro that path can
leave recording playback dead until the camera is power cycled — at which point
the camera's own web UI can only show the live view either, so it is not
client-side. Its responses on this camera also intermittently carry a malformed,
empty-name header line right after Content-Disposition, which a strict parser
such as aiohttp rejects outright. My field notes on both are in
home-assistant/core#147960 (comment).

The streaming cmd=Playback response is not a substitute. It does not make
the camera prepare a temporary file, but it is a live flv stream of unknown
length, so a consumer using it directly gets no total duration and no seeking.

That leaves a choice between fragile-but-seekable and safe-but-not-seekable.
Baichuan is neither: the camera reports the exact file size up front and then
sends the stored mp4 as-is, with its moov atom at the front, without preparing
anything on the SD card. A consumer can serve that with a correct
Content-Length and answer range requests.

Protocol

cmd_id 14  FileInfoList{searchAITrack,channelId,streamType,startTime,endTime}
                       -> handle
cmd_id 15  FileInfoList{channelId,searchAITrack,handle}
                       -> the recordings in that window, each with name, an
                          absolute Id, sizeL/sizeH, fileType, containsAudio
cmd_id 16  same body   -> close the search
cmd_id 13  FileInfoList{Id,channelId[,name]}
                       -> handle, sizeL/sizeH, fileType, containsAudio
cmd_id  8  same body   -> push messages carrying the file as payload
cmd_id  9  FileInfoList{channelId,handle} -> release the VOD session

get_vod_file_info() searches a one second window around the recording's own
start time, which returns a single entry rather than a day's worth, and falls
back to cmd_id 13 when it cannot: the file name carries no usable timestamp, or
the stream is one Baichuan has no known name for.

Details that are not obvious:

  • The search is what supplies the path. cmd_id 13's answer carries no Id,
    cmd_id 15's listing does, and the two are not always the same — see Model
    support.
  • cmd_id 14 alone is not enough. Its answer carries the handle and nothing
    else; the recordings come from cmd_id 15.
  • The search only answers for one stream. Asking mainStream for a sub
    stream recording returns nothing, and asking subStream for a main stream one
    returns the sub recording of the same moment, which starts a second earlier.
    The entry is matched on <name> so that one is rejected.
  • There is no terminator for cmd_id 8. The camera simply stops sending, so
    completion is determined by comparing the received length against sizeL.
  • cmd_id 9 needs a body. An empty one is answered with status 400;
    channelId alone is accepted. The handle from cmd_id 13 is sent as well
    because that is what the camera hands back, but both of my cameras return 0
    for it, so I cannot show that a different value is honoured. It is sent from a
    finally block because the camera only has one VOD session, which also means
    stopping the iterator early still releases it.
  • Payload chunks carry <encryptLen>, which _push_callback already
    decrypts, so nothing extra was needed there.

API shape

download_vod() is an async iterator rather than following the existing
send_payload() pattern. Two reasons.

send_payload() cannot be reused here. It completes when a message arrives
carrying a zero length payload, and cmd_id 8 never sends one — the transfer just
ends once the reported size has arrived. Its two current users, cmd_id 109
(snapshot) and 298 (CoverPreview), depend on that terminator.

Returning bytes does not scale to recordings. Those two users fetch images.
One 30 s 4K recording here is 17 MB, delivered as 521 chunks of roughly 33 KB, and
longer clips are a multiple of that. Buffering a whole recording in memory is a
lot to ask of the hardware Home Assistant often runs on.

A synchronous callback was my first attempt and I would advise against it. The
callback is invoked from the event loop, so every consumer has to bridge it to
whatever actually writes the data, and both obvious bridges are wrong:

  • a bounded thread queue deadlocks the event loop if the writer ever stops
    early. That is not theoretical — mine stopped when the cache file could not be
    opened, and after the queue filled (256 chunks, about 8 MB, well within a
    17 MB recording) Queue.put blocked the loop with nothing left to drain it;
  • an unbounded queue removes the deadlock but moves the problem to memory.

An async iterator gives the consumer backpressure for free, and aclosing() makes
an early exit release the camera's single VOD session deterministically, which I
verified by breaking out mid transfer and then downloading the same file again.

Internally the chunks are handed over on a bounded asyncio.Queue. The camera
pushes data whether or not the consumer keeps up, so an unbounded queue would let a
slow or stalled disk accumulate a whole recording in memory. Instead the transfer
fails once the queue is full, since dropping a chunk would corrupt the file, and
the consumer can fall back to whatever it did before. A deliberately slow consumer
raises that error and a normal download of the same recording still succeeds
afterwards, so the session is released properly on that path too.

get_vod_file_info() returns a VOD_file_info named tuple, alongside the
existing Parsed_VOD_file_name and VOD_download.

Model support

Tested on two cameras. They report their recording paths differently through the
same Search command — /mnt/sda/Mp4Record/... on the Lumus Pro,
Mp4Record/... on the E1 Zoom — and cmd_id 8 only accepts the absolute form:

Lumus Pro v3.2.0.4243 E1 Zoom v3.1.0.4417
cmd_id 8 with the path the caller was given works status 400
cmd_id 8 with the path from the cmd_id 15 listing works works

This is why the search runs first: the camera's own listing is the authoritative
source for the path, and prefixing /mnt/sda/ would be a guess from a sample of
two. Separately, the E1 Zoom answers cmd_id 13 and 8 with status 400 unless
<name> accompanies the <Id>, so it is added whenever the start timestamp can
be read from the file name and left out when it cannot.

Two limits worth stating. Baichuan names its streams differently from the rest of
the API, and only main and sub are known to translate — get_vod_source maps
autotrack_* and telephoto_* onto their own stream numbers, so they are
separate streams rather than variants. Learning what Baichuan calls them needs a
camera that records them, which I do not have, so those skip the search and are
left to cmd_id 13, which works wherever the caller's path is already the one the
camera wants. And a channel is only remembered as refusing downloads when it
rejects a path the camera itself reported; a rejected caller path says nothing
about the camera.

Relationship to #164

#164 is a larger PR — VOD browsing, replay streaming and live streaming — and the
two are complementary rather than competing.

  • Reading a recording. Add VOD browsing, Baichuan recording playback, and live streaming #164's stream_recording_bc is for baichuan_only
    cameras where HTTP download is unavailable. It yields
    (microseconds, video_bytes, codec) frames over the replay protocol
    (MSG 5/8/0x17d) through a BcMedia parser, which a consumer still has to mux and
    which has no total length, so no seeking. This PR is for the opposite case —
    HTTP is available but harmful (the Lumus Pro's cmd=Download) or not seekable
    — and asks for the stored file, receiving the mp4 byte for byte with its
    size known up front.
  • File search. This is where they touch: Add VOD browsing, Baichuan recording playback, and live streaming #164 uses cmd_id 142/14/15 for
    day-level listing, this PR uses 14/15/16 to resolve one recording's path. Same
    command family, different question — which days have recordings versus a single
    file's <Id>. If Add VOD browsing, Baichuan recording playback, and live streaming #164 lands first they could share that layer; I have kept
    this PR self contained so it does not depend on that.

This PR was worked out by capturing the traffic of Reolink's own published CLI
against my camera (see Provenance below), not by decompiling anything.

Related pull request

home-assistant/core#177436
is the Home Assistant side that consumes this, and it depends on this PR: it
cannot bump its pinned reolink-aio, and so cannot be merged, until this is
released. Both were developed and tested together against the same two cameras.

Validation

Against both cameras on the LAN:

  • Downloads are byte identical across runs and match the same recording
    fetched independently.
  • ffprobe reads them as normal seekable mp4s.
  • The search costs about what the cmd_id 13 it replaces did: 368 ms against
    298 ms on the Lumus Pro, 232 ms against 105 ms on the E1 Zoom, because the
    window returns a single entry rather than the whole day.
  • Stopping the iterator early releases the session cleanly; a subsequent
    download of the same recording still completes and matches.
  • Works on a freshly power cycled camera with no priming at all, which is the
    case that leaves the HTTP playback handler dead on the Lumus Pro.
  • On the E1 Zoom, which refuses the caller's relative path, the download over the
    searched path is byte identical to what Reolink's own CLI produces for the same
    recording.
  • black, isort, flake8, pylint (10.00/10) and mypy are all clean. The
    only change to existing code is two import lines.

Provenance

The protocol was worked out by capturing the LAN traffic of Reolink's own
officially published CLI
talking to my
camera, decoding it with this library's existing header parsing and crypto
helpers, and then verifying every field by direct experimentation on both
cameras. Which fields are actually required, the cmd_id 9 body requirement,
the completion rule and the per-model differences above all come from that
experimentation.

No vendor code was decompiled and nothing was copied from a proprietary source.

@1eft0ver
1eft0ver force-pushed the baichuan-vod-download branch 2 times, most recently from a5553ad to 55ff47c Compare July 28, 2026 04:39
@1eft0ver
1eft0ver force-pushed the baichuan-vod-download branch 2 times, most recently from 0fc95a0 to e41efa7 Compare July 28, 2026 05:41
@1eft0ver
1eft0ver marked this pull request as ready for review July 28, 2026 05:55
@1eft0ver
1eft0ver force-pushed the baichuan-vod-download branch 2 times, most recently from 7421494 to 43ee61a Compare July 28, 2026 18:01
@1eft0ver
1eft0ver force-pushed the baichuan-vod-download branch from 43ee61a to 9a1bb52 Compare July 28, 2026 22:57
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.

1 participant