diff --git a/.changeset/mp4-recording-download.md b/.changeset/mp4-recording-download.md new file mode 100644 index 0000000..5c1c20d --- /dev/null +++ b/.changeset/mp4-recording-download.md @@ -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. diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index 03693f5..f2cec06 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -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`. diff --git a/packages/python-sdk/PARITY.md b/packages/python-sdk/PARITY.md index 5408c5e..fd18f05 100644 --- a/packages/python-sdk/PARITY.md +++ b/packages/python-sdk/PARITY.md @@ -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) diff --git a/packages/python-sdk/tests/_async/test_box_browser.py b/packages/python-sdk/tests/_async/test_box_browser.py index 714874c..e38e331 100644 --- a/packages/python-sdk/tests/_async/test_box_browser.py +++ b/packages/python-sdk/tests/_async/test_box_browser.py @@ -4,6 +4,7 @@ """ import base64 +import os import httpx import pytest @@ -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"nope", 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) diff --git a/packages/python-sdk/tests/_sync/test_sync_client.py b/packages/python-sdk/tests/_sync/test_sync_client.py index 6e8c336..a2bc040 100644 --- a/packages/python-sdk/tests/_sync/test_sync_client.py +++ b/packages/python-sdk/tests/_sync/test_sync_client.py @@ -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() diff --git a/packages/python-sdk/upstash_box/_async/client.py b/packages/python-sdk/upstash_box/_async/client.py index 91d2e2d..c969362 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -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 @@ -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 diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 52eed36..34dd199 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -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 @@ -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 diff --git a/packages/python-sdk/upstash_box/types.py b/packages/python-sdk/upstash_box/types.py index 283847d..aba5c74 100644 --- a/packages/python-sdk/upstash_box/types.py +++ b/packages/python-sdk/upstash_box/types.py @@ -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 diff --git a/packages/sdk/src/__tests__/box-browser.test.ts b/packages/sdk/src/__tests__/box-browser.test.ts index 548b4a3..2ebb497 100644 --- a/packages/sdk/src/__tests__/box-browser.test.ts +++ b/packages/sdk/src/__tests__/box-browser.test.ts @@ -1,4 +1,7 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { Buffer } from "node:buffer"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod/v4"; import type { BrowserRecording, @@ -8,6 +11,22 @@ import type { } from "../index.js"; import { createTestBox, mockResponse } from "./helpers.js"; +const { writeFileMock, mkdirMock, unlinkMock, renameMock } = vi.hoisted(() => ({ + writeFileMock: vi.fn().mockResolvedValue(undefined), + mkdirMock: vi.fn().mockResolvedValue(undefined), + unlinkMock: vi.fn().mockResolvedValue(undefined), + renameMock: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("node:fs/promises", () => ({ + writeFile: writeFileMock, + mkdir: mkdirMock, + unlink: unlinkMock, + rename: renameMock, +})); + +// The un-mocked module, for tests that exercise real streaming writes. +const realFs = await vi.importActual("node:fs/promises"); + type PublicRecordingTypes = [ BrowserRecording, BrowserRecordingHandle, @@ -15,7 +34,28 @@ type PublicRecordingTypes = [ BrowserRecordingOptions, ]; +function mockVideoResponse(contentType: string, bytes: Uint8Array): Response { + return { + ...mockResponse({}), + headers: new Headers({ "content-type": contentType }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), + } as unknown as Response; +} + describe("Box browser operations", () => { + beforeEach(() => { + // Reset call history and per-test implementations, restoring the default + // resolved-undefined so each test starts clean. + writeFileMock.mockReset().mockResolvedValue(undefined); + mkdirMock.mockReset().mockResolvedValue(undefined); + unlinkMock.mockReset().mockResolvedValue(undefined); + renameMock.mockReset().mockResolvedValue(undefined); + }); afterEach(() => vi.restoreAllMocks()); it("addresses page operations through a tab", async () => { @@ -289,6 +329,168 @@ describe("Box browser operations", () => { expect(fetchMock.mock.calls[3]?.[0]).toContain("browser/recordings/stop"); }); + it("downloads a recording as mp4 named after the served content type", async () => { + const { box, fetchMock } = await createTestBox(); + const bytes = new TextEncoder().encode("mp4-bytes"); + fetchMock.mockResolvedValueOnce(mockVideoResponse("video/mp4", bytes)); + + // Default path is cwd-relative; run in a temp cwd so the real stream/rename + // don't touch the repo. + const dir = await realFs.mkdtemp(join(tmpdir(), "box-sdk-test-")); + const cwd = process.cwd(); + process.chdir(dir); + renameMock.mockImplementation((from: string, to: string) => realFs.rename(from, to)); + try { + const dest = await box.browser.recordings.download("recording-1"); + + expect(dest).toBe("./box-recording-recording-1.mp4"); + expect(await realFs.readFile(join(dir, "box-recording-recording-1.mp4"), "utf8")).toBe( + "mp4-bytes", + ); + } finally { + process.chdir(cwd); + await realFs.rm(dir, { recursive: true, force: true }); + } + expect(fetchMock.mock.calls[1]?.[0]).toContain( + "/v2/box/box-123/browser/recordings/recording-1/download", + ); + }); + + it("downloads a legacy recording as raw MPEG-TS", async () => { + const { box, fetchMock } = await createTestBox(); + const bytes = new TextEncoder().encode("ts-bytes"); + fetchMock.mockResolvedValueOnce(mockVideoResponse("video/mp2t", bytes)); + + const dir = await realFs.mkdtemp(join(tmpdir(), "box-sdk-test-")); + const cwd = process.cwd(); + process.chdir(dir); + renameMock.mockImplementation((from: string, to: string) => realFs.rename(from, to)); + try { + const dest = await box.browser.recordings.download("recording-1"); + + expect(dest).toBe("./box-recording-recording-1.ts"); + expect(await realFs.readFile(join(dir, "box-recording-recording-1.ts"), "utf8")).toBe( + "ts-bytes", + ); + } finally { + process.chdir(cwd); + await realFs.rm(dir, { recursive: true, force: true }); + } + }); + + it("rejects a download whose content type is neither MP4 nor MPEG-TS", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockVideoResponse("text/html", new TextEncoder().encode("nope")), + ); + + await expect(box.browser.recordings.download("recording-1")).rejects.toThrow( + "Unexpected recording content type: text/html", + ); + // Rejected before any file is opened. + expect(renameMock).not.toHaveBeenCalled(); + }); + + it("throws when the response body is not streamable", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce({ + ...mockResponse({}), + headers: new Headers({ "content-type": "video/mp4" }), + body: null, + } as Response); + + await expect(box.browser.recordings.download("recording-1")).rejects.toThrow( + "Streaming not supported", + ); + }); + + it("surfaces the backend error body when a download request fails", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ error: "recording is not ready for download" }, 409), + ); + + await expect(box.browser.recordings.download("recording-1")).rejects.toThrow( + "recording is not ready for download", + ); + }); + + it("downloads a recording to an explicit path, creating parent directories", async () => { + const { box, fetchMock } = await createTestBox(); + const bytes = new TextEncoder().encode("mp4-bytes"); + fetchMock.mockResolvedValueOnce(mockVideoResponse("video/mp4", bytes)); + + const dir = await realFs.mkdtemp(join(tmpdir(), "box-sdk-test-")); + const dest = join(dir, "recordings", "nested", "demo.mp4"); + mkdirMock.mockImplementation((p: string, opts: object) => realFs.mkdir(p, opts)); + renameMock.mockImplementation((from: string, to: string) => realFs.rename(from, to)); + + const saved = await box.browser.recordings.download("recording-1", { path: dest }); + + expect(saved).toBe(dest); + expect(mkdirMock).toHaveBeenCalledWith(join(dir, "recordings", "nested"), { recursive: true }); + expect(await realFs.readFile(dest, "utf8")).toBe("mp4-bytes"); + await realFs.rm(dir, { recursive: true, force: true }); + }); + + it("streams a download body to disk and tolerates content-type parameters", async () => { + const { box, fetchMock } = await createTestBox(); + const dir = await realFs.mkdtemp(join(tmpdir(), "box-sdk-test-")); + const dest = join(dir, "streamed.mp4"); + const bytes = new TextEncoder().encode("streamed-mp4-bytes"); + fetchMock.mockResolvedValueOnce({ + ...mockResponse({}), + headers: new Headers({ "content-type": "Video/MP4; some=param" }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), + } as unknown as Response); + + // The temp file is a real sibling of dest; move it onto dest as the code does. + renameMock.mockImplementationOnce((from: string, to: string) => realFs.rename(from, to)); + + const saved = await box.browser.recordings.download("recording-1", { path: dest }); + + expect(saved).toBe(dest); + expect(await realFs.readFile(dest, "utf8")).toBe("streamed-mp4-bytes"); + // Streamed, not buffered: the whole-body fallback never fired for this file. + expect(writeFileMock).not.toHaveBeenCalledWith(dest, expect.anything()); + // Renamed from a sibling temp file, never written to dest directly. + expect(renameMock.mock.calls[0]?.[0]).toMatch(/\.tmp$/); + expect(renameMock.mock.calls[0]?.[1]).toBe(dest); + await realFs.rm(dir, { recursive: true, force: true }); + }); + + it("removes only the temp file, preserving an existing dest, when a stream fails", async () => { + const { box, fetchMock } = await createTestBox(); + const dir = await realFs.mkdtemp(join(tmpdir(), "box-sdk-test-")); + const dest = join(dir, "partial.mp4"); + // A prior good download exists at dest; a failed download must not destroy it. + await realFs.writeFile(dest, "existing-recording"); + fetchMock.mockResolvedValueOnce({ + ...mockResponse({}), + headers: new Headers({ "content-type": "video/mp4" }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + controller.error(new Error("connection reset")); + }, + }), + } as unknown as Response); + + await expect(box.browser.recordings.download("recording-1", { path: dest })).rejects.toThrow( + "Failed to save recording recording-1", + ); + // Only the sibling temp file is unlinked; dest itself is never touched. + expect(unlinkMock.mock.calls[0]?.[0]).toMatch(/\.tmp$/); + expect(unlinkMock).not.toHaveBeenCalledWith(dest); + expect(await realFs.readFile(dest, "utf8")).toBe("existing-recording"); + await realFs.rm(dir, { recursive: true, force: true }); + }); + it("does not stop a newer recording from a stale handle", async () => { const { box, fetchMock } = await createTestBox(); fetchMock diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index a62dac1..16c764e 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -736,6 +736,8 @@ export class Box { list: () => Promise; /** Fetch one recording's metadata. */ get: (recordingId: string) => Promise; + /** Download a recording's video (MP4, or MPEG-TS for pre-MP4 recordings) to a local file; returns the path written. */ + download: (recordingId: string, options?: { path?: string }) => Promise; }; }; @@ -894,6 +896,7 @@ export class Box { stop: () => this._recordingStop(), list: () => this._recordingList(), get: (recordingId) => this._recordingGet(recordingId), + download: (recordingId, options) => this._recordingDownload(recordingId, options), }, }; } @@ -2460,6 +2463,7 @@ export class Box { durationMs: num(raw.duration_ms), sizeBytes: num(raw.size_bytes), segmentCount: num(raw.segment_count), + mp4SizeBytes: num(raw.mp4_size_bytes), stoppedReason: raw.stopped_reason ? String(raw.stopped_reason) : undefined, maxDurationSeconds: num(raw.max_duration_seconds), markers: markers.map((m) => ({ @@ -2533,6 +2537,73 @@ export class Box { return this._mapRecording(resp); } + private async _recordingDownload( + recordingId: string, + options?: { path?: string }, + ): Promise { + const url = `${this._baseUrl}/v2/box/${this.id}/browser/recordings/${recordingId}/download`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this._timeout); + let response: Response; + try { + response = await fetch(url, { headers: this._headers, signal: controller.signal }); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + throw new BoxError("Request timeout"); + } + throw new BoxError(error instanceof Error ? error.message : "Unknown error"); + } + try { + if (!response.ok) { + throw new BoxError(await parseErrorResponse(response), response.status); + } + // The backend serves only remuxed MP4 or legacy MPEG-TS; reject anything else. + const contentType = (response.headers.get("content-type") ?? "") + .split(";")[0]! + .trim() + .toLowerCase(); + const extension = + contentType === "video/mp4" ? "mp4" : contentType === "video/mp2t" ? "ts" : null; + if (!extension) { + throw new BoxError(`Unexpected recording content type: ${contentType || "unknown"}`); + } + if (!response.body) { + throw new BoxError("Streaming not supported in this environment"); + } + const dest = options?.path ?? `./box-recording-${recordingId}.${extension}`; + const fs = await this._getFs(); + const path = await this._getPath(); + const parent = path.dirname(dest); + if (parent && parent !== ".") { + await fs.mkdir(parent, { recursive: true }); + } + // Stream to a sibling temp file, then atomically replace dest, so a failed + // download never truncates or removes an existing file at dest. + const tmp = `${dest}.${Math.random().toString(36).slice(2)}.tmp`; + try { + const [{ Readable }, { pipeline }, { createWriteStream }] = await Promise.all([ + import("node:stream"), + import("node:stream/promises"), + import("node:fs"), + ]); + await pipeline( + Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), + createWriteStream(tmp), + ); + await fs.rename(tmp, dest); + } catch (err) { + await fs.unlink(tmp).catch(() => {}); + throw new BoxError( + `Failed to save recording ${recordingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return dest; + } finally { + clearTimeout(timeoutId); + } + } + /** @internal — Authenticated live-view URL for a tab (used by Tab.liveViewUrl). */ async _browserLiveViewUrl(tabId?: string): Promise { const resp = await this._request<{ screencast_url?: string }>( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 3fd167f..85605af 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1242,6 +1242,8 @@ export interface BrowserRecording { durationMs?: number; sizeBytes?: number; segmentCount?: number; + /** Size of the downloadable MP4 in bytes; absent when the download falls back to MPEG-TS. */ + mp4SizeBytes?: number; /** Why the recording ended: "requested" | "max_duration" | "idle" | "browser_disconnected" | "lost". */ stoppedReason?: string; maxDurationSeconds?: number;