mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): multi-cdn support (#1668)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b9e0a75c1b
commit
00aa6247b8
@@ -124,56 +124,146 @@ function updateThroughputDisplay() {
|
||||
throughputDiv.className = 'has-data';
|
||||
}
|
||||
|
||||
// Signature of the currently-rendered video rendition set. Lets the picker
|
||||
// rebuild its buttons only when the set actually changes — selection and ABR/
|
||||
// manual changes update existing buttons in place (see renderRenditionPicker),
|
||||
// so a click or hover isn't interrupted by a full DOM teardown on every ABR
|
||||
// switch (selectedVideoTrackId changes frequently during playback).
|
||||
let videoTrackSetKey = '';
|
||||
|
||||
function renderRenditionPicker() {
|
||||
if (!engine || !signals) return;
|
||||
const presentation = engine.state.presentation.get();
|
||||
const selectedVideoTrackId = engine.state.selectedVideoTrackId.get();
|
||||
const isManual = engine.state.userVideoTrackSelection.get() !== undefined;
|
||||
const userFilter = engine.state.userVideoTrackSelection.get();
|
||||
const tracks = getVideoTracks(presentation);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
renditionButtonsDiv.textContent = presentation ? 'No video tracks found' : 'Waiting for presentation…';
|
||||
videoTrackSetKey = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection pins by bitrate + resolution (see the click handler), not track
|
||||
// id, so redundant-stream renditions duplicated across CDNs share one
|
||||
// selection identity and are NOT independently selectable. Collapse to one
|
||||
// button per identity so the UI matches what selection actually guarantees.
|
||||
const groups = getVideoSelectionGroups(tracks);
|
||||
const setKey = groups.map((group) => group.key).join('|');
|
||||
if (setKey !== videoTrackSetKey) {
|
||||
videoTrackSetKey = setKey;
|
||||
buildVideoTrackButtons(groups);
|
||||
}
|
||||
updateVideoTrackSelection(tracks, selectedVideoTrackId, userFilter);
|
||||
}
|
||||
|
||||
// One selectable video identity: a bitrate + resolution. `key` is exactly what
|
||||
// the click handler pins on (as a partial-track filter), so highlighting and
|
||||
// dedupe share one notion of identity.
|
||||
interface VideoSelectionGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
filter: { bandwidth: number; width?: number; height?: number };
|
||||
members: string[];
|
||||
}
|
||||
|
||||
/** Stable bitrate+resolution identity for a video track (matches the pin filter). */
|
||||
function videoSelectionKey(track: ReturnType<typeof getVideoTracks>[number]): string {
|
||||
const width = 'width' in track ? track.width : undefined;
|
||||
const height = 'height' in track ? track.height : undefined;
|
||||
return `${track.bandwidth}|${width ?? ''}×${height ?? ''}`;
|
||||
}
|
||||
|
||||
/** Collapse video tracks to one entry per selection identity (bitrate + resolution). */
|
||||
function getVideoSelectionGroups(tracks: ReturnType<typeof getVideoTracks>): VideoSelectionGroup[] {
|
||||
const groups = new Map<string, VideoSelectionGroup>();
|
||||
for (const track of tracks) {
|
||||
const key = videoSelectionKey(track);
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
const width = 'width' in track ? track.width : undefined;
|
||||
const height = 'height' in track ? track.height : undefined;
|
||||
const res = width && height ? `${width}×${height} @ ` : '';
|
||||
const filter: VideoSelectionGroup['filter'] = { bandwidth: track.bandwidth };
|
||||
if (width) filter.width = width;
|
||||
if (height) filter.height = height;
|
||||
group = { key, label: `${res}${formatBandwidth(track.bandwidth)}`, filter, members: [] };
|
||||
groups.set(key, group);
|
||||
}
|
||||
// Member ids (one per CDN for redundant streams) are surfaced in the
|
||||
// tooltip so the collapsed renditions are still inspectable.
|
||||
group.members.push(track.id);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** (Re)build the static button list — one per selection group, tagged with its key. */
|
||||
function buildVideoTrackButtons(groups: VideoSelectionGroup[]) {
|
||||
renditionButtonsDiv.innerHTML = '';
|
||||
|
||||
const statusRow = document.createElement('div');
|
||||
statusRow.id = 'video-status-row';
|
||||
statusRow.className = 'abr-status';
|
||||
const modeLabel = document.createElement('span');
|
||||
modeLabel.className = isManual ? 'mode-manual' : 'mode-abr';
|
||||
modeLabel.textContent = isManual ? '🔒 Manual' : '⟳ ABR';
|
||||
statusRow.appendChild(modeLabel);
|
||||
if (isManual) {
|
||||
const enableBtn = document.createElement('button');
|
||||
enableBtn.type = 'button';
|
||||
enableBtn.className = 'enable-abr-btn';
|
||||
enableBtn.textContent = 'Enable ABR';
|
||||
enableBtn.addEventListener('click', () => {
|
||||
log('ABR re-enabled', 'success');
|
||||
signals.state.userVideoTrackSelection.set(undefined);
|
||||
});
|
||||
statusRow.appendChild(enableBtn);
|
||||
}
|
||||
renditionButtonsDiv.appendChild(statusRow);
|
||||
|
||||
for (const track of tracks) {
|
||||
const isSelected = track.id === selectedVideoTrackId;
|
||||
for (const group of groups) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `rendition-btn${isSelected ? (isManual ? ' selected-manual' : ' selected-abr') : ''}`;
|
||||
const res = 'width' in track && track.width && track.height ? `${track.width}×${track.height} @ ` : '';
|
||||
const badge = isSelected ? (isManual ? ' 🔒' : ' ⟳') : '';
|
||||
btn.textContent = `${res}${formatBandwidth(track.bandwidth)}${badge}`;
|
||||
btn.title = track.id;
|
||||
btn.dataset.selectionKey = group.key;
|
||||
// Base label minus the selection badge; the badge is toggled in place.
|
||||
btn.dataset.label = group.label;
|
||||
btn.title =
|
||||
group.members.length > 1
|
||||
? `${group.members.length} rendition(s) across CDNs: ${group.members.join(', ')}`
|
||||
: (group.members[0] ?? group.key);
|
||||
btn.addEventListener('click', () => {
|
||||
log(`Manual rendition select: ${formatBandwidth(track.bandwidth)} (ABR disabled)`, 'warning');
|
||||
signals.state.userVideoTrackSelection.set({ id: track.id });
|
||||
log(`Manual rendition select: ${JSON.stringify(group.filter)} (ABR disabled)`, 'warning');
|
||||
signals.state.userVideoTrackSelection.set(group.filter);
|
||||
});
|
||||
renditionButtonsDiv.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the status row and per-button selected state without tearing down. */
|
||||
function updateVideoTrackSelection(
|
||||
tracks: ReturnType<typeof getVideoTracks>,
|
||||
selectedVideoTrackId: string | undefined,
|
||||
userFilter: SimpleHlsEngineState['userVideoTrackSelection']
|
||||
) {
|
||||
const isManual = userFilter !== undefined;
|
||||
|
||||
const statusRow = document.getElementById('video-status-row');
|
||||
if (statusRow) {
|
||||
statusRow.innerHTML = '';
|
||||
const modeLabel = document.createElement('span');
|
||||
modeLabel.className = isManual ? 'mode-manual' : 'mode-abr';
|
||||
modeLabel.textContent = isManual ? `🔒 Manual: ${JSON.stringify(userFilter)}` : '⟳ ABR';
|
||||
statusRow.appendChild(modeLabel);
|
||||
if (isManual) {
|
||||
const enableBtn = document.createElement('button');
|
||||
enableBtn.type = 'button';
|
||||
enableBtn.className = 'enable-abr-btn';
|
||||
enableBtn.textContent = 'Enable ABR';
|
||||
enableBtn.addEventListener('click', () => {
|
||||
log('ABR re-enabled', 'success');
|
||||
signals.state.userVideoTrackSelection.set(undefined);
|
||||
});
|
||||
statusRow.appendChild(enableBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// The selected track belongs to a group keyed by its bitrate + resolution;
|
||||
// highlight that group's button.
|
||||
const selectedTrack = tracks.find((track) => track.id === selectedVideoTrackId);
|
||||
const selectedKey = selectedTrack ? videoSelectionKey(selectedTrack) : undefined;
|
||||
for (const btn of renditionButtonsDiv.querySelectorAll<HTMLButtonElement>('button[data-selection-key]')) {
|
||||
const isSelected = btn.dataset.selectionKey === selectedKey;
|
||||
btn.className = `rendition-btn${isSelected ? (isManual ? ' selected-manual' : ' selected-abr') : ''}`;
|
||||
const badge = isSelected ? (isManual ? ' 🔒' : ' ⟳') : '';
|
||||
btn.textContent = `${btn.dataset.label ?? ''}${badge}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Signature of the currently-rendered audio track set. Lets the picker rebuild
|
||||
// its buttons only when the track set actually changes — selection and pin
|
||||
// changes update existing buttons in place (see renderAudioTrackPicker) so a
|
||||
|
||||
@@ -1,211 +1,278 @@
|
||||
---
|
||||
status: draft
|
||||
date: 2026-05-20
|
||||
definition: coarse
|
||||
date: 2026-06-05
|
||||
definition: sketched
|
||||
---
|
||||
|
||||
# Multi-CDN failover
|
||||
|
||||
Alternate-URI rotation for HLS sources with multiple CDN paths to the
|
||||
same content. When a fetch fails on the active URI (after
|
||||
[network-resilience](./network-resilience.md)'s retries are
|
||||
exhausted), rotate to the next URI in the rendition's alternate-URI
|
||||
list. Mux Video produces such sources via the `?redundant_streams=true`
|
||||
playback URL parameter; HLS spec / vendor conventions provide the
|
||||
manifest-side declaration. Cluster G sister feature to
|
||||
`network-resilience`; consumes the foundation's retry + circuit-
|
||||
breaker primitives and adds the URI-rotation policy on top.
|
||||
CDN selection and failover for HLS sources that publish the same content
|
||||
on more than one host (e.g. Mux Video's `?redundant_streams=true`). The
|
||||
redundant variants parse as ordinary candidate tracks — one per
|
||||
(rendition × CDN) — so the work is **selecting which CDN to use and
|
||||
keeping the whole presentation on it**, modeled inside the
|
||||
[track-switching rule model](../track-switching-model.md) rather than as
|
||||
URL rewriting at fetch time.
|
||||
|
||||
A **Media-src feature** for sources that genuinely require it
|
||||
(redundant-streams sources where the customer expects automatic
|
||||
failover) and a **Player feature** at Tier 2 (customer-customizable
|
||||
rotation policies). Notion epic #9 classifies as "Media-src? /
|
||||
Player?" — the ambiguity reflects the dual scope.
|
||||
Two sub-features, mapping onto the two rule kinds in that model:
|
||||
|
||||
1. **Sticky CDN pick** *(implemented)* — a session-level behavior picks a
|
||||
CDN (the manifest-head host) and holds it; a shared **scope** rule
|
||||
(`preferActiveCdn`) narrows every track type's candidates to that CDN,
|
||||
so video / audio / text resolve from one host. This is the
|
||||
*active-pathway scope* the track-switching model lists for
|
||||
multi-cdn-failover.
|
||||
2. **Failover** *(deferred)* — when requests to the active CDN fail too
|
||||
often within a window, a **constraint** removes that CDN's tracks from
|
||||
the candidate set during cooldown and the session behavior rotates the
|
||||
active CDN. This is the *failed-CDN constraint* the model lists, and it
|
||||
consumes [network-resilience](./network-resilience.md)'s per-host
|
||||
circuit-breaker — its hard prerequisite.
|
||||
|
||||
A **Media-src feature** for sources that genuinely require failover, with
|
||||
a **Player feature** surface at the failover tier (customer-customizable
|
||||
CDN policy). Notion epic #9 classifies as "Media-src? / Player?" — the
|
||||
ambiguity reflects that split.
|
||||
|
||||
## Status
|
||||
|
||||
- **Composition:** not implemented in `createSimpleHlsEngine`. The
|
||||
parser doesn't recognize alternate-URI declarations; no rotation
|
||||
policy state; no failover behavior. Single-URI behavior throughout.
|
||||
- **Definition depth:** coarse — scope from Notion epic + Mux Video
|
||||
convention + network-resilience composition; SPF touchpoints
|
||||
sketched at the cluster level. Implementation details (parser
|
||||
syntax, state-slot shape, rotation defaults) tracked as open
|
||||
questions.
|
||||
- **Hard prerequisite:** [network-resilience](./network-resilience.md).
|
||||
Rotation triggers on the foundation's retry-exhaustion signal;
|
||||
per-URI health tracking consumes the foundation's circuit-breaker
|
||||
state.
|
||||
- **Composition:** sub-feature 1 (sticky CDN pick) is implemented in
|
||||
`createSimpleHlsEngine` and `createHlsAudioOnlyEngine`. The
|
||||
`resolveCdnPriority` behavior owns the `cdnPriority` signal (the
|
||||
manifest-ordered CDN list, most-preferred first); the `preferActiveCdn`
|
||||
scope rule (shared by the video + audio chains in `track-switching`)
|
||||
narrows candidates to the highest-priority CDN with surviving tracks.
|
||||
Failover (sub-feature 2) is not implemented — no constraints pass, no
|
||||
per-CDN failure tracking, no rotation.
|
||||
- **Definition depth:** sketched — sub-feature 1 has a populated
|
||||
implementation surface + verification; sub-feature 2 stays at the
|
||||
scope-and-constraints level pending its prerequisite.
|
||||
- **Hard prerequisite (failover only):**
|
||||
[network-resilience](./network-resilience.md). The failed-CDN
|
||||
constraint consumes the foundation's per-host circuit-breaker /
|
||||
retry-exhaustion state. Sub-feature 1 has no such dependency — it's
|
||||
pure selection over the already-parsed candidate set.
|
||||
- **Governing model:** [track-switching-model.md](../track-switching-model.md)
|
||||
— multi-CDN is the canonical *constraint + scope* feature there. The
|
||||
active-CDN scope is a soft filter in the rule chain; the failed-CDN
|
||||
constraint is a hard filter in the (not-yet-built) constraints pre-pass.
|
||||
|
||||
## How redundant streams are modeled
|
||||
|
||||
There is no `alternateUris` field and no fetch-time URL rotation. A
|
||||
redundant-streams manifest lists each rendition once per CDN (duplicate
|
||||
`#EXT-X-STREAM-INF` / `#EXT-X-MEDIA` entries on different hosts), and the
|
||||
existing parser already emits one `Track` per entry with a unique id and
|
||||
its own absolute `url`. So the candidate set for each type *already*
|
||||
contains one variant per CDN. CDN identity is derived from each track
|
||||
URL's origin (`getCdnId`); the set of CDNs is published as a single
|
||||
per-presentation ordered signal (`cdnPriority`, most-preferred first —
|
||||
mirroring HLS content steering's `PATHWAY-PRIORITY`). The *active* CDN is
|
||||
not stored: the scope derives it as the highest-priority `cdnPriority`
|
||||
entry that still has tracks after the constraints pass. Selecting a
|
||||
CDN-tagged track id means `resolveTrack` / segment loading fetch from that
|
||||
CDN with no further plumbing.
|
||||
|
||||
This list shape is what makes failover fall out cleanly: the failed-CDN
|
||||
constraint (sub-feature 2) prunes a cooled-down CDN's tracks, so
|
||||
"first-with-survivors" moves to the next CDN automatically and returns to
|
||||
the primary when it recovers — no reactive rewrite of an "active" value.
|
||||
Content steering, likewise, just reorders `cdnPriority` (pathway priority
|
||||
as a sort key).
|
||||
|
||||
This is why sub-feature 1 needed **no parser change** and no new data
|
||||
shape — only a selection behavior and a scope rule over existing tracks.
|
||||
|
||||
## Phases of complexity
|
||||
|
||||
[Tier 1 / Tier 2 framing](./clusters.md#tier-1-spec-compliant-baseline-vs-tier-2-custom-behavior)
|
||||
per Notion epic #9 ("Tier 1: Parse spec-extension alternate URIs.
|
||||
Tier 2: Rotation policy, backoff strategy."). Each phase notes Naive
|
||||
vs Full depth where relevant per the
|
||||
[Naive vs Full framing](./clusters.md#naive-vs-full-implementation-depth).
|
||||
|
||||
| Phase | Tier | What | Notes |
|
||||
|---|---|---|---|
|
||||
| Alternate-URI parsing + presentation surfacing | Tier 1 | Parser extracts alternate-URI lists from multivariant playlist (HLS spec extension or vendor convention; syntax open). Presentation `Track` data shape grows an `alternateUris: string[]` field (or similar) on each rendition | Parser extension; [presentation-modeling](../presentation-modeling.md)'s `Track` shape grows. Tier 1 spec-compliant baseline: surface what the manifest says. **Naive:** parse the simplest known syntax (Mux convention). **Full:** support multiple alternate-URI declarations across HLS spec drafts + vendor variants |
|
||||
| Active-URI state + initial selection | Tier 1 | New state slot — per-rendition active-URI tracking (e.g., `selectedRenditionUris: Map<TrackId, string>` or per-Track field on resolved presentation). Initial value: first URI in each rendition's `alternateUris` list. Behaviors consuming `Track.uri` (segment loading, playlist reload, manifest fetch) read the active URI rather than the canonical URI | Constraint+filter shape: active-URI slot is the read-side for downstream consumers; rotation policy (Tier 2) is the write-side. Without rotation, this phase is degenerate-equivalent to single-URI behavior — Tier 1 alone provides parsing but not failover |
|
||||
| Rotation on retry-exhaustion | Tier 2 | When `network-resilience` exhausts retries on the active URI for a given rendition, rotate to the next URI in the list. Active-URI slot updates; consumers re-fetch using the new URI. The rotation policy controls *which* URI is chosen next | Consumes [network-resilience](./network-resilience.md)'s retry-exhaustion signal. **Naive:** round-robin through the list. **Full:** primary-preferred-with-fallback (return to primary when its circuit-breaker cools), or weighted, or region-aware. Live + multi-CDN composition: reload-loop failover during live consumes this phase too |
|
||||
| Per-URI health tracking | Tier 2 | Combine `network-resilience`'s per-URI circuit-breaker state into a health score per alternate URI. Rotation reads health when choosing next URI — skip known-unhealthy URIs without trying them. Health values surface from the breaker's `healthy` / `cooldown` / `unhealthy` state | Consumes `network-resilience`'s circuit-breaker primitive. Likely a derived signal (computed from breaker state). **Naive:** binary healthy/unhealthy from breaker state. **Full:** time-decayed health score that distinguishes "recently-cooled" from "long-healthy" |
|
||||
| Customer-policy hooks | Tier 2 | Pluggable hooks: `selectAlternateUri(failedUri, candidates, history) → string`. Customer can override default rotation (region-preferred ordering, weighted, A/B testing, regulatory-compliant routing) | Tier 2 customer-policy surface. Built-in defaults; hooks override when set. Adapter-layer customer-facing toggles ("prefer CDN A" UI) wire through these hooks |
|
||||
| Phase | Sub-feature | Kind | What | State |
|
||||
|---|---|---|---|---|
|
||||
| Sticky CDN pick | 1 | scope | `resolveCdnPriority` publishes the manifest-ordered CDN list (`cdnPriority`); `preferActiveCdn` narrows every type's candidates to the highest-priority CDN with surviving tracks, falling through when nothing matches. Shared list → all types on one CDN | **Implemented** |
|
||||
| Failed-CDN constraint | 2 | constraint | A constraint in the track-switching constraints pre-pass removes a cooled-down CDN's tracks from the candidate set. The scope then picks the next `cdnPriority` entry automatically. Requires the generic constraints phase (track-switching-model "Phase 2") to be built first | Deferred |
|
||||
| Per-CDN failure tracking | 2 | — | Count per-CDN fetch failures within a window; mark a CDN unhealthy / in cooldown. Consumes `network-resilience`'s circuit-breaker | Deferred (prereq) |
|
||||
| CDN priority override / steering | 2 | scope | Reorder `cdnPriority` to bias the pick (region-preferred, weighted, or content-steering's pathway priority). No reactive "active" rewrite needed — the order *is* the policy | Deferred |
|
||||
| Customer CDN-id derivation | 2 | config | Pluggable `getCdnId` for non-origin identity. The origin-based default is built in; a config seam is anticipated | Deferred |
|
||||
|
||||
## What's in scope vs out of scope
|
||||
|
||||
**In scope:**
|
||||
- All five phases above for HLS sources with alternate-URI
|
||||
declarations
|
||||
- Parser support for alternate-URI lists (Mux convention syntax + any
|
||||
HLS spec extension forms)
|
||||
- Active-URI state slot + rotation policy
|
||||
- Integration with `network-resilience`'s retry-exhaustion + circuit-
|
||||
breaker primitives
|
||||
- Customer-pluggable rotation hooks
|
||||
- Live + multi-CDN composition (reload-loop failover during live
|
||||
streams)
|
||||
- Sticky per-presentation CDN selection (sub-feature 1, done)
|
||||
- Failover via a track-switching constraint + active-CDN rotation
|
||||
(sub-feature 2)
|
||||
- Per-CDN health derived from `network-resilience`'s circuit-breaker
|
||||
- Customer-configurable CDN-id derivation / rotation policy
|
||||
|
||||
**Out of scope (separate cluster G sister features):**
|
||||
- **[network-resilience](./network-resilience.md)** *(foundation,
|
||||
prerequisite)* — retry + backoff + circuit-breaker. Multi-CDN
|
||||
consumes; doesn't reimplement.
|
||||
- **[content-steering](./content-steering.md)** — HLS content-
|
||||
steering protocol. Server-side host-pool advertisement (dynamically
|
||||
updated). Different mechanism than static alternate-URI lists.
|
||||
Content-steering's pathway-priority composes with this feature's
|
||||
rotation primitive: pathway-priority is the dynamic ordering bias
|
||||
(a sort key); static manifest alternate-URI lists are the static
|
||||
candidate set.
|
||||
prerequisite for failover)* — retry + backoff + circuit-breaker.
|
||||
Multi-CDN consumes; doesn't reimplement.
|
||||
- **[content-steering](./content-steering.md)** — HLS content-steering
|
||||
protocol (server-advertised, dynamically-updated host pool). Different
|
||||
mechanism than static redundant streams, but the *same* active-pathway
|
||||
scope shape: content-steering picks the active pathway dynamically; the
|
||||
scope reflecting it is the one implemented here. Designed-with-in-mind:
|
||||
`cdnPriority` is a reorderable list a steering behavior writes (pathway
|
||||
priority as a sort key), and the scope honors the order unchanged.
|
||||
|
||||
**Out of scope (different architectural layer):**
|
||||
- Adapter-layer customer-facing UI surfaces (e.g., "Switch CDN"
|
||||
buttons, region-preferred dropdowns). Consumer policy expressed via
|
||||
this feature's Tier 2 hooks.
|
||||
- CDN-side load balancer / origin-shield / health-check infrastructure.
|
||||
Service-side concerns; engine reacts to what the CDN responds with.
|
||||
- DRM key-server failover. Even when license fetches are CDN-routed,
|
||||
the failover concern lives under [drm-support](./drm-support.md)
|
||||
(license-fetch retries) + this feature's primitive may compose, but
|
||||
the key-server-specific rotation policy is DRM-side state.
|
||||
- Adapter-layer customer-facing UI ("Switch CDN" buttons). Consumer
|
||||
policy is expressed via the failover tier's config seam.
|
||||
- CDN-side load balancer / origin-shield infrastructure. Service-side.
|
||||
- DRM key-server failover — lives under [drm-support](./drm-support.md).
|
||||
|
||||
## Likely cross-cutting impact
|
||||
|
||||
Things this feature probably forces decisions on, not just additions:
|
||||
|
||||
- **Per-rendition vs per-presentation active URI.** Each rendition
|
||||
can have its own alternate-URI list (different CDN paths per
|
||||
bitrate variant) OR all renditions share the same active-URI
|
||||
index. Per-rendition is more flexible (one rendition's CDN can be
|
||||
unhealthy while others are fine); per-presentation is simpler
|
||||
(one rotation state for the source). Lean: per-rendition. Affects
|
||||
state-slot shape (`Map<TrackId, string>` vs single index).
|
||||
- **Active-URI slot writer composition.** This feature writes the
|
||||
active URI; downstream behaviors read it. Single-writer slot —
|
||||
this feature's rotation behavior is sole writer. The slot is read
|
||||
by segment-loading, playlist-reload (when live-stream-support
|
||||
lands), manifest-fetch. Standard constraint+filter pattern.
|
||||
- **Parser surface for alternate-URI declarations.** HLS spec
|
||||
extensions vary; Mux uses one convention. Parser-pluggability
|
||||
question from [presentation-modeling](../presentation-modeling.md)
|
||||
is sharpened by this feature — alternate-URI parsing extends the
|
||||
`parseMediaPlaylist` / `parseMultivariantPlaylist` schema. Likely
|
||||
HLS-only initially; format-extension to DASH/MoQ adds different
|
||||
shapes.
|
||||
- **Live + multi-CDN composition.** During live playback, manifest
|
||||
reload-loop fetches periodically. Reload-fetch retry-exhaustion
|
||||
should trigger rotation (and the new URI's reload-loop continues).
|
||||
Cross-feature with [live-stream-support](./live-stream-support.md)
|
||||
(not implemented yet).
|
||||
- **Composition with `[content-steering]`.** Content-steering's
|
||||
server-advertised host pool changes the rotation's candidate set
|
||||
dynamically. Two composition shapes: (a) content-steering writes
|
||||
to the `alternateUris` list (replacing the static manifest values);
|
||||
(b) content-steering writes a separate `steeredHosts` slot that
|
||||
composes with `alternateUris` (intersect, prefer, etc.). Open
|
||||
question — when content-steering lands.
|
||||
- **Rotation state across source changes.** When the consumer changes
|
||||
`presentation.url`, the active-URI state tears down with the source
|
||||
(per [source-replacement](./source-replacement.md)'s cascade). Per-
|
||||
URI circuit-breaker state in `network-resilience` may persist across
|
||||
sources for the same hosts (cross-source-resilience benefit).
|
||||
- **Per-stream-type rotation coordination.** A presentation with
|
||||
separate audio and video URIs (each possibly with their own
|
||||
alternate-URI lists) can rotate them independently. Live + multi-
|
||||
CDN with per-track rotation: each track's reload-loop manages its
|
||||
own active-URI rotation. Inherits live-stream-support's per-type
|
||||
reload-coordination open question.
|
||||
- **Per-presentation, not per-rendition (resolved).** A single
|
||||
`cdnPriority` list governs all track types — the per-presentation
|
||||
coherence requirement. The track-switching model's
|
||||
"cross-type consistency is a composition convention" applies: both the
|
||||
video and audio chains reference the *same* `preferActiveCdn` definition
|
||||
reading the *same* list, so they agree on the CDN even if their per-type
|
||||
track arrays differ. (The doc's earlier per-rendition lean is superseded.)
|
||||
- **`cdnPriority` writer composition.** `resolveCdnPriority` is the sole
|
||||
writer today (publishes the manifest order). Failover needs no second
|
||||
writer — the failed-CDN constraint prunes tracks and the scope re-derives
|
||||
the active CDN. Content-steering would *reorder* `cdnPriority` (still a
|
||||
single owning behavior; the list reflects one upstream priority).
|
||||
- **Active CDN is derived, not stored.** The scope computes "highest
|
||||
priority with surviving tracks," so failover and recovery need no extra
|
||||
state: pruning moves the pick to the next CDN, un-pruning returns it to
|
||||
the primary. This is why the array beats a single reactive `activeCdn`
|
||||
value — the failed-set information is applied once (in the constraint),
|
||||
not duplicated into an active-value rewrite.
|
||||
- **Constraints phase is a shared prerequisite.** The failed-CDN
|
||||
constraint can't land until the generic constraints pre-pass
|
||||
(`applyConstraints` + the `constraints` config field + empty-playable-set
|
||||
terminal state) is built into `setupTrackSwitching`. The seam exists
|
||||
(`candidateSet` computed); the machinery does not. capability-probing
|
||||
shares this prerequisite.
|
||||
- **Live + multi-CDN.** During live playback the reload loop re-resolves
|
||||
the presentation; `resolveCdnPriority` re-publishes only when the CDN set
|
||||
changes (idempotent for a stable manifest). Cross-feature with
|
||||
[live-stream-support](./live-stream-support.md) (not yet implemented).
|
||||
- **Priority state across source changes.** `cdnPriority` tears down with
|
||||
the source via the resolved/unresolved cascade (per
|
||||
[source-replacement](./source-replacement.md)); per-host circuit-breaker
|
||||
state in `network-resilience` may outlive a source.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Alternate-URI manifest syntax.** HLS spec extension(s) vs Mux
|
||||
convention vs both. Parser scope question. Open until the first
|
||||
alternate-URI-bearing manifest lands as a test fixture.
|
||||
- **Per-rendition vs per-presentation active URI.** Per the cross-
|
||||
cutting note; lean per-rendition for flexibility.
|
||||
- **Default rotation policy.** Round-robin vs primary-preferred vs
|
||||
weighted. Lean: primary-preferred-with-circuit-breaker-cooldown-
|
||||
return.
|
||||
- **Composition with content-steering.** Static `alternateUris`
|
||||
manifest values + dynamic content-steering host-pool: how to
|
||||
combine? Replacement vs intersection vs preference order?
|
||||
- **Rotation-state preservation.** Reset on source change (default)
|
||||
vs preserve via the `bandwidthState`-style cross-source-survival
|
||||
pattern (rare in this case — rotation state is per-URI, and URIs
|
||||
are per-source).
|
||||
- **Customer-hook contract.** Function signature, async semantics,
|
||||
failover-after-hook-failure policy. Same shape question as
|
||||
network-resilience's hook design; harmonize.
|
||||
- **DRM license-fetch interaction.** When `drm-support` lands, license
|
||||
fetches go through CDN routing too. Multi-CDN rotation for license
|
||||
fetches: same feature, or DRM-side?
|
||||
- **Per-stream-type rotation coordination.** Independent rotation
|
||||
per type (video / audio / text) is the default; whether to allow
|
||||
coordinated rotation (single failover decision rotates all types)
|
||||
is an open Tier 2 question.
|
||||
- **Constraints-phase shape.** How `applyConstraints` and the
|
||||
empty-playable-set terminal state are modeled — owned by the
|
||||
track-switching constraints work, consumed here. (See
|
||||
[track-switching-model.md](../track-switching-model.md) → *Fitting the
|
||||
model to the track-switching behavior*.)
|
||||
- **Failure-window policy.** Threshold count / window length / cooldown
|
||||
duration for marking a CDN unhealthy. Empirical; lives with
|
||||
`network-resilience`'s circuit-breaker.
|
||||
- **CDN-id derivation configurability.** Origin-based `getCdnId` is the
|
||||
default; whether/where to expose a consumer override (config field vs.
|
||||
rule config view) is deferred until a non-origin case appears.
|
||||
- **Composition with content-steering.** Static redundant CDNs +
|
||||
dynamic steered host pool: does steering replace, intersect, or
|
||||
reprioritize the candidate CDNs? Open until content-steering lands; the
|
||||
reorderable `cdnPriority` list keeps it tractable (steering reorders it).
|
||||
|
||||
### Resolved during sub-feature 1 implementation
|
||||
|
||||
- **Signal shape** → a per-presentation ordered `cdnPriority` list (active
|
||||
= first-with-survivors, derived), not a single stored `activeCdn` value
|
||||
and not per-rendition. The list makes failover a pure constraint and
|
||||
composes with content-steering as a reorder. Matches the cross-type
|
||||
coherence requirement (one shared list).
|
||||
- **CDN identity** → URL origin (`getCdnId`); configurable derivation
|
||||
deferred.
|
||||
- **Manifest syntax / parser** → no change. Redundant variants already
|
||||
parse as separate per-CDN tracks; no `alternateUris` field needed.
|
||||
- **Architecture** → constraint + scope in the track-switching model, not
|
||||
active-URI rotation in `resolveTrack`.
|
||||
|
||||
## Implementation surface
|
||||
|
||||
- **`packages/spf/src/media/utils/cdn.ts`** — `getCdnId(url)` (origin-based
|
||||
CDN identity) and `getOrderedCdnIds(presentation)` (distinct CDNs in
|
||||
manifest order; head = primary).
|
||||
- **`packages/spf/src/playback/behaviors/resolve-cdn-priority.ts`** —
|
||||
`resolveCdnPriority` behavior + `ResolveCdnPriorityState`. Machine reactor
|
||||
on `presentation-unresolved` ↔ `presentation-resolved`; owns the
|
||||
`cdnPriority` signal; publishes `getOrderedCdnIds(presentation)` (skipping
|
||||
the write when the CDN set is unchanged), clears on exit.
|
||||
- **`packages/spf/src/playback/behaviors/track-switching.ts`** —
|
||||
`preferActiveCdn` scope rule (soft filter on `cdnPriority`: narrow to the
|
||||
highest-priority CDN with surviving tracks), added to both variants'
|
||||
chains: `[filterByUserSelection, preferActiveCdn, rankByBandwidth]`.
|
||||
`SwitchableTrack` gains `url` (the rule's input).
|
||||
- **`packages/spf/src/playback/engines/hls/engine.ts` +
|
||||
`engine-audio-only.ts`** — `resolveCdnPriority` composed after
|
||||
`resolvePresentation`; `cdnPriority?: string[]` added to both engine state
|
||||
interfaces.
|
||||
|
||||
State signal: `cdnPriority?: string[]` (CDN origins, most-preferred first),
|
||||
owned by `resolveCdnPriority`, read optionally by `preferActiveCdn`.
|
||||
|
||||
## Verification
|
||||
|
||||
Sub-feature 1:
|
||||
|
||||
- `media/utils/tests/cdn.test.ts` — `getCdnId` (origin extraction;
|
||||
same/different host; scheme+port; unparseable fallback);
|
||||
`getOrderedCdnIds` (distinct CDNs in order; dedupe; single-CDN;
|
||||
unresolved → `[]`).
|
||||
- `playback/behaviors/tests/resolve-cdn-priority.test.ts` — publishes the
|
||||
manifest-ordered CDN list; single-CDN source; skips the write when a
|
||||
resolved swap keeps the same CDNs; updates when the order changes; clears
|
||||
on src unload + on destroy; re-publishes after a src reset.
|
||||
- `playback/behaviors/tests/track-switching.test.ts` (`preferActiveCdn`
|
||||
block) — narrows to the highest-priority CDN overriding manifest track
|
||||
order; keeps the pick on the primary; falls through to the next CDN when
|
||||
the first has no survivors; falls through to all when none match; no-op
|
||||
when `cdnPriority` absent; re-picks reactively when the order changes
|
||||
(steering/failover seam); same scope applied to the audio chain
|
||||
(cross-type coherence).
|
||||
- `playback/engines/hls/tests/engine.test.ts` — integration: a
|
||||
redundant-stream presentation yields `cdnPriority` = manifest order and a
|
||||
video selection on the primary; reordering `cdnPriority` re-narrows and
|
||||
the selection follows.
|
||||
|
||||
Out of scope / deferred:
|
||||
|
||||
- End-to-end + sandbox verification against a real Mux
|
||||
`?redundant_streams=true` source (needs a fixture).
|
||||
- All of sub-feature 2 (failover): blocked on the constraints phase +
|
||||
`network-resilience`.
|
||||
|
||||
## Related features
|
||||
|
||||
- **[network-resilience](./network-resilience.md)** *(hard
|
||||
prerequisite)* — retry + backoff + circuit-breaker foundation.
|
||||
Multi-CDN consumes the retry-exhaustion signal (rotation trigger)
|
||||
and the per-URI circuit-breaker state (per-URI health tracking).
|
||||
- **[content-steering](./content-steering.md)** — parallel sister;
|
||||
dynamic host-pool advertisement variant. Pathway-priority composes
|
||||
with this feature's rotation primitive (sort-key shape).
|
||||
- **[presentation-modeling](../presentation-modeling.md)** — `Track`
|
||||
data shape grows `alternateUris` field; parser extension is in
|
||||
scope here.
|
||||
- **[live-stream-support](./live-stream-support.md)** *(not yet
|
||||
implemented)* — reload-loop failover during live consumes this
|
||||
feature's rotation primitive. Per-type reload coordination open
|
||||
question applies.
|
||||
- **[source-replacement](./source-replacement.md)** — active-URI
|
||||
state tears down via the resolved/unresolved cascade on source
|
||||
change.
|
||||
- **[mse-mms-pipeline](./mse-mms-pipeline.md)** — segment-fetch
|
||||
sites consume the active-URI slot indirectly via `Track.uri` reads.
|
||||
- **[drm-support](./drm-support.md)** *(not implemented)* — license-
|
||||
fetch failover question: same feature's rotation, or DRM-side?
|
||||
- **[video-abr](./video-abr.md)** / **[audio-abr](./audio-abr.md)** —
|
||||
ABR operates within a rendition; rotation operates on the rendition's
|
||||
URI. Orthogonal axes; both compose.
|
||||
- **[track-switching-model.md](../track-switching-model.md)** *(governing
|
||||
model)* — multi-CDN is its canonical constraint + scope feature; the
|
||||
active-CDN scope is implemented against the rule chain it specifies.
|
||||
- **[network-resilience](./network-resilience.md)** *(hard prerequisite
|
||||
for failover)* — per-host circuit-breaker the failed-CDN constraint
|
||||
consumes.
|
||||
- **[content-steering](./content-steering.md)** — dynamic host-pool
|
||||
sibling; shares the active-pathway scope shape (`cdnPriority` as a
|
||||
reorderable reflected list — pathway priority as a sort key).
|
||||
- **[capability-probing](./capability-probing.md)** — shares the
|
||||
not-yet-built constraints pre-pass with the failed-CDN constraint.
|
||||
- **[live-stream-support](./live-stream-support.md)** *(not implemented)*
|
||||
— reload-loop re-resolution; `cdnPriority` is republished only when the
|
||||
CDN set changes.
|
||||
- **[source-replacement](./source-replacement.md)** — `cdnPriority` tears
|
||||
down via the resolved/unresolved cascade on source change.
|
||||
- **[video-abr](./video-abr.md)** / **[audio-abr](./audio-abr.md)** — ABR
|
||||
ranks within the CDN-narrowed set; the scope runs before the ranker.
|
||||
|
||||
## See also
|
||||
|
||||
- [track-switching-model.md](../track-switching-model.md) — the rule
|
||||
model (constraints → soft filters → ranker) this feature composes into
|
||||
- [clusters.md § Selection resilience](./clusters.md#selection-resilience)
|
||||
— cluster G description; this feature is the selection-side
|
||||
resilience sister to `network-resilience`'s response-error
|
||||
handling
|
||||
- [clusters.md § Feature classification axes](./clusters.md#feature-classification-axes)
|
||||
— Tier 1 / Tier 2 framing; Media-src? / Player? classification
|
||||
ambiguity
|
||||
- [presentation-modeling.md](../presentation-modeling.md) — parser-
|
||||
pluggability open question; alternate-URI parsing is one
|
||||
forcing function
|
||||
- [network-resilience.md](./network-resilience.md) — hard prerequisite;
|
||||
retry + circuit-breaker foundation
|
||||
— cluster G; this feature is the selection-side resilience sister to
|
||||
`network-resilience`'s response-error handling
|
||||
- [clusters.md § Selection / filtering across clusters](./clusters.md#selection--filtering-across-clusters)
|
||||
— cluster G's role: alternate-CDN selection within the chosen track
|
||||
- [network-resilience.md](./network-resilience.md) — circuit-breaker
|
||||
foundation the failover tier consumes
|
||||
- [SPF Epics Working Doc](https://www.notion.so/35f97a7f89d08123a13fecab1ca1cac4)
|
||||
— source material; epic #9 (Multi-CDN Failover)
|
||||
- [Mux Video — `?redundant_streams=true`](https://www.mux.com/docs/guides/play-back-on-multiple-cdns)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { MaybeResolvedPresentation, TrackType } from '../types';
|
||||
|
||||
/**
|
||||
* Identify the CDN a URL is served from, used to group redundant-stream
|
||||
* variants that point at the same content on different hosts. Defaults to the
|
||||
* URL's origin (scheme + host + port); falls back to the raw string when the
|
||||
* URL can't be parsed, so the return value is always a stable grouping key.
|
||||
*
|
||||
* Sub-feature 1 (sticky CDN pick) uses origin-based identity; a more advanced
|
||||
* or consumer-configurable derivation can replace this default later.
|
||||
*/
|
||||
export function getCdnId(url: string): string {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Track-type priority for CDN ordering: video first, then audio, then text.
|
||||
// Selection sets are visited in this order so the head of the returned list is
|
||||
// always video-derived. `preferActiveCdn` anchors every track type to the
|
||||
// first CDN with surviving tracks (the head), so this makes "the primary CDN
|
||||
// is the video CDN" a guarantee of `getOrderedCdnIds` rather than a side effect
|
||||
// of the order tracks happen to be parsed in.
|
||||
const CDN_TYPE_PRIORITY: Record<TrackType, number> = { video: 0, audio: 1, text: 2 };
|
||||
|
||||
/**
|
||||
* The distinct CDNs a presentation's tracks are served from, ordered video CDNs
|
||||
* first, then audio, then text (manifest order within a type). The head is the
|
||||
* primary CDN — the one a sticky pick defaults to — and is always video-derived
|
||||
* when the source has video. Returns `[]` for an unresolved presentation with
|
||||
* no tracks.
|
||||
*
|
||||
* Redundant-stream sources list the same content on multiple hosts (e.g. Mux's
|
||||
* `?redundant_streams=true`), so each host contributes its own candidate tracks;
|
||||
* this collapses them to the set of CDNs across every track type.
|
||||
*/
|
||||
export function getOrderedCdnIds(presentation: MaybeResolvedPresentation): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
// Stable sort keeps manifest order among same-type selection sets.
|
||||
const selectionSets = [...(presentation.selectionSets ?? [])].sort(
|
||||
(a, b) => CDN_TYPE_PRIORITY[a.type] - CDN_TYPE_PRIORITY[b.type]
|
||||
);
|
||||
for (const selectionSet of selectionSets) {
|
||||
for (const switchingSet of selectionSet.switchingSets) {
|
||||
for (const track of switchingSet.tracks) {
|
||||
const id = getCdnId(track.url);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MaybeResolvedPresentation } from '../../types';
|
||||
import { getCdnId, getOrderedCdnIds } from '../cdn';
|
||||
|
||||
const presentationWith = (urlsByType: {
|
||||
video?: string[];
|
||||
audio?: string[];
|
||||
text?: string[];
|
||||
}): MaybeResolvedPresentation => ({
|
||||
url: 'https://cdn-a.example.com/master.m3u8',
|
||||
selectionSets: (['video', 'audio', 'text'] as const)
|
||||
.filter((type) => urlsByType[type]?.length)
|
||||
.map((type) => ({
|
||||
id: `${type}-set`,
|
||||
type,
|
||||
switchingSets: [
|
||||
{
|
||||
id: `${type}-switching`,
|
||||
type,
|
||||
tracks: urlsByType[type]!.map((url, i) => ({ id: `${type}-${i}`, type, url, bandwidth: 0 })),
|
||||
},
|
||||
],
|
||||
})) as MaybeResolvedPresentation['selectionSets'],
|
||||
});
|
||||
|
||||
describe('getCdnId', () => {
|
||||
it('returns the origin of an absolute URL', () => {
|
||||
expect(getCdnId('https://cdn-a.example.com/path/720p.m3u8')).toBe('https://cdn-a.example.com');
|
||||
});
|
||||
|
||||
it('treats different paths on the same host as the same CDN', () => {
|
||||
const a = getCdnId('https://cdn-a.example.com/720p.m3u8');
|
||||
const b = getCdnId('https://cdn-a.example.com/1080p/index.m3u8');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('treats different hosts as different CDNs', () => {
|
||||
const a = getCdnId('https://cdn-a.example.com/720p.m3u8');
|
||||
const b = getCdnId('https://cdn-b.example.com/720p.m3u8');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('distinguishes scheme and port via the origin', () => {
|
||||
expect(getCdnId('https://cdn.example.com/x.m3u8')).not.toBe(getCdnId('http://cdn.example.com/x.m3u8'));
|
||||
expect(getCdnId('https://cdn.example.com:8443/x.m3u8')).not.toBe(getCdnId('https://cdn.example.com/x.m3u8'));
|
||||
});
|
||||
|
||||
it('falls back to the raw string when the URL cannot be parsed', () => {
|
||||
expect(getCdnId('not a url')).toBe('not a url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrderedCdnIds', () => {
|
||||
it('lists distinct CDNs across all track types in manifest order', () => {
|
||||
const presentation = presentationWith({
|
||||
video: ['https://cdn-a.example.com/720p.m3u8', 'https://cdn-b.example.com/720p.m3u8'],
|
||||
audio: ['https://cdn-a.example.com/audio.m3u8', 'https://cdn-b.example.com/audio.m3u8'],
|
||||
});
|
||||
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
});
|
||||
|
||||
it('dedupes repeated hosts, keeping first occurrence', () => {
|
||||
const presentation = presentationWith({
|
||||
video: [
|
||||
'https://cdn-a.example.com/720p.m3u8',
|
||||
'https://cdn-a.example.com/1080p.m3u8',
|
||||
'https://cdn-b.example.com/720p.m3u8',
|
||||
],
|
||||
});
|
||||
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
});
|
||||
|
||||
it('returns a single CDN for a non-redundant source', () => {
|
||||
const presentation = presentationWith({ video: ['https://cdn-a.example.com/720p.m3u8'] });
|
||||
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com']);
|
||||
});
|
||||
|
||||
it('returns [] for an unresolved presentation', () => {
|
||||
expect(getOrderedCdnIds({ url: 'https://cdn-a.example.com/master.m3u8' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('orders video CDNs ahead of audio regardless of selection-set order', () => {
|
||||
// Audio selection set listed first, cdn-b ahead of cdn-a within it; video
|
||||
// listed second with cdn-a first. Raw manifest order would make cdn-b primary,
|
||||
// but the head must be video-derived (cdn-a).
|
||||
const presentation: MaybeResolvedPresentation = {
|
||||
url: 'https://cdn-a.example.com/master.m3u8',
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'audio-set',
|
||||
type: 'audio',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'audio-switching',
|
||||
type: 'audio',
|
||||
tracks: [
|
||||
{ id: 'aud-b', type: 'audio', url: 'https://cdn-b.example.com/audio.m3u8', bandwidth: 0 },
|
||||
{ id: 'aud-a', type: 'audio', url: 'https://cdn-a.example.com/audio.m3u8', bandwidth: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'video-set',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'video-switching',
|
||||
type: 'video',
|
||||
tracks: [
|
||||
{ id: 'vid-a', type: 'video', url: 'https://cdn-a.example.com/720p.m3u8', bandwidth: 0 },
|
||||
{ id: 'vid-b', type: 'video', url: 'https://cdn-b.example.com/720p.m3u8', bandwidth: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as MaybeResolvedPresentation['selectionSets'],
|
||||
};
|
||||
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* **Session-level CDN priority.** While a presentation is resolved, owns the
|
||||
* `cdnPriority` signal: the distinct CDNs the source is served from (origin of
|
||||
* each track's URL), in manifest priority order — most-preferred first. Cleared
|
||||
* on src unload. The name mirrors HLS content steering's `PATHWAY-PRIORITY`.
|
||||
*
|
||||
* Redundant-stream sources (e.g. Mux's `?redundant_streams=true`) list the same
|
||||
* content on multiple hosts, so the candidate tracks already include one variant
|
||||
* per CDN. This behavior publishes *which* CDNs exist and their priority; the
|
||||
* `preferActiveCdn` scope rule in `track-switching` reads `cdnPriority` and
|
||||
* narrows each type's candidates to the first CDN with surviving tracks — so
|
||||
* video / audio / text all resolve from one host (the shared list is the
|
||||
* per-presentation coherence guarantee).
|
||||
*
|
||||
* The "active" CDN is not stored — it's derived by the scope as the
|
||||
* highest-priority entry in `cdnPriority` that still has tracks after the
|
||||
* constraints pre-pass. That makes failover a pure consequence of the (future)
|
||||
* failed-CDN constraint: when the primary's tracks are pruned during cooldown,
|
||||
* the scope falls to the next CDN; when the primary recovers, it snaps back.
|
||||
* Content steering, when it lands, reorders `cdnPriority` (pathway priority as a
|
||||
* sort key).
|
||||
*
|
||||
* Lifecycle: `'presentation-unresolved'` ↔ `'presentation-resolved'`, mirroring
|
||||
* `setupTrackSwitching`. The resolved state owns the signal; its entry-returned
|
||||
* cleanup clears it on exit (canonical cleanup-binds-to-setup per `reactors.md`).
|
||||
*/
|
||||
|
||||
import { defineBehavior } from '../../core/composition/create-composition';
|
||||
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
|
||||
import { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
|
||||
import { isResolvedPresentation, type MaybeResolvedPresentation } from '../../media/types';
|
||||
import { getOrderedCdnIds } from '../../media/utils/cdn';
|
||||
|
||||
export interface ResolveCdnPriorityState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
cdnPriority?: string[];
|
||||
}
|
||||
|
||||
const samePriority = (a: string[] | undefined, b: string[]): boolean =>
|
||||
!!a && a.length === b.length && a.every((cdn, i) => cdn === b[i]);
|
||||
|
||||
/**
|
||||
* Manage `cdnPriority`: publish the manifest-ordered CDN list on src load, clear
|
||||
* on src unload.
|
||||
*
|
||||
* @example
|
||||
* const reactor = resolveCdnPriority.setup({ state });
|
||||
*/
|
||||
export const resolveCdnPriority = defineBehavior({
|
||||
stateKeys: ['presentation', 'cdnPriority'],
|
||||
contextKeys: [],
|
||||
setup: ({
|
||||
state,
|
||||
}: {
|
||||
state: {
|
||||
presentation: ReadonlySignal<ResolveCdnPriorityState['presentation']>;
|
||||
cdnPriority: Signal<ResolveCdnPriorityState['cdnPriority']>;
|
||||
};
|
||||
}) => {
|
||||
const derivedStateSignal = computed(() =>
|
||||
isResolvedPresentation(state.presentation.get())
|
||||
? ('presentation-resolved' as const)
|
||||
: ('presentation-unresolved' as const)
|
||||
);
|
||||
|
||||
return createMachineReactor({
|
||||
initial: 'presentation-unresolved',
|
||||
monitor: () => derivedStateSignal.get(),
|
||||
states: {
|
||||
'presentation-unresolved': {},
|
||||
'presentation-resolved': {
|
||||
// Cleanup-binds-to-setup: the CDN priority list is valid for exactly
|
||||
// 'presentation-resolved'. Clear fires on exit (src unload + destroy).
|
||||
entry: () => () => state.cdnPriority.set(undefined),
|
||||
effects: [
|
||||
() => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return;
|
||||
const next = getOrderedCdnIds(presentation);
|
||||
// Skip the write when the CDN set is unchanged — a live reload swaps
|
||||
// in a new presentation object with the same hosts, and re-setting a
|
||||
// fresh array would re-fire the scope for an identical result.
|
||||
// `peek` reads without subscribing, so this never self-triggers.
|
||||
if (!samePriority(peek(state.cdnPriority), next)) state.cdnPriority.set(next);
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StateSignals } from '../../../core/composition/create-composition';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import type { MaybeResolvedPresentation, PartiallyResolvedVideoTrack, Presentation } from '../../../media/types';
|
||||
import { type ResolveCdnPriorityState, resolveCdnPriority } from '../resolve-cdn-priority';
|
||||
|
||||
function makeState(initial: Partial<ResolveCdnPriorityState> = {}): StateSignals<ResolveCdnPriorityState> {
|
||||
return {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(initial.presentation),
|
||||
cdnPriority: signal<string[] | undefined>(initial.cdnPriority),
|
||||
};
|
||||
}
|
||||
|
||||
const videoTrack = (id: string, url: string): PartiallyResolvedVideoTrack => ({
|
||||
type: 'video',
|
||||
codecs: [],
|
||||
id,
|
||||
url,
|
||||
bandwidth: 2_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
|
||||
const presentationWith = (urls: string[], id = 'pres-1'): Presentation =>
|
||||
({
|
||||
id,
|
||||
url: 'https://cdn-a.example.com/master.m3u8',
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'video-set',
|
||||
type: 'video' as const,
|
||||
switchingSets: [
|
||||
{ id: 'video-switching', type: 'video' as const, tracks: urls.map((u, i) => videoTrack(`v${i}`, u)) },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as Presentation;
|
||||
|
||||
// A redundant-streams presentation: each rendition duplicated across CDNs, in
|
||||
// manifest priority order (cdn-a first).
|
||||
const redundant = (id = 'pres-1'): Presentation =>
|
||||
presentationWith(
|
||||
[
|
||||
'https://cdn-a.example.com/720p.m3u8',
|
||||
'https://cdn-b.example.com/720p.m3u8',
|
||||
'https://cdn-a.example.com/1080p.m3u8',
|
||||
'https://cdn-b.example.com/1080p.m3u8',
|
||||
],
|
||||
id
|
||||
);
|
||||
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
describe('resolveCdnPriority', () => {
|
||||
it('does nothing without a presentation', async () => {
|
||||
const state = makeState();
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toBeUndefined();
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('publishes the manifest-ordered CDN list on src load', async () => {
|
||||
const state = makeState({ presentation: redundant() });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('publishes a single-entry list for a non-redundant source', async () => {
|
||||
const state = makeState({ presentation: presentationWith(['https://cdn-a.example.com/720p.m3u8']) });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com']);
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('does not re-set the list when a resolved swap keeps the same CDNs', async () => {
|
||||
const state = makeState({ presentation: redundant() });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
const first = state.cdnPriority.get();
|
||||
|
||||
// Live-reload-style swap: new presentation object, same hosts.
|
||||
state.presentation.set(redundant('pres-2'));
|
||||
await flush();
|
||||
// Same reference — the no-churn guard skipped the write.
|
||||
expect(state.cdnPriority.get()).toBe(first);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('updates the list when a resolved swap changes the CDN order', async () => {
|
||||
const state = makeState({ presentation: redundant() });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
|
||||
state.presentation.set(
|
||||
presentationWith(['https://cdn-b.example.com/720p.m3u8', 'https://cdn-a.example.com/720p.m3u8'], 'pres-2')
|
||||
);
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toEqual(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('clears cdnPriority on src unload', async () => {
|
||||
const state = makeState({ presentation: redundant() });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toBeDefined();
|
||||
|
||||
state.presentation.set(undefined);
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toBeUndefined();
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('clears cdnPriority on destroy', async () => {
|
||||
const state = makeState({ presentation: redundant() });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toBeDefined();
|
||||
|
||||
reactor.destroy();
|
||||
expect(state.cdnPriority.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-publishes after a src reset (undefined → new resolved)', async () => {
|
||||
const state = makeState({ presentation: redundant() });
|
||||
const reactor = resolveCdnPriority.setup({ state });
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
|
||||
state.presentation.set(undefined);
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toBeUndefined();
|
||||
|
||||
state.presentation.set(redundant('pres-2'));
|
||||
await flush();
|
||||
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -734,3 +734,132 @@ describe('applyRules', () => {
|
||||
expect(received).toEqual([all, deps]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// preferActiveCdn — active-CDN scope (shared by video + audio)
|
||||
// ============================================================================
|
||||
|
||||
describe('preferActiveCdn (active-CDN scope)', () => {
|
||||
const cdnVideoTrack = (id: string, host: string, bandwidth: number): PartiallyResolvedVideoTrack => ({
|
||||
type: 'video',
|
||||
codecs: [],
|
||||
id,
|
||||
url: `https://${host}/${id}.m3u8`,
|
||||
bandwidth,
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
|
||||
// Two renditions duplicated across cdn-a (listed first) and cdn-b.
|
||||
const multiCdn = () =>
|
||||
createPresentation([
|
||||
cdnVideoTrack('720p-a', 'cdn-a.example.com', 2_400_000),
|
||||
cdnVideoTrack('720p-b', 'cdn-b.example.com', 2_400_000),
|
||||
cdnVideoTrack('1080p-a', 'cdn-a.example.com', 4_800_000),
|
||||
cdnVideoTrack('1080p-b', 'cdn-b.example.com', 4_800_000),
|
||||
]);
|
||||
|
||||
// Bandwidth high enough that 1080p fits, so the pick is the highest rendition
|
||||
// on whichever CDN the scope leaves standing.
|
||||
const makeCdnState = (cdnPriority?: string[]) => ({
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(multiCdn()),
|
||||
bandwidthState: signal<BandwidthState | undefined>(createBandwidthState(10_000_000)),
|
||||
selectedVideoTrackId: signal<string | undefined>(undefined),
|
||||
userVideoTrackSelection: signal<Partial<VideoTrack> | undefined>(undefined),
|
||||
cdnPriority: signal<string[] | undefined>(cdnPriority),
|
||||
});
|
||||
|
||||
it('narrows the pick to the highest-priority CDN, overriding manifest track order', async () => {
|
||||
// cdn-a's 1080p is listed first in the tracks, but cdn-b is first in `cdnPriority`,
|
||||
// so the scope picks cdn-b. (Without the scope abr would pick 1080p-a.)
|
||||
const state = makeCdnState(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-b');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('keeps the pick on the primary CDN when it is first in the list', async () => {
|
||||
const state = makeCdnState(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('falls through to the next CDN when the first has no surviving tracks', async () => {
|
||||
// cdn-z has no tracks (as if pruned by a failover constraint), so the scope
|
||||
// skips it and narrows to cdn-a.
|
||||
const state = makeCdnState(['https://cdn-z.example.com', 'https://cdn-a.example.com']);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('falls through to all CDNs when no list entry matches any track', async () => {
|
||||
const state = makeCdnState(['https://cdn-z.example.com']);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('is a no-op when no cdnPriority list is present', async () => {
|
||||
const state = makeCdnState(undefined);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('applies the scope when cdnPriority arrives after the first pick (composition-order independence)', async () => {
|
||||
// Guards against the pick depending on `resolveCdnPriority` being composed
|
||||
// *before* `switchVideoTrack`. The worst case — resolveCdnPriority last — is
|
||||
// equivalent to cdnPriority being written after switch*'s first pick. The
|
||||
// scope subscribes to cdnPriority even while it's undefined, so a late write
|
||||
// must re-fire and correct the pick.
|
||||
const state = makeCdnState(undefined);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
// No cdnPriority yet → scope is a no-op → ranker picks the manifest head.
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
|
||||
state.cdnPriority.set(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-b');
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('re-picks when the CDN order changes while staying resolved (steering/failover seam)', async () => {
|
||||
const state = makeCdnState(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
|
||||
state.cdnPriority.set(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-b');
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('applies the same scope to the audio chain (cross-type CDN coherence)', async () => {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(
|
||||
createAudioPresentation([
|
||||
makeAudioTrack('aud-a', { url: 'https://cdn-a.example.com/aud.m3u8' }),
|
||||
makeAudioTrack('aud-b', { url: 'https://cdn-b.example.com/aud.m3u8' }),
|
||||
])
|
||||
),
|
||||
bandwidthState: signal<BandwidthState | undefined>(undefined),
|
||||
selectedAudioTrackId: signal<string | undefined>(undefined),
|
||||
userAudioTrackSelection: signal<Partial<AudioTrack> | undefined>(undefined),
|
||||
cdnPriority: signal<string[] | undefined>(['https://cdn-b.example.com', 'https://cdn-a.example.com']),
|
||||
};
|
||||
const reactor = switchAudioTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedAudioTrackId.get()).toBe('aud-b');
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
* Selection runs a small ordered chain of rules over the candidate tracks
|
||||
* (`applyRules`). Each rule narrows or reorders the list and reads the signals
|
||||
* it needs at apply time, so the effect subscribes to exactly what the applied
|
||||
* rules consult. Today the chain is two rules, most authoritative first:
|
||||
* rules consult. Today the chain is three rules, most authoritative first:
|
||||
*
|
||||
* 1. **user intent** — a soft filter on `user*TrackSelection`: narrow to the
|
||||
* partial-track match; an empty match falls through to the full set.
|
||||
* 2. **ranking** — the terminal sort: `rankByBandwidth`, shared by video and
|
||||
* 2. **active CDN** — a soft filter on `cdnPriority` (`preferActiveCdn`):
|
||||
* narrow to the highest-priority CDN that still has tracks; an empty match
|
||||
* falls through. Shared by video and audio, so every type stays on one CDN
|
||||
* (`resolveCdnPriority` owns the list). No-op for non-redundant sources.
|
||||
* 3. **ranking** — the terminal sort: `rankByBandwidth`, shared by video and
|
||||
* audio. Fitting tracks (within the throughput threshold) first, highest
|
||||
* bitrate first; over-throughput tracks after, least-over first. Hysteresis
|
||||
* via boosting the current track's sort weight by `upgradeMargin`.
|
||||
@@ -28,13 +32,15 @@
|
||||
* The pick is the head of the chain's result (`applyRules(...)[0]`). Each
|
||||
* variant supplies its **rule chain** via config; `setupTrackSwitching` owns
|
||||
* only the lifecycle and runs whatever chain it's given. Both variants today run
|
||||
* `[filterByUserSelection, rankByBandwidth]`; `switchVideoTrack` also accepts ABR
|
||||
* tuning config, `switchAudioTrack` takes none.
|
||||
* `[filterByUserSelection, preferActiveCdn, rankByBandwidth]`; `switchVideoTrack`
|
||||
* also accepts ABR tuning config, `switchAudioTrack` takes none.
|
||||
*
|
||||
* Deferred (not yet in the chain): a hard-constraints pre-pass (capability
|
||||
* probing, CDN failover) gating the candidate set, and audio's preferred-
|
||||
* language / default-track selection as standing soft-filter rules — previously
|
||||
* the empty-slot picker, dropped in the move to the rule chain.
|
||||
* probing, CDN *failover* — excluding a failed CDN's tracks during cooldown)
|
||||
* gating the candidate set, and audio's preferred-language / default-track
|
||||
* selection as standing soft-filter rules — previously the empty-slot picker,
|
||||
* dropped in the move to the rule chain. (The active-CDN *scope* above is the
|
||||
* sticky-pick half of multi-CDN; the failed-CDN constraint is the failover half.)
|
||||
*/
|
||||
|
||||
import { type AnySlotMap, defineBehavior } from '../../core/composition/create-composition';
|
||||
@@ -50,6 +56,7 @@ import {
|
||||
type PartiallyResolvedVideoTrack,
|
||||
type VideoTrack,
|
||||
} from '../../media/types';
|
||||
import { getCdnId } from '../../media/utils/cdn';
|
||||
import { getTracksByType } from '../../media/utils/tracks';
|
||||
import type { BandwidthConfig, BandwidthState } from '../../network/bandwidth-estimator';
|
||||
import { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate } from '../../network/bandwidth-estimator';
|
||||
@@ -178,11 +185,14 @@ export function applyRules<T, State, Context, Config>(
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Minimum candidate-track shape consumed by the helper. `bandwidth` feeds the
|
||||
* ranker's throughput sort; `width`/`height` are the equal-bitrate tie-break
|
||||
* (absent on audio, so audio candidates area-compare equal).
|
||||
* Minimum candidate-track shape consumed by the helper and its rules: an `id`
|
||||
* (the pick), a `url` (the active-CDN scope derives the CDN from it), an
|
||||
* optional `bandwidth` (the ranker's throughput sort), and optional
|
||||
* `width`/`height` (the ranker's equal-bitrate tie-break — absent on audio, so
|
||||
* audio candidates area-compare equal). Every resolved/partially-resolved video
|
||||
* track carries them all; audio tracks omit the dimensions.
|
||||
*/
|
||||
type SwitchableTrack = { id: string; bandwidth?: number; width?: number; height?: number };
|
||||
type SwitchableTrack = { id: string; url: string; bandwidth?: number; width?: number; height?: number };
|
||||
|
||||
type SelectionKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';
|
||||
type UserSelectionKey = 'userVideoTrackSelection' | 'userAudioTrackSelection';
|
||||
@@ -264,6 +274,17 @@ type BandwidthRankerStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S>
|
||||
type BandwidthRankerConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> &
|
||||
SwitchVideoTrackConfig;
|
||||
|
||||
/**
|
||||
* State the active-CDN scope reads: the lifecycle map plus an *optional*
|
||||
* `cdnPriority` — the manifest-ordered CDN list (most-preferred first). The
|
||||
* signal exists only when the composition includes `resolveCdnPriority` (which
|
||||
* materializes + owns it); the scope reads it defensively and passes through
|
||||
* when it's absent (no CDN preference).
|
||||
*/
|
||||
type CdnScopeStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S> & {
|
||||
cdnPriority?: ReadonlySignal<string[] | undefined>;
|
||||
};
|
||||
|
||||
type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack;
|
||||
type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack;
|
||||
|
||||
@@ -289,6 +310,39 @@ function filterByUserSelection<S extends SelectionKey, U extends UserSelectionKe
|
||||
return filter ? tracks.filter((track) => matchesPartialTrack(track, filter)) : tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active-CDN scope — a soft filter, shared by video and audio. Narrows to the
|
||||
* highest-priority CDN in `cdnPriority` (owned by `resolveCdnPriority`) that
|
||||
* still has tracks, so every track type stays on one CDN. A redundant-streams
|
||||
* source lists the same renditions on multiple hosts; this keeps the pick on one
|
||||
* host rather than letting the ranker drift across them.
|
||||
*
|
||||
* "Active" is derived, not stored: constraints run before the rule chain, so a
|
||||
* failed CDN's tracks are already pruned by the time this runs — "first CDN with
|
||||
* survivors" *is* the active CDN, and it falls through to the next on failover
|
||||
* (and snaps back to the primary when it recovers). Content steering reorders
|
||||
* `cdnPriority`; this rule just honors the order.
|
||||
*
|
||||
* Soft-filter semantics: passes through when there's no `cdnPriority` signal/value
|
||||
* (no preference) or when nothing matches (`applyRules` skips an empty result).
|
||||
* Non-redundant sources have one CDN, so the narrow is a no-op.
|
||||
*
|
||||
* The CDN-id derivation (`getCdnId`, origin-based) is hardcoded for now; a
|
||||
* consumer-configurable derivation can move onto a rule config view later.
|
||||
*/
|
||||
function preferActiveCdn<S extends SelectionKey, T extends SwitchableTrack>(
|
||||
tracks: readonly T[],
|
||||
{ state }: SelectionRuleDeps<CdnScopeStateMap<S>, AnySlotMap, TrackSwitchingConfig<S, T>>
|
||||
): readonly T[] {
|
||||
const cdnPriority = state.cdnPriority?.get();
|
||||
if (!cdnPriority?.length) return tracks;
|
||||
for (const cdn of cdnPriority) {
|
||||
const tracksUsingCdn = tracks.filter((track) => getCdnId(track.url) === cdn);
|
||||
if (tracksUsingCdn.length) return tracksUsingCdn;
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bandwidth ranking — the terminal sort, shared by video and audio. Orders by
|
||||
* the throughput estimate: tracks within the bandwidth threshold first
|
||||
@@ -470,7 +524,7 @@ export const switchVideoTrack = defineBehavior({
|
||||
selectionKey: 'selectedVideoTrackId',
|
||||
userSelectionKey: 'userVideoTrackSelection',
|
||||
getTracks: (presentation) => getTracksByType(presentation, 'video') as readonly VideoTrackCandidate[],
|
||||
rules: [filterByUserSelection, rankByBandwidth],
|
||||
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -503,7 +557,7 @@ export const switchAudioTrack = defineBehavior({
|
||||
selectionKey: 'selectedAudioTrackId',
|
||||
userSelectionKey: 'userAudioTrackSelection',
|
||||
getTracks: (presentation) => getTracksByType(presentation, 'audio') as readonly AudioTrackCandidate[],
|
||||
rules: [filterByUserSelection, rankByBandwidth],
|
||||
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
|
||||
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
|
||||
import { resolveCdnPriority } from '../../behaviors/resolve-cdn-priority';
|
||||
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveAudioTrack } from '../../behaviors/resolve-track';
|
||||
import { syncPreload } from '../../behaviors/sync-preload';
|
||||
@@ -51,6 +52,13 @@ export interface SimpleHlsAudioOnlyEngineState {
|
||||
* Multi-language-audio Tier 2 programmatic-write path.
|
||||
*/
|
||||
userAudioTrackSelection?: Partial<AudioTrack>;
|
||||
/**
|
||||
* The CDNs the source is served from, in manifest priority order (mirrors
|
||||
* HLS content steering's `PATHWAY-PRIORITY`). Owned by `resolveCdnPriority`,
|
||||
* read by `track-switching`'s `preferActiveCdn` scope. Only meaningful for
|
||||
* redundant-stream sources; a single-CDN source has one entry.
|
||||
*/
|
||||
cdnPriority?: string[];
|
||||
currentTime?: number;
|
||||
loadActivated?: boolean;
|
||||
}
|
||||
@@ -143,6 +151,17 @@ export function createHlsAudioOnlyEngine(
|
||||
trackLoadTriggers,
|
||||
resolvePresentation,
|
||||
|
||||
// Session-level CDN priority for redundant-stream sources. Owns
|
||||
// `cdnPriority`; switchAudioTrack's preferActiveCdn scope reads it. No-op
|
||||
// for single-CDN sources.
|
||||
//
|
||||
// With a single track type there's no cross-type coherence to enforce and
|
||||
// the first pick is the primary CDN regardless, so composition order is
|
||||
// not load-bearing here today. It earns its place for forward-consistency
|
||||
// with the default engine and for future failover / steering, where the
|
||||
// active CDN changes dynamically (and selection stays reactive either way).
|
||||
resolveCdnPriority,
|
||||
|
||||
// Audio track selection — slot owner with filter reactivity.
|
||||
// Mid-stream flush on language switch is handled in segment-loader's
|
||||
// planTasks, not here.
|
||||
|
||||
@@ -35,6 +35,7 @@ import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
|
||||
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
|
||||
import { resolveCdnPriority } from '../../behaviors/resolve-cdn-priority';
|
||||
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../behaviors/resolve-track';
|
||||
import { selectTextTrack } from '../../behaviors/select-tracks';
|
||||
@@ -71,6 +72,16 @@ export interface SimpleHlsEngineState {
|
||||
* when it changes. Multi-language-audio Tier 2 programmatic-write path.
|
||||
*/
|
||||
userAudioTrackSelection?: Partial<AudioTrack>;
|
||||
/**
|
||||
* The CDNs the source is served from (track-URL origins), in manifest
|
||||
* priority order — most-preferred first (mirrors HLS content steering's
|
||||
* `PATHWAY-PRIORITY`). Owned by `resolveCdnPriority`, read by
|
||||
* `track-switching`'s `preferActiveCdn` scope, which narrows to the
|
||||
* highest-priority CDN with surviving tracks so video / audio / text stay on
|
||||
* one host. Only meaningful for redundant-stream sources; a single-CDN source
|
||||
* has one entry.
|
||||
*/
|
||||
cdnPriority?: string[];
|
||||
currentTime?: number;
|
||||
loadActivated?: boolean;
|
||||
}
|
||||
@@ -248,6 +259,22 @@ export function createSimpleHlsEngine(
|
||||
trackLoadTriggers,
|
||||
resolvePresentation,
|
||||
|
||||
// Session-level CDN priority for redundant-stream sources. Owns
|
||||
// `cdnPriority`; `track-switching`'s preferActiveCdn scope reads it so
|
||||
// every type stays on one CDN. No-op for single-CDN sources.
|
||||
//
|
||||
// Placed before switch* so `cdnPriority` is set before the first pick —
|
||||
// but this ordering is only *mildly* load-bearing, not required for
|
||||
// correctness. Selection is reactive: a late `cdnPriority` re-fires the
|
||||
// pick and converges on the same result (see the late-arrival test in
|
||||
// track-switching.test.ts). Order affects only a transient, and only for
|
||||
// an *asymmetric* manifest (a type listing a non-primary CDN first):
|
||||
// composing this after switch* would let that type fire one wasted
|
||||
// media-playlist fetch to the wrong CDN before correcting. Symmetric
|
||||
// redundant streams (the norm) never hit it — the first-listed CDN is
|
||||
// already the primary we'd pick anyway.
|
||||
resolveCdnPriority,
|
||||
|
||||
// Track selection (reads config for initial preferences).
|
||||
// Video selection lives in switchVideoTrack (composed below);
|
||||
// audio selection lives in switchAudioTrack (composed below) —
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { snapshot } from '../../../../core/signals/primitives';
|
||||
import type { PartiallyResolvedAudioTrack, PartiallyResolvedVideoTrack, Presentation } from '../../../../media/types';
|
||||
import { createSimpleHlsEngine } from '../engine';
|
||||
|
||||
// Mock appendSegment to succeed without real MP4 data
|
||||
@@ -59,6 +60,7 @@ describe('createSimpleHlsEngine', () => {
|
||||
// Everything else starts as `undefined` and behaviors write their
|
||||
// own slots in response to inputs.
|
||||
expect(snapshot(engine.state)).toEqual({
|
||||
cdnPriority: undefined,
|
||||
userVideoTrackSelection: undefined,
|
||||
bandwidthState: {
|
||||
fastEstimate: 0,
|
||||
@@ -82,6 +84,132 @@ describe('createSimpleHlsEngine', () => {
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('publishes the CDN list and keeps the video selection on the primary (redundant-stream source)', async () => {
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
const engine = createSimpleHlsEngine();
|
||||
|
||||
const videoTrack = (id: string, host: string): PartiallyResolvedVideoTrack => ({
|
||||
type: 'video',
|
||||
id,
|
||||
codecs: [],
|
||||
url: `https://${host}/${id}.m3u8`,
|
||||
bandwidth: 2_400_000,
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
|
||||
// Same rendition duplicated across cdn-a (manifest head) and cdn-b.
|
||||
engine.state.presentation.set({
|
||||
id: 'pres-1',
|
||||
url: 'https://cdn-a.example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'v',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'vs',
|
||||
type: 'video',
|
||||
tracks: [videoTrack('720p-a', 'cdn-a.example.com'), videoTrack('720p-b', 'cdn-b.example.com')],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Presentation);
|
||||
await flush();
|
||||
|
||||
// resolveCdns publishes the manifest-ordered list; preferActiveCdn narrows
|
||||
// the video pick to the primary (first-with-survivors) CDN.
|
||||
expect(engine.state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBe('720p-a');
|
||||
|
||||
// Reorder the CDN list (steering/override seam): the scope re-narrows and the
|
||||
// selection follows to the other CDN's matching rendition.
|
||||
engine.state.cdnPriority.set(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
|
||||
await flush();
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBe('720p-b');
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('keeps audio on the same CDN as video even when the audio rendition order differs', async () => {
|
||||
// Order-effect guard: `resolveCdnPriority` derives the list from track order,
|
||||
// so a same-ordered source can't distinguish "scope applied" from "scope is
|
||||
// a no-op". This source is doubly adversarial to the desired result: the
|
||||
// audio selection set comes BEFORE video in the manifest, and within it the
|
||||
// audio renditions list cdn-b first. By raw parse order that would make
|
||||
// cdn-b primary and pull everything onto cdn-b. `getOrderedCdnIds` instead
|
||||
// visits video selection sets before audio, so `cdnPriority` is video-derived
|
||||
// (cdn-a primary) *by guarantee*, and the scope pulls audio onto cdn-a —
|
||||
// `aud-a`, NOT the parse-order `aud-b`. This pins the type-priority ordering,
|
||||
// not a manifest/parse-order coincidence.
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
const engine = createSimpleHlsEngine();
|
||||
|
||||
const videoTrack = (id: string, host: string): PartiallyResolvedVideoTrack => ({
|
||||
type: 'video',
|
||||
id,
|
||||
codecs: [],
|
||||
url: `https://${host}/${id}.m3u8`,
|
||||
bandwidth: 2_400_000,
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
const audioTrack = (id: string, host: string): PartiallyResolvedAudioTrack => ({
|
||||
type: 'audio',
|
||||
id,
|
||||
codecs: ['mp4a.40.2'],
|
||||
url: `https://${host}/${id}.m3u8`,
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
groupId: 'audio',
|
||||
name: id,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
});
|
||||
|
||||
engine.state.presentation.set({
|
||||
id: 'pres-asym',
|
||||
url: 'https://cdn-a.example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
// Audio selection set listed FIRST, cdn-b first within it — the reverse of
|
||||
// the video order on both axes. Type-priority ordering must still put video
|
||||
// (cdn-a) at the head of cdnPriority.
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'a',
|
||||
type: 'audio',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'as',
|
||||
type: 'audio',
|
||||
tracks: [audioTrack('aud-b', 'cdn-b.example.com'), audioTrack('aud-a', 'cdn-a.example.com')],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'v',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'vs',
|
||||
type: 'video',
|
||||
tracks: [videoTrack('vid-a', 'cdn-a.example.com'), videoTrack('vid-b', 'cdn-b.example.com')],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Presentation);
|
||||
await flush();
|
||||
|
||||
expect(engine.state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBe('vid-a');
|
||||
// The discriminator: audio is listed first and lists aud-b first, but the
|
||||
// video-derived cdnPriority puts cdn-a first, so the scope picks aud-a.
|
||||
expect(engine.state.selectedAudioTrackId.get()).toBe('aud-a');
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('allows patching state and owners from outside', async () => {
|
||||
const engine = createSimpleHlsEngine();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user