feat(playback): rank recovery recommendations

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent d1d75e130c
commit aae11613fc
4 files changed
+1342 -12

No files matched your search

@@ -707,7 +707,10 @@ type PlaybackRecommendationCandidate =
export function recommendPlaybackRecovery(
context: PlaybackRecommendationContext
): readonly PlaybackRecommendation[] {
const candidates = buildCandidates(context).filter(
const capabilityIndex = isPlayerOrientedDiagnostic(context.diagnostic.code)
? createCapabilityIndex(context)
: null;
const candidates = buildCandidates(context, capabilityIndex).filter(
(candidate): candidate is PlaybackRecommendationCandidate =>
candidate !== null
);
@@ -723,14 +726,11 @@ export function recommendPlaybackRecovery(
) {
return false;
}
const capability = context.targetCapabilities.find(
({ target }) => target === candidate.target
);
if (!capability?.available) {
if (capabilityIndex?.get(candidate.target)?.available !== true) {
return false;
}
if (
capability.kind === 'external' &&
isExternalTarget(candidate.target) &&
(context.source.drm === 'untransferable' ||
!context.source.externalTransferable)
) {
@@ -780,6 +780,7 @@ switch (context.diagnostic.code) {
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.DifferentEngineFamily
),
external(
@@ -796,6 +797,7 @@ switch (context.diagnostic.code) {
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.CompatibleDrmPath
),
alternative(context),
@@ -809,12 +811,18 @@ switch (context.diagnostic.code) {
`buildCandidates` returns
`readonly (PlaybackRecommendationCandidate | null)[]`.
`alternative(context)` returns null
when `alternativeSourceCount <= 0`. `distinctInline` first validates that the
active target has an available inline capability with a non-null engine family,
then returns the first available inline capability in canonical order whose
family differs from the active family. For MPEG-TS, DASH, native, and unknown
matrices this naturally returns null.
`alternative(context)` returns null unless `alternativeSourceCount` is a
positive safe integer. Before building candidates for a player-oriented
diagnostic, validate one complete, unique capability record for each canonical
target, including exact target/kind pairing and source-kind engine-family
mapping. Any malformed matrix returns Retry plus a valid alternative instead
of trusting player candidates. `distinctInline` then selects the fixed HLS
representative from that validated index independently of capability-array
order: HTML5 represents hls.js after a Video.js/VHS failure, while Video.js
represents VHS after an HTML5 or ArtPlayer hls.js failure. If HTML5 is current,
unavailable, or already attempted, filtering proceeds to external and
alternative candidates without substituting ArtPlayer. For MPEG-TS, DASH,
native, and unknown matrices this naturally returns null.
If a player-oriented diagnostic has no coherent active inline capability,
return `retryUnknown()` plus `alternative(context)` instead of trusting any
+1
View File
@@ -4,5 +4,6 @@ export * from './lib/diagnostics/shaka-error-contract';
export * from './lib/diagnostics/shaka-error-lifecycle';
export type * from './lib/diagnostics/shaka-error.types';
export * from './lib/playback-recommendation.model';
export * from './lib/playback-recommendation-policy';
export * from './lib/playback-session-key';
export * from './lib/playback-target-capabilities';
@@ -0,0 +1,966 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
PlaybackRecommendationReason,
PlaybackSourceKind,
type PlaybackRecommendation,
type PlaybackRecommendationContext,
type PlaybackRecommendationPriority,
type PlaybackRecommendationTarget,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
import { recommendPlaybackRecovery } from './playback-recommendation-policy';
import { createPlaybackTargetCapabilities } from './playback-target-capabilities';
interface ContextOptions {
readonly code?: PlaybackDiagnosticCode;
readonly sourceKind?: PlaybackSourceKind;
readonly activeTarget?: PlaybackRecommendationTarget;
readonly attemptedTargets?: ReadonlySet<PlaybackRecommendationTarget>;
readonly targetCapabilities?: readonly PlaybackTargetCapability[];
readonly managedExternalPlayersAvailable?: boolean;
readonly drm?: 'none' | 'untransferable';
readonly externalTransferable?: boolean;
readonly alternativeSourceCount?: number;
}
function diagnostic(code: PlaybackDiagnosticCode): PlaybackDiagnostic {
return {
code,
source: PlaybackDiagnosticSource.Source,
sourceUrl: 'https://example.com/stream',
container: '',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
};
}
function context(options: ContextOptions = {}): PlaybackRecommendationContext {
const sourceKind = options.sourceKind ?? PlaybackSourceKind.Hls;
return {
diagnostic: diagnostic(
options.code ?? PlaybackDiagnosticCode.UnknownPlaybackError
),
activeTarget: options.activeTarget ?? InlinePlaybackPlayer.VideoJs,
attemptedTargets: options.attemptedTargets ?? new Set(),
targetCapabilities:
options.targetCapabilities ??
createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
options.managedExternalPlayersAvailable ?? true,
}),
source: {
kind: sourceKind,
isLive: false,
drm: options.drm ?? 'none',
externalTransferable: options.externalTransferable ?? true,
},
alternativeSourceCount: options.alternativeSourceCount ?? 1,
};
}
function retry(
reason: PlaybackRecommendationReason,
priority: PlaybackRecommendationPriority = 'primary'
): PlaybackRecommendation {
return { action: 'retry', reason, priority };
}
function alternative(
priority: PlaybackRecommendationPriority = 'secondary'
): PlaybackRecommendation {
return {
action: 'alternative-source',
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority,
};
}
function player(
target: PlaybackRecommendationTarget,
reason: PlaybackRecommendationReason,
priority: PlaybackRecommendationPriority = 'secondary'
): PlaybackRecommendation {
return { action: 'player', target, reason, priority };
}
function untrustedCapabilityMatrix(
entry: unknown
): readonly PlaybackTargetCapability[] {
return Object.freeze([
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
entry,
]) as readonly PlaybackTargetCapability[];
}
function sparseCapabilityMatrix(): readonly PlaybackTargetCapability[] {
const capabilities: unknown[] = [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
];
capabilities.length += 1;
return Object.freeze(capabilities) as readonly PlaybackTargetCapability[];
}
describe('recommendPlaybackRecovery', () => {
it.each([
{
name: 'network error',
input: context({ code: PlaybackDiagnosticCode.NetworkError }),
expected: [
retry(PlaybackRecommendationReason.RetryTransientFailure),
alternative(),
],
},
{
name: 'unknown playback error',
input: context({
code: PlaybackDiagnosticCode.UnknownPlaybackError,
}),
expected: [
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
],
},
{
name: 'browser access error',
input: context({ code: PlaybackDiagnosticCode.BrowserAccessError }),
expected: [
player(
'mpv',
PlaybackRecommendationReason.ExternalBrowserAccess,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalBrowserAccess
),
alternative(),
],
},
{
name: 'unsupported codec',
input: context({ code: PlaybackDiagnosticCode.UnsupportedCodec }),
expected: [
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
],
},
{
name: 'unsupported container',
input: context({
code: PlaybackDiagnosticCode.UnsupportedContainer,
}),
expected: [
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
],
},
{
name: 'HLS VHS decode error',
input: context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
}),
expected: [
player(
InlinePlaybackPlayer.Html5,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
],
},
{
name: 'HLS hls.js decode error on HTML5',
input: context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.Html5,
}),
expected: [
player(
InlinePlaybackPlayer.VideoJs,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
],
},
{
name: 'HLS hls.js decode error on ArtPlayer',
input: context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.ArtPlayer,
}),
expected: [
player(
InlinePlaybackPlayer.VideoJs,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
],
},
...[PlaybackSourceKind.MpegTs, PlaybackSourceKind.Native].map(
(sourceKind) => ({
name: `${sourceKind} decode error`,
input: context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind,
}),
expected: [
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
],
})
),
{
name: 'DASH decode error',
input: context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind: PlaybackSourceKind.Dash,
activeTarget: InlinePlaybackPlayer.Html5,
}),
expected: [
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
],
},
{
name: 'untransferable DASH DRM error',
input: context({
code: PlaybackDiagnosticCode.DrmOrEncryption,
sourceKind: PlaybackSourceKind.Dash,
activeTarget: InlinePlaybackPlayer.Html5,
drm: 'untransferable',
}),
expected: [alternative('primary')],
},
])(
'returns exact ordered recommendations for $name',
({ input, expected }) => {
expect(recommendPlaybackRecovery(input)).toEqual(expected);
}
);
it('excludes the active target and every attempted player target', () => {
const recommendations = recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
activeTarget: InlinePlaybackPlayer.VideoJs,
attemptedTargets: new Set([
InlinePlaybackPlayer.Html5,
'mpv',
'vlc',
]),
})
);
expect(recommendations).toEqual([alternative('primary')]);
expect(
recommendations.some(
(recommendation) =>
recommendation.action === 'player' &&
recommendation.target === InlinePlaybackPlayer.VideoJs
)
).toBe(false);
});
it('promotes the first surviving result and marks later results secondary', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.UnsupportedCodec,
attemptedTargets: new Set(['mpv']),
})
)
).toEqual([
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
alternative(),
]);
});
it('caps results at three after filtering', () => {
const recommendations = recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
activeTarget: InlinePlaybackPlayer.VideoJs,
})
);
expect(recommendations).toHaveLength(3);
expect(recommendations).toEqual([
player(
InlinePlaybackPlayer.Html5,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
]);
});
it('uses one canonical representative per distinct HLS engine family', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
activeTarget: InlinePlaybackPlayer.VideoJs,
managedExternalPlayersAvailable: false,
})
)
).toEqual([
player(
InlinePlaybackPlayer.Html5,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
alternative(),
]);
});
it('does not substitute ArtPlayer when canonical HTML5 is unavailable', () => {
const targetCapabilities = createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).map((capability) =>
capability.kind === 'inline' &&
capability.target === InlinePlaybackPlayer.Html5
? { ...capability, available: false }
: capability
);
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
targetCapabilities,
})
)
).toEqual([
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
]);
});
it('does not substitute ArtPlayer when canonical HTML5 was attempted', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
attemptedTargets: new Set([InlinePlaybackPlayer.Html5]),
})
)
).toEqual([
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
]);
});
it('ignores capability array order when choosing canonical HTML5', () => {
const targetCapabilities = [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
].reverse();
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
targetCapabilities,
})
)
).toEqual([
player(
InlinePlaybackPlayer.Html5,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
]);
});
it('uses canonical Video.js for an ArtPlayer hls.js failure', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
activeTarget: InlinePlaybackPlayer.ArtPlayer,
})
)
).toEqual([
player(
InlinePlaybackPlayer.VideoJs,
PlaybackRecommendationReason.DifferentEngineFamily,
'primary'
),
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
]);
});
it('excludes external players for untransferable DRM', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.DrmOrEncryption,
drm: 'untransferable',
})
)
).toEqual([
player(
InlinePlaybackPlayer.Html5,
PlaybackRecommendationReason.CompatibleDrmPath,
'primary'
),
alternative(),
]);
});
it('excludes external players when the source is not transferable', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.UnsupportedContainer,
externalTransferable: false,
})
)
).toEqual([alternative('primary')]);
});
it('excludes unavailable managed external capabilities', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.BrowserAccessError,
managedExternalPlayersAvailable: false,
})
)
).toEqual([alternative('primary')]);
});
it.each([
{
name: 'missing',
capabilities: createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).filter(
(capability) =>
capability.target !== InlinePlaybackPlayer.VideoJs
),
},
{
name: 'duplicate',
capabilities: [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
{
kind: 'inline' as const,
target: InlinePlaybackPlayer.VideoJs,
available: true,
engineFamily: null,
},
],
},
{
name: 'unavailable',
capabilities: createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Unknown,
managedExternalPlayersAvailable: true,
}),
},
{
name: 'null-family',
capabilities: [
{
kind: 'inline' as const,
target: InlinePlaybackPlayer.VideoJs,
available: true,
engineFamily: null,
},
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).slice(1),
],
},
])(
'fails closed for a $name active inline capability',
({ capabilities }) => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
targetCapabilities: capabilities,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
}
);
it.each([
{
name: 'duplicate MPV availability records',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
{
kind: 'external' as const,
target: 'mpv' as const,
available: false,
},
],
},
{
name: 'duplicate inline records with contradictory family and availability',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
{
kind: 'inline' as const,
target: InlinePlaybackPlayer.Html5,
available: false,
engineFamily: PlaybackEngineFamily.Vhs,
},
],
},
{
name: 'wrong target and kind pairing',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).filter((capability) => capability.target !== 'mpv'),
{
kind: 'inline',
target: 'mpv',
available: true,
engineFamily: PlaybackEngineFamily.HlsJs,
} as unknown as PlaybackTargetCapability,
],
},
{
name: 'missing canonical target record',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).filter(
(capability) =>
capability.target !== InlinePlaybackPlayer.ArtPlayer
),
},
{
name: 'future target record',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
{
kind: 'external',
target: 'future-player',
available: true,
} as unknown as PlaybackTargetCapability,
],
},
{
name: 'future engine family',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).map((capability) =>
capability.kind === 'inline' &&
capability.target === InlinePlaybackPlayer.ArtPlayer
? ({
...capability,
engineFamily: 'future-family',
} as unknown as PlaybackTargetCapability)
: capability
),
},
{
name: 'source kind and engine family mismatch',
sourceKind: PlaybackSourceKind.Hls,
activeTarget: InlinePlaybackPlayer.VideoJs,
capabilities: createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).map((capability) =>
capability.kind === 'inline' &&
capability.target === InlinePlaybackPlayer.VideoJs
? {
...capability,
engineFamily: PlaybackEngineFamily.HlsJs,
}
: capability
),
},
{
name: 'available null-family path',
sourceKind: PlaybackSourceKind.Dash,
activeTarget: InlinePlaybackPlayer.Html5,
capabilities: createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Dash,
managedExternalPlayersAvailable: true,
}).map((capability) =>
capability.kind === 'inline' &&
capability.target === InlinePlaybackPlayer.VideoJs
? { ...capability, available: true }
: capability
),
},
])(
'fails closed for $name in the complete capability context',
({ sourceKind, activeTarget, capabilities }) => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind,
activeTarget,
targetCapabilities: capabilities,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
}
);
it('omits an unavailable alternative when capability validation fails', () => {
const targetCapabilities = [
...createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}),
{
kind: 'external' as const,
target: 'mpv' as const,
available: false,
},
];
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.UnsupportedCodec,
targetCapabilities,
alternativeSourceCount: 0,
})
)
).toEqual([retry(PlaybackRecommendationReason.RetryUnknownFailure)]);
});
it.each([
['null', null],
['undefined', undefined],
['number', 42],
['string', 'invalid-capability'],
['boolean', true],
])('fails closed for a %s capability entry', (_name, entry) => {
const targetCapabilities = untrustedCapabilityMatrix(entry);
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
targetCapabilities,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
});
it('fails closed for a sparse capability array without mutating it', () => {
const targetCapabilities = sparseCapabilityMatrix();
const keysBefore = Object.keys(targetCapabilities);
const lengthBefore = targetCapabilities.length;
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
targetCapabilities,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
expect(Object.keys(targetCapabilities)).toEqual(keysBefore);
expect(targetCapabilities).toHaveLength(lengthBefore);
});
it.each([
PlaybackDiagnosticCode.NetworkError,
PlaybackDiagnosticCode.UnknownPlaybackError,
])('keeps %s total with malformed capabilities', (code) => {
expect(
recommendPlaybackRecovery(
context({
code,
targetCapabilities: untrustedCapabilityMatrix(null),
})
)
).toEqual([
retry(
code === PlaybackDiagnosticCode.NetworkError
? PlaybackRecommendationReason.RetryTransientFailure
: PlaybackRecommendationReason.RetryUnknownFailure
),
alternative(),
]);
});
it('fails closed when the active capability is external', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.UnsupportedCodec,
activeTarget: 'mpv',
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 0.5, 0, -1])(
'omits alternative source when its count is invalid or non-positive: %s',
(alternativeSourceCount) => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.NetworkError,
alternativeSourceCount,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryTransientFailure),
]);
}
);
it('includes alternative source for a positive safe integer count', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.NetworkError,
alternativeSourceCount: Number.MAX_SAFE_INTEGER,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryTransientFailure),
alternative(),
]);
});
it('does not mutate attempted targets or capability inputs', () => {
const attemptedTargets = new Set<PlaybackRecommendationTarget>(['mpv']);
const targetCapabilities = Object.freeze(
createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
}).map((capability) => Object.freeze({ ...capability }))
);
const attemptedBefore = [...attemptedTargets];
const capabilitiesBefore = targetCapabilities.map((capability) => ({
...capability,
}));
const first = recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
attemptedTargets,
targetCapabilities,
})
);
const second = recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
attemptedTargets,
targetCapabilities,
})
);
expect([...attemptedTargets]).toEqual(attemptedBefore);
expect(targetCapabilities).toEqual(capabilitiesBefore);
expect(second).toEqual(first);
expect(second).not.toBe(first);
expect(second[0]).not.toBe(first[0]);
});
it('handles unknown source kinds without throwing', () => {
expect(() =>
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind: PlaybackSourceKind.Unknown,
})
)
).not.toThrow();
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
sourceKind: PlaybackSourceKind.Unknown,
})
)
).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
});
it('handles future diagnostic codes with the safe default', () => {
const unknownDiagnostic = {
...diagnostic(PlaybackDiagnosticCode.UnknownPlaybackError),
code: 'future-playback-error',
} as unknown as PlaybackDiagnostic;
const input = {
...context(),
diagnostic: unknownDiagnostic,
};
expect(() => recommendPlaybackRecovery(input)).not.toThrow();
expect(recommendPlaybackRecovery(input)).toEqual([
retry(PlaybackRecommendationReason.RetryUnknownFailure),
alternative(),
]);
});
});
@@ -0,0 +1,355 @@
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
} from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
PlaybackRecommendationReason,
type PlaybackRecommendation,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
import { getInlinePlaybackEngineFamily } from './playback-target-capabilities';
type WithoutPriority<T> = T extends unknown ? Omit<T, 'priority'> : never;
type PlaybackRecommendationCandidate = WithoutPriority<PlaybackRecommendation>;
type InlineCapability = Extract<
PlaybackTargetCapability,
{ readonly kind: 'inline' }
>;
type AvailableInlineCapability = InlineCapability & {
readonly available: true;
readonly engineFamily: PlaybackEngineFamily;
};
interface RuntimeCapabilityRecord {
readonly target: unknown;
readonly kind: unknown;
readonly available: unknown;
readonly engineFamily?: unknown;
}
type CapabilityIndex = ReadonlyMap<
PlaybackRecommendationTarget,
PlaybackTargetCapability
>;
const CANONICAL_TARGETS: readonly PlaybackRecommendationTarget[] = [
InlinePlaybackPlayer.VideoJs,
InlinePlaybackPlayer.Html5,
InlinePlaybackPlayer.ArtPlayer,
'mpv',
'vlc',
];
export function recommendPlaybackRecovery(
context: PlaybackRecommendationContext
): readonly PlaybackRecommendation[] {
const capabilityIndex = isPlayerOrientedDiagnostic(context.diagnostic.code)
? createCapabilityIndex(context)
: null;
const seenTargets = new Set<PlaybackRecommendationTarget>();
const candidates = buildCandidates(context, capabilityIndex).filter(
isCandidate
);
const filtered = candidates.filter((candidate) => {
if (candidate.action !== 'player') {
return true;
}
if (
candidate.target === context.activeTarget ||
context.attemptedTargets.has(candidate.target) ||
seenTargets.has(candidate.target) ||
!hasAvailableCapability(capabilityIndex, candidate.target)
) {
return false;
}
if (
isExternalTarget(candidate.target) &&
!canTransferExternally(context)
) {
return false;
}
seenTargets.add(candidate.target);
return true;
});
return filtered.slice(0, 3).map((candidate, index) => ({
...candidate,
priority: index === 0 ? 'primary' : 'secondary',
}));
}
function buildCandidates(
context: PlaybackRecommendationContext,
capabilityIndex: CapabilityIndex | null
): readonly (PlaybackRecommendationCandidate | null)[] {
if (
isPlayerOrientedDiagnostic(context.diagnostic.code) &&
capabilityIndex === null
) {
return [retryUnknown(), alternative(context)];
}
switch (context.diagnostic.code) {
case PlaybackDiagnosticCode.NetworkError:
return [retryTransient(), alternative(context)];
case PlaybackDiagnosticCode.BrowserAccessError:
return [
external(
'mpv',
PlaybackRecommendationReason.ExternalBrowserAccess
),
external(
'vlc',
PlaybackRecommendationReason.ExternalBrowserAccess
),
alternative(context),
];
case PlaybackDiagnosticCode.UnsupportedCodec:
case PlaybackDiagnosticCode.UnsupportedContainer:
return [
externalCodecSupport('mpv'),
externalCodecSupport('vlc'),
alternative(context),
];
case PlaybackDiagnosticCode.MediaDecodeError:
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.DifferentEngineFamily
),
externalCodecSupport('mpv'),
externalCodecSupport('vlc'),
alternative(context),
];
case PlaybackDiagnosticCode.DrmOrEncryption:
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.CompatibleDrmPath
),
alternative(context),
external('mpv', PlaybackRecommendationReason.CompatibleDrmPath),
external('vlc', PlaybackRecommendationReason.CompatibleDrmPath),
];
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return [retryUnknown(), alternative(context)];
}
}
function retryTransient(): PlaybackRecommendationCandidate {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryTransientFailure,
};
}
function retryUnknown(): PlaybackRecommendationCandidate {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryUnknownFailure,
};
}
function alternative(
context: PlaybackRecommendationContext
): PlaybackRecommendationCandidate | null {
return !Number.isSafeInteger(context.alternativeSourceCount) ||
context.alternativeSourceCount <= 0
? null
: {
action: 'alternative-source',
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
};
}
function distinctInline(
context: PlaybackRecommendationContext,
capabilityIndex: CapabilityIndex | null,
reason: PlaybackRecommendationCandidate['reason']
): PlaybackRecommendationCandidate | null {
const activeCapability = getActiveInlineCapability(
context,
capabilityIndex
);
if (activeCapability === null) {
return null;
}
const canonicalTarget = getCanonicalDistinctInlineTarget(
activeCapability.engineFamily
);
if (canonicalTarget === null) {
return null;
}
const capability = capabilityIndex?.get(canonicalTarget);
return capability?.kind !== 'inline' ||
!capability.available ||
capability.engineFamily === null ||
capability.engineFamily === activeCapability.engineFamily
? null
: { action: 'player', target: capability.target, reason };
}
function getCanonicalDistinctInlineTarget(
activeFamily: PlaybackEngineFamily
): InlinePlaybackPlayer | null {
if (activeFamily === PlaybackEngineFamily.Vhs) {
return InlinePlaybackPlayer.Html5;
}
return activeFamily === PlaybackEngineFamily.HlsJs
? InlinePlaybackPlayer.VideoJs
: null;
}
function getActiveInlineCapability(
context: PlaybackRecommendationContext,
capabilityIndex: CapabilityIndex | null
): AvailableInlineCapability | null {
const capability = capabilityIndex?.get(context.activeTarget);
return capability?.kind === 'inline' &&
capability.available &&
capability.engineFamily !== null
? (capability as AvailableInlineCapability)
: null;
}
function externalCodecSupport(
target: ExternalPlayerName
): PlaybackRecommendationCandidate {
return external(
target,
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
);
}
function external(
target: ExternalPlayerName,
reason: PlaybackRecommendationCandidate['reason']
): PlaybackRecommendationCandidate {
return { action: 'player', target, reason };
}
function isPlayerOrientedDiagnostic(
code: PlaybackRecommendationContext['diagnostic']['code']
): boolean {
return (
code === PlaybackDiagnosticCode.BrowserAccessError ||
code === PlaybackDiagnosticCode.UnsupportedCodec ||
code === PlaybackDiagnosticCode.UnsupportedContainer ||
code === PlaybackDiagnosticCode.MediaDecodeError ||
code === PlaybackDiagnosticCode.DrmOrEncryption
);
}
function hasAvailableCapability(
capabilityIndex: CapabilityIndex | null,
target: PlaybackRecommendationTarget
): boolean {
return capabilityIndex?.get(target)?.available === true;
}
function createCapabilityIndex(
context: PlaybackRecommendationContext
): CapabilityIndex | null {
const index = new Map<
PlaybackRecommendationTarget,
PlaybackTargetCapability
>();
for (const capability of context.targetCapabilities) {
if (!isValidCapability(context, capability, index)) {
return null;
}
index.set(capability.target, capability);
}
if (index.size !== CANONICAL_TARGETS.length) {
return null;
}
const active = index.get(context.activeTarget);
return active?.kind === 'inline' &&
active.available &&
active.engineFamily !== null
? index
: null;
}
function isValidCapability(
context: PlaybackRecommendationContext,
capability: unknown,
index: CapabilityIndex
): capability is PlaybackTargetCapability {
if (
!hasRequiredCapabilityFields(capability) ||
!isKnownTarget(capability.target) ||
index.has(capability.target) ||
typeof capability.available !== 'boolean'
) {
return false;
}
if (!isInlineTarget(capability.target)) {
return capability.kind === 'external';
}
if (capability.kind !== 'inline' || !('engineFamily' in capability)) {
return false;
}
const expectedFamily = getInlinePlaybackEngineFamily(
context.source.kind,
capability.target
);
return (
capability.engineFamily === expectedFamily &&
!(expectedFamily === null && capability.available)
);
}
function hasRequiredCapabilityFields(
value: unknown
): value is RuntimeCapabilityRecord {
return (
typeof value === 'object' &&
value !== null &&
'target' in value &&
'kind' in value &&
'available' in value
);
}
function isKnownTarget(value: unknown): value is PlaybackRecommendationTarget {
return CANONICAL_TARGETS.includes(value as PlaybackRecommendationTarget);
}
function isInlineTarget(
value: PlaybackRecommendationTarget
): value is InlinePlaybackPlayer {
return !isExternalTarget(value);
}
function isExternalTarget(
target: PlaybackRecommendationTarget
): target is ExternalPlayerName {
return target === 'mpv' || target === 'vlc';
}
function canTransferExternally(
context: PlaybackRecommendationContext
): boolean {
return (
context.source.drm !== 'untransferable' &&
context.source.externalTransferable
);
}
function isCandidate(
candidate: PlaybackRecommendationCandidate | null
): candidate is PlaybackRecommendationCandidate {
return candidate !== null;
}