mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(playback): rank recovery recommendations
This commit is contained in:
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user