From d1d75e130c83f785b51baee259c4081f5585a07a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 19:36:47 +0200 Subject: [PATCH] feat(playback): define recovery recommendation contracts --- .../2026-08-03-playback-recommendations.md | 159 +++++---- ...6-08-03-playback-recommendations-design.md | 14 +- libs/playback/util/src/index.ts | 3 + .../src/lib/playback-recommendation.model.ts | 95 ++++++ .../util/src/lib/playback-session-key.spec.ts | 196 +++++++++++ .../util/src/lib/playback-session-key.ts | 38 +++ .../lib/playback-target-capabilities.spec.ts | 304 ++++++++++++++++++ .../src/lib/playback-target-capabilities.ts | 133 ++++++++ 8 files changed, 879 insertions(+), 63 deletions(-) create mode 100644 libs/playback/util/src/lib/playback-recommendation.model.ts create mode 100644 libs/playback/util/src/lib/playback-session-key.spec.ts create mode 100644 libs/playback/util/src/lib/playback-session-key.ts create mode 100644 libs/playback/util/src/lib/playback-target-capabilities.spec.ts create mode 100644 libs/playback/util/src/lib/playback-target-capabilities.ts diff --git a/docs/superpowers/plans/2026-08-03-playback-recommendations.md b/docs/superpowers/plans/2026-08-03-playback-recommendations.md index 8d817ecd2..f68c95a1d 100644 --- a/docs/superpowers/plans/2026-08-03-playback-recommendations.md +++ b/docs/superpowers/plans/2026-08-03-playback-recommendations.md @@ -396,9 +396,14 @@ it.each([ }); ``` -Also assert that HLS diagnostics resolve from `hls` and VHS `.m3u8`, DASH from -Shaka and VHS `.mpd`, MPEG-TS from `mpegts`, native from `native`, and unknown -or contradictory evidence stays `unknown`. +Also assert that engine-specific `hls`, `mpegts`, `shaka`, and `native` +diagnostic sources are authoritative even when generic metadata disagrees. +For generic `source` and multi-format `vhs`, accept only normalized exact base +MIME/container evidence: `m3u`/`m3u8` and the established HLS MIME aliases, +or `mpd` and `application/dash+xml`. Cover parameters/casing, MIME-only DASH +and HLS, symmetric container/MIME contradictions, malformed MIME substrings, +and insufficient evidence. Contradictory or insufficient evidence stays +`unknown`. Run: @@ -524,7 +529,8 @@ export function createPlaybackTargetCapabilities(options: { }): readonly PlaybackTargetCapability[]; ``` -Use this fail-closed mapping: +Use this fail-closed mapping. Engine-specific sources identify the diagnostic +boundary that emitted the failure and therefore outrank generic metadata: ```typescript switch (diagnostic.source) { @@ -536,13 +542,7 @@ switch (diagnostic.source) { return PlaybackSourceKind.Dash; case PlaybackDiagnosticSource.Source: case PlaybackDiagnosticSource.Vhs: - return diagnostic.container === 'mpd' - ? PlaybackSourceKind.Dash - : diagnostic.container === 'm3u' || - diagnostic.container === 'm3u8' || - diagnostic.mimeType?.toLowerCase().includes('mpegurl') - ? PlaybackSourceKind.Hls - : PlaybackSourceKind.Unknown; + return resolveSourceOrVhsKind(diagnostic); case PlaybackDiagnosticSource.Native: return PlaybackSourceKind.Native; default: @@ -550,6 +550,15 @@ switch (diagnostic.source) { } ``` +`resolveSourceOrVhsKind` normalizes MIME to its trimmed, lowercased base +value with parameters stripped. It recognizes exact +`application/vnd.apple.mpegurl`, `application/x-mpegurl`, and the repository's +established `audio/x-mpegurl` alias as HLS, and exact +`application/dash+xml` as DASH. Gather container and MIME evidence separately; +if both are recognized and disagree, return `Unknown`. Otherwise return the +single recognized kind, the shared kind when both agree, or `Unknown`. Never +use substring MIME matching. + Return inline capabilities in canonical order `videojs`, `html5`, `artplayer` and external capabilities in `mpv`, `vlc` order. Video.js is unavailable for DASH recommendations; the other matrix rows use the engine families asserted @@ -557,15 +566,19 @@ in Step 1. - [ ] **Step 4: Drive and implement collision-safe session keys** -Write tests proving that a source change does not affect a key, while channel, -movie, or episode identity does. Include `:` and `|` in identifiers to prove -parts cannot collide. +Write tests proving callers can reuse one host-owned canonical logical identity +across different provider copies and source URLs, while changing channel, +movie, or episode identity changes the key. Include `:` and `|` in identifiers +to prove parts cannot collide, and assert the module does not expose an adapter +that guesses identity from provider-scoped playback metadata. Create `playback-session-key.ts`: ```typescript -import type { PlayerContentInfo } from '@iptvnator/shared/interfaces'; - +/** + * Host-owned canonical logical content identity. Source and content IDs must + * not come from the currently selected provider copy or playback URL. + */ export type PlaybackSessionIdentity = | { readonly kind: 'live'; @@ -600,29 +613,12 @@ export function createPlaybackSessionKey( return parts.map((part) => `${part.length}:${part}`).join('|'); } - -export function createPlaybackSessionKeyFromContentInfo( - info: PlayerContentInfo -): string { - return createPlaybackSessionKey( - info.contentType === 'episode' - ? { - kind: 'episode', - sourceId: info.playlistId, - contentId: info.contentXtreamId, - seriesId: info.seriesXtreamId, - seasonNumber: info.seasonNumber, - episodeNumber: info.episodeNumber, - } - : { - kind: 'vod', - sourceId: info.playlistId, - contentId: info.contentXtreamId, - } - ); -} ``` +Do not add a `PlayerContentInfo` adapter: multi-source resolution replaces its +playlist/content IDs with the selected provider copy, so it cannot represent a +stable recovery session. + Run: ```bash @@ -984,6 +980,25 @@ git commit -m "feat(playback): track session recovery attempts" - Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts` - Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts` - Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.html` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` - [ ] **Step 1: Add failing host identity tests** @@ -995,8 +1010,11 @@ Prove these invariants in the closest existing specs: - Stalker live: current playlist `_id` + normalized selected channel ID. - Unified live: active item playlist ID + `uid`; timeshift URL changes keep the key. -- Portal inline VOD/episode: `PlayerContentInfo`; alternative source URL changes - keep the key, while movie or episode identity changes it. +- Portal inline VOD/episode: the Xtream/Stalker route or series host derives a + canonical key from its original route/catalog identity and passes it through + unchanged. Replacing playback with an alternative provider copy changes its + URL and provider-scoped `contentInfo` but keeps the key; selecting a + different original movie or episode changes it. - Transfer contract: M3U preserves the resolved request URL plus active-channel User-Agent/Referer/Origin; Xtream, Stalker, unified live, and portal inline pass the exact `ResolvedPortalPlayback` (including headers and content info) @@ -1011,20 +1029,24 @@ pnpm nx test portal-xtream-feature -- --runTestsByPath libs/portal/xtream/featur pnpm nx test portal-stalker-feature -- --runTestsByPath libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts --runInBand pnpm nx test portal-shared-ui -- --runTestsByPath libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts --runInBand pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts --runInBand +pnpm nx test portal-xtream-feature -- --runTestsByPath libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts --runInBand +pnpm nx test portal-stalker-feature -- --runTestsByPath libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts --runInBand ``` Expected: FAIL because neither the required input nor the host bindings exist. -- [ ] **Step 2: Make content identity a required WebPlayerView input** +- [ ] **Step 2: Make content identity required through the player chain** Add this input to `WebPlayerViewComponent`; Task 7 will consume it, while this -task first makes every current caller compile with a stable key: +task first makes every current caller compile with a stable key. Add the same +required input to `PortalInlinePlayerComponent` and `VodDetailsComponent`, +which only thread the host-owned value to their nested player: ```typescript readonly playbackSessionKey = input.required(); ``` -Update every `StubWebPlayerViewComponent` with the same required input: +Update every stub for these three components with the same required input: ```typescript readonly playbackSessionKey = input.required(); @@ -1060,41 +1082,58 @@ Add that binding to each existing component tag without deleting or changing any of its other input/output bindings. Do not include the current stream/catch-up URL in these keys. -- [ ] **Step 4: Derive portal inline keys from content metadata** +- [ ] **Step 4: Derive VOD and episode keys in their owning hosts** -In `PortalInlinePlayerComponent`, add: +Xtream VOD derives its key from the current route playlist and original +`selectedVodId`, not `inlinePlayback().contentInfo`. Xtream series derives an +episode key from the route playlist/series plus the host's active original +episode, season, and episode coordinates. The shapes are: ```typescript -readonly playbackSessionKey = computed(() => { - const info = this.playback()?.contentInfo; - if (info) { - return createPlaybackSessionKeyFromContentInfo(info); - } +readonly vodPlaybackSessionKey = computed(() => + createPlaybackSessionKey({ + kind: 'vod', + sourceId: this.xtreamStore.currentPlaylist()?.id ?? '', + contentId: this.selectedVodId(), + }) +); - const playback = this.playback(); +readonly episodePlaybackSessionKey = computed(() => { + const originalEpisode = this.playback.inlineEpisodeState()?.episode; return createPlaybackSessionKey({ - kind: playback?.isLive ? 'live' : 'vod', - sourceId: 'portal-inline', - contentId: playback?.title?.trim() || 'unknown-content', + kind: 'episode', + sourceId: this.xtreamStore.currentPlaylist()?.id ?? '', + contentId: originalEpisode?.id ?? '', + seriesId: this.routeParams().serialId ?? '', + seasonNumber: originalEpisode?.season, + episodeNumber: originalEpisode?.episode_num, }); }); ``` -The fallback is fail-safe for legacy/test playback objects; production Xtream -and Stalker VOD/episode flows must be covered by tests proving `contentInfo` is -present. Bind this key to the nested WebPlayerView. Do not use `streamUrl` as a -fallback because alternative sources must retain the session. +Use the equivalent original catalog/route item and active episode identity in +Stalker VOD and series hosts. Thread the required key through any +`StalkerInlineDetailComponent`/`VodDetailsComponent` intermediary, bind it to +`PortalInlinePlayerComponent`, and have that component pass the exact input to +its nested `WebPlayerViewComponent`. + +Do not derive or fall back from `playback.contentInfo`, `streamUrl`, or title. +Alternative-source resolution intentionally rewrites `contentInfo.playlistId` +and `contentXtreamId` for the selected provider copy; those fields remain +playback/resume metadata and are not recovery-session identity. Host specs must +replace the full playback payload with such an alternative copy and prove the +required key is unchanged. - [ ] **Step 5: Verify every required binding and host project** Run: ```bash -rg -l "; + readonly targetCapabilities: readonly PlaybackTargetCapability[]; + readonly source: PlaybackRecommendationSourceContext; + readonly alternativeSourceCount: number; +} + +export type PlaybackRecommendation = + | { + readonly action: 'retry'; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + } + | { + readonly action: 'alternative-source'; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + } + | { + readonly action: 'player'; + readonly target: PlaybackRecommendationTarget; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + }; diff --git a/libs/playback/util/src/lib/playback-session-key.spec.ts b/libs/playback/util/src/lib/playback-session-key.spec.ts new file mode 100644 index 000000000..61426380c --- /dev/null +++ b/libs/playback/util/src/lib/playback-session-key.spec.ts @@ -0,0 +1,196 @@ +import * as playbackSessionKey from './playback-session-key'; +import type { PlaybackSessionIdentity } from './playback-session-key'; + +const { createPlaybackSessionKey } = playbackSessionKey; + +describe('playback session key', () => { + it('length-prefixes every identity part', () => { + expect( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'list:west|hd', + contentId: 'ep:4|finale', + seriesId: 'series|2:remaster', + seasonNumber: 3, + episodeNumber: 4, + }) + ).toBe( + '7:episode|12:list:west|hd|11:ep:4|finale|17:series|2:remaster|1:3|1:4' + ); + }); + + it.each([ + [ + { kind: 'live', sourceId: 'a', contentId: 'b|c' }, + { kind: 'live', sourceId: 'a|b', contentId: 'c' }, + ], + [ + { kind: 'vod', sourceId: 'a:1', contentId: '2' }, + { kind: 'vod', sourceId: 'a', contentId: '1:2' }, + ], + [ + { + kind: 'episode', + sourceId: 'source', + contentId: 'content', + seriesId: 'series:1|2', + seasonNumber: 3, + episodeNumber: 4, + }, + { + kind: 'episode', + sourceId: 'source', + contentId: 'content', + seriesId: 'series', + seasonNumber: 1, + episodeNumber: 2, + }, + ], + ] as const)( + 'does not collide delimiter-bearing identities %#', + (left, right) => { + expect(createPlaybackSessionKey(left)).not.toBe( + createPlaybackSessionKey(right) + ); + } + ); + + it('reuses canonical movie identity across provider copies and source URLs', () => { + const logicalIdentity: PlaybackSessionIdentity = { + kind: 'vod', + sourceId: 'catalog:featured', + contentId: 'movie:42', + }; + const originalCopy = { + providerId: 'provider:one', + sourceUrl: 'https://one.example/movie/42', + logicalIdentity, + }; + const alternativeCopy = { + providerId: 'provider|two', + sourceUrl: 'https://two.example/alternate/9001', + logicalIdentity, + }; + + expect(createPlaybackSessionKey(originalCopy.logicalIdentity)).toBe( + createPlaybackSessionKey(alternativeCopy.logicalIdentity) + ); + }); + + it.each([ + [{ kind: 'live', sourceId: 'playlist:2', contentId: 42 }], + [{ kind: 'live', sourceId: 'playlist:1', contentId: 43 }], + [{ kind: 'vod', sourceId: 'playlist:1', contentId: 42 }], + ] as const)( + 'changes when a live channel identity changes: %o', + (changedIdentity) => { + const original = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist:1', + contentId: 42, + }); + + expect(createPlaybackSessionKey(changedIdentity)).not.toBe( + original + ); + } + ); + + it.each([ + [{ kind: 'vod', sourceId: 'playlist:2', contentId: 42 }], + [{ kind: 'vod', sourceId: 'playlist:1', contentId: 43 }], + ] as const)( + 'changes when a logical movie identity changes: %o', + (changedIdentity) => { + const original = createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist:1', + contentId: 42, + }); + + expect(createPlaybackSessionKey(changedIdentity)).not.toBe( + original + ); + } + ); + + it.each([ + [ + 'source', + { + kind: 'episode', + sourceId: 'playlist:2', + contentId: 42, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 4, + }, + ], + [ + 'content', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 43, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 4, + }, + ], + [ + 'series', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 8, + seasonNumber: 3, + episodeNumber: 4, + }, + ], + [ + 'season', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 7, + seasonNumber: 4, + episodeNumber: 4, + }, + ], + [ + 'episode', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 5, + }, + ], + ] as const)( + 'changes when the logical episode %s identity changes', + (_field, changedIdentity) => { + const original = createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 4, + }); + + expect(createPlaybackSessionKey(changedIdentity)).not.toBe( + original + ); + } + ); + + it('does not expose a provider-scoped content-info adapter', () => { + expect(playbackSessionKey).not.toHaveProperty( + 'createPlaybackSessionKeyFromContentInfo' + ); + }); +}); diff --git a/libs/playback/util/src/lib/playback-session-key.ts b/libs/playback/util/src/lib/playback-session-key.ts new file mode 100644 index 000000000..0bd7d4f50 --- /dev/null +++ b/libs/playback/util/src/lib/playback-session-key.ts @@ -0,0 +1,38 @@ +/** + * Host-owned canonical logical content identity. Source and content IDs must + * not come from the currently selected provider copy or playback URL. + */ +export type PlaybackSessionIdentity = + | { + readonly kind: 'live'; + readonly sourceId: string; + readonly contentId: string | number; + } + | { + readonly kind: 'vod'; + readonly sourceId: string; + readonly contentId: string | number; + } + | { + readonly kind: 'episode'; + readonly sourceId: string; + readonly contentId: string | number; + readonly seriesId?: string | number; + readonly seasonNumber?: number; + readonly episodeNumber?: number; + }; + +export function createPlaybackSessionKey( + identity: PlaybackSessionIdentity +): string { + const parts = [ + identity.kind, + identity.sourceId, + String(identity.contentId), + identity.kind === 'episode' ? String(identity.seriesId ?? '') : '', + identity.kind === 'episode' ? String(identity.seasonNumber ?? '') : '', + identity.kind === 'episode' ? String(identity.episodeNumber ?? '') : '', + ]; + + return parts.map((part) => `${part.length}:${part}`).join('|'); +} diff --git a/libs/playback/util/src/lib/playback-target-capabilities.spec.ts b/libs/playback/util/src/lib/playback-target-capabilities.spec.ts new file mode 100644 index 000000000..c7aae1445 --- /dev/null +++ b/libs/playback/util/src/lib/playback-target-capabilities.spec.ts @@ -0,0 +1,304 @@ +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + PlaybackSourceKind, +} from './playback-recommendation.model'; +import { + createPlaybackTargetCapabilities, + getInlinePlaybackEngineFamily, + resolvePlaybackSourceKind, +} from './playback-target-capabilities'; + +function diagnostic( + overrides: Partial +): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.UnknownPlaybackError, + source: PlaybackDiagnosticSource.Source, + sourceUrl: 'https://example.com/stream', + container: '', + audioCodecs: [], + videoCodecs: [], + externalFallbackRecommended: false, + ...overrides, + }; +} + +describe('playback target capabilities', () => { + it.each([ + [ + PlaybackSourceKind.Hls, + InlinePlaybackPlayer.VideoJs, + PlaybackEngineFamily.Vhs, + ], + [ + PlaybackSourceKind.Hls, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.HlsJs, + ], + [ + PlaybackSourceKind.Hls, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.HlsJs, + ], + [ + PlaybackSourceKind.MpegTs, + InlinePlaybackPlayer.VideoJs, + PlaybackEngineFamily.MpegTsJs, + ], + [ + PlaybackSourceKind.MpegTs, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.MpegTsJs, + ], + [ + PlaybackSourceKind.MpegTs, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.MpegTsJs, + ], + [PlaybackSourceKind.Dash, InlinePlaybackPlayer.VideoJs, null], + [ + PlaybackSourceKind.Dash, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.Shaka, + ], + [ + PlaybackSourceKind.Dash, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.Shaka, + ], + [ + PlaybackSourceKind.Native, + InlinePlaybackPlayer.VideoJs, + PlaybackEngineFamily.NativeMedia, + ], + [ + PlaybackSourceKind.Native, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.NativeMedia, + ], + [ + PlaybackSourceKind.Native, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.NativeMedia, + ], + ] as const)('maps %s on %s to %s', (sourceKind, target, expectedFamily) => { + expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe( + expectedFamily + ); + }); + + it.each([ + [PlaybackDiagnosticSource.Hls, {}, PlaybackSourceKind.Hls], + [ + PlaybackDiagnosticSource.Vhs, + { container: 'm3u8' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Source, + { container: 'm3u' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Vhs, + { + mimeType: ' Application/Vnd.Apple.MPEGURL; charset=UTF-8 ', + }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Source, + { mimeType: 'application/x-mpegurl' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Vhs, + { mimeType: 'audio/x-mpegurl' }, + PlaybackSourceKind.Hls, + ], + [PlaybackDiagnosticSource.Shaka, {}, PlaybackSourceKind.Dash], + [ + PlaybackDiagnosticSource.Source, + { container: 'mpd' }, + PlaybackSourceKind.Dash, + ], + [ + PlaybackDiagnosticSource.Vhs, + { container: 'mpd' }, + PlaybackSourceKind.Dash, + ], + [ + PlaybackDiagnosticSource.Source, + { mimeType: ' Application/Dash+XML; profile=live ' }, + PlaybackSourceKind.Dash, + ], + [PlaybackDiagnosticSource.MpegTs, {}, PlaybackSourceKind.MpegTs], + [PlaybackDiagnosticSource.Native, {}, PlaybackSourceKind.Native], + ] as const)( + 'resolves %s diagnostics with %o as %s', + (source, metadata, expectedKind) => { + expect( + resolvePlaybackSourceKind(diagnostic({ source, ...metadata })) + ).toBe(expectedKind); + } + ); + + it.each([ + diagnostic({ source: PlaybackDiagnosticSource.Source }), + diagnostic({ + source: PlaybackDiagnosticSource.Source, + container: 'mp4', + mimeType: 'video/mp4', + }), + diagnostic({ + source: PlaybackDiagnosticSource.Vhs, + container: 'mpd', + mimeType: 'application/vnd.apple.mpegurl', + }), + diagnostic({ + source: PlaybackDiagnosticSource.Source, + container: 'm3u8', + mimeType: 'application/dash+xml', + }), + diagnostic({ + source: PlaybackDiagnosticSource.Vhs, + mimeType: 'application/not-mpegurl', + }), + ])('keeps insufficient or contradictory evidence unknown', (issue) => { + expect(resolvePlaybackSourceKind(issue)).toBe( + PlaybackSourceKind.Unknown + ); + }); + + it.each([ + [ + PlaybackDiagnosticSource.Hls, + { container: 'mpd', mimeType: 'application/dash+xml' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.MpegTs, + { container: 'mpd', mimeType: 'application/dash+xml' }, + PlaybackSourceKind.MpegTs, + ], + [ + PlaybackDiagnosticSource.Shaka, + { + container: 'm3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, + PlaybackSourceKind.Dash, + ], + [ + PlaybackDiagnosticSource.Native, + { + container: 'm3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, + PlaybackSourceKind.Native, + ], + ] as const)( + 'treats engine-specific %s source evidence as authoritative', + (source, metadata, expectedKind) => { + expect( + resolvePlaybackSourceKind(diagnostic({ source, ...metadata })) + ).toBe(expectedKind); + } + ); + + it('creates capabilities in canonical order', () => { + expect( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }) + ).toEqual([ + { + kind: 'inline', + target: InlinePlaybackPlayer.VideoJs, + available: true, + engineFamily: PlaybackEngineFamily.Vhs, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.Html5, + available: true, + engineFamily: PlaybackEngineFamily.HlsJs, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.ArtPlayer, + available: true, + engineFamily: PlaybackEngineFamily.HlsJs, + }, + { kind: 'external', target: 'mpv', available: true }, + { kind: 'external', target: 'vlc', available: true }, + ]); + }); + + it('marks Video.js unavailable for DASH and mirrors external availability', () => { + expect( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Dash, + managedExternalPlayersAvailable: false, + }) + ).toEqual([ + { + kind: 'inline', + target: InlinePlaybackPlayer.VideoJs, + available: false, + engineFamily: null, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.Html5, + available: true, + engineFamily: PlaybackEngineFamily.Shaka, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.ArtPlayer, + available: true, + engineFamily: PlaybackEngineFamily.Shaka, + }, + { kind: 'external', target: 'mpv', available: false }, + { kind: 'external', target: 'vlc', available: false }, + ]); + }); + + it('fails closed for unknown inline source capabilities', () => { + expect( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Unknown, + managedExternalPlayersAvailable: true, + }) + ).toEqual([ + { + kind: 'inline', + target: InlinePlaybackPlayer.VideoJs, + available: false, + engineFamily: null, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.Html5, + available: false, + engineFamily: null, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.ArtPlayer, + available: false, + engineFamily: null, + }, + { kind: 'external', target: 'mpv', available: true }, + { kind: 'external', target: 'vlc', available: true }, + ]); + }); +}); diff --git a/libs/playback/util/src/lib/playback-target-capabilities.ts b/libs/playback/util/src/lib/playback-target-capabilities.ts new file mode 100644 index 000000000..9ed48d006 --- /dev/null +++ b/libs/playback/util/src/lib/playback-target-capabilities.ts @@ -0,0 +1,133 @@ +import { + InlinePlaybackPlayer, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + PlaybackSourceKind, + type PlaybackTargetCapability, +} from './playback-recommendation.model'; + +const HLS_MIME_TYPES = new Set([ + 'application/vnd.apple.mpegurl', + 'application/x-mpegurl', + 'audio/x-mpegurl', +]); +const DASH_MIME_TYPE = 'application/dash+xml'; + +export function resolvePlaybackSourceKind( + diagnostic: PlaybackDiagnostic +): PlaybackSourceKind { + switch (diagnostic.source) { + // Engine-specific sources identify the boundary that emitted the + // failure, so generic container/MIME metadata cannot override them. + case PlaybackDiagnosticSource.Hls: + return PlaybackSourceKind.Hls; + case PlaybackDiagnosticSource.MpegTs: + return PlaybackSourceKind.MpegTs; + case PlaybackDiagnosticSource.Shaka: + return PlaybackSourceKind.Dash; + case PlaybackDiagnosticSource.Source: + case PlaybackDiagnosticSource.Vhs: + return resolveSourceOrVhsKind(diagnostic); + case PlaybackDiagnosticSource.Native: + return PlaybackSourceKind.Native; + default: + return PlaybackSourceKind.Unknown; + } +} + +export function getInlinePlaybackEngineFamily( + sourceKind: PlaybackSourceKind, + target: InlinePlaybackPlayer +): PlaybackEngineFamily | null { + switch (sourceKind) { + case PlaybackSourceKind.Hls: + return target === InlinePlaybackPlayer.VideoJs + ? PlaybackEngineFamily.Vhs + : PlaybackEngineFamily.HlsJs; + case PlaybackSourceKind.MpegTs: + return PlaybackEngineFamily.MpegTsJs; + case PlaybackSourceKind.Dash: + return target === InlinePlaybackPlayer.VideoJs + ? null + : PlaybackEngineFamily.Shaka; + case PlaybackSourceKind.Native: + return PlaybackEngineFamily.NativeMedia; + case PlaybackSourceKind.Unknown: + default: + return null; + } +} + +export function createPlaybackTargetCapabilities(options: { + readonly sourceKind: PlaybackSourceKind; + readonly managedExternalPlayersAvailable: boolean; +}): readonly PlaybackTargetCapability[] { + const inlineTargets = [ + InlinePlaybackPlayer.VideoJs, + InlinePlaybackPlayer.Html5, + InlinePlaybackPlayer.ArtPlayer, + ] as const; + const inlineCapabilities = inlineTargets.map((target) => { + const engineFamily = getInlinePlaybackEngineFamily( + options.sourceKind, + target + ); + return { + kind: 'inline' as const, + target, + available: engineFamily !== null, + engineFamily, + }; + }); + + return [ + ...inlineCapabilities, + { + kind: 'external', + target: 'mpv', + available: options.managedExternalPlayersAvailable, + }, + { + kind: 'external', + target: 'vlc', + available: options.managedExternalPlayersAvailable, + }, + ]; +} + +function resolveSourceOrVhsKind( + diagnostic: PlaybackDiagnostic +): PlaybackSourceKind { + const containerKind = resolveContainerKind(diagnostic.container); + const mimeTypeKind = resolveMimeTypeKind(diagnostic.mimeType); + + if ( + containerKind !== null && + mimeTypeKind !== null && + containerKind !== mimeTypeKind + ) { + return PlaybackSourceKind.Unknown; + } + + return containerKind ?? mimeTypeKind ?? PlaybackSourceKind.Unknown; +} + +function resolveContainerKind(container: string): PlaybackSourceKind | null { + if (container === 'm3u' || container === 'm3u8') { + return PlaybackSourceKind.Hls; + } + return container === 'mpd' ? PlaybackSourceKind.Dash : null; +} + +function resolveMimeTypeKind( + mimeType: string | undefined +): PlaybackSourceKind | null { + const baseMimeType = mimeType?.split(';', 1)[0]?.trim().toLowerCase() ?? ''; + if (HLS_MIME_TYPES.has(baseMimeType)) { + return PlaybackSourceKind.Hls; + } + return baseMimeType === DASH_MIME_TYPE ? PlaybackSourceKind.Dash : null; +}