fix(playback): preserve distinct recovery fallbacks

This commit is contained in:
4gray committed 2026-08-04 21:27:34 +02:00
1 parent 0dd8c1a57d
commit eb9ae0de18
9 files changed
+325 -110

No files matched your search

+5 -3
View File
@@ -1008,9 +1008,11 @@ player in settings.
unknown stage/failure. Public DASH text-parser codes likewise retain their
exact `TEXT` category/code but keep stage/failure unknown. A failed
`Player.isBrowserSupported()` preflight also stays unknown rather than
claiming container incompatibility; clear DASH still offers configured
external-player actions, while KODIPROP DRM keeps them disabled because
those players never receive its keys.
claiming container incompatibility. It carries only the enumerated,
app-owned `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker — never a
producer recommendation hint or engine payload. Clear DASH still offers
configured external-player actions, while KODIPROP DRM keeps them disabled
because those players never receive its keys.
Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic
with a fixed safe detail string and without starting an engine.
- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer:
@@ -32,6 +32,13 @@ export const PlaybackDiagnosticSource = {
export type PlaybackDiagnosticSource =
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
export const PlaybackRuntimeSupport = {
ShakaBrowserUnsupported: 'shaka-browser-unsupported',
} as const;
export type PlaybackRuntimeSupport =
(typeof PlaybackRuntimeSupport)[keyof typeof PlaybackRuntimeSupport];
export const InlinePlaybackPlayer = {
VideoJs: 'videojs',
Html5: 'html5',
@@ -255,6 +262,7 @@ export interface PlaybackDiagnostic {
readonly player?: InlinePlaybackPlayer;
readonly audioCodecs: readonly string[];
readonly videoCodecs: readonly string[];
readonly runtimeSupport?: PlaybackRuntimeSupport;
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
@@ -5,6 +5,7 @@ import type {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRuntimeSupport,
PlaybackSourceMetadata,
ShakaPlaybackEvidence,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
@@ -200,6 +201,7 @@ export function createPlaybackDiagnostic(options: {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
readonly metadata: PlaybackSourceMetadata;
readonly runtimeSupport?: PlaybackRuntimeSupport;
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
@@ -214,6 +216,7 @@ export function createPlaybackDiagnostic(options: {
code,
source,
metadata,
runtimeSupport,
details,
nativeErrorCode,
nativeErrorMessage,
@@ -234,6 +237,7 @@ export function createPlaybackDiagnostic(options: {
player: metadata.player,
audioCodecs: metadata.audioCodecs,
videoCodecs: metadata.videoCodecs,
runtimeSupport,
details: details || undefined,
nativeErrorCode,
nativeErrorMessage,
@@ -11,6 +11,7 @@ import {
ShakaPlaybackCategory,
ShakaPlaybackDisposition as ShakaDisposition,
ShakaPlaybackFailure,
PlaybackRuntimeSupport,
} from './playback-diagnostics.model';
import { createPlaybackDiagnostic } from './playback-diagnostics.util';
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
@@ -91,6 +92,19 @@ export function createUnsupportedDrmDiagnostic(
});
}
/** Exact app-owned evidence for Shaka's browser-support preflight. */
export function createShakaBrowserUnsupportedDiagnostic(
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnknownPlaybackError,
source: DiagnosticSource.Shaka,
metadata,
runtimeSupport: PlaybackRuntimeSupport.ShakaBrowserUnsupported,
shaka: createShakaPlaybackEvidence(null, ShakaDisposition.Terminal),
});
}
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
export function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
if (!error || typeof error !== 'object') {
@@ -0,0 +1,144 @@
import { InlinePlaybackPlayer } from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
import { getInlinePlaybackEngineFamily } from './playback-target-capabilities';
interface RuntimeCapabilityRecord {
readonly target: unknown;
readonly kind: unknown;
readonly available: unknown;
readonly engineFamily?: unknown;
}
export type PlaybackCapabilityIndex = ReadonlyMap<
PlaybackRecommendationTarget,
PlaybackTargetCapability
>;
const CANONICAL_TARGETS: readonly PlaybackRecommendationTarget[] = [
InlinePlaybackPlayer.VideoJs,
InlinePlaybackPlayer.Html5,
InlinePlaybackPlayer.ArtPlayer,
'mpv',
'vlc',
];
export function createPlaybackCapabilityIndex(
context: PlaybackRecommendationContext
): PlaybackCapabilityIndex | 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;
}
export function hasAvailableCapability(
capabilityIndex: PlaybackCapabilityIndex | null,
target: PlaybackRecommendationTarget
): boolean {
return capabilityIndex?.get(target)?.available === true;
}
export function getAttemptedInlineFamilies(
context: PlaybackRecommendationContext,
capabilityIndex: PlaybackCapabilityIndex | null
): ReadonlySet<PlaybackEngineFamily> {
const families = new Set<PlaybackEngineFamily>();
if (capabilityIndex === null) {
return families;
}
for (const target of context.attemptedTargets) {
const capability = capabilityIndex.get(target);
if (
capability?.kind === 'inline' &&
capability.available &&
capability.engineFamily !== null
) {
families.add(capability.engineFamily);
}
}
return families;
}
export function isInlineFamilyAttempted(
target: PlaybackRecommendationTarget,
capabilityIndex: PlaybackCapabilityIndex | null,
attemptedFamilies: ReadonlySet<PlaybackEngineFamily>
): boolean {
const capability = capabilityIndex?.get(target);
return (
capability?.kind === 'inline' &&
capability.engineFamily !== null &&
attemptedFamilies.has(capability.engineFamily)
);
}
function isValidCapability(
context: PlaybackRecommendationContext,
capability: unknown,
index: PlaybackCapabilityIndex
): 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 value !== 'mpv' && value !== 'vlc';
}
@@ -18,6 +18,7 @@ import { recommendPlaybackRecovery } from './playback-recommendation-policy';
import { createPlaybackTargetCapabilities } from './playback-target-capabilities';
interface ContextOptions {
readonly diagnostic?: PlaybackDiagnostic;
readonly code?: PlaybackDiagnosticCode;
readonly sourceKind?: PlaybackSourceKind;
readonly activeTarget?: PlaybackRecommendationTarget;
@@ -43,9 +44,11 @@ function diagnostic(code: PlaybackDiagnosticCode): PlaybackDiagnostic {
function context(options: ContextOptions = {}): PlaybackRecommendationContext {
const sourceKind = options.sourceKind ?? PlaybackSourceKind.Hls;
return {
diagnostic: diagnostic(
options.code ?? PlaybackDiagnosticCode.UnknownPlaybackError
),
diagnostic:
options.diagnostic ??
diagnostic(
options.code ?? PlaybackDiagnosticCode.UnknownPlaybackError
),
activeTarget: options.activeTarget ?? InlinePlaybackPlayer.VideoJs,
attemptedTargets: options.attemptedTargets ?? new Set(),
targetCapabilities:
@@ -65,6 +68,16 @@ function context(options: ContextOptions = {}): PlaybackRecommendationContext {
};
}
function unsupportedShakaBrowserDiagnostic(): PlaybackDiagnostic {
return {
...diagnostic(PlaybackDiagnosticCode.UnknownPlaybackError),
source: PlaybackDiagnosticSource.Shaka,
container: 'mpd',
mimeType: 'application/dash+xml',
runtimeSupport: 'shaka-browser-unsupported',
};
}
function retry(
reason: PlaybackRecommendationReason,
priority: PlaybackRecommendationPriority = 'primary'
@@ -450,6 +463,74 @@ describe('recommendPlaybackRecovery', () => {
]);
});
it('excludes an inline family already attempted through a sibling target', () => {
expect(
recommendPlaybackRecovery(
context({
code: PlaybackDiagnosticCode.MediaDecodeError,
activeTarget: InlinePlaybackPlayer.VideoJs,
attemptedTargets: new Set([InlinePlaybackPlayer.ArtPlayer]),
})
)
).toEqual([
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
]);
});
it('keeps managed external recovery for exact clear DASH browser-support evidence', () => {
expect(
recommendPlaybackRecovery(
context({
diagnostic: unsupportedShakaBrowserDiagnostic(),
sourceKind: PlaybackSourceKind.Dash,
activeTarget: InlinePlaybackPlayer.Html5,
})
)
).toEqual([
player(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'primary'
),
player(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(),
]);
});
it.each([
{
name: 'PWA runtime',
options: { managedExternalPlayersAvailable: false },
},
{
name: 'untransferable ClearKey DRM',
options: { drm: 'untransferable' as const },
},
])('suppresses DASH browser-support fallback in $name', ({ options }) => {
expect(
recommendPlaybackRecovery(
context({
diagnostic: unsupportedShakaBrowserDiagnostic(),
sourceKind: PlaybackSourceKind.Dash,
activeTarget: InlinePlaybackPlayer.Html5,
...options,
})
)
).toEqual([alternative('primary')]);
});
it('ignores capability array order when choosing canonical HTML5', () => {
const targetCapabilities = [
...createPlaybackTargetCapabilities({
@@ -2,16 +2,25 @@ import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRuntimeSupport,
} from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
PlaybackRecommendationReason,
PlaybackSourceKind,
type PlaybackRecommendation,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
import { getInlinePlaybackEngineFamily } from './playback-target-capabilities';
import {
createPlaybackCapabilityIndex,
getAttemptedInlineFamilies,
hasAvailableCapability,
isInlineFamilyAttempted,
type PlaybackCapabilityIndex,
} from './playback-recommendation-capabilities';
type WithoutPriority<T> = T extends unknown ? Omit<T, 'priority'> : never;
@@ -27,32 +36,16 @@ type AvailableInlineCapability = InlineCapability & {
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)
const capabilityIndex = hasPlayerRecoveryEvidence(context)
? createPlaybackCapabilityIndex(context)
: null;
const attemptedInlineFamilies = getAttemptedInlineFamilies(
context,
capabilityIndex
);
const seenTargets = new Set<PlaybackRecommendationTarget>();
const candidates = buildCandidates(context, capabilityIndex).filter(
isCandidate
@@ -69,6 +62,15 @@ export function recommendPlaybackRecovery(
) {
return false;
}
if (
isInlineFamilyAttempted(
candidate.target,
capabilityIndex,
attemptedInlineFamilies
)
) {
return false;
}
if (
isExternalTarget(candidate.target) &&
!canTransferExternally(context)
@@ -87,15 +89,20 @@ export function recommendPlaybackRecovery(
function buildCandidates(
context: PlaybackRecommendationContext,
capabilityIndex: CapabilityIndex | null
capabilityIndex: PlaybackCapabilityIndex | null
): readonly (PlaybackRecommendationCandidate | null)[] {
if (
isPlayerOrientedDiagnostic(context.diagnostic.code) &&
capabilityIndex === null
) {
if (hasPlayerRecoveryEvidence(context) && capabilityIndex === null) {
return [retryUnknown(), alternative(context)];
}
if (isShakaBrowserUnsupported(context)) {
return [
externalCodecSupport('mpv'),
externalCodecSupport('vlc'),
alternative(context),
];
}
switch (context.diagnostic.code) {
case PlaybackDiagnosticCode.NetworkError:
return [retryTransient(), alternative(context)];
@@ -174,7 +181,7 @@ function alternative(
function distinctInline(
context: PlaybackRecommendationContext,
capabilityIndex: CapabilityIndex | null,
capabilityIndex: PlaybackCapabilityIndex | null,
reason: PlaybackRecommendationCandidate['reason']
): PlaybackRecommendationCandidate | null {
const activeCapability = getActiveInlineCapability(
@@ -213,7 +220,7 @@ function getCanonicalDistinctInlineTarget(
function getActiveInlineCapability(
context: PlaybackRecommendationContext,
capabilityIndex: CapabilityIndex | null
capabilityIndex: PlaybackCapabilityIndex | null
): AvailableInlineCapability | null {
const capability = capabilityIndex?.get(context.activeTarget);
return capability?.kind === 'inline' &&
@@ -251,88 +258,28 @@ function isPlayerOrientedDiagnostic(
);
}
function hasAvailableCapability(
capabilityIndex: CapabilityIndex | null,
target: PlaybackRecommendationTarget
): boolean {
return capabilityIndex?.get(target)?.available === true;
}
function createCapabilityIndex(
function hasPlayerRecoveryEvidence(
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
);
): boolean {
return (
capability.engineFamily === expectedFamily &&
!(expectedFamily === null && capability.available)
isPlayerOrientedDiagnostic(context.diagnostic.code) ||
isShakaBrowserUnsupported(context)
);
}
function hasRequiredCapabilityFields(
value: unknown
): value is RuntimeCapabilityRecord {
function isShakaBrowserUnsupported(
context: PlaybackRecommendationContext
): boolean {
return (
typeof value === 'object' &&
value !== null &&
'target' in value &&
'kind' in value &&
'available' in value
context.diagnostic.code ===
PlaybackDiagnosticCode.UnknownPlaybackError &&
context.diagnostic.source === PlaybackDiagnosticSource.Shaka &&
context.diagnostic.runtimeSupport ===
PlaybackRuntimeSupport.ShakaBrowserUnsupported &&
context.source.kind === PlaybackSourceKind.Dash
);
}
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 {
@@ -398,6 +398,7 @@ describe('ShakaVideoSession', () => {
});
expect(JSON.stringify(issues[0])).not.toContain(secret);
expect(JSON.stringify(issues[0])).not.toContain('provider.example');
expect(issues[0].runtimeSupport).toBeUndefined();
});
it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => {
@@ -508,6 +509,10 @@ describe('ShakaVideoSession', () => {
stage: 'unknown',
failure: 'unknown',
});
expect(issues[0]).toHaveProperty(
'runtimeSupport',
'shaka-browser-unsupported'
);
});
it('emits the same unknown diagnostic when browser support fails with DRM', async () => {
@@ -526,6 +531,10 @@ describe('ShakaVideoSession', () => {
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0]).toHaveProperty(
'runtimeSupport',
'shaka-browser-unsupported'
);
});
it('destroy tears down the engine and blocks later starts', async () => {
@@ -4,6 +4,7 @@ import {
asShakaError,
classifyShakaPlaybackIssue,
createPlaybackSourceMetadata,
createShakaBrowserUnsupportedDiagnostic,
createUnsupportedDrmDiagnostic,
getShakaErrorEventDisposition,
isShakaLoadInterrupted,
@@ -139,7 +140,12 @@ export class ShakaVideoSession {
}
if (!module.Player.isBrowserSupported()) {
this.emitTerminalIfCurrent(generation, null, url);
this.emitIfCurrent(
generation,
createShakaBrowserUnsupportedDiagnostic(
this.createMetadata(url)
)
);
return;
}