Add VOD file download over Baichuan - #186
Open
1eft0ver wants to merge 2 commits into
Open
Conversation
1eft0ver
force-pushed
the
baichuan-vod-download
branch
2 times, most recently
from
July 28, 2026 04:39
a5553ad to
55ff47c
Compare
21 tasks
1eft0ver
force-pushed
the
baichuan-vod-download
branch
2 times, most recently
from
July 28, 2026 05:41
0fc95a0 to
e41efa7
Compare
1eft0ver
marked this pull request as ready for review
July 28, 2026 05:55
1eft0ver
force-pushed
the
baichuan-vod-download
branch
2 times, most recently
from
July 28, 2026 18:01
7421494 to
43ee61a
Compare
1eft0ver
force-pushed
the
baichuan-vod-download
branch
from
July 28, 2026 22:57
43ee61a to
9a1bb52
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds
get_vod_file_info()anddownload_vod()to the Baichuan client, so arecording can be fetched over the Baichuan connection instead of the HTTP
cmd=Playback/cmd=Downloadhandler. The sequence follows what Reolink's ownCLI does against my cameras: a file search for the recording, then the download.
download_vod()is an async iterator:It works on both of my cameras.
Why
On the Lumus Pro
cmd=Playbackanswers withvideo/x-flv, so the integrationfalls back to
cmd=Downloadto get a real mp4. That fallback is the problem.cmd=Downloadis fragile. It, andcmd=Playbackwithoutput=, make thecamera 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 parsersuch as aiohttp rejects outright. My field notes on both are in
home-assistant/core#147960 (comment).
The streaming
cmd=Playbackresponse is not a substitute. It does not makethe 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
moovatom at the front, without preparinganything on the SD card. A consumer can serve that with a correct
Content-Lengthand answer range requests.Protocol
get_vod_file_info()searches a one second window around the recording's ownstart time, which returns a single entry rather than a day's worth, and falls
back to
cmd_id13 when it cannot: the file name carries no usable timestamp, orthe stream is one Baichuan has no known name for.
Details that are not obvious:
cmd_id13's answer carries noId,cmd_id15's listing does, and the two are not always the same — see Modelsupport.
cmd_id14 alone is not enough. Its answer carries the handle and nothingelse; the recordings come from
cmd_id15.mainStreamfor a substream recording returns nothing, and asking
subStreamfor a main stream onereturns the sub recording of the same moment, which starts a second earlier.
The entry is matched on
<name>so that one is rejected.cmd_id8. The camera simply stops sending, socompletion is determined by comparing the received length against
sizeL.cmd_id9 needs a body. An empty one is answered with status 400;channelIdalone is accepted. The handle fromcmd_id13 is sent as wellbecause that is what the camera hands back, but both of my cameras return
0for it, so I cannot show that a different value is honoured. It is sent from a
finallyblock because the camera only has one VOD session, which also meansstopping the iterator early still releases it.
<encryptLen>, which_push_callbackalreadydecrypts, so nothing extra was needed there.
API shape
download_vod()is an async iterator rather than following the existingsend_payload()pattern. Two reasons.send_payload()cannot be reused here. It completes when a message arrivescarrying a zero length payload, and
cmd_id8 never sends one — the transfer justends once the reported size has arrived. Its two current users,
cmd_id109(snapshot) and 298 (CoverPreview), depend on that terminator.
Returning
bytesdoes 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:
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.putblocked the loop with nothing left to drain it;An async iterator gives the consumer backpressure for free, and
aclosing()makesan 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 camerapushes 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 aVOD_file_infonamed tuple, alongside theexisting
Parsed_VOD_file_nameandVOD_download.Model support
Tested on two cameras. They report their recording paths differently through the
same
Searchcommand —/mnt/sda/Mp4Record/...on the Lumus Pro,Mp4Record/...on the E1 Zoom — andcmd_id8 only accepts the absolute form:v3.2.0.4243v3.1.0.4417cmd_id8 with the path the caller was givencmd_id8 with the path from thecmd_id15 listingThis 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 oftwo. Separately, the E1 Zoom answers
cmd_id13 and 8 with status 400 unless<name>accompanies the<Id>, so it is added whenever the start timestamp canbe 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
mainandsubare known to translate —get_vod_sourcemapsautotrack_*andtelephoto_*onto their own stream numbers, so they areseparate 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_id13, which works wherever the caller's path is already the one thecamera 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.
stream_recording_bcis forbaichuan_onlycameras 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.
cmd_id142/14/15 forday-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 keptthis 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 isreleased. Both were developed and tested together against the same two cameras.
Validation
Against both cameras on the LAN:
fetched independently.
ffprobereads them as normal seekable mp4s.cmd_id13 it replaces did: 368 ms against298 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.
download of the same recording still completes and matches.
case that leaves the HTTP playback handler dead on the Lumus Pro.
searched path is byte identical to what Reolink's own CLI produces for the same
recording.
black,isort,flake8,pylint(10.00/10) andmypyare all clean. Theonly 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_id9 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.