feat(playback): define recovery recommendation contracts

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent 2668fe0dd5
commit d1d75e130c
8 files changed
+879 -63

No files matched your search

@@ -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<string>();
```
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<string>();
@@ -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 "<app-web-player-view" libs apps --glob '*.html'
rg -l "<app-web-player-view|<app-portal-inline-player|<app-vod-details" libs apps --glob '*.html'
```
Inspect every returned template and confirm it binds
`[playbackSessionKey]`. Then run:
Inspect every returned template and confirm each required link in the player
chain binds `[playbackSessionKey]`. Then run:
```bash
pnpm nx test playlist-m3u-feature-player
@@ -267,12 +267,20 @@ Technical details instead of fabricating a guess.
### Stable content identity
Every `WebPlayerViewComponent` host supplies a required
`playbackSessionKey`. The key identifies content, not the current URL:
`playbackSessionKey`. The key identifies canonical logical content, not the
current URL or selected provider copy:
- an M3U live channel key uses playlist/source identity plus channel identity;
- Xtream and Stalker live keys use provider/account plus content identity;
- movie keys use provider/account plus movie identity;
- episode keys additionally include season and episode identity.
- movie keys use the owning route/catalog's original source plus movie
identity;
- episode keys use the owning series route's original source and series plus
season and episode identity.
An alternative source's `playback.contentInfo` is provider-scoped playback and
resume metadata, not recovery-session identity. Source-owning route and series
hosts derive the key before passing it through the inline player, so replacing
that playback payload cannot replace the recovery session.
Retry and alternative sources for the same channel, movie, or episode keep the
same key. Selecting a different channel, movie, or episode changes it. A key
+3
View File
@@ -3,3 +3,6 @@ export * from './lib/diagnostics/shaka-error-classifier';
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-session-key';
export * from './lib/playback-target-capabilities';
@@ -0,0 +1,95 @@
import type {
ExternalPlayerName,
} from '@iptvnator/shared/interfaces';
import type {
InlinePlaybackPlayer,
PlaybackDiagnostic,
} from './diagnostics/playback-diagnostics.model';
export const PlaybackRecommendationReason = {
RetryTransientFailure: 'retry-transient-failure',
RetryUnknownFailure: 'retry-unknown-failure',
AlternativeSourceAvailable: 'alternative-source-available',
DifferentEngineFamily: 'different-engine-family',
ExternalCodecOrContainerSupport: 'external-codec-or-container-support',
ExternalBrowserAccess: 'external-browser-access',
CompatibleDrmPath: 'compatible-drm-path',
} as const;
export type PlaybackRecommendationReason =
(typeof PlaybackRecommendationReason)[keyof typeof PlaybackRecommendationReason];
export type PlaybackRecommendationPriority = 'primary' | 'secondary';
export type PlaybackRecommendationTarget =
| InlinePlaybackPlayer
| ExternalPlayerName;
export const PlaybackSourceKind = {
Hls: 'hls',
MpegTs: 'mpegts',
Dash: 'dash',
Native: 'native',
Unknown: 'unknown',
} as const;
export type PlaybackSourceKind =
(typeof PlaybackSourceKind)[keyof typeof PlaybackSourceKind];
export const PlaybackEngineFamily = {
Vhs: 'vhs',
HlsJs: 'hls.js',
MpegTsJs: 'mpegts.js',
Shaka: 'shaka',
NativeMedia: 'native-media',
} as const;
export type PlaybackEngineFamily =
(typeof PlaybackEngineFamily)[keyof typeof PlaybackEngineFamily];
export type PlaybackTargetCapability =
| {
readonly kind: 'inline';
readonly target: InlinePlaybackPlayer;
readonly available: boolean;
readonly engineFamily: PlaybackEngineFamily | null;
}
| {
readonly kind: 'external';
readonly target: ExternalPlayerName;
readonly available: boolean;
};
export interface PlaybackRecommendationSourceContext {
readonly kind: PlaybackSourceKind;
readonly isLive: boolean;
readonly drm: 'none' | 'untransferable';
readonly externalTransferable: boolean;
}
export interface PlaybackRecommendationContext {
readonly diagnostic: PlaybackDiagnostic;
readonly activeTarget: PlaybackRecommendationTarget;
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
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;
};
@@ -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'
);
});
});
@@ -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('|');
}
@@ -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>
): 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 },
]);
});
});
@@ -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;
}