# Multi-Source Playback — Integration Guide

**Audience:** third-party integrators (publishers, rite3, anyone embedding ZiB-hosted video).
**TL;DR:** it's a **drop-in**. Load the ZiB SDK, add **one line** for your player, and HLS
segments are pulled from multiple operator nodes in parallel instead of one — smooth
720p/1080p playback from home/edge nodes that individually couldn't sustain it.

---

## 1. Why this exists

ZiB content is served **from the operator nodes themselves** (that's how operators get
paid — egress stays on their hardware, never a CDN edge-cache or colo). A single home node
serves over its internet **upload** (~1.75 Mbps typical), which is below what a 720p stream
needs → buffering and quality downswitches.

But every title is replicated across **≥3 nodes**. Multi-source playback uses that: it pulls
**consecutive, distinct** segments from **different** holder nodes **concurrently**, so the
inbound bandwidth is the *sum* of several nodes (e.g. 5 × 1.75 ≈ ~9 Mbps) — far more than one
stream needs. Each segment still egresses exactly **once** (it's not "the same segment from N
nodes"), so total egress = 1× video size, spread fairly across operators.

This is HLS used exactly as designed: segments are independent, individually-addressable
units that can be sourced from anywhere.

---

## 2. Is it a drop-in? — Yes.

As long as **all three** of these hold, you change **one or two lines** and nothing else:

1. You load the ZiB SDK: `https://app.zibnetwork.com/sdk/zib-sdk.js`
2. You play a **ZiB-hosted HLS stream**: `https://cdn.zibnetwork.com/stream/{fileId}/master.m3u8`
3. You add the hook for your player (below).

Your player code, UI, captions, ABR, analytics — **untouched**. The SDK only changes how
*segment bytes* are fetched. If anything fails (SDK missing, node unreachable, unsupported
player), it **degrades gracefully** to the normal single-source path — playback never breaks,
it just isn't accelerated.

| Player | Integration | Drop-in? |
|---|---|---|
| **hls.js** | `fLoader: ZiBStorage.createHlsLoader({ fileId, Hls })` | ✅ one config line |
| **video.js / VHS** | `ZiBStorage.installGlobalMultiSource({ fileId })` before load | ✅ one line |
| **Bitmovin** | `ZiBStorage.installGlobalMultiSource({ fileId })` before load | ✅ one line |
| **Shaka / dash.js** | `ZiBStorage.installGlobalMultiSource({ fileId })` before load | ✅ one line |
| **Safari native HLS** (`<video src>`) | n/a — loader is a black box | ⚠️ stays single-source (graceful) |

> **Safari note:** Safari's native HLS engine doesn't expose a loader hook, so it can't
> multi-source. It automatically uses the normal single-source path and still works — you
> just don't get the aggregation on that one engine. Every controllable player (hls.js,
> video.js, Bitmovin, Shaka, dash.js) gets the full benefit.

---

## 3. Integration by player

### 3a. hls.js — `createHlsLoader`

