mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(playback): define recovery recommendation contracts
This commit is contained in:
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,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;
|
||||
}
|
||||
Reference in new issue
Block a user