diff --git a/apps/sandbox/templates/spf-segment-loading/main.ts b/apps/sandbox/templates/spf-segment-loading/main.ts index 10567a10..6aba92fc 100644 --- a/apps/sandbox/templates/spf-segment-loading/main.ts +++ b/apps/sandbox/templates/spf-segment-loading/main.ts @@ -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[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): VideoSelectionGroup[] { + const groups = new Map(); + 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, + 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('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 diff --git a/internal/design/spf/features/multi-cdn-failover.md b/internal/design/spf/features/multi-cdn-failover.md index 39dcd3bb..c088b97a 100644 --- a/internal/design/spf/features/multi-cdn-failover.md +++ b/internal/design/spf/features/multi-cdn-failover.md @@ -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` 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` 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) diff --git a/packages/spf/src/media/utils/cdn.ts b/packages/spf/src/media/utils/cdn.ts new file mode 100644 index 00000000..97020e7a --- /dev/null +++ b/packages/spf/src/media/utils/cdn.ts @@ -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 = { 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(); + 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; +} diff --git a/packages/spf/src/media/utils/tests/cdn.test.ts b/packages/spf/src/media/utils/tests/cdn.test.ts new file mode 100644 index 00000000..8ef38355 --- /dev/null +++ b/packages/spf/src/media/utils/tests/cdn.test.ts @@ -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']); + }); +}); diff --git a/packages/spf/src/playback/behaviors/resolve-cdn-priority.ts b/packages/spf/src/playback/behaviors/resolve-cdn-priority.ts new file mode 100644 index 00000000..ec530707 --- /dev/null +++ b/packages/spf/src/playback/behaviors/resolve-cdn-priority.ts @@ -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; + cdnPriority: Signal; + }; + }) => { + 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); + }, + ], + }, + }, + }); + }, +}); diff --git a/packages/spf/src/playback/behaviors/tests/resolve-cdn-priority.test.ts b/packages/spf/src/playback/behaviors/tests/resolve-cdn-priority.test.ts new file mode 100644 index 00000000..095d9b18 --- /dev/null +++ b/packages/spf/src/playback/behaviors/tests/resolve-cdn-priority.test.ts @@ -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 = {}): StateSignals { + return { + presentation: signal(initial.presentation), + cdnPriority: signal(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(); + }); +}); diff --git a/packages/spf/src/playback/behaviors/tests/track-switching.test.ts b/packages/spf/src/playback/behaviors/tests/track-switching.test.ts index 88a3cd69..db087dc7 100644 --- a/packages/spf/src/playback/behaviors/tests/track-switching.test.ts +++ b/packages/spf/src/playback/behaviors/tests/track-switching.test.ts @@ -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(multiCdn()), + bandwidthState: signal(createBandwidthState(10_000_000)), + selectedVideoTrackId: signal(undefined), + userVideoTrackSelection: signal | undefined>(undefined), + cdnPriority: signal(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( + 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(undefined), + selectedAudioTrackId: signal(undefined), + userAudioTrackSelection: signal | undefined>(undefined), + cdnPriority: signal(['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(); + }); +}); diff --git a/packages/spf/src/playback/behaviors/track-switching.ts b/packages/spf/src/playback/behaviors/track-switching.ts index fa6017c1..0cfe2cba 100644 --- a/packages/spf/src/playback/behaviors/track-switching.ts +++ b/packages/spf/src/playback/behaviors/track-switching.ts @@ -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( // ============================================================================ /** - * 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 = TrackSwitchingStateMap type BandwidthRankerConfig = TrackSwitchingConfig & 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 = TrackSwitchingStateMap & { + cdnPriority?: ReadonlySignal; +}; + type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack; type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack; @@ -289,6 +310,39 @@ function filterByUserSelection 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( + tracks: readonly T[], + { state }: SelectionRuleDeps, AnySlotMap, TrackSwitchingConfig> +): 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], }, }), }); diff --git a/packages/spf/src/playback/engines/hls/engine-audio-only.ts b/packages/spf/src/playback/engines/hls/engine-audio-only.ts index ee69cf98..d3fb2384 100644 --- a/packages/spf/src/playback/engines/hls/engine-audio-only.ts +++ b/packages/spf/src/playback/engines/hls/engine-audio-only.ts @@ -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; + /** + * 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. diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 85c89791..71884ed1 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -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; + /** + * 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) — diff --git a/packages/spf/src/playback/engines/hls/tests/engine.test.ts b/packages/spf/src/playback/engines/hls/tests/engine.test.ts index d3e8bdf6..48254c6d 100644 --- a/packages/spf/src/playback/engines/hls/tests/engine.test.ts +++ b/packages/spf/src/playback/engines/hls/tests/engine.test.ts @@ -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();