From 73a4bbe91ed2000f50efbcd3d4bf216bc487002f Mon Sep 17 00:00:00 2001
From: 1eft0ver <1eft0ver@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:56:51 +0200
Subject: [PATCH 1/2] Add VOD file download over Baichuan
---
reolink_aio/baichuan/baichuan.py | 99 +++++++++++++++++++++++++++++++-
reolink_aio/baichuan/xmls.py | 33 +++++++++++
2 files changed, 131 insertions(+), 1 deletion(-)
mode change 100644 => 100755 reolink_aio/baichuan/baichuan.py
mode change 100644 => 100755 reolink_aio/baichuan/xmls.py
diff --git a/reolink_aio/baichuan/baichuan.py b/reolink_aio/baichuan/baichuan.py
old mode 100644
new mode 100755
index 9efce85a..28b7e9cc
--- a/reolink_aio/baichuan/baichuan.py
+++ b/reolink_aio/baichuan/baichuan.py
@@ -4,7 +4,8 @@
import asyncio
import logging
-from collections.abc import Callable
+import re
+from collections.abc import AsyncIterator, Callable
from datetime import datetime, timedelta
from inspect import getmembers
from time import time as time_now
@@ -82,6 +83,8 @@
_LOGGER = logging.getLogger(__name__)
+VOD_QUEUE_CHUNKS = 512
+
KEEP_ALLIVE_INTERVAL = 30 # seconds
MIN_KEEP_ALLIVE_INTERVAL = 9 # seconds
BATTERY_CLOSE_TIME = 5 # seconds
@@ -144,6 +147,9 @@ def __init__(
self._user_hash: str | None = None
self._password_hash: str | None = None
self._aes_key: bytes | None = None
+ self._vod_queue: asyncio.Queue[bytes] | None = None
+ self._vod_overflow: bool = False
+ self._vod_download_rejected: set[int] = set()
self._log_once: set[str] = set()
self._log_error: bool = True
self.last_privacy_check: float = 0
@@ -879,6 +885,15 @@ def _parse_xml(self, cmd_id: int, xml: str, payload: bytes = b"", mess_id: int |
if self.http_api.api_version("GetRec") >= 1:
rec_set["scheduleEnable"] = enable
+ elif cmd_id == 8 and self._vod_queue is not None: # VOD file download
+ if payload:
+ try:
+ self._vod_queue.put_nowait(payload)
+ except asyncio.QueueFull:
+ # dropping a chunk would corrupt the file, so end the transfer
+ self._vod_overflow = True
+ return
+
elif cmd_id in {109, 298}: # 109=Snapshot, 298=CoverPreview
if mess_id is None:
_LOGGER.warning("Reolink %s Baichuan push cmd_id %s received with payload without mess_id", self.http_api.nvr_name, cmd_id)
@@ -4219,6 +4234,88 @@ async def search_vod_type(
return vod_type_dict, vod_dict
+ @staticmethod
+ def _vod_name_element(file_id: str, channel: int) -> str:
+ """Build the element older models need in addition to the path."""
+ match = re.search(r"Rec\w{3}(?:_DST|_)(\d{8})_(\d{6})_", file_id)
+ if match is None:
+ return ""
+ return f"\n{channel + 1:02d}{match.group(1)}{match.group(2)}"
+
+ async def get_vod_file_info(self, channel: int, file_id: str) -> dict[str, Any]:
+ """Get the size, handle and type of a recording file using cmd_id 13."""
+ if channel in self._vod_download_rejected:
+ raise NotSupportedError(f"Baichuan host {self._host}: camera refused to download recordings on channel {channel}")
+
+ name = self._vod_name_element(file_id, channel)
+ try:
+ mess = await self.send(cmd_id=13, body=xmls.VodFileInfo.format(file_id=file_id, channel=channel, name=name))
+ except ApiError as err:
+ if err.rspCode == 400:
+ self._vod_download_rejected.add(channel)
+ raise
+
+ size = (get_value_from_xml(mess, "sizeL", int) or 0) + ((get_value_from_xml(mess, "sizeH", int) or 0) << 32)
+ if not size:
+ raise UnexpectedDataError(f"Baichuan host {self._host}: no size reported for VOD file '{file_id}'")
+
+ return {
+ "size": size,
+ "handle": get_value_from_xml(mess, "handle") or "0",
+ "file_type": get_value_from_xml(mess, "fileType"),
+ "contains_audio": get_value_from_xml(mess, "containsAudio", bool) or False,
+ }
+
+ async def download_vod(
+ self,
+ channel: int,
+ file_id: str,
+ info: dict[str, Any] | None = None,
+ timeout: int = 60,
+ ) -> AsyncIterator[bytes]:
+ """Download a recording over Baichuan, yielding it chunk by chunk."""
+ if info is None:
+ info = await self.get_vod_file_info(channel, file_id)
+
+ if self._vod_queue is not None:
+ raise ReolinkError(f"Baichuan host {self._host}: a VOD download is already in progress")
+
+ size = info["size"]
+ received = 0
+ # bounded, so a consumer that cannot keep up fails instead of growing
+ # until it runs the host out of memory
+ queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=VOD_QUEUE_CHUNKS)
+ self._vod_queue = queue
+ self._vod_overflow = False
+
+ try:
+ body = xmls.VodFileDownload.format(file_id=file_id, channel=channel, name=self._vod_name_element(file_id, channel))
+ try:
+ await self.send(cmd_id=8, body=body)
+ except ApiError as err:
+ if err.rspCode == 400:
+ self._vod_download_rejected.add(channel)
+ raise
+
+ # the camera sends no terminator, the transfer ends at the reported size
+ while received < size:
+ if self._vod_overflow:
+ raise ReolinkError(f"Baichuan host {self._host}: VOD '{file_id}' arrived faster than it was consumed")
+ try:
+ async with asyncio.timeout(timeout):
+ chunk = await queue.get()
+ except asyncio.TimeoutError as err:
+ raise ReolinkTimeoutError(f"Baichuan host {self._host}: timeout downloading VOD '{file_id}', received {received} of {size} bytes") from err
+ received += len(chunk)
+ yield chunk
+ finally: # the camera only has one VOD session, always release it
+ self._vod_queue = None
+ self._vod_overflow = False
+ try:
+ await self.send(cmd_id=9, body=xmls.VodFileStop.format(channel=channel, handle=info["handle"]))
+ except ReolinkError as err:
+ _LOGGER.debug("Baichuan host %s: could not stop VOD download after %s bytes: %s", self._host, received, err)
+
@property
def events_active(self) -> bool:
return self._events_active and time_now() - self._time_connection_lost > 120
diff --git a/reolink_aio/baichuan/xmls.py b/reolink_aio/baichuan/xmls.py
old mode 100644
new mode 100755
index edcf2cd3..e615ae55
--- a/reolink_aio/baichuan/xmls.py
+++ b/reolink_aio/baichuan/xmls.py
@@ -278,6 +278,39 @@
+
+
+{file_id}
+{channel}{name}
+
+
+
+
+
+{file_id}
+{channel}{name}
+
+
+
+
+
+{channel}
+{handle}
+
+
+
From 9a1bb5238b43ecc9d8fbe05c7e5679a7ad8a06f2 Mon Sep 17 00:00:00 2001
From: 1eft0ver <1eft0ver@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:56:51 +0200
Subject: [PATCH 2/2] Resolve the recording path from the camera's own file
list
---
reolink_aio/baichuan/baichuan.py | 149 +++++++++++++++++++++++++------
reolink_aio/baichuan/xmls.py | 40 +++++++++
reolink_aio/typings.py | 12 +++
3 files changed, 176 insertions(+), 25 deletions(-)
mode change 100644 => 100755 reolink_aio/typings.py
diff --git a/reolink_aio/baichuan/baichuan.py b/reolink_aio/baichuan/baichuan.py
index 28b7e9cc..2bd2df42 100755
--- a/reolink_aio/baichuan/baichuan.py
+++ b/reolink_aio/baichuan/baichuan.py
@@ -4,7 +4,6 @@
import asyncio
import logging
-import re
from collections.abc import AsyncIterator, Callable
from datetime import datetime, timedelta
from inspect import getmembers
@@ -53,7 +52,7 @@
UnexpectedDataError,
)
from ..software_version import SoftwareVersion
-from ..typings import VOD_file, VOD_trigger, cmd_list_type
+from ..typings import VOD_file, VOD_file_info, VOD_trigger, cmd_list_type, parse_file_name
from ..utils import (
datetime_to_reolink_time,
reolink_time_to_datetime,
@@ -84,6 +83,7 @@
_LOGGER = logging.getLogger(__name__)
VOD_QUEUE_CHUNKS = 512
+VOD_SEARCH_PAGES = 30
KEEP_ALLIVE_INTERVAL = 30 # seconds
MIN_KEEP_ALLIVE_INTERVAL = 9 # seconds
@@ -4234,56 +4234,154 @@ async def search_vod_type(
return vod_type_dict, vod_dict
+ @staticmethod
+ def _vod_name(channel: int, start: datetime) -> str:
+ """Build the name the camera lists a recording under."""
+ return f"{channel + 1:02d}{to_reolink_time_id(start)}"
+
@staticmethod
def _vod_name_element(file_id: str, channel: int) -> str:
"""Build the element older models need in addition to the path."""
- match = re.search(r"Rec\w{3}(?:_DST|_)(\d{8})_(\d{6})_", file_id)
- if match is None:
+ start = Baichuan._vod_start_time(file_id)
+ if start is None:
return ""
- return f"\n{channel + 1:02d}{match.group(1)}{match.group(2)}"
+ return f"\n{Baichuan._vod_name(channel, start)}"
+
+ @staticmethod
+ def _vod_start_time(file_id: str) -> datetime | None:
+ """Read the start time out of a recording file name."""
+ try:
+ parsed = parse_file_name(file_id)
+ except ValueError:
+ return None
+ if parsed is None:
+ return None
+ return datetime.combine(parsed.date, parsed.start)
+
+ async def _search_vod_file(self, channel: int, file_id: str, stream: str | None) -> VOD_file_info | None:
+ """Look a recording up by its start time, for the path the camera itself reports.
+
+ Returns None when the file name carries no usable start time.
+ """
+ start = self._vod_start_time(file_id)
+ if start is None:
+ return None
- async def get_vod_file_info(self, channel: int, file_id: str) -> dict[str, Any]:
- """Get the size, handle and type of a recording file using cmd_id 13."""
+ # the search only answers for the stream it is asked about, and only these
+ # two translate to what Baichuan calls them
+ if stream is None:
+ stream = "sub" if file_id.rsplit("/", 1)[-1].startswith("RecS") else "main"
+ if stream not in ["main", "sub"]:
+ return None
+ end = start + timedelta(seconds=1)
+ xml = xmls.VodFileSearchOpen.format(
+ channel=channel,
+ stream=f"{stream}Stream",
+ start_year=start.year,
+ start_month=start.month,
+ start_day=start.day,
+ start_hour=start.hour,
+ start_minute=start.minute,
+ start_second=start.second,
+ end_year=end.year,
+ end_month=end.month,
+ end_day=end.day,
+ end_hour=end.hour,
+ end_minute=end.minute,
+ end_second=end.second,
+ )
+ mess = await self.send(cmd_id=14, body=xml)
+ body: str | None = None
+ try:
+ body = xmls.VodFileSearchHandle.format(channel=channel, handle=get_value_from_xml(mess, "handle") or "0")
+ entries: list[XML.Element] = []
+ for _ in range(VOD_SEARCH_PAGES):
+ page = await self.send(cmd_id=15, body=body)
+ found = [item for item in XML.fromstring(page).findall(".//FileInfo") if item.find("Id") is not None]
+ entries.extend(found)
+ if not found:
+ break
+ finally:
+ if body is not None:
+ try:
+ await self.send(cmd_id=16, body=body)
+ except ReolinkError as err:
+ _LOGGER.debug("Baichuan host %s: could not close VOD file search: %s", self._host, err)
+
+ name = self._vod_name(channel, start)
+ for entry in entries:
+ if get_value_from_xml(entry, "name") != name:
+ continue
+ size = (get_value_from_xml(entry, "sizeL", int) or 0) + ((get_value_from_xml(entry, "sizeH", int) or 0) << 32)
+ path = get_value_from_xml(entry, "Id")
+ if not size or not path:
+ return None
+ return VOD_file_info(
+ size=size,
+ handle="0",
+ file_id=path,
+ resolved=True,
+ file_type=get_value_from_xml(entry, "fileType"),
+ contains_audio=get_value_from_xml(entry, "containsAudio", bool) or False,
+ )
+ return None
+
+ async def get_vod_file_info(self, channel: int, file_id: str, stream: str | None = None) -> VOD_file_info:
+ """Get the size, path and type of a recording file.
+
+ The search is preferred because the camera reports the recording's own
+ path there, which is what the download needs; some models answer their
+ file list with an absolute path while asking for a relative one. It has
+ to be told which stream the recording belongs to, so pass the one the
+ file list was requested with; it is guessed from the file name if not.
+ """
if channel in self._vod_download_rejected:
raise NotSupportedError(f"Baichuan host {self._host}: camera refused to download recordings on channel {channel}")
- name = self._vod_name_element(file_id, channel)
try:
- mess = await self.send(cmd_id=13, body=xmls.VodFileInfo.format(file_id=file_id, channel=channel, name=name))
- except ApiError as err:
- if err.rspCode == 400:
- self._vod_download_rejected.add(channel)
- raise
+ info = await self._search_vod_file(channel, file_id, stream)
+ except ReolinkError as err:
+ _LOGGER.debug("Baichuan host %s: could not search for VOD file '%s': %s", self._host, file_id, err)
+ else:
+ if info is not None:
+ return info
+
+ name = self._vod_name_element(file_id, channel)
+ mess = await self.send(cmd_id=13, body=xmls.VodFileInfo.format(file_id=file_id, channel=channel, name=name))
size = (get_value_from_xml(mess, "sizeL", int) or 0) + ((get_value_from_xml(mess, "sizeH", int) or 0) << 32)
if not size:
raise UnexpectedDataError(f"Baichuan host {self._host}: no size reported for VOD file '{file_id}'")
- return {
- "size": size,
- "handle": get_value_from_xml(mess, "handle") or "0",
- "file_type": get_value_from_xml(mess, "fileType"),
- "contains_audio": get_value_from_xml(mess, "containsAudio", bool) or False,
- }
+ return VOD_file_info(
+ size=size,
+ handle=get_value_from_xml(mess, "handle") or "0",
+ file_id=file_id,
+ resolved=False,
+ file_type=get_value_from_xml(mess, "fileType"),
+ contains_audio=get_value_from_xml(mess, "containsAudio", bool) or False,
+ )
async def download_vod(
self,
channel: int,
file_id: str,
- info: dict[str, Any] | None = None,
+ info: VOD_file_info | None = None,
timeout: int = 60,
) -> AsyncIterator[bytes]:
"""Download a recording over Baichuan, yielding it chunk by chunk."""
if info is None:
info = await self.get_vod_file_info(channel, file_id)
+ # the camera reports its own path, not always the one the caller was given
+ file_id = info.file_id or file_id
+
if self._vod_queue is not None:
raise ReolinkError(f"Baichuan host {self._host}: a VOD download is already in progress")
- size = info["size"]
+ size = info.size
received = 0
- # bounded, so a consumer that cannot keep up fails instead of growing
- # until it runs the host out of memory
+ # bounded, so a slow consumer fails instead of exhausting memory
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=VOD_QUEUE_CHUNKS)
self._vod_queue = queue
self._vod_overflow = False
@@ -4293,7 +4391,8 @@ async def download_vod(
try:
await self.send(cmd_id=8, body=body)
except ApiError as err:
- if err.rspCode == 400:
+ # only a refusal of the camera's own path says anything about it
+ if err.rspCode == 400 and info.resolved:
self._vod_download_rejected.add(channel)
raise
@@ -4312,7 +4411,7 @@ async def download_vod(
self._vod_queue = None
self._vod_overflow = False
try:
- await self.send(cmd_id=9, body=xmls.VodFileStop.format(channel=channel, handle=info["handle"]))
+ await self.send(cmd_id=9, body=xmls.VodFileStop.format(channel=channel, handle=info.handle))
except ReolinkError as err:
_LOGGER.debug("Baichuan host %s: could not stop VOD download after %s bytes: %s", self._host, received, err)
diff --git a/reolink_aio/baichuan/xmls.py b/reolink_aio/baichuan/xmls.py
index e615ae55..d70bc53e 100755
--- a/reolink_aio/baichuan/xmls.py
+++ b/reolink_aio/baichuan/xmls.py
@@ -300,6 +300,46 @@
+
+
+1
+{channel}
+{stream}
+
+{start_year}
+{start_month}
+{start_day}
+{start_hour}
+{start_minute}
+{start_second}
+
+
+{end_year}
+{end_month}
+{end_day}
+{end_hour}
+{end_minute}
+{end_second}
+
+
+
+
+
+
+{channel}
+1
+{handle}
+
+
+
diff --git a/reolink_aio/typings.py b/reolink_aio/typings.py
old mode 100644
new mode 100755
index 1b2098c9..77c5344c
--- a/reolink_aio/typings.py
+++ b/reolink_aio/typings.py
@@ -324,6 +324,18 @@ class VOD_trigger(IntFlag):
],
)
+VOD_file_info = NamedTuple(
+ "VOD_file_info",
+ [
+ ("size", int),
+ ("handle", str),
+ ("file_id", str),
+ ("resolved", bool),
+ ("file_type", Optional[str]),
+ ("contains_audio", bool),
+ ],
+)
+
VOD_download = NamedTuple(
"VOD_download",
[