From aae11613fc3572d0c25abff5947c7cd379792adf Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 20:13:02 +0200 Subject: [PATCH] feat(playback): rank recovery recommendations --- .../2026-08-03-playback-recommendations.md | 32 +- libs/playback/util/src/index.ts | 1 + .../playback-recommendation-policy.spec.ts | 966 ++++++++++++++++++ .../src/lib/playback-recommendation-policy.ts | 355 +++++++ 4 files changed, 1342 insertions(+), 12 deletions(-) create mode 100644 libs/playback/util/src/lib/playback-recommendation-policy.spec.ts create mode 100644 libs/playback/util/src/lib/playback-recommendation-policy.ts diff --git a/docs/superpowers/plans/2026-08-03-playback-recommendations.md b/docs/superpowers/plans/2026-08-03-playback-recommendations.md index f68c95a1d..b437b5c42 100644 --- a/docs/superpowers/plans/2026-08-03-playback-recommendations.md +++ b/docs/superpowers/plans/2026-08-03-playback-recommendations.md @@ -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 diff --git a/libs/playback/util/src/index.ts b/libs/playback/util/src/index.ts index 61efb4a9e..14e312989 100644 --- a/libs/playback/util/src/index.ts +++ b/libs/playback/util/src/index.ts @@ -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'; diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts new file mode 100644 index 000000000..450696f9d --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts @@ -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; + 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(['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(), + ]); + }); +}); diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.ts b/libs/playback/util/src/lib/playback-recommendation-policy.ts new file mode 100644 index 000000000..8e8c7624a --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation-policy.ts @@ -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 extends unknown ? Omit : never; + +type PlaybackRecommendationCandidate = WithoutPriority; + +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(); + 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; +}