hls.js with `enableWorker` fetches segments inside a Web Worker, so it needs its own loader
(a global XHR patch can't reach a Worker). Pass our fragment loader as `fLoader`:

```html
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>
<video id="v" controls playsinline></video>
<script>
  const fileId = '8b68d603-4759-4c72-ba2a-19e5330012fb';
  const hls = new Hls({
    fLoader: ZiBStorage.createHlsLoader({ fileId, Hls }), // ← the only ZiB line
    enableWorker: true,
  });
  hls.loadSource(`https://cdn.zibnetwork.com/stream/${fileId}/master.m3u8`);
  hls.attachMedia(document.getElementById('v'));
</script>
```

Only **fragment** requests go multi-source; the playlist, key, and init segments use hls.js's
default loader. Everything else about your hls.js setup stays the same.

### 3b. video.js / Bitmovin / Shaka / dash.js — `installGlobalMultiSource`

These run on the **main thread**, so one engine-agnostic hook covers them all. It patches
`XMLHttpRequest.prototype` so that whole-segment GETs are served from the multi-source
prefetcher; **every other request behaves as a normal XHR.** Call it **before** the player
starts loading, and call the returned function on teardown.

```html
<link href="https://cdn.jsdelivr.net/npm/video.js@8/dist/video-js.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/video.js@8/dist/video.min.js"></script>
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>
<video id="v" class="video-js" controls playsinline></video>
<script>
  const fileId = '8b68d603-4759-4c72-ba2a-19e5330012fb';

  // ← install BEFORE the player creates its first XHR
  const off = ZiBStorage.installGlobalMultiSource({ fileId });

  const player = videojs('v', {
    sources: [{ src: `https://cdn.zibnetwork.com/stream/${fileId}/master.m3u8`,
                type: 'application/x-mpegURL' }],
  });

  // on teardown:
  // player.dispose(); off();
</script>
```

**Bitmovin** is identical — call `installGlobalMultiSource({ fileId })` before
`player.load(...)`. Bitmovin's segment requests go through main-thread XHR, so the same patch
applies; you don't touch Bitmovin's `NetworkConfig` at all.

**Multiple players on one page** (e.g. trailer + full video) are supported — the hook is
ref-counted; each install returns its own `off()`, and multi-source stays active until the
last player tears down.

---

## 4. ABR / quality

Auto quality works normally — the SDK feeds the player realistic bandwidth so its ABR climbs.
A player also caps quality to the **display size** (it won't fetch 1080p to render in a small
box — that's correct, not a bug). So on Auto you'll see the rendition scale with the player:
small inline → 360p, fullscreen → 720p/1080p. Forcing a level always works too.

---

## 5. Build-your-own loader (optional — the public endpoint)

If you don't use the SDK loader (custom player, server-side prefetch, native app), you can
hit the holders endpoint directly. It has two shapes.

### 5a. Rendition-level (preferred, SDK ≥ 1.9.0)

```
GET https://api.zibnetwork.com/stream/{fileId}/holders?quality=720p
```

```jsonc
{
  "key_url": "https://cdn.zibnetwork.com/stream/{fileId}/key",
  "rendition_root": "<blake3-root of the rendition>",
  "holders": [
    { "node_id": "ZB-746400", "url_base": "https://zb-746400.zibnetwork.com", "region": "AU-…" },
    { "node_id": "ZB-937180", "url_base": "https://zb-937180.zibnetwork.com", "region": "…" }
  ],
  "segments": [
    { "index": 0, "segment_name": "720p_segment_000.ts", "root": "<blake3-root>" }
    // …every segment of the rendition…
  ],
  "expires_at": "…",
  "scope": "rendition",
  "ps_accepted": true      // present when you passed ?ps=<session>: whether it was accepted
}
```

`key_url` is signed; your player normally takes the key URI from the playlist anyway, so the
SDK does not use this field.

Every listed holder has the whole rendition, so one call covers the title at that quality —
cache it until `expires_at`. Fetch segment `i` from `{url_base}/blob/{root}`; choosing the
holder is the client's job. The SDK does: round-robin across holders (segment `i` →
`holders[(i + offset) % n]` first) so concurrent prefetches land on different nodes; a
speed bias toward holders with lower measured TTFB + transfer time, scaled by how many of
our fetches each already has in flight (so it never collapses onto one node); immediate
failover to the next holder on error / timeout / non-200 / hash mismatch, with the failing
holder tried last for 10 s; and **only when every holder has failed one segment** a single
re-fetch of this answer, then the CDN path below.

**Integrity:** a segment's `root` is the BLAKE3 hash of its bytes. Verify it before handing
bytes to the player (the SDK does, with a built-in BLAKE3; `verify: false` turns it off) —
a node serving the wrong bytes is then caught, attributed, and routed around instead of
surfacing as a decoder error.

If the answer has no `holders`/`segments` (an older backend), use the windowed form:

### 5b. Windowed (older backends)

```
GET https://cdn.zibnetwork.com/stream/{fileId}/holders?quality=720p&from=0&count=24
```

```jsonc
{
  "file_id": "8b68d603-…",
  "quality": "720p",
  "key_url": "https://cdn.zibnetwork.com/stream/{fileId}/key",
  "segments": [
    {
      "segment_name": "720p_segment_000.ts",
      "segment_index": 0,
      "root": "<blake3-root>",
      "candidates": [
        { "node_id": "ZB-746400", "url": "https://zb-746400.zibnetwork.com/blob/<root>" },
        { "node_id": "ZB-937180", "url": "https://zb-937180.zibnetwork.com/blob/<root>" },
        { "node_id": "ZB-457619", "url": "https://zb-457619.zibnetwork.com/blob/<root>" }
      ]
    }
    // …count segments…
  ],
  "next_from": 24
}
```

Contract for a correct loader:
- **One window call** resolves candidates for the next ~24 segments (not one redirect per
  segment). Re-request from `next_from` as playback advances.
- **Assign distinct, consecutive segments to different nodes** and fetch them concurrently —
  that parallelism *is* the bandwidth aggregation. Candidates are returned **health-ordered**
  (freshest-verified, most-recently-alive first), so **try `candidates[0]` first**.
- **Failover:** on `404` / `5xx` / timeout, immediately retry the next candidate, then fall
  back to the CDN path `…/stream/{fileId}/segments/{name}` (a server-resolved 302). Cap at a
  few attempts before surfacing an error.
- **CORS:** node `/blob/` responses send `Access-Control-Allow-Origin: *` — fetch directly
  from the browser.
- **Encryption:** segments are HLS AES-128. Fetch the key once from `key_url`; the player
  decrypts locally. Multi-source only moves ciphertext — the crypto is unchanged.

The SDK loaders implement all of this for you; the endpoint is here for non-SDK consumers.

---

## 6. Telemetry (already wired into the SDK loaders)

Each segment fetch is measured per node (`{ node_id, ttfb_ms, total_ms, size_bytes, status }`,
plus optional `http_status`, `shard_hash` — the blob root — `attempt` — 0 for the first
choice, 1 for the first failover, … — and `source: "cdn"` for the CDN fallback). `status` is a
class: `"200"`, `"miss"` (HTTP 404: the node honestly does not have it — not an error),
`"error"`, `"timeout"`, `"too_slow"` or `"hash_mismatch"`. CDN-fallback bytes are BLAKE3-checked
too whenever the root is known; a mismatch fails the fragment rather than playing it.

Play counting is separate and comes from the **player clock**, not from fetches: pass your
publisher `sessionToken` to `ZiBStorage.attachToVideo(videoEl, { fileId, sessionToken })` and the
SDK beacons watched seconds to `/v1/api/telemetry/progress`. Full wire format, both endpoints:
[`telemetry.md`](./telemetry.md).
and used two ways: locally, to weight/rotate toward faster nodes; and reported back to ZiB to
improve holder selection. You don't need to wire anything — it's on by default in both
loaders. If you build your own loader and want to contribute the signal, `POST` the same shape
to `https://api.zibnetwork.com/v1/api/telemetry/segment-fetch`.

---

## 7. Transports — what those `zb-*.zibnetwork.com` hosts are

Integrators see segments fetched from `https://zb-XXXXXX.zibnetwork.com/blob/{root}` and ask
which network that is. There are **three distinct transports** in ZiB, and multi-source
playback only touches the third:

1. **Node ↔ node (content replication & sync): iroh.**
   Content is content-addressed (BLAKE3 root) and replicated peer-to-peer over **iroh**
   (QUIC, direct paths with relay fallback). This is the "mesh" for *data* — it's how a blob
   gets onto ≥3 nodes in the first place. The `/blob/{root}` address you fetch is the same
   content-addressed root iroh uses.

2. **Browser ↔ node (serve / playback): the node's public HTTPS tunnel (Cloudflare).**
   Your browser is **not** on the mesh. It reaches each node over that node's public URL
   (`zb-XXXXXX.zibnetwork.com`, a Cloudflare tunnel to the node's local serve port). This is
   the path multi-source playback uses — plain HTTPS GETs to `/blob/{root}`, one per segment,
   spread across nodes. No special client networking required.

3. **Backend ↔ node (control / liveness): a Headscale/Tailscale control mesh.**
   The ZiB backend keeps a private control mesh to health-check nodes and coordinate. This is
   internal plumbing — integrators never touch it, and it is **not** the data path. As iroh
   matures it's increasingly just liveness/control, not content transport.

So: **iroh is the node-to-node mesh; the browser fetches over each node's public Cloudflare
tunnel; Tailscale is backend-internal control.** Multi-source playback is entirely transport
#2 (public HTTPS) — there's nothing mesh-specific for you to integrate.

---

## 8. Quick checklist

- [ ] `<script src="https://app.zibnetwork.com/sdk/zib-sdk.js">` loaded.
- [ ] Source is `https://cdn.zibnetwork.com/stream/{fileId}/master.m3u8`.
- [ ] hls.js → `fLoader: ZiBStorage.createHlsLoader({ fileId, Hls })`.
      Everything else → `ZiBStorage.installGlobalMultiSource({ fileId })` before load (+ `off()` on teardown).
- [ ] (Verify) DevTools → Network: segments hit multiple `zb-*.zibnetwork.com/blob/` hosts,
      no per-segment `302` redirects, `Access-Control-Allow-Origin: *` on the responses.
