Skip to content

Commit 677ca08

Browse files
feat: mp4 format for broser recordings (#221)
* feat: mp4 format for broser recordings * fix: formatting
1 parent f080d23 commit 677ca08

11 files changed

Lines changed: 557 additions & 1 deletion

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@upstash/box": patch
3+
---
4+
5+
Add `box.browser.recordings.download(recordingId, { path? })` to save a
6+
recording's video to a local file (streamed to disk, parent directories
7+
created as needed) and expose `mp4SizeBytes` on recording metadata.
8+
Recordings are downloaded as MP4; recordings captured before MP4 support
9+
(or whose remux failed) download as raw MPEG-TS with a `.ts` extension.

packages/python-sdk/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to `upstash-box` (Python) are documented here.
44

55
## Unreleased
66

7+
- `browser.recordings.download(recording_id, path=...)` — save a recording's
8+
video to a local file (streamed to disk, parent directories created as
9+
needed) and return the path written. Recordings download as MP4; recordings
10+
captured before MP4 support (or whose remux failed) download as raw MPEG-TS
11+
with a `.ts` extension. Adds `mp4_size_bytes` to `BrowserRecording`.
12+
Mirrors `recordings.download` in `@upstash/box`.
713
- `git.clone(depth=...)` — shallow clone support (`git clone --depth N`).
814
`depth=1` fetches only the latest commit; omitting it keeps the current
915
full-clone behavior. Mirrors `depth` in `@upstash/box` `git.clone`.

