# Streams — Recipes

> End-to-end streams examples: browser go-live with signed playback, an RTMP-to-many event, and a TUS2 VOD-to-playback pipeline.

Longer, end-to-end walkthroughs that combine the control plane and data plane
into realistic flows. Each assumes a control-plane `Streams` client constructed
with `baseUrl`/`apiKey`/`orgId`.

## Recipe 1 — Go live from the browser, watch with a signed URL

A complete "create input → publish a webcam → watch" loop, entirely in the
browser, using the SDK's MoQT publisher and viewer.

  1. **Create a live input**
```ts
    import { Streams } from "@clutchcall/sdk/streams";

    const streams = new Streams({
      baseUrl: "https://app.clutchcall.dev",
      apiKey:  process.env.CLUTCHCALL_CREDENTIALS!,
      orgId:   "org_abc",
    });

    const { input, streamKey } = await streams.liveInputs.create({
      name: "Live Demo", ingest: "fmp4",
    });
    // Persist streamKey now — it is never returned again.
    ```
  2. **Publish a webcam over MoQT**
```ts
    const dataPlane = new Streams({ relayUrl: "https://relay.clutchcall.dev" });
    const pub  = dataPlane.publisher({ timesliceMs: 1000 });
    const cam  = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

    await pub.publish(
      input.external_input_id,
      streamKey,
      cam,
      `stream/org_abc/${input.external_input_id}`,
    );
    ```
  3. **Mint a signed playback URL for viewers**
```ts
    const { url } = await input.signedPlaybackUrl({ ttlSeconds: 3600 });
    // Hand `url` (or just the input id + a token) to each viewer.
    ```
  4.
">
    ```ts
    const viewerStreams = new Streams({ apiBase: "", relayUrl: "https://relay.clutchcall.dev" });
    const viewer = viewerStreams.viewer({
      onStarted: () => console.log("live"),
      onError:   (e) => console.error("playback error", e),
    });
    viewer.attach(document.querySelector("video")!);
    await viewer.play(input.external_input_id);   // lv_… → live over MoQT
    ```
  5. **Tear down**
```ts
    pub.stop();
    viewer.stop();
    ```

> **TIP:**
> On a warm same-region path this loop is sub-second glass-to-glass. Across an
> internet RTT, latency is roughly one RTT plus one MoQT group time — so keep the
> publisher's `timesliceMs` short (250–500 ms) for tighter latency at the cost of
> slightly higher overhead.

## Recipe 2 — RTMP event to thousands of viewers

A hardware/OBS encoder contributes over RTMP; the engine transcodes and fans the
stream out to every viewer over MoQT. The control plane mints per-viewer signed
URLs.

  1. **Provision the input and rotate a fresh key**
```ts
    const { input } = await streams.liveInputs.create({
      name: "Keynote 2026", ingest: "rtmp",
    });
    const { streamKey } = await input.rotateStreamKey();  // fresh key for the event
    ```
  2. **Point the encoder at the RTMP endpoint**
```text
    Server:     rtmp://ingest.clutchcall.dev/live/<external_input_id>
    Stream key: <streamKey>
    ```
    The engine ingests, transcodes to multiple ABR renditions, and publishes the
    catalog in-band on the `.catalog` track.
  3. **Issue a signed URL per viewer (server side)**
```ts
    // In your app server, behind your own auth:
    async function playbackForViewer(): Promise<string> {
      const inp = await streams.liveInputs.get({ id: "li_keynote" });
      const { url } = await inp.signedPlaybackUrl({ ttlSeconds: 900 });
      return url;   // 15-minute token; client re-fetches before expiry
    }
    ```
  4. **Each browser plays the URL**
```ts
    const dataPlane = new Streams({ apiBase: "", token: tokenFromUrl });
    const viewer = dataPlane.viewer();
    viewer.attach(videoEl);
    await viewer.play("lv_keynote");
    ```
    Late joiners start at the most recent group boundary (keyframe); the relay
    drops by priority under loss rather than blocking the stream.

> **NOTE:**
> Because the relay mesh fans out each group, the encoder uploads **once** no
> matter how many viewers attach — fan-out cost lives at the edge POPs, not the
> contribution link.

## Recipe 3 — TUS2 upload to VOD playback

Upload a recorded file resumably, let the engine package it to CMAF, then play
the resulting asset from the same viewer the live path uses.

  1. **Start a resumable upload**
```ts
    // Preview surface: streams.assets.* — until the typed helper ships, drive
    // the TUS2 endpoint with the same bearer key.
    const { uploadUrl, assetId } = await streams.assets.createUpload({
      name: "Session-Recording.mp4",
    });
    ```
  2. **Upload in resumable chunks**
```ts
    async function tusUpload(uploadUrl: string, bytes: Uint8Array, chunk = 8 << 20) {
      // On reconnect, HEAD uploadUrl to read Upload-Offset and resume there.
      let offset = Number(
        (await fetch(uploadUrl, { method: "HEAD", headers: { "tus-resumable": "2.0.0" } }))
          .headers.get("upload-offset") ?? 0,
      );
      while (offset < bytes.byteLength) {
        const end = Math.min(offset + chunk, bytes.byteLength);
        const res = await fetch(uploadUrl, {
          method: "PATCH",
          headers: {
            "tus-resumable": "2.0.0",
            "upload-offset": String(offset),
            "content-type": "application/offset+octet-stream",
          },
          body: bytes.slice(offset, end),
        });
        offset = Number(res.headers.get("upload-offset") ?? end);
      }
    }
    ```
  3. **Wait for packaging, then play the asset**
On completion the engine packages the file to CMAF/fMP4 and assigns a
    playback id (`pb_…`). The same viewer plays VOD over HTTPS from the catalog:

    ```ts
    const dataPlane = new Streams({ apiBase: "" });
    const viewer = dataPlane.viewer();
    viewer.attach(videoEl);
    await viewer.play("pb_def456");   // pb_… → VOD: catalog.json + CMAF over HTTPS
    ```

> **TIP:**
> VOD reuses the live `stream/<org>/…` namespace and the same catalog format, so a
> single player component handles both live (`lv_…`) and on-demand (`pb_…`) ids —
> the only difference is the delivery framing (MoQT vs HTTPS), which the viewer
> picks by id prefix.

## Recipe 4 — Headless capture of a live stream (Python)

Record a live broadcast to disk server-side, with signing-key rotation handled
ahead of token expiry.

```python
import os, time
from clutchcall.streams import Streams, BroadcastViewer

streams = Streams(
    base_url="https://app.clutchcall.dev",
    api_key=os.environ["CLUTCHCALL_CREDENTIALS"],
    org_id="org_abc",
)

inp    = streams.live_inputs.get(id="li_xyz")
ticket = inp.signed_playback_url(ttl_seconds=3600)

out = open("broadcast.mp4", "wb")

def on_chunk(is_init, chunk):
    # first chunk is the CMAF init segment; the rest are media segments
    out.write(chunk.data)

def on_close(reason, detail):
    out.close()
    print("capture closed:", reason, detail)

viewer = BroadcastViewer.open(ticket.url, on_chunk=on_chunk, on_close=on_close)

# Run until the broadcast ends or you stop it.
try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    viewer.close()
```

> **WARNING:**
> A bad or expired playback JWT closes the session with `auth_failed` (surfaced
> through `on_close`). For captures longer than the token TTL, re-mint with
> `inp.signed_playback_url(...)` and re-open the viewer before `expires_at`.
