# Recipes

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

This page gives longer, end-to-end walkthroughs. They combine the control
plane and data plane into realistic flows. Each recipe assumes a
control-plane `Streams` client constructed with `baseUrl`/`apiKey`/`orgId`.

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

This is a complete "create input → publish a webcam → watch" loop. It runs
entirely in the browser with the SDK's MoQT publisher and viewer.

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

    const streams = new Streams({
      baseUrl: "https://streams.firstbyte.fast",
      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.firstbyte.fast" });
    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.firstbyte.fast" });
    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 approximately one RTT plus one MoQT group time. 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.firstbyte.fast/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). Under
    loss, the relay drops by priority. It does not block the stream.

> **NOTE:**
> The relay mesh fans out each group. Thus the encoder uploads **once**,
> whatever number of viewers attach. Fan-out cost lives at the edge POPs, not
> on the contribution link.

## Recipe 3 — TUS2 upload to VOD playback

Upload a recorded file with resumable chunks. Let the engine package it to
CMAF. Then play the resulting asset from the same viewer that 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.
> Thus a single player component handles both live (`lv_…`) and on-demand
> (`pb_…`) ids. The only difference is the delivery framing (MoQT vs HTTPS).
> The viewer picks the framing by id prefix.

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

Record a live broadcast to disk on the server side. Handle signing-key
rotation before the token expires.

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

streams = Streams(
    base_url="https://streams.firstbyte.fast",
    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` (reported
> through `on_close`). For captures longer than the token TTL, re-mint with
> `inp.signed_playback_url(...)`. Re-open the viewer before `expires_at`.
