Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mp4-recording-download.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@upstash/box": patch
---

Add `box.browser.recordings.download(recordingId, { path? })` to save a
recording's video to a local file (streamed to disk, parent directories
created as needed) and expose `mp4SizeBytes` on recording metadata.
Recordings are downloaded as MP4; recordings captured before MP4 support
(or whose remux failed) download as raw MPEG-TS with a `.ts` extension.
6 changes: 6 additions & 0 deletions packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `upstash-box` (Python) are documented here.

## Unreleased

- `browser.recordings.download(recording_id, path=...)` — save a recording's
video to a local file (streamed to disk, parent directories created as
needed) and return the path written. Recordings download as MP4; recordings
captured before MP4 support (or whose remux failed) download as raw MPEG-TS
with a `.ts` extension. Adds `mp4_size_bytes` to `BrowserRecording`.
Mirrors `recordings.download` in `@upstash/box`.
- `git.clone(depth=...)` — shallow clone support (`git clone --depth N`).
`depth=1` fetches only the latest commit; omitting it keeps the current
full-clone behavior. Mirrors `depth` in `@upstash/box` `git.clone`.
Expand Down
1 change: 1 addition & 0 deletions packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun`
| `browser.tab.create` | `browser.tab.create` |
| `browser.listTabs` / `browser.getTab` / `browser.cdpUrl` | `browser.list_tabs` / `browser.get_tab` / `browser.cdp_url` |
| `browser.recordings.start/stop/list/get` | same (snake) |
| `browser.recordings.download(id, { path? })` | `browser.recordings.download(id, path=...)` |

## `Tab` (browser)

Expand Down
112 changes: 112 additions & 0 deletions packages/python-sdk/tests/_async/test_box_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import base64
import os

import httpx
import pytest
Expand Down Expand Up @@ -415,6 +416,117 @@ async def test_recording_start_stop_and_mapping():
await box.aclose()


@respx.mock
async def test_recording_download_mp4(tmp_path):
box = await make_async_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
return_value=httpx.Response(
# Content-type parameters must not defeat the MP4 detection.
200,
content=b"mp4-bytes",
headers={"content-type": "video/mp4; some=param"},
)
)

# Parent directories are created as needed.
dest = await box.browser.recordings.download(
"recording-1", path=str(tmp_path / "recordings" / "nested" / "demo.mp4")
)

assert dest == str(tmp_path / "recordings" / "nested" / "demo.mp4")
with open(dest, "rb") as fh:
assert fh.read() == b"mp4-bytes"
await box.aclose()


@respx.mock
async def test_recording_download_default_extension_follows_content_type(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
box = await make_async_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
return_value=httpx.Response(
200, content=b"ts-bytes", headers={"content-type": "video/mp2t"}
)
)

# Legacy recordings without an MP4 remux stream raw MPEG-TS.
dest = await box.browser.recordings.download("recording-1")

assert dest == "./box-recording-recording-1.ts"
with open(dest, "rb") as fh:
assert fh.read() == b"ts-bytes"
await box.aclose()


@respx.mock
async def test_recording_download_rejects_unexpected_content_type(tmp_path):
box = await make_async_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
return_value=httpx.Response(
200, content=b"<html>nope</html>", headers={"content-type": "text/html"}
)
)

dest = str(tmp_path / "demo.mp4")
with pytest.raises(BoxError, match="Unexpected recording content type: text/html"):
await box.browser.recordings.download("recording-1", path=dest)
assert not os.path.exists(dest)
await box.aclose()


@respx.mock
async def test_recording_download_surfaces_backend_error(tmp_path):
box = await make_async_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
return_value=httpx.Response(409, json={"error": "recording is not ready for download"})
)

with pytest.raises(BoxError, match="recording is not ready for download"):
await box.browser.recordings.download("recording-1", path=str(tmp_path / "demo.mp4"))
await box.aclose()


@respx.mock
async def test_recording_download_preserves_existing_file_on_failure(tmp_path):
box = await make_async_box(respx.mock)
dest = tmp_path / "demo.mp4"
dest.write_bytes(b"existing-recording")

# Headers arrive, then the body aborts mid-stream after a partial chunk.
async def _partial_then_error():
yield b"partial"
raise httpx.ReadError("connection reset")

respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
return_value=httpx.Response(
200,
headers={"content-type": "video/mp4"},
content=_partial_then_error(),
)
)

# Interrupted streams surface as BoxError, like _request().
with pytest.raises(BoxError, match="connection reset"):
await box.browser.recordings.download("recording-1", path=str(dest))

# The existing file at dest must survive intact, and no temp file is left behind.
assert dest.read_bytes() == b"existing-recording"
assert [p.name for p in tmp_path.iterdir()] == ["demo.mp4"]
await box.aclose()


@respx.mock
async def test_recording_download_wraps_transport_timeout(tmp_path):
box = await make_async_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/recording-1/download").mock(
side_effect=httpx.ConnectTimeout("timed out")
)

with pytest.raises(BoxError, match="Request timeout"):
await box.browser.recordings.download("recording-1", path=str(tmp_path / "demo.mp4"))
await box.aclose()


@respx.mock
async def test_stale_handle_does_not_stop_newer_recording():
box = await make_async_box(respx.mock)
Expand Down
37 changes: 37 additions & 0 deletions packages/python-sdk/tests/_sync/test_sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,40 @@ def test_browser_tab_flow_sync():
# seconds -> ms normalization survives sync generation
assert recording.expires_at == 1_209_601_000
box.close()


@respx.mock
def test_recording_download_sync(tmp_path):
box = make_sync_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/rec-1/download").mock(
return_value=httpx.Response(
200, content=b"mp4-bytes", headers={"content-type": "video/mp4"}
)
)

# Parent directories are created as needed.
dest = box.browser.recordings.download("rec-1", path=str(tmp_path / "nested" / "demo.mp4"))

assert dest == str(tmp_path / "nested" / "demo.mp4")
with open(dest, "rb") as fh:
assert fh.read() == b"mp4-bytes"
box.close()


@respx.mock
def test_recording_download_sync_legacy_ts(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
box = make_sync_box(respx.mock)
respx.get(f"{BASE}/browser/recordings/rec-1/download").mock(
return_value=httpx.Response(
200, content=b"ts-bytes", headers={"content-type": "video/mp2t"}
)
)

# Legacy recordings without an MP4 remux stream raw MPEG-TS.
dest = box.browser.recordings.download("rec-1")

assert dest == "./box-recording-rec-1.ts"
with open(dest, "rb") as fh:
assert fh.read() == b"ts-bytes"
box.close()
57 changes: 57 additions & 0 deletions packages/python-sdk/upstash_box/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,14 @@ async def get(self, recording_id: str) -> BrowserRecording:
"""Fetch one recording's metadata."""
return await self._box._recording_get(recording_id)

async def download(self, recording_id: str, *, path: Optional[str] = None) -> str:
"""Download a recording's video and return the local path written.

Recordings are MP4; recordings captured before MP4 support (or whose
remux failed) download as raw MPEG-TS.
"""
return await self._box._recording_download(recording_id, path)


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

async def _recording_download(self, recording_id: str, path: Optional[str]) -> str:
url = f"{self._base_url}/v2/box/{self.id}/browser/recordings/{recording_id}/download"
# Normalize transport failures (header timeouts, connection resets, interrupted
# streams) to BoxError, matching _request(); BoxError from validation propagates.
try:
async with self._client.stream(
"GET", url, headers=self._headers, timeout=_ms_to_seconds(self._timeout_ms)
) as response:
if not response.is_success:
await response.aread()
common.raise_for_status(response)
# The backend serves only remuxed MP4 or legacy MPEG-TS; reject anything else.
content_type = (
response.headers.get("content-type", "").split(";")[0].strip().lower()
)
if content_type == "video/mp4":
extension = "mp4"
elif content_type == "video/mp2t":
extension = "ts"
else:
raise BoxError(
f"Unexpected recording content type: {content_type or 'unknown'}"
)
dest = path or f"./box-recording-{recording_id}.{extension}"
parent = os.path.dirname(dest)
if parent:
os.makedirs(parent, exist_ok=True)
# Write to a sibling temp file, then atomically replace dest, so a failed
# download never truncates or removes an existing file at dest.
tmp = f"{dest}.{uuid.uuid4().hex}.tmp"
try:
with open(tmp, "wb") as fh:
async for chunk in response.aiter_bytes():
fh.write(chunk)
os.replace(tmp, dest)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
except httpx.TimeoutException as e:
raise BoxError("Request timeout") from e
except BoxError:
raise
except Exception as e:
raise BoxError(str(e)) from e
return dest

# ==================== Static methods ====================

@classmethod
Expand Down
57 changes: 57 additions & 0 deletions packages/python-sdk/upstash_box/_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,14 @@ def get(self, recording_id: str) -> BrowserRecording:
"""Fetch one recording's metadata."""
return self._box._recording_get(recording_id)

def download(self, recording_id: str, *, path: Optional[str] = None) -> str:
"""Download a recording's video and return the local path written.

Recordings are MP4; recordings captured before MP4 support (or whose
remux failed) download as raw MPEG-TS.
"""
return self._box._recording_download(recording_id, path)


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

def _recording_download(self, recording_id: str, path: Optional[str]) -> str:
url = f"{self._base_url}/v2/box/{self.id}/browser/recordings/{recording_id}/download"
# Normalize transport failures (header timeouts, connection resets, interrupted
# streams) to BoxError, matching _request(); BoxError from validation propagates.
try:
with self._client.stream(
"GET", url, headers=self._headers, timeout=_ms_to_seconds(self._timeout_ms)
) as response:
if not response.is_success:
response.read()
common.raise_for_status(response)
# The backend serves only remuxed MP4 or legacy MPEG-TS; reject anything else.
content_type = (
response.headers.get("content-type", "").split(";")[0].strip().lower()
)
if content_type == "video/mp4":
extension = "mp4"
elif content_type == "video/mp2t":
extension = "ts"
else:
raise BoxError(
f"Unexpected recording content type: {content_type or 'unknown'}"
)
dest = path or f"./box-recording-{recording_id}.{extension}"
parent = os.path.dirname(dest)
if parent:
os.makedirs(parent, exist_ok=True)
# Write to a sibling temp file, then atomically replace dest, so a failed
# download never truncates or removes an existing file at dest.
tmp = f"{dest}.{uuid.uuid4().hex}.tmp"
try:
with open(tmp, "wb") as fh:
for chunk in response.iter_bytes():
fh.write(chunk)
os.replace(tmp, dest)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
except httpx.TimeoutException as e:
raise BoxError("Request timeout") from e
except BoxError:
raise
except Exception as e:
raise BoxError(str(e)) from e
return dest

# ==================== Static methods ====================

@classmethod
Expand Down
2 changes: 2 additions & 0 deletions packages/python-sdk/upstash_box/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,8 @@ class BrowserRecording(_Model):
duration_ms: Optional[int] = None
size_bytes: Optional[int] = None
segment_count: Optional[int] = None
# Size of the downloadable MP4 in bytes; absent when the download falls back to MPEG-TS.
mp4_size_bytes: Optional[int] = None
# Why the recording ended: "requested" | "max_duration" | "idle" |
# "browser_disconnected" | "lost".
stopped_reason: Optional[str] = None
Expand Down
Loading
Loading