diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index ffaf74942..76a6ec298 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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: diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts index d244c323b..413e89105 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts @@ -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; diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts index 3dbad20e6..99d272b11 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts @@ -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, diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts index c44383bab..7f914497b 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts @@ -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 | null { if (!error || typeof error !== 'object') { diff --git a/libs/playback/util/src/lib/playback-recommendation-capabilities.ts b/libs/playback/util/src/lib/playback-recommendation-capabilities.ts new file mode 100644 index 000000000..c9ee6c3ce --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation-capabilities.ts @@ -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 { + const families = new Set(); + 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 +): 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'; +} diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts index adccb11a3..b73a425de 100644 --- a/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts +++ b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts @@ -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({ diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.ts b/libs/playback/util/src/lib/playback-recommendation-policy.ts index 8e8c7624a..dcec6a38a 100644 --- a/libs/playback/util/src/lib/playback-recommendation-policy.ts +++ b/libs/playback/util/src/lib/playback-recommendation-policy.ts @@ -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 extends unknown ? Omit : 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(); 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 { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index 8be1d30c1..f9cc1d0e4 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -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 () => { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 824ced37d..5443d3f77 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -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; }