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
@@ -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