packages/python-sdk/PARITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun`
4242
| `browser.tab.create` | `browser.tab.create` |
4343
| `browser.listTabs` / `browser.getTab` / `browser.cdpUrl` | `browser.list_tabs` / `browser.get_tab` / `browser.cdp_url` |
4444
| `browser.recordings.start/stop/list/get` | same (snake) |
45+
| `browser.recordings.download(id, { path? })` | `browser.recordings.download(id, path=...)` |
4546

4647
## `Tab` (browser)
4748

packages/python-sdk/tests/_async/test_box_browser.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import base64
7+
import os
78

89
import httpx
910
import pytest
@@ -415,6 +416,117 @@ async def test_recording_start_stop_and_mapping():
415416
await box.aclose()
416417

417418

419+
@respx.mock
420+
async def test_recording_download_mp4(tmp_path):
421+
box = await make_async_box(respx.mock)
422+
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
423+
return_value=httpx.Response(
424+
# Content-type parameters must not defeat the MP4 detection.
425+
200,
426+
content=b"mp4-bytes",
427+
headers={"content-type": "video/mp4; some=param"},
428+
)
429+
)
430+
431+
# Parent directories are created as needed.
432+
dest = await box.browser.recordings.download(
433+
"recording-1", path=str(tmp_path / "recordings" / "nested" / "demo.mp4")
434+
)
435+
436+
assert dest == str(tmp_path / "recordings" / "nested" / "demo.mp4")
437+
with open(dest, "rb") as fh:
438+
assert fh.read() == b"mp4-bytes"
439+
await box.aclose()
440+
441+
442+
@respx.mock
443+
async def test_recording_download_default_extension_follows_content_type(tmp_path, monkeypatch):
444+
monkeypatch.chdir(tmp_path)
445+
box = await make_async_box(respx.mock)
446+
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
447+
return_value=httpx.Response(
448+
200, content=b"ts-bytes", headers={"content-type": "video/mp2t"}
449+
)
450+
)
451+
452+
# Legacy recordings without an MP4 remux stream raw MPEG-TS.
453+
dest = await box.browser.recordings.download("recording-1")
454+
455+
assert dest == "./box-recording-recording-1.ts"
456+
with open(dest, "rb") as fh:
457+
assert fh.read() == b"ts-bytes"
458+
await box.aclose()
459+
460+
461+
@respx.mock
462+
async def test_recording_download_rejects_unexpected_content_type(tmp_path):
463+
box = await make_async_box(respx.mock)
464+
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
465+
return_value=httpx.Response(
466+
200, content=b"<html>nope</html>", headers={"content-type": "text/html"}
467+
)
468+
)
469+
470+
dest = str(tmp_path / "demo.mp4")
471+
with pytest.raises(BoxError, match="Unexpected recording content type: text/html"):
472+
await box.browser.recordings.download("recording-1", path=dest)
473+
assert not os.path.exists(dest)
474+
await box.aclose()
475+
476+
477+
@respx.mock
478+
async def test_recording_download_surfaces_backend_error(tmp_path):
479+
box = await make_async_box(respx.mock)
480+
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
481+
return_value=httpx.Response(409, json={"error": "recording is not ready for download"})
482+
)
483+
484+
with pytest.raises(BoxError, match="recording is not ready for download"):
485+
await box.browser.recordings.download("recording-1", path=str(tmp_path / "demo.mp4"))
486+
await box.aclose()
487+
488+
489+
@respx.mock
490+
async def test_recording_download_preserves_existing_file_on_failure(tmp_path):
491+
box = await make_async_box(respx.mock)
492+
dest = tmp_path / "demo.mp4"
493+
dest.write_bytes(b"existing-recording")
494+
495+
# Headers arrive, then the body aborts mid-stream after a partial chunk.
496+
async def _partial_then_error():
497+
yield b"partial"
498+
raise httpx.ReadError("connection reset")
499+
500+
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
501+
return_value=httpx.Response(
502+
200,
503+
headers={"content-type": "video/mp4"},
504+
content=_partial_then_error(),
505+
)
506+
)
507+
508+
# Interrupted streams surface as BoxError, like _request().
509+
with pytest.raises(BoxError, match="connection reset"):
510+
await box.browser.recordings.download("recording-1", path=str(dest))
511+
512+
# The existing file at dest must survive intact, and no temp file is left behind.
513+
assert dest.read_bytes() == b"existing-recording"
514+
assert [p.name for p in tmp_path.iterdir()] == ["demo.mp4"]
515+
await box.aclose()
516+
517+
518+
@respx.mock
519+
async def test_recording_download_wraps_transport_timeout(tmp_path):
520+
box = await make_async_box(respx.mock)
521+
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
522+
side_effect=httpx.ConnectTimeout("timed out")
523+
)
524+
525+
with pytest.raises(BoxError, match="Request timeout"):
526+
await box.browser.recordings.download("recording-1", path=str(tmp_path / "demo.mp4"))
527+
await box.aclose()
528+
529+
418530
@respx.mock
419531
async def test_stale_handle_does_not_stop_newer_recording():
420532
box = await make_async_box(respx.mock)

packages/python-sdk/tests/_sync/test_sync_client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,40 @@ def test_browser_tab_flow_sync():
306306
# seconds -> ms normalization survives sync generation
307307
assert recording.expires_at == 1_209_601_000
308308
box.close()
309+
310+
311+
@respx.mock
312+
def test_recording_download_sync(tmp_path):
313+
box = make_sync_box(respx.mock)
314+
respx.get(f"{BASE}/browser/recordings/rec-1/download").mock(
315+
return_value=httpx.Response(
316+
200, content=b"mp4-bytes", headers={"content-type": "video/mp4"}
317+
)
318+
)
319+
320+
# Parent directories are created as needed.
321+
dest = box.browser.recordings.download("rec-1", path=str(tmp_path / "nested" / "demo.mp4"))
322+
323+
assert dest == str(tmp_path / "nested" / "demo.mp4")
324+
with open(dest, "rb") as fh:
325+
assert fh.read() == b"mp4-bytes"
326+
box.close()
327+
328+
329+
@respx.mock
330+
def test_recording_download_sync_legacy_ts(tmp_path, monkeypatch):
331+
monkeypatch.chdir(tmp_path)
332+
box = make_sync_box(respx.mock)
333+
respx.get(f"{BASE}/browser/recordings/rec-1/download").mock(
334+
return_value=httpx.Response(
335+
200, content=b"ts-bytes", headers={"content-type": "video/mp2t"}
336+
)
337+
)
338+
339+
# Legacy recordings without an MP4 remux stream raw MPEG-TS.
340+
dest = box.browser.recordings.download("rec-1")
341+
342+
assert dest == "./box-recording-rec-1.ts"
343+
with open(dest, "rb") as fh:
344+
assert fh.read() == b"ts-bytes"
345+
box.close()

packages/python-sdk/upstash_box/_async/client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,14 @@ async def get(self, recording_id: str) -> BrowserRecording:
719719
"""Fetch one recording's metadata."""
720720
return await self._box._recording_get(recording_id)
721721

722+
async def download(self, recording_id: str, *, path: Optional[str] = None) -> str:
723+
"""Download a recording's video and return the local path written.
724+
725+
Recordings are MP4; recordings captured before MP4 support (or whose
726+
remux failed) download as raw MPEG-TS.
727+
"""
728+
return await self._box._recording_download(recording_id, path)
729+
722730

723731
class AsyncBrowserNamespace:
724732
"""Browser namespace — DOM-aware control of Chromium via CDP. Requires a
@@ -1740,6 +1748,55 @@ async def _recording_get(self, recording_id: str) -> BrowserRecording:
17401748
resp = await self._request("GET", f"/v2/box/{self.id}/browser/recordings/{recording_id}")
17411749
return self._map_recording(resp)
17421750

1751+
async def _recording_download(self, recording_id: str, path: Optional[str]) -> str:
1752+
url = f"{self._base_url}/v2/box/{self.id}/browser/recordings/{recording_id}/download"
1753+
# Normalize transport failures (header timeouts, connection resets, interrupted
1754+
# streams) to BoxError, matching _request(); BoxError from validation propagates.
1755+
try:
1756+
async with self._client.stream(
1757+
"GET", url, headers=self._headers, timeout=_ms_to_seconds(self._timeout_ms)
1758+
) as response:
1759+
if not response.is_success:
1760+
await response.aread()
1761+
common.raise_for_status(response)
1762+
# The backend serves only remuxed MP4 or legacy MPEG-TS; reject anything else.
1763+
content_type = (
1764+
response.headers.get("content-type", "").split(";")[0].strip().lower()
1765+
)
1766+
if content_type == "video/mp4":
1767+
extension = "mp4"
1768+
elif content_type == "video/mp2t":
1769+
extension = "ts"
1770+
else:
1771+
raise BoxError(
1772+
f"Unexpected recording content type: {content_type or 'unknown'}"
1773+
)
1774+
dest = path or f"./box-recording-{recording_id}.{extension}"
1775+
parent = os.path.dirname(dest)
1776+
if parent:
1777+
os.makedirs(parent, exist_ok=True)
1778+
# Write to a sibling temp file, then atomically replace dest, so a failed
1779+
# download never truncates or removes an existing file at dest.
1780+
tmp = f"{dest}.{uuid.uuid4().hex}.tmp"
1781+
try:
1782+
with open(tmp, "wb") as fh:
1783+
async for chunk in response.aiter_bytes():
1784+
fh.write(chunk)
1785+
os.replace(tmp, dest)
1786+
except BaseException:
1787+
try:
1788+
os.remove(tmp)
1789+
except OSError:
1790+
pass
1791+
raise
1792+
except httpx.TimeoutException as e:
1793+
raise BoxError("Request timeout") from e
1794+
except BoxError:
1795+
raise
1796+
except Exception as e:
1797+
raise BoxError(str(e)) from e
1798+
return dest
1799+
17431800
# ==================== Static methods ====================
17441801

17451802
@classmethod

packages/python-sdk/upstash_box/_sync/client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,14 @@ def get(self, recording_id: str) -> BrowserRecording:
710710
"""Fetch one recording's metadata."""
711711
return self._box._recording_get(recording_id)
712712

713+
def download(self, recording_id: str, *, path: Optional[str] = None) -> str:
714+
"""Download a recording's video and return the local path written.
715+
716+
Recordings are MP4; recordings captured before MP4 support (or whose
717+
remux failed) download as raw MPEG-TS.
718+
"""
719+
return self._box._recording_download(recording_id, path)
720+
713721

714722
class BrowserNamespace:
715723
"""Browser namespace — DOM-aware control of Chromium via CDP. Requires a
@@ -1717,6 +1725,55 @@ def _recording_get(self, recording_id: str) -> BrowserRecording:
17171725
resp = self._request("GET", f"/v2/box/{self.id}/browser/recordings/{recording_id}")
17181726
return self._map_recording(resp)
17191727

1728+
def _recording_download(self, recording_id: str, path: Optional[str]) -> str:
1729+
url = f"{self._base_url}/v2/box/{self.id}/browser/recordings/{recording_id}/download"
1730+
# Normalize transport failures (header timeouts, connection resets, interrupted
1731+
# streams) to BoxError, matching _request(); BoxError from validation propagates.
1732+
try:
1733+
with self._client.stream(
1734+
"GET", url, headers=self._headers, timeout=_ms_to_seconds(self._timeout_ms)
1735+
) as response:
1736+
if not response.is_success:
1737+
response.read()
1738+
common.raise_for_status(response)
1739+
# The backend serves only remuxed MP4 or legacy MPEG-TS; reject anything else.
1740+
content_type = (
1741+
response.headers.get("content-type", "").split(";")[0].strip().lower()
1742+
)
1743+
if content_type == "video/mp4":
1744+
extension = "mp4"
1745+
elif content_type == "video/mp2t":
1746+
extension = "ts"
1747+
else:
1748+
raise BoxError(
1749+
f"Unexpected recording content type: {content_type or 'unknown'}"
1750+
)
1751+
dest = path or f"./box-recording-{recording_id}.{extension}"
1752+
parent = os.path.dirname(dest)
1753+
if parent:
1754+
os.makedirs(parent, exist_ok=True)
1755+
# Write to a sibling temp file, then atomically replace dest, so a failed
1756+
# download never truncates or removes an existing file at dest.
1757+
tmp = f"{dest}.{uuid.uuid4().hex}.tmp"
1758+
try:
1759+
with open(tmp, "wb") as fh:
1760+
for chunk in response.iter_bytes():
1761+
fh.write(chunk)
1762+
os.replace(tmp, dest)
1763+
except BaseException:
1764+
try:
1765+
os.remove(tmp)
1766+
except OSError:
1767+
pass
1768+
raise
1769+
except httpx.TimeoutException as e:
1770+
raise BoxError("Request timeout") from e
1771+
except BoxError:
1772+
raise
1773+
except Exception as e:
1774+
raise BoxError(str(e)) from e
1775+
return dest
1776+
17201777
# ==================== Static methods ====================
17211778

17221779
@classmethod

packages/python-sdk/upstash_box/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,8 @@ class BrowserRecording(_Model):
868868
duration_ms: Optional[int] = None
869869
size_bytes: Optional[int] = None
870870
segment_count: Optional[int] = None
871+
# Size of the downloadable MP4 in bytes; absent when the download falls back to MPEG-TS.
872+
mp4_size_bytes: Optional[int] = None
871873
# Why the recording ended: "requested" | "max_duration" | "idle" |
872874
# "browser_disconnected" | "lost".
873875
stopped_reason: Optional[str] = None

0 commit comments

Comments
 (0)