feat(playback): recommend recovery actions (#1374)

* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
This commit is contained in:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
export * from './lib/embedded-mpv-player/embedded-mpv-session-controller';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/playback-diagnostics/playback-diagnostics.util';
export * from '@iptvnator/playback/util';
export * from './lib/player-controls';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
@@ -1,4 +1,4 @@
import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-diagnostics.util';
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
/**
* ArtPlayer option overrides for the legacy and shared-control surfaces.
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
} from '@iptvnator/playback/util';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks,
@@ -1,6 +1,6 @@
import type Artplayer from 'artplayer';
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session';
@@ -1,4 +1,4 @@
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
MockHls,
MockMpegTsPlayer,
@@ -90,6 +90,84 @@ describe('ArtPlayerSourceSession', () => {
expect(attach).not.toHaveBeenCalled();
});
it('uses browser media-type support for HLS manifest codec diagnostics', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: { isTypeSupported: jest.fn(() => false) },
});
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
try {
hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, {
levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }],
});
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(emitted).toEqual([
expect.objectContaining({
code: 'unsupported-codec',
source: 'source',
}),
]);
});
it('keeps HLS manifest handling alive when the browser support probe throws', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: {
isTypeSupported: () => {
throw new Error('provider-controlled-codec');
},
},
});
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
try {
expect(() =>
hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, {
levels: [
{
audioCodec: 'provider-controlled-codec',
videoCodec: 'avc1.64001f',
},
],
})
).not.toThrow();
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(emitted).toEqual([]);
});
it('uses authoritative VOD metadata for shared MPEG-TS and catches autoplay rejection', async () => {
const { session, player, video, adapter } = createSession({
sharedControls: true,
@@ -299,3 +377,11 @@ describe('ArtPlayerSourceSession', () => {
expect(refresh).toHaveBeenCalled();
});
});
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -12,7 +12,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
import type { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
@@ -307,7 +308,10 @@ export class ArtPlayerSourceSession {
.map((level) => level.videoCodec)
.filter((codec): codec is string => Boolean(codec))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
isBrowserMediaTypeSupported
);
if (issue) {
this.config.emitPlaybackIssue(issue);
}
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
export interface ArtPlayerVideoSessionConfig {
player: Artplayer;
@@ -93,7 +93,6 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.com/archive/movie.mkv',
externalFallbackRecommended: true,
}),
]);
});
@@ -124,7 +123,6 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-codec',
source: 'hls',
sourceUrl: 'https://example.com/live/playlist.m3u8',
externalFallbackRecommended: true,
}),
]);
});
@@ -256,7 +254,6 @@ describe('ArtPlayerComponent', () => {
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
externalFallbackRecommended: true,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
@@ -13,7 +13,7 @@ import {
} from '@angular/core';
import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -9,7 +9,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
/**
* HTML5-player diagnostics glue extracted from the component: builds source
@@ -46,7 +47,10 @@ export function emitUnsupportedHlsManifestCodecs(
.map((level) => level.videoCodec)
.filter((codec): codec is string => Boolean(codec))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
isBrowserMediaTypeSupported
);
if (issue) {
emitPlaybackIssue(issue);
}
@@ -154,7 +154,6 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
@@ -4,7 +4,12 @@ import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js';
import {
ErrorDetails,
ErrorTypes,
type ErrorData,
type ManifestParsedData,
} from 'hls.js';
import {
PlayerControlsComponent,
WebVideoControlsAdapter,
@@ -198,11 +203,90 @@ describe('HtmlVideoPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'http://test.ts',
externalFallbackRecommended: true,
}),
]);
});
it('uses browser media-type support for HLS manifest codec diagnostics', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: { isTypeSupported: jest.fn(() => false) },
});
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
try {
(
component as unknown as {
handleHlsManifestParsed: (
url: string,
data: ManifestParsedData
) => void;
}
).handleHlsManifestParsed(
'https://example.com/live/playlist.m3u8',
{
levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }],
} as ManifestParsedData
);
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(issues).toEqual([
expect.objectContaining({
code: 'unsupported-codec',
source: 'source',
}),
]);
});
it('keeps HLS manifest handling alive when the browser support probe throws', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: {
isTypeSupported: () => {
throw new Error('provider-controlled-codec');
},
},
});
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
try {
expect(() =>
(
component as unknown as {
handleHlsManifestParsed: (
url: string,
data: ManifestParsedData
) => void;
}
).handleHlsManifestParsed(
'https://example.com/live/playlist.m3u8',
{
levels: [
{
audioCodec: 'provider-controlled-codec',
videoCodec: 'avc1.64001f',
},
],
} as ManifestParsedData
)
).not.toThrow();
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(issues).toEqual([]);
});
it('does not emit a playback issue when HLS.js reports a recoverable error', () => {
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => {
@@ -355,3 +439,11 @@ describe('HtmlVideoPlayerComponent', () => {
expect(events).toEqual(['previous']);
});
});
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -21,7 +21,7 @@ import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -0,0 +1,184 @@
@let issue = diagnostic();
<section
class="web-player-diagnostic"
data-test-id="playback-diagnostic-banner"
role="status"
>
<div class="web-player-diagnostic__content">
<div class="web-player-diagnostic__badge">
<mat-icon aria-hidden="true">warning</mat-icon>
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
</div>
<h2 class="web-player-diagnostic__headline">
{{ diagnosticHeadlineKey() | translate }}
</h2>
<div class="web-player-diagnostic__description">
{{ getDiagnosticDescriptionKey(issue) | translate }}
</div>
@if (getDiagnosticMeta(issue); as meta) {
<div class="web-player-diagnostic__meta">
{{ meta }}
</div>
}
@if (getDiagnosticCodecHint(issue); as codecHint) {
<div
class="web-player-diagnostic__codec-hint"
data-test-id="playback-codec-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
| translate: { codecs: codecHint }
}}
</div>
}
<div
class="web-player-diagnostic__recommendations"
role="group"
[attr.aria-label]="
'PLAYBACK_DIAGNOSTICS.RECOMMENDATIONS_LABEL' | translate
"
>
@for (
recommendation of recommendations();
track getRecommendationKey(recommendation)
) {
@if (recommendation.action === 'alternative-source') {
<fieldset
class="web-player-diagnostic__alternatives dark-theme"
[class.web-player-diagnostic__alternatives--primary]="
recommendation.priority === 'primary'
"
[disabled]="pending()"
data-test-id="playback-alternative-sources"
>
<legend
class="web-player-diagnostic__alternatives-title"
>
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</legend>
@for (
source of visibleAlternatives();
track source.id
) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(checkRequested)="
sourceCheckRequested.emit($event)
"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: {
count: hiddenAlternativeCount(),
}
}}
</p>
}
</fieldset>
} @else {
<button
type="button"
class="web-player-diagnostic__player-card"
[class.web-player-diagnostic__player-card--primary]="
recommendation.priority === 'primary'
"
[attr.data-test-id]="
getRecommendationTestId(recommendation)
"
[disabled]="pending()"
(click)="activate(recommendation)"
>
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
getRecommendationLabelKey(recommendation)
| translate
: getRecommendationParams(
recommendation
)
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
getRecommendationReasonKey(
recommendation.reason
) | translate
}}
</span>
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
<span
class="web-player-diagnostic__player-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER_HINT'
| translate
}}
</span>
}
</span>
</button>
}
}
</div>
<div class="web-player-diagnostic__utilities">
<button
class="web-player-diagnostic__utility-button"
type="button"
[cdkCopyToClipboard]="playback().streamUrl"
[matTooltip]="playback().streamUrl"
data-test-id="playback-copy-url"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<details
class="web-player-diagnostic__details"
data-test-id="playback-diagnostic-details"
>
<summary>
<mat-icon aria-hidden="true">expand_more</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
</summary>
<dl>
@for (
detail of getDiagnosticDetails(issue);
track detail.labelKey
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
</div>
}
</dl>
</details>
</div>
</div>
</section>
@@ -0,0 +1,530 @@
:host {
display: contents;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
overflow: auto;
background:
radial-gradient(
circle at 18% 18%,
rgb(255 176 76 / 6%),
transparent 28%
),
rgb(0 0 0 / 96%);
color: #f6f6f6;
}
.web-player-diagnostic button,
.web-player-diagnostic summary {
app-region: no-drag;
}
.web-player-diagnostic__content {
width: min(100%, 720px);
min-width: 0;
}
.web-player-diagnostic__badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 8px;
padding: 5px 11px;
border: 1px solid rgb(255 174 66 / 42%);
border-radius: 6px;
background: rgb(255 148 35 / 15%);
color: #ffb24c;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.web-player-diagnostic__badge mat-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
font-size: 16px;
}
.web-player-diagnostic__badge span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__headline {
max-width: 660px;
margin: 20px 0 0;
color: #fff;
font-size: 1.65rem;
font-weight: 760;
letter-spacing: 0;
line-height: 1.05;
}
.web-player-diagnostic__description {
max-width: 760px;
margin-top: 12px;
color: rgb(255 255 255 / 73%);
font-size: 1rem;
line-height: 1.48;
}
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 12px;
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
font-weight: 620;
line-height: 1.3;
overflow-wrap: anywhere;
}
.web-player-diagnostic__recommendations {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__recommendations:empty {
display: none;
}
.web-player-diagnostic__alternatives {
/* The diagnostic surface is always dark, independent of the application
* theme. Map every source-row surface and foreground locally so light-theme
* app tokens cannot leak dark text into this overlay. */
--playback-diagnostic-source-surface: #161b24;
--playback-diagnostic-source-foreground: #f3f6fb;
--playback-diagnostic-source-muted: #b9c2d3;
--playback-diagnostic-source-accent: #8bb7ff;
--playback-diagnostic-source-on-accent: #151515;
--playback-diagnostic-source-hover: #252d3b;
--playback-diagnostic-source-selected: #243b5c;
--playback-diagnostic-source-border: #657086;
--playback-diagnostic-source-ok-foreground: #4ade80;
--playback-diagnostic-source-ok-surface: #173928;
--playback-diagnostic-source-warn-foreground: #ffcf99;
--playback-diagnostic-source-warn-surface: #422e1d;
--playback-diagnostic-source-bad-foreground: #ff9aa6;
--playback-diagnostic-source-bad-surface: #451f27;
--app-on-surface: var(--playback-diagnostic-source-foreground);
--app-eyebrow-color: var(--playback-diagnostic-source-muted);
--app-card-hover-bg: var(--playback-diagnostic-source-hover);
--app-selection-color: var(--playback-diagnostic-source-accent);
--app-selection-surface: var(--playback-diagnostic-source-selected);
--app-widget-bg: var(--playback-diagnostic-source-surface);
--app-widget-border: var(--playback-diagnostic-source-border);
--mat-sys-on-primary: var(--playback-diagnostic-source-on-accent);
width: min(420px, 100%);
min-width: 0;
grid-column: 1 / -1;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: var(--playback-diagnostic-source-surface);
text-align: left;
max-height: min(46vh, 320px);
overflow-y: auto;
}
/* VodSourceRow defines status variables on its own host. This more-specific,
* panel-owned mapping keeps those tags on the same dark-overlay contract. */
.web-player-diagnostic__alternatives app-vod-source-row {
--vod-source-ok-fg: var(--playback-diagnostic-source-ok-foreground);
--vod-source-ok-bg: var(--playback-diagnostic-source-ok-surface);
--vod-source-warn-fg: var(--playback-diagnostic-source-warn-foreground);
--vod-source-warn-bg: var(--playback-diagnostic-source-warn-surface);
--vod-source-bad-fg: var(--playback-diagnostic-source-bad-foreground);
--vod-source-bad-bg: var(--playback-diagnostic-source-bad-surface);
}
.web-player-diagnostic__alternatives--primary {
outline: 1px solid rgb(255 255 255 / 34%);
outline-offset: 1px;
}
.web-player-diagnostic__alternatives:disabled {
opacity: 0.56;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
overflow-wrap: anywhere;
text-transform: uppercase;
}
.web-player-diagnostic__player-card {
display: grid;
min-width: 0;
min-height: 60px;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
color: #f5f5f5;
cursor: pointer;
text-align: left;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.web-player-diagnostic__player-card:hover {
border-color: rgb(255 255 255 / 28%);
background: rgb(255 255 255 / 6%);
}
.web-player-diagnostic__player-card:active {
transform: translateY(1px);
}
.web-player-diagnostic__player-card:disabled {
cursor: wait;
opacity: 0.56;
transform: none;
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
outline: 2px solid #ffb24c;
outline-offset: 3px;
}
.web-player-diagnostic__player-card--primary {
border-color: transparent;
background: #fff;
color: #151515;
}
.web-player-diagnostic__player-card--primary:hover {
border-color: transparent;
background: rgb(255 255 255 / 88%);
}
.web-player-diagnostic__player-card mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.web-player-diagnostic__player-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.web-player-diagnostic__player-label {
overflow: hidden;
font-size: 0.95rem;
font-weight: 760;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__player-hint {
min-width: 0;
overflow: visible;
overflow-wrap: anywhere;
color: currentColor;
font-size: 0.78rem;
line-height: 1.1;
opacity: 0.62;
text-overflow: clip;
white-space: normal;
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
margin-top: 22px;
color: rgb(255 255 255 / 45%);
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-weight: 640;
line-height: 1.2;
}
.web-player-diagnostic__utility-button:hover,
.web-player-diagnostic__details summary:hover {
color: rgb(255 255 255 / 76%);
}
.web-player-diagnostic__utility-button mat-icon,
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__divider {
width: 1px;
height: 18px;
margin-top: 3px;
background: rgb(255 255 255 / 16%);
}
.web-player-diagnostic__details {
min-width: 0;
max-width: 100%;
}
.web-player-diagnostic__details summary {
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details[open] {
flex-basis: 100%;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 7px 14px;
max-width: min(100%, 640px);
margin: 12px 0 0;
padding: 12px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
line-height: 1.3;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: rgb(255 255 255 / 48%);
font-weight: 700;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
@container (max-width: 760px) {
.web-player-diagnostic {
align-items: flex-start;
padding: 22px 16px;
}
.web-player-diagnostic__badge,
.web-player-diagnostic__badge span {
white-space: normal;
}
.web-player-diagnostic__headline {
margin-top: 16px;
font-size: 1.48rem;
}
.web-player-diagnostic__utilities {
gap: 10px 12px;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 520px) {
.web-player-diagnostic {
align-items: flex-start;
padding: clamp(14px, 5cqi, 22px);
}
.web-player-diagnostic__content {
width: 100%;
}
.web-player-diagnostic__badge {
gap: 6px;
padding: 4px 8px;
font-size: 0.7rem;
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: nowrap;
}
.web-player-diagnostic__headline {
margin-top: 14px;
font-size: 1.35rem;
line-height: 1.08;
}
.web-player-diagnostic__description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.38;
}
.web-player-diagnostic__meta {
margin-top: 8px;
font-size: 0.74rem;
}
.web-player-diagnostic__codec-hint {
margin-top: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__recommendations {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
.web-player-diagnostic__alternatives {
width: 100%;
}
.web-player-diagnostic__player-card {
min-height: 52px;
padding: 10px 12px;
}
.web-player-diagnostic__player-label {
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.web-player-diagnostic__utilities {
display: grid;
gap: 7px;
margin-top: 16px;
}
.web-player-diagnostic__divider {
display: none;
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
width: fit-content;
min-height: 22px;
font-size: 0.82rem;
}
.web-player-diagnostic__details[open] {
flex-basis: auto;
}
.web-player-diagnostic__details dl {
max-height: 260px;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
margin-top: 8px;
padding: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 340px) {
.web-player-diagnostic {
padding: 12px;
}
.web-player-diagnostic__headline {
font-size: 1.22rem;
}
.web-player-diagnostic__description {
font-size: 0.84rem;
}
.web-player-diagnostic__player-label {
font-size: 0.88rem;
}
}
@@ -0,0 +1,619 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRecommendationReason,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type {
ResolvedPortalPlayback,
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
source: PlaybackDiagnosticSource.Source,
sourceUrl: 'https://example.com/archive/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
};
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/archive/movie.mkv',
title: 'Example Movie',
};
const PANEL_STYLE_SOURCE = readFileSync(
resolve(
process.cwd(),
'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss'
),
'utf8'
);
function contrastRatio(foreground: string, background: string): number {
const luminance = (hex: string): number => {
const channels = [1, 3, 5].map((offset) =>
Number.parseInt(hex.slice(offset, offset + 2), 16)
);
const [red, green, blue] = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
};
const values = [luminance(foreground), luminance(background)].sort(
(left, right) => right - left
);
return (values[0] + 0.05) / (values[1] + 0.05);
}
function recommendation(
action: PlaybackRecommendation['action'],
overrides: Partial<PlaybackRecommendation> = {}
): PlaybackRecommendation {
if (action === 'retry') {
return {
action,
reason: PlaybackRecommendationReason.RetryTransientFailure,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
if (action === 'alternative-source') {
return {
action,
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
return {
action,
target: 'html5',
reason: PlaybackRecommendationReason.DifferentEngineFamily,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
function source(index: number): VodSourceDescriptor {
return {
id: `playlist-${index}:xtream:${index}`,
playlistId: `playlist-${index}`,
playlistName: `Portal ${index}`,
portalType: 'xtream',
contentId: index,
rawTitle: `Example Movie ${index}`,
matchConfidence: 'exact',
isActive: false,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
};
}
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
let component: PlaybackDiagnosticPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
PlaybackDiagnosticPanelComponent,
TranslateModule.forRoot(),
],
})
.overrideComponent(PlaybackDiagnosticPanelComponent, {
add: { styles: [PANEL_STYLE_SOURCE] },
})
.compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PLAYBACK_DIAGNOSTICS: {
ACTION_TRY_PLAYER: 'Try {{player}}',
ACTION_TRY_PLAYER_HINT:
'This temporary player change applies only to the current playback session and leaves your saved preference unchanged.',
REASON_DIFFERENT_ENGINE_FAMILY:
'This player uses a different playback engine that may support the stream format more reliably.',
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
},
PORTALS: {
MULTI_SOURCE: {
MORE_SOURCES: '{{count}} more sources',
TRY_ANOTHER_SOURCE: 'Try another source',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(PlaybackDiagnosticPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('diagnostic', DIAGNOSTIC);
fixture.componentRef.setInput('recommendations', []);
fixture.componentRef.setInput('playback', PLAYBACK);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('playbackExternallyTransferable', true);
});
it('applies the component host style from the actual panel stylesheet', () => {
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(getComputedStyle(host).display).toBe('contents');
});
it('renders supplied policy-ranked action buttons in order with priority styling', () => {
const rankedRecommendations = [
recommendation('player', {
target: 'html5',
priority: 'primary',
}),
recommendation('player', { target: 'mpv' }),
recommendation('retry'),
];
fixture.componentRef.setInput('recommendations', rankedRecommendations);
fixture.detectChanges();
const cards = fixture.debugElement.queryAll(
By.css('.web-player-diagnostic__player-card')
);
expect(cards.map((card) => card.attributes['data-test-id'])).toEqual([
'playback-recommendation-html5',
'playback-fallback-mpv',
'playback-retry',
]);
expect(
cards.map(
(card) =>
card.classes[
'web-player-diagnostic__player-card--primary'
] ?? false
)
).toEqual([true, false, false]);
for (const card of cards) {
const button = card.nativeElement as HTMLButtonElement;
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
expect(button.type).toBe('button');
}
});
it('exposes the recommendation set and alternatives with native grouping semantics', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
const group = fixture.nativeElement.querySelector(
'.web-player-diagnostic__recommendations'
) as HTMLElement;
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const legend = alternatives.firstElementChild as HTMLElement;
expect(group.getAttribute('role')).toBe('group');
expect(group.getAttribute('aria-label')).toBe(
'Recommended recovery actions'
);
expect(legend.tagName).toBe('LEGEND');
expect(legend.classList).toContain(
'web-player-diagnostic__alternatives-title'
);
expect(legend.textContent).toContain('Try another source');
});
it('provides a high-contrast dark token contract to alternative source rows', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const styles = getComputedStyle(alternatives);
const token = (name: string): string =>
styles.getPropertyValue(name).trim();
const expectedTokens: Readonly<Record<string, string>> = {
'--playback-diagnostic-source-surface': '#161b24',
'--playback-diagnostic-source-foreground': '#f3f6fb',
'--playback-diagnostic-source-muted': '#b9c2d3',
'--playback-diagnostic-source-accent': '#8bb7ff',
'--playback-diagnostic-source-on-accent': '#151515',
'--playback-diagnostic-source-hover': '#252d3b',
'--playback-diagnostic-source-selected': '#243b5c',
'--playback-diagnostic-source-border': '#657086',
'--playback-diagnostic-source-ok-foreground': '#4ade80',
'--playback-diagnostic-source-ok-surface': '#173928',
'--playback-diagnostic-source-warn-foreground': '#ffcf99',
'--playback-diagnostic-source-warn-surface': '#422e1d',
'--playback-diagnostic-source-bad-foreground': '#ff9aa6',
'--playback-diagnostic-source-bad-surface': '#451f27',
};
expect(alternatives.classList).toContain('dark-theme');
for (const [name, value] of Object.entries(expectedTokens)) {
expect(token(name)).toBe(value);
}
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-on-surface:\s*var\(--playback-diagnostic-source-foreground\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-eyebrow-color:\s*var\(--playback-diagnostic-source-muted\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-card-hover-bg:\s*var\(--playback-diagnostic-source-hover\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-selection-surface:\s*var\(--playback-diagnostic-source-selected\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-selection-color:\s*var\(--playback-diagnostic-source-accent\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-widget-border:\s*var\(--playback-diagnostic-source-border\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-widget-bg:\s*var\(--playback-diagnostic-source-surface\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--mat-sys-on-primary:\s*var\(--playback-diagnostic-source-on-accent\)/
);
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-ok-fg:');
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-warn-fg:');
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-bad-fg:');
expect(PANEL_STYLE_SOURCE).toMatch(
/web-player-diagnostic__alternatives app-vod-source-row[\s\S]*--vod-source-ok-fg:/
);
const color = (name: string): string => expectedTokens[name];
const surface = color('--playback-diagnostic-source-surface');
for (const foreground of [
'--playback-diagnostic-source-foreground',
'--playback-diagnostic-source-muted',
'--playback-diagnostic-source-accent',
]) {
expect(
contrastRatio(color(foreground), surface)
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(color('--playback-diagnostic-source-border'), surface)
).toBeGreaterThanOrEqual(3);
for (const tone of ['ok', 'warn', 'bad']) {
expect(
contrastRatio(
color(`--playback-diagnostic-source-${tone}-foreground`),
color(`--playback-diagnostic-source-${tone}-surface`)
)
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(
color('--playback-diagnostic-source-on-accent'),
color('--playback-diagnostic-source-accent')
)
).toBeGreaterThanOrEqual(4.5);
});
it('disables recommendation buttons and source controls while pending', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('retry', { priority: 'primary' }),
recommendation('alternative-source'),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.componentRef.setInput('pending', true);
fixture.detectChanges();
const retry = fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
) as HTMLButtonElement;
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const sourcePlay = fixture.nativeElement.querySelector(
'.source-row__action--play'
) as HTMLButtonElement;
expect(retry.disabled).toBe(true);
expect(alternatives.disabled).toBe(true);
expect(sourcePlay.matches(':disabled')).toBe(true);
});
it('bounds alternative sources to five and reports the hidden count', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput(
'alternativeSources',
Array.from({ length: 7 }, (_, index) => source(index + 1))
);
fixture.detectChanges();
expect(
fixture.debugElement.queryAll(By.css('app-vod-source-row'))
).toHaveLength(5);
expect(
fixture.nativeElement.querySelector(
'.web-player-diagnostic__alternatives-more'
).textContent
).toContain('2');
});
it('emits exact retry, player, source play, and source check requests', () => {
const retries: void[] = [];
const players: PlaybackRecommendationTarget[] = [];
const playedSources: string[] = [];
const checkedSources: string[] = [];
component.retryRequested.subscribe(() => retries.push(undefined));
component.playerRequested.subscribe((target) => players.push(target));
component.alternativeSourceRequested.subscribe((id) =>
playedSources.push(id)
);
component.sourceCheckRequested.subscribe((id) =>
checkedSources.push(id)
);
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'artplayer',
priority: 'primary',
}),
recommendation('retry'),
recommendation('alternative-source'),
]);
fixture.componentRef.setInput('alternativeSources', [source(8)]);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="playback-recommendation-artplayer"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'.source-row__action--play'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'.source-tag--action'
) as HTMLButtonElement
).click();
expect(players).toEqual(['artplayer']);
expect(retries).toEqual([undefined]);
expect(playedSources).toEqual(['playlist-8:xtream:8']);
expect(checkedSources).toEqual(['playlist-8:xtream:8']);
});
it('always renders Copy URL and Technical details utilities', () => {
fixture.detectChanges();
const copy = fixture.nativeElement.querySelector(
'[data-test-id="playback-copy-url"]'
) as HTMLButtonElement;
const details = fixture.nativeElement.querySelector(
'[data-test-id="playback-diagnostic-details"]'
) as HTMLDetailsElement;
expect(copy).not.toBeNull();
expect(copy.type).toBe('button');
expect(copy.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL'
);
expect(details).not.toBeNull();
expect(details.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
});
it('uses runtime capability for browser-access guidance without implying an external action', () => {
const browserIssue: PlaybackDiagnostic = {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
};
fixture.componentRef.setInput('diagnostic', browserIssue);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('recommendations', []);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]'
)
).toBeNull();
});
it('uses PWA browser-access guidance when managed external players are unavailable', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('recommendations', []);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
it('uses a native headline only when an external player is ranked', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'vlc',
priority: 'primary',
reason: PlaybackRecommendationReason.ExternalBrowserAccess,
}),
]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
});
it('uses a neutral headline for untransferable DRM without a ranked external target', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput(
'playbackExternallyTransferable',
false
);
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).not.toContain(
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
});
it('shows the temporary hint only for built-in player targets', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'videojs',
priority: 'primary',
}),
recommendation('player', { target: 'mpv' }),
recommendation('player', { target: 'vlc' }),
]);
fixture.detectChanges();
const builtIn = fixture.nativeElement.querySelector(
'[data-test-id="playback-recommendation-videojs"]'
) as HTMLButtonElement;
const mpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
const vlc = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-vlc"]'
) as HTMLButtonElement;
expect(builtIn.textContent).toContain(
'leaves your saved preference unchanged'
);
expect(mpv.textContent).not.toContain(
'leaves your saved preference unchanged'
);
expect(vlc.textContent).not.toContain(
'leaves your saved preference unchanged'
);
});
it('wraps long translated reasons and temporary hints without clipping', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'videojs',
priority: 'primary',
}),
]);
fixture.detectChanges();
const hints = Array.from(
fixture.nativeElement.querySelectorAll(
'.web-player-diagnostic__player-hint'
) as NodeListOf<HTMLElement>
);
expect(hints).toHaveLength(2);
expect(hints[0].textContent).toContain(
'different playback engine that may support the stream format'
);
expect(hints[1].textContent).toContain(
'leaves your saved preference unchanged'
);
for (const hint of hints) {
const styles = getComputedStyle(hint);
expect(styles.whiteSpace).toBe('normal');
expect(styles.overflowWrap).toBe('anywhere');
expect(styles.textOverflow).toBe('clip');
expect(styles.overflow).not.toBe('hidden');
}
});
it('uses native focus order without autofocus or a focus trap', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('retry', { priority: 'primary' }),
]);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('[autofocus], [cdkTrapFocus]')
).toBeNull();
expect(document.activeElement).not.toBe(
fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
)
);
});
it('declares focus, container-query, and drag-region CSS contracts for browser integration', () => {
const styles = PANEL_STYLE_SOURCE;
expect(styles).toContain(
'.web-player-diagnostic__player-card:focus-visible'
);
expect(styles).toContain('outline: 2px solid #ffb24c');
expect(styles).toContain('@container (max-width: 520px)');
expect(styles).toMatch(
/web-player-diagnostic__recommendations[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/
);
expect(styles).toContain('flex-wrap: wrap');
expect(styles).toContain('min-width: 0');
expect(styles).toContain('overflow-wrap: anywhere');
expect(styles).toContain('app-region: no-drag');
});
});
@@ -0,0 +1,126 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type {
ResolvedPortalPlayback,
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './playback-diagnostic-view.util';
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isExternalPlayerRecommendation,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
/** How many recovery options the error screen shows before it stops helping. */
const ERROR_SCREEN_ALTERNATIVES = 5;
@Component({
selector: 'app-playback-diagnostic-panel',
templateUrl: './playback-diagnostic-panel.component.html',
styleUrl: './playback-diagnostic-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
MatIconModule,
MatTooltipModule,
TranslateModule,
VodSourceRowComponent,
],
host: { class: 'playback-diagnostic-panel' },
})
export class PlaybackDiagnosticPanelComponent {
readonly diagnostic = input.required<PlaybackDiagnostic>();
readonly recommendations =
input.required<readonly PlaybackRecommendation[]>();
readonly playback = input.required<ResolvedPortalPlayback>();
readonly supportsManagedExternalPlayers = input.required<boolean>();
readonly playbackExternallyTransferable = input.required<boolean>();
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly pending = input(false);
readonly retryRequested = output<void>();
readonly playerRequested = output<PlaybackRecommendationTarget>();
readonly alternativeSourceRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(
0,
this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES
)
);
readonly hasExternalPlayerRecommendation = computed(() =>
this.recommendations().some(isExternalPlayerRecommendation)
);
readonly diagnosticHeadlineKey = computed(() => {
if (this.hasExternalPlayerRecommendation()) {
return 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE';
}
return this.playbackExternallyTransferable()
? 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
: 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE';
});
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
readonly getRecommendationKey = getRecommendationKey;
readonly getRecommendationTestId = getRecommendationTestId;
readonly getRecommendationIcon = getRecommendationIcon;
readonly getRecommendationLabelKey = getRecommendationLabelKey;
readonly getRecommendationParams = getRecommendationParams;
readonly getRecommendationReasonKey = getRecommendationReasonKey;
readonly isTemporaryBuiltInRecommendation =
isTemporaryBuiltInRecommendation;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
issue,
this.supportsManagedExternalPlayers(),
this.playbackExternallyTransferable()
);
}
activate(recommendation: PlaybackRecommendation): void {
switch (recommendation.action) {
case 'retry':
this.retryRequested.emit();
return;
case 'player':
this.playerRequested.emit(recommendation.target);
return;
case 'alternative-source':
return;
}
}
}
@@ -0,0 +1,139 @@
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './playback-diagnostic-view.util';
describe('playback diagnostic view formatters', () => {
const issue: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedCodec,
source: PlaybackDiagnosticSource.Native,
sourceUrl: 'https://example.com/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: 'videojs',
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
};
it('preserves existing title, metadata, codec and detail formatting', () => {
expect(getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.TITLE'
);
expect(getDiagnosticMeta(issue)).toBe('hvc1.1.6.L93.B0, ac-3');
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: 'Video.js',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'Native media element',
},
])
);
});
describe.each([
{
diagnostic: 'browser access',
code: PlaybackDiagnosticCode.BrowserAccessError,
desktopKey:
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION',
},
{
diagnostic: 'unsupported codec',
code: PlaybackDiagnosticCode.UnsupportedCodec,
desktopKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.DESCRIPTION',
},
{
diagnostic: 'unsupported container',
code: PlaybackDiagnosticCode.UnsupportedContainer,
desktopKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
pwaKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
},
{
diagnostic: 'media decode',
code: PlaybackDiagnosticCode.MediaDecodeError,
desktopKey: 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR.DESCRIPTION',
},
])('$diagnostic description', ({ code, desktopKey, pwaKey }) => {
const diagnosticIssue: PlaybackDiagnostic = { ...issue, code };
it.each([
{
runtime: 'desktop',
supportsManagedExternalPlayers: true,
expected: desktopKey,
},
{
runtime: 'PWA',
supportsManagedExternalPlayers: false,
expected: pwaKey,
},
])('preserves transferable $runtime copy', ({
supportsManagedExternalPlayers,
expected,
}) => {
expect(
getDiagnosticDescriptionKey(
diagnosticIssue,
supportsManagedExternalPlayers,
true
)
).toBe(expected);
});
it.each([
['desktop', true],
['PWA', false],
])(
'uses neutral protected copy in the %s runtime',
(_runtime, supportsManagedExternalPlayers) => {
expect(
getDiagnosticDescriptionKey(
diagnosticIssue,
supportsManagedExternalPlayers,
false
)
).toBe('PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION');
}
);
});
it.each([
[
'network',
PlaybackDiagnosticCode.NetworkError,
'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.DESCRIPTION',
],
[
'unknown',
PlaybackDiagnosticCode.UnknownPlaybackError,
'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR.DESCRIPTION',
],
[
'DRM or encryption',
PlaybackDiagnosticCode.DrmOrEncryption,
'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION',
],
])('preserves neutral %s copy for protected playback', (_label, code, expected) => {
expect(
getDiagnosticDescriptionKey({ ...issue, code }, true, false)
).toBe(expected);
});
});
@@ -2,7 +2,16 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
PlaybackDiagnostic['code']
> = new Set([
PlaybackDiagnosticCode.BrowserAccessError,
PlaybackDiagnosticCode.UnsupportedCodec,
PlaybackDiagnosticCode.UnsupportedContainer,
PlaybackDiagnosticCode.MediaDecodeError,
]);
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
@@ -15,8 +24,16 @@ export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
export function getDiagnosticDescriptionKey(
issue: PlaybackDiagnostic,
supportsManagedExternalPlayers: boolean
supportsManagedExternalPlayers: boolean,
playbackExternallyTransferable: boolean
): string {
if (
!playbackExternallyTransferable &&
UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES.has(issue.code)
) {
return 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION';
}
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!supportsManagedExternalPlayers
@@ -0,0 +1,162 @@
import {
PlaybackRecommendationReason,
type PlaybackRecommendation,
type PlaybackRecommendationPriority,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
function player(
target: PlaybackRecommendationTarget,
priority: PlaybackRecommendationPriority = 'secondary'
): PlaybackRecommendation {
return {
action: 'player',
target,
reason: PlaybackRecommendationReason.DifferentEngineFamily,
priority,
};
}
function retry(): PlaybackRecommendation {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryTransientFailure,
priority: 'primary',
};
}
function alternative(): PlaybackRecommendation {
return {
action: 'alternative-source',
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority: 'secondary',
};
}
describe('playback recommendation view mappings', () => {
it.each([
['videojs', 'playback-recommendation-videojs'],
['html5', 'playback-recommendation-html5'],
['artplayer', 'playback-recommendation-artplayer'],
['mpv', 'playback-fallback-mpv'],
['vlc', 'playback-fallback-vlc'],
] as const)('maps %s to its stable test id', (target, testId) => {
expect(getRecommendationTestId(player(target))).toBe(testId);
});
it('keeps stable retry and alternative-source test ids', () => {
expect(getRecommendationTestId(retry())).toBe('playback-retry');
expect(getRecommendationTestId(alternative())).toBe(
'playback-alternative-sources'
);
});
it.each([
['videojs', 'Video.js'],
['html5', 'HTML5'],
['artplayer', 'ArtPlayer'],
] as const)(
'maps built-in target %s to translated player copy',
(target, name) => {
const recommendation = player(target);
expect(getRecommendationLabelKey(recommendation)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER'
);
expect(getRecommendationParams(recommendation)).toEqual({
player: name,
});
expect(getRecommendationIcon(recommendation)).toBe('play_circle');
expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(true);
}
);
it.each([
['mpv', 'MPV', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'],
['vlc', 'VLC', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'],
] as const)(
'maps external target %s without a temporary-player hint',
(target, name, labelKey) => {
const recommendation = player(target);
expect(getRecommendationLabelKey(recommendation)).toBe(labelKey);
expect(getRecommendationParams(recommendation)).toEqual({
player: name,
});
expect(getRecommendationIcon(recommendation)).toBe('open_in_new');
expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(
false
);
}
);
it('maps non-player actions exhaustively', () => {
expect(getRecommendationLabelKey(retry())).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY'
);
expect(getRecommendationParams(retry())).toEqual({});
expect(getRecommendationIcon(retry())).toBe('refresh');
expect(getRecommendationKey(retry())).toBe('retry');
expect(isTemporaryBuiltInRecommendation(retry())).toBe(false);
expect(getRecommendationLabelKey(alternative())).toBe(
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
);
expect(getRecommendationParams(alternative())).toEqual({});
expect(getRecommendationIcon(alternative())).toBe('swap_horiz');
expect(getRecommendationKey(alternative())).toBe('alternative-source');
expect(isTemporaryBuiltInRecommendation(alternative())).toBe(false);
});
it.each([
[
PlaybackRecommendationReason.RetryTransientFailure,
'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE',
],
[
PlaybackRecommendationReason.RetryUnknownFailure,
'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE',
],
[
PlaybackRecommendationReason.AlternativeSourceAvailable,
'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE',
],
[
PlaybackRecommendationReason.DifferentEngineFamily,
'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY',
],
[
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT',
],
[
PlaybackRecommendationReason.ExternalBrowserAccess,
'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS',
],
[
PlaybackRecommendationReason.CompatibleDrmPath,
'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH',
],
] as const)('maps reason %s to its translation key', (reason, key) => {
expect(getRecommendationReasonKey(reason)).toBe(key);
});
it('keeps player recommendation keys stable and target-specific', () => {
expect(getRecommendationKey(player('videojs'))).toBe('player-videojs');
expect(getRecommendationKey(player('html5'))).toBe('player-html5');
expect(getRecommendationKey(player('artplayer'))).toBe(
'player-artplayer'
);
expect(getRecommendationKey(player('mpv'))).toBe('player-mpv');
expect(getRecommendationKey(player('vlc'))).toBe('player-vlc');
});
});
@@ -0,0 +1,148 @@
import {
type PlaybackRecommendation,
type PlaybackRecommendationReason,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
export function getRecommendationKey(
recommendation: PlaybackRecommendation
): string {
return recommendation.action === 'player'
? `player-${recommendation.target}`
: recommendation.action;
}
export function getRecommendationTestId(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'playback-retry';
case 'alternative-source':
return 'playback-alternative-sources';
case 'player':
return getPlayerTestId(recommendation.target);
}
}
export function getRecommendationLabelKey(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY';
case 'alternative-source':
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
case 'player':
return getPlayerLabelKey(recommendation.target);
}
}
export function getRecommendationParams(
recommendation: PlaybackRecommendation
): Readonly<Record<string, string>> {
return recommendation.action === 'player'
? { player: getPlayerName(recommendation.target) }
: {};
}
export function getRecommendationIcon(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'refresh';
case 'alternative-source':
return 'swap_horiz';
case 'player':
return isExternalTarget(recommendation.target)
? 'open_in_new'
: 'play_circle';
}
}
export function getRecommendationReasonKey(
reason: PlaybackRecommendationReason
): string {
switch (reason) {
case 'retry-transient-failure':
return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE';
case 'retry-unknown-failure':
return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE';
case 'alternative-source-available':
return 'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE';
case 'different-engine-family':
return 'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY';
case 'external-codec-or-container-support':
return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT';
case 'external-browser-access':
return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS';
case 'compatible-drm-path':
return 'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH';
}
}
export function isTemporaryBuiltInRecommendation(
recommendation: PlaybackRecommendation
): boolean {
return (
recommendation.action === 'player' &&
!isExternalTarget(recommendation.target)
);
}
export function isExternalPlayerRecommendation(
recommendation: PlaybackRecommendation
): boolean {
return (
recommendation.action === 'player' &&
isExternalTarget(recommendation.target)
);
}
function getPlayerTestId(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
return 'playback-recommendation-videojs';
case 'html5':
return 'playback-recommendation-html5';
case 'artplayer':
return 'playback-recommendation-artplayer';
case 'mpv':
return 'playback-fallback-mpv';
case 'vlc':
return 'playback-fallback-vlc';
}
}
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
case 'html5':
case 'artplayer':
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
case 'mpv':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
case 'vlc':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
}
}
function getPlayerName(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
case 'mpv':
return 'MPV';
case 'vlc':
return 'VLC';
}
}
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
return target === 'mpv' || target === 'vlc';
}
@@ -1,213 +0,0 @@
import {
ErrorDetails,
ErrorTypes,
type ErrorData,
type LoaderResponse,
} from 'hls.js';
import * as diagnostics from './playback-diagnostics.util';
interface ExpectedHlsPlaybackEvidence {
readonly engineType: string;
readonly engineDetails: string;
readonly disposition: string;
readonly stage: string;
readonly failure: string;
readonly httpStatus?: number;
}
type EvidenceFactory = (data: ErrorData) => ExpectedHlsPlaybackEvidence;
describe('HLS playback evidence', () => {
it('extracts a fatal manifest HTTP failure from the hls.js response shape', () => {
const evidence = createEvidence(
createErrorData({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_LOAD_ERROR,
response: createResponse(404),
})
);
expect(evidence).toEqual({
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'http',
httpStatus: 404,
});
});
it('extracts exact timeout and level-stage evidence', () => {
const evidence = createEvidence(
createErrorData({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_LOAD_TIMEOUT,
})
);
expect(evidence).toEqual({
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.LEVEL_LOAD_TIMEOUT,
disposition: 'fatal',
stage: 'level',
failure: 'timeout',
});
});
it('marks a non-fatal fragment load event as recoverable network evidence', () => {
const evidence = createEvidence(
createErrorData({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_ERROR,
fatal: false,
})
);
expect(evidence).toEqual({
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.FRAG_LOAD_ERROR,
disposition: 'recoverable',
stage: 'segment',
failure: 'network',
});
});
it.each([
{
details: ErrorDetails.MANIFEST_PARSING_ERROR,
stage: 'manifest',
},
{ details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR, stage: 'level' },
{ details: ErrorDetails.FRAG_DECRYPT_ERROR, stage: 'segment' },
{ details: ErrorDetails.KEY_LOAD_ERROR, stage: 'key' },
{ details: ErrorDetails.BUFFER_ADD_CODEC_ERROR, stage: 'media' },
{ details: ErrorDetails.INTERNAL_EXCEPTION, stage: 'unknown' },
])('maps $details to the exact $stage stage', ({ details, stage }) => {
expect(createEvidence(createErrorData({ details })).stage).toBe(stage);
});
it('keeps a successful response status as evidence without calling it an HTTP failure', () => {
const evidence = createEvidence(
createErrorData({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_PARSING_ERROR,
response: createResponse(200),
})
);
expect(evidence.httpStatus).toBe(200);
expect(evidence.failure).toBe('network');
});
it.each([
{ status: 99, expected: undefined },
{ status: 100, expected: 100 },
{ status: 599, expected: 599 },
{ status: 600, expected: undefined },
{ status: 404.5, expected: undefined },
])(
'retains HTTP status $status only inside the protocol range',
({ status, expected }) => {
const evidence = createEvidence(
createErrorData({
response: createResponse(status),
})
);
expect(evidence.httpStatus).toBe(expected);
}
);
it('uses unknown for unrecognized runtime engine identifiers', () => {
const evidence = createEvidence(
createErrorData({
type: 'providerNetworkCodecError' as ErrorTypes,
details: 'providerStatusCodecFailure' as ErrorDetails,
})
);
expect(evidence).toEqual({
engineType: 'unknown',
engineDetails: ErrorDetails.UNKNOWN,
disposition: 'fatal',
stage: 'unknown',
failure: 'unknown',
});
});
it('drops URLs, headers, bodies, credentials, and arbitrary provider payloads', () => {
const secret = 'evidence-secret-sentinel';
const evidence = createEvidence(
createErrorData({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_LOAD_ERROR,
error: new Error(`provider message ${secret}`),
reason: `provider reason ${secret}`,
url: `https://user:${secret}@provider.example/manifest.m3u8`,
response: {
code: 503,
url: `https://provider.example/error?token=${secret}`,
text: `response text ${secret}`,
data: { body: secret },
},
context: {
url: `https://provider.example/context?token=${secret}`,
responseType: 'text',
headers: { Authorization: `Bearer ${secret}` },
} as ErrorData['context'],
networkDetails: {
responseURL: `https://provider.example/xhr?token=${secret}`,
responseText: secret,
},
})
);
const serialized = JSON.stringify(evidence);
expect(evidence.httpStatus).toBe(503);
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('response text');
expect(Object.keys(evidence).sort()).toEqual(
[
'disposition',
'engineDetails',
'engineType',
'failure',
'httpStatus',
'stage',
].sort()
);
});
});
function createEvidence(data: ErrorData): ExpectedHlsPlaybackEvidence {
const factory = (
diagnostics as unknown as {
readonly createHlsPlaybackEvidence?: EvidenceFactory;
}
).createHlsPlaybackEvidence;
expect(factory).toBeDefined();
if (!factory) {
throw new Error('createHlsPlaybackEvidence is not exported');
}
return factory(data);
}
function createErrorData(overrides: Partial<ErrorData> = {}): ErrorData {
return {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.UNKNOWN,
error: new Error('provider payload must not survive'),
fatal: true,
...overrides,
};
}
function createResponse(code: number): LoaderResponse {
return {
url: 'https://provider.example/response-must-not-survive',
code,
};
}
@@ -1,234 +0,0 @@
import {
ErrorDetails,
ErrorTypes,
type ErrorData,
} from 'hls.js';
import {
type HlsPlaybackEngineType,
type HlsPlaybackEvidence,
HlsPlaybackDisposition,
HlsPlaybackFailure,
HlsPlaybackStage,
HlsPlaybackUnknownEngineType,
type PlaybackDiagnosticCode,
PlaybackDiagnosticCode as DiagnosticCode,
} from './playback-diagnostics.model';
const ENGINE_TYPES = new Set<string>(Object.values(ErrorTypes));
const ENGINE_DETAILS = new Set<string>(Object.values(ErrorDetails));
const MANIFEST_DETAILS = new Set<ErrorDetails>([
ErrorDetails.MANIFEST_LOAD_ERROR,
ErrorDetails.MANIFEST_LOAD_TIMEOUT,
ErrorDetails.MANIFEST_PARSING_ERROR,
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
]);
const LEVEL_DETAILS = new Set<ErrorDetails>([
ErrorDetails.LEVEL_EMPTY_ERROR,
ErrorDetails.LEVEL_LOAD_ERROR,
ErrorDetails.LEVEL_LOAD_TIMEOUT,
ErrorDetails.LEVEL_PARSING_ERROR,
ErrorDetails.LEVEL_SWITCH_ERROR,
ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT,
ErrorDetails.SUBTITLE_LOAD_ERROR,
ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT,
]);
const SEGMENT_DETAILS = new Set<ErrorDetails>([
ErrorDetails.FRAG_LOAD_ERROR,
ErrorDetails.FRAG_LOAD_TIMEOUT,
ErrorDetails.FRAG_DECRYPT_ERROR,
ErrorDetails.FRAG_PARSING_ERROR,
ErrorDetails.FRAG_GAP,
]);
const KEY_SYSTEM_DETAILS = new Set<ErrorDetails>([
ErrorDetails.KEY_SYSTEM_NO_KEYS,
ErrorDetails.KEY_SYSTEM_NO_ACCESS,
ErrorDetails.KEY_SYSTEM_NO_SESSION,
ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE,
ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED,
ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED,
ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED,
ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED,
ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR,
ErrorDetails.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR,
ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR,
ErrorDetails.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR,
]);
const KEY_DETAILS = new Set<ErrorDetails>([
ErrorDetails.KEY_LOAD_ERROR,
ErrorDetails.KEY_LOAD_TIMEOUT,
...KEY_SYSTEM_DETAILS,
]);
const MEDIA_DETAILS = new Set<ErrorDetails>([
ErrorDetails.REMUX_ALLOC_ERROR,
ErrorDetails.BUFFER_ADD_CODEC_ERROR,
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
ErrorDetails.BUFFER_APPEND_ERROR,
ErrorDetails.BUFFER_APPENDING_ERROR,
ErrorDetails.BUFFER_STALLED_ERROR,
ErrorDetails.BUFFER_FULL_ERROR,
ErrorDetails.BUFFER_SEEK_OVER_HOLE,
ErrorDetails.BUFFER_NUDGE_ON_STALL,
ErrorDetails.ATTACH_MEDIA_ERROR,
]);
const TIMEOUT_DETAILS = new Set<ErrorDetails>([
ErrorDetails.MANIFEST_LOAD_TIMEOUT,
ErrorDetails.LEVEL_LOAD_TIMEOUT,
ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT,
ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT,
ErrorDetails.FRAG_LOAD_TIMEOUT,
ErrorDetails.KEY_LOAD_TIMEOUT,
ErrorDetails.ASSET_LIST_LOAD_TIMEOUT,
]);
const NETWORK_DETAILS = new Set<ErrorDetails>([
ErrorDetails.MANIFEST_LOAD_ERROR,
ErrorDetails.LEVEL_LOAD_ERROR,
ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
ErrorDetails.SUBTITLE_LOAD_ERROR,
ErrorDetails.FRAG_LOAD_ERROR,
ErrorDetails.KEY_LOAD_ERROR,
ErrorDetails.ASSET_LIST_LOAD_ERROR,
]);
const CODEC_DETAILS = new Set<ErrorDetails>([
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
ErrorDetails.BUFFER_ADD_CODEC_ERROR,
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
]);
const DRM_DETAILS = new Set<ErrorDetails>([
ErrorDetails.FRAG_DECRYPT_ERROR,
...KEY_SYSTEM_DETAILS,
]);
export function createHlsPlaybackEvidence(
data: ErrorData
): HlsPlaybackEvidence {
const engineType = getEngineType(data.type);
const engineDetails = getEngineDetails(data.details);
const httpStatus = getHttpStatus(data.response?.code);
const evidence: HlsPlaybackEvidence = {
engineType,
engineDetails,
disposition:
data.fatal === true
? HlsPlaybackDisposition.Fatal
: HlsPlaybackDisposition.Recoverable,
stage: getStage(engineDetails),
failure: getFailure(engineType, engineDetails, httpStatus),
};
return httpStatus === undefined
? evidence
: { ...evidence, httpStatus };
}
export function getHlsPlaybackDiagnosticCode(
evidence: HlsPlaybackEvidence
): PlaybackDiagnosticCode {
if (CODEC_DETAILS.has(evidence.engineDetails)) {
return DiagnosticCode.UnsupportedCodec;
}
if (
evidence.engineType === ErrorTypes.KEY_SYSTEM_ERROR ||
DRM_DETAILS.has(evidence.engineDetails)
) {
return DiagnosticCode.DrmOrEncryption;
}
if (evidence.failure === HlsPlaybackFailure.Access) {
return DiagnosticCode.BrowserAccessError;
}
if (
evidence.engineType === ErrorTypes.NETWORK_ERROR ||
evidence.failure === HlsPlaybackFailure.Http ||
evidence.failure === HlsPlaybackFailure.Timeout ||
evidence.failure === HlsPlaybackFailure.Network
) {
return DiagnosticCode.NetworkError;
}
if (
evidence.engineType === ErrorTypes.MEDIA_ERROR ||
evidence.engineType === ErrorTypes.MUX_ERROR
) {
return DiagnosticCode.MediaDecodeError;
}
return DiagnosticCode.UnknownPlaybackError;
}
function getEngineType(value: unknown): HlsPlaybackEngineType {
return typeof value === 'string' && ENGINE_TYPES.has(value)
? (value as ErrorTypes)
: HlsPlaybackUnknownEngineType;
}
function getEngineDetails(value: unknown): ErrorDetails {
return typeof value === 'string' && ENGINE_DETAILS.has(value)
? (value as ErrorDetails)
: ErrorDetails.UNKNOWN;
}
function getHttpStatus(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= 100 &&
value <= 599
? value
: undefined;
}
function getStage(details: ErrorDetails): HlsPlaybackEvidence['stage'] {
if (MANIFEST_DETAILS.has(details)) {
return HlsPlaybackStage.Manifest;
}
if (LEVEL_DETAILS.has(details)) {
return HlsPlaybackStage.Level;
}
if (SEGMENT_DETAILS.has(details)) {
return HlsPlaybackStage.Segment;
}
if (KEY_DETAILS.has(details)) {
return HlsPlaybackStage.Key;
}
if (MEDIA_DETAILS.has(details)) {
return HlsPlaybackStage.Media;
}
return HlsPlaybackStage.Unknown;
}
function getFailure(
engineType: HlsPlaybackEngineType,
details: ErrorDetails,
httpStatus: number | undefined
): HlsPlaybackEvidence['failure'] {
if (TIMEOUT_DETAILS.has(details)) {
return HlsPlaybackFailure.Timeout;
}
if (
httpStatus !== undefined &&
httpStatus >= 400 &&
httpStatus <= 599
) {
return HlsPlaybackFailure.Http;
}
if (
engineType === ErrorTypes.NETWORK_ERROR ||
NETWORK_DETAILS.has(details)
) {
return HlsPlaybackFailure.Network;
}
return HlsPlaybackFailure.Unknown;
}
@@ -1,64 +0,0 @@
export const MpegTsPlaybackEngineType = {
Network: 'NetworkError',
Media: 'MediaError',
Other: 'OtherError',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineType =
(typeof MpegTsPlaybackEngineType)[keyof typeof MpegTsPlaybackEngineType];
export const MpegTsPlaybackEngineDetails = {
NetworkException: 'Exception',
HttpStatusCodeInvalid: 'HttpStatusCodeInvalid',
ConnectingTimeout: 'ConnectingTimeout',
UnrecoverableEarlyEof: 'UnrecoverableEarlyEof',
MediaMseError: 'MediaMSEError',
FormatError: 'FormatError',
FormatUnsupported: 'FormatUnsupported',
CodecUnsupported: 'CodecUnsupported',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineDetails =
(typeof MpegTsPlaybackEngineDetails)[keyof typeof MpegTsPlaybackEngineDetails];
export const MpegTsPlaybackDisposition = {
Terminal: 'terminal',
} as const;
export type MpegTsPlaybackDisposition =
(typeof MpegTsPlaybackDisposition)[keyof typeof MpegTsPlaybackDisposition];
export const MpegTsPlaybackStage = {
Loader: 'loader',
Demux: 'demux',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackStage =
(typeof MpegTsPlaybackStage)[keyof typeof MpegTsPlaybackStage];
export const MpegTsPlaybackFailure = {
Http: 'http',
Timeout: 'timeout',
Network: 'network',
TruncatedStream: 'truncated-stream',
Format: 'format',
Codec: 'codec',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackFailure =
(typeof MpegTsPlaybackFailure)[keyof typeof MpegTsPlaybackFailure];
export interface MpegTsPlaybackEvidence {
readonly engineType: MpegTsPlaybackEngineType;
readonly engineDetails: MpegTsPlaybackEngineDetails;
readonly disposition: MpegTsPlaybackDisposition;
readonly stage: MpegTsPlaybackStage;
readonly failure: MpegTsPlaybackFailure;
readonly httpStatus?: number;
}
@@ -1,239 +0,0 @@
import mpegts from 'mpegts.js';
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
classifyMpegTsPlaybackIssue,
createPlaybackSourceMetadata,
} from './playback-diagnostics.util';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
} from './mpegts-playback-evidence.model';
import {
MPEGTS_DIAGNOSTIC_VERSION,
createMpegTsPlaybackEvidence,
} from './mpegts-playback-evidence.util';
const METADATA = createPlaybackSourceMetadata({
url: 'https://example.test/live.ts',
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
});
describe('mpegts.js playback evidence', () => {
it('locks the accepted public contract to mpegts.js 1.8.0', () => {
expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION);
expect(mpegts.ErrorTypes).toEqual({
NETWORK_ERROR: MpegTsPlaybackEngineType.Network,
MEDIA_ERROR: MpegTsPlaybackEngineType.Media,
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
});
expect(mpegts.ErrorDetails).toEqual({
NETWORK_EXCEPTION:
MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_STATUS_CODE_INVALID:
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
NETWORK_UNRECOVERABLE_EARLY_EOF:
MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof,
MEDIA_MSE_ERROR: MpegTsPlaybackEngineDetails.MediaMseError,
MEDIA_FORMAT_ERROR: MpegTsPlaybackEngineDetails.FormatError,
MEDIA_FORMAT_UNSUPPORTED:
MpegTsPlaybackEngineDetails.FormatUnsupported,
MEDIA_CODEC_UNSUPPORTED:
MpegTsPlaybackEngineDetails.CodecUnsupported,
});
});
it.each([
[
'NetworkError',
'HttpStatusCodeInvalid',
'loader',
'http',
],
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
['NetworkError', 'Exception', 'loader', 'network'],
[
'NetworkError',
'UnrecoverableEarlyEof',
'loader',
'truncated-stream',
],
['MediaError', 'FormatError', 'demux', 'format'],
['MediaError', 'FormatUnsupported', 'demux', 'format'],
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
[
'MediaError',
'MediaMSEError',
'media-source',
'media-source',
],
])(
'maps %s + %s to %s/%s',
(engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(
engineType,
engineDetails,
undefined
)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
}
);
it.each([
['OtherError', 'CodecUnsupported'],
['NetworkError', 'CodecUnsupported'],
['MediaError', 'ConnectingTimeout'],
['networkerror', 'HttpStatusCodeInvalid'],
['NetworkError', 'httpstatuscodeinvalid'],
[{ type: 'NetworkError' }, 'Exception'],
['MediaError', { details: 'FormatError' }],
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
});
it.each([399, 600, 404.5, '404', null, undefined])(
'rejects invalid HTTP status %p',
(code) => {
const evidence = createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code }
);
expect(evidence.httpStatus).toBeUndefined();
}
);
it('reads HTTP status only from the exact top-level public slot', () => {
expect(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code: 404 }
).httpStatus
).toBe(404);
expect(
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
code: 503,
}).httpStatus
).toBeUndefined();
expect(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ response: { code: 503 } }
).httpStatus
).toBeUndefined();
});
it('does not inspect or retain arbitrary engine information', () => {
const secret = 'mpegts-info-secret';
const circularInfo: Record<string, unknown> = {
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
headers: { Authorization: secret },
};
circularInfo['self'] = circularInfo;
const evidence = createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
circularInfo
);
expect(evidence.httpStatus).toBe(404);
expect(JSON.stringify(evidence)).not.toContain(secret);
expect(JSON.stringify(evidence)).not.toContain('provider.example');
});
it.each([
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
['NetworkError', 'ConnectingTimeout', 'network-error', false],
['NetworkError', 'Exception', 'network-error', false],
[
'NetworkError',
'UnrecoverableEarlyEof',
'media-decode-error',
true,
],
['MediaError', 'FormatError', 'media-decode-error', true],
[
'MediaError',
'FormatUnsupported',
'unsupported-container',
true,
],
[
'MediaError',
'CodecUnsupported',
'unsupported-codec',
true,
],
['MediaError', 'MediaMSEError', 'media-decode-error', true],
['OtherError', 'Exception', 'unknown-playback-error', false],
])(
'classifies %s + %s as %s with fallback=%s',
(type, details, code, externalFallbackRecommended) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
expect(issue.code).toBe(code);
expect(issue.externalFallbackRecommended).toBe(
externalFallbackRecommended
);
expect(issue.details).toBeUndefined();
}
);
it('preserves an exact HTTP failure without retaining provider details', () => {
const secret = 'mpegts-http-secret';
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
}
),
METADATA
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 404,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
disposition: 'terminal',
stage: 'loader',
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
expect(issue.details).toBeUndefined();
expect(JSON.stringify(issue)).not.toContain(secret);
});
});
@@ -1,167 +0,0 @@
import {
type MpegTsPlaybackEngineDetails as MpegTsPlaybackEngineDetailsValue,
type MpegTsPlaybackEngineType as MpegTsPlaybackEngineTypeValue,
type MpegTsPlaybackEvidence,
type MpegTsPlaybackFailure as MpegTsPlaybackFailureValue,
type MpegTsPlaybackStage as MpegTsPlaybackStageValue,
MpegTsPlaybackDisposition,
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
MpegTsPlaybackFailure,
MpegTsPlaybackStage,
} from './mpegts-playback-evidence.model';
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.0';
interface MpegTsPlaybackCause {
readonly stage: MpegTsPlaybackStageValue;
readonly failure: MpegTsPlaybackFailureValue;
}
export function createMpegTsPlaybackEvidence(
type: unknown,
details: unknown,
info: unknown
): MpegTsPlaybackEvidence {
const engineType = normalizeEngineType(type);
const engineDetails = normalizeEngineDetails(details);
const cause = deriveMpegTsPlaybackCause(engineType, engineDetails);
const httpStatus =
engineType === MpegTsPlaybackEngineType.Network &&
engineDetails ===
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid
? readHttpStatus(info)
: undefined;
return {
engineType,
engineDetails,
disposition: MpegTsPlaybackDisposition.Terminal,
...cause,
...(httpStatus === undefined ? {} : { httpStatus }),
};
}
function normalizeEngineType(value: unknown): MpegTsPlaybackEngineTypeValue {
switch (value) {
case MpegTsPlaybackEngineType.Network:
case MpegTsPlaybackEngineType.Media:
case MpegTsPlaybackEngineType.Other:
return value;
default:
return MpegTsPlaybackEngineType.Unknown;
}
}
function normalizeEngineDetails(
value: unknown
): MpegTsPlaybackEngineDetailsValue {
switch (value) {
case MpegTsPlaybackEngineDetails.NetworkException:
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
case MpegTsPlaybackEngineDetails.MediaMseError:
case MpegTsPlaybackEngineDetails.FormatError:
case MpegTsPlaybackEngineDetails.FormatUnsupported:
case MpegTsPlaybackEngineDetails.CodecUnsupported:
return value;
default:
return MpegTsPlaybackEngineDetails.Unknown;
}
}
function deriveMpegTsPlaybackCause(
type: MpegTsPlaybackEngineTypeValue,
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
if (type === MpegTsPlaybackEngineType.Network) {
return deriveNetworkCause(details);
}
if (type === MpegTsPlaybackEngineType.Media) {
return deriveMediaCause(details);
}
return unknownCause();
}
function deriveNetworkCause(
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
switch (details) {
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Http
);
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Timeout
);
case MpegTsPlaybackEngineDetails.NetworkException:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Network
);
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.TruncatedStream
);
default:
return unknownCause();
}
}
function deriveMediaCause(
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
switch (details) {
case MpegTsPlaybackEngineDetails.FormatError:
case MpegTsPlaybackEngineDetails.FormatUnsupported:
return cause(
MpegTsPlaybackStage.Demux,
MpegTsPlaybackFailure.Format
);
case MpegTsPlaybackEngineDetails.CodecUnsupported:
return cause(
MpegTsPlaybackStage.Demux,
MpegTsPlaybackFailure.Codec
);
case MpegTsPlaybackEngineDetails.MediaMseError:
return cause(
MpegTsPlaybackStage.MediaSource,
MpegTsPlaybackFailure.MediaSource
);
default:
return unknownCause();
}
}
function cause(
stage: MpegTsPlaybackStageValue,
failure: MpegTsPlaybackFailureValue
): MpegTsPlaybackCause {
return { stage, failure };
}
function unknownCause(): MpegTsPlaybackCause {
return cause(
MpegTsPlaybackStage.Unknown,
MpegTsPlaybackFailure.Unknown
);
}
function readHttpStatus(info: unknown): number | undefined {
if (typeof info !== 'object' || info === null || Array.isArray(info)) {
return undefined;
}
const code = (info as Readonly<Record<string, unknown>>)['code'];
return typeof code === 'number' &&
Number.isInteger(code) &&
code >= 400 &&
code <= 599
? code
: undefined;
}
@@ -1,274 +0,0 @@
import type {
ExternalPlayerName,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
export * from './mpegts-playback-evidence.model';
export const PlaybackDiagnosticCode = {
UnsupportedContainer: 'unsupported-container',
UnsupportedCodec: 'unsupported-codec',
MediaDecodeError: 'media-decode-error',
NetworkError: 'network-error',
BrowserAccessError: 'browser-access-error',
DrmOrEncryption: 'drm-or-encryption',
UnknownPlaybackError: 'unknown-playback-error',
} as const;
export type PlaybackDiagnosticCode =
(typeof PlaybackDiagnosticCode)[keyof typeof PlaybackDiagnosticCode];
export const PlaybackDiagnosticSource = {
Source: 'source',
Native: 'native',
Vhs: 'vhs',
Hls: 'hls',
MpegTs: 'mpegts',
Shaka: 'shaka',
} as const;
export type PlaybackDiagnosticSource =
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
export const InlinePlaybackPlayer = {
VideoJs: 'videojs',
Html5: 'html5',
ArtPlayer: 'artplayer',
} as const;
export type InlinePlaybackPlayer =
(typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer];
export interface PlaybackSourceMetadataInput {
readonly url: string;
readonly mimeType?: string;
readonly player?: InlinePlaybackPlayer;
readonly audioCodecs?: readonly string[];
readonly videoCodecs?: readonly string[];
}
export interface PlaybackSourceMetadata {
readonly url: string;
readonly extension: string;
readonly container: string;
readonly mimeType?: string;
readonly player?: InlinePlaybackPlayer;
readonly audioCodecs: readonly string[];
readonly videoCodecs: readonly string[];
}
export interface NativePlaybackErrorMetadataInput {
readonly errorType?: unknown;
}
export interface NativePlaybackErrorInput {
readonly code?: number;
readonly message?: string;
readonly status?: number;
readonly metadata?: NativePlaybackErrorMetadataInput;
}
export const VhsPlaybackEngineType = {
NetworkBadStatus: 'networkbadstatus',
NetworkRequestFailed: 'networkrequestfailed',
NetworkRequestAborted: 'networkrequestaborted',
NetworkRequestTimeout: 'networkrequesttimeout',
NetworkBodyParserFailed: 'networkbodyparserfailed',
StreamingHlsPlaylistParserError: 'streaminghlsplaylistparsererror',
StreamingDashManifestParserError: 'streamingdashmanifestparsererror',
StreamingContentSteeringParserError: 'streamingcontentsteeringparsererror',
StreamingVttParserError: 'streamingvttparsererror',
StreamingFailedToSelectNextSegment: 'streamingfailedtoselectnextsegment',
StreamingFailedToDecryptSegment: 'streamingfailedtodecryptsegment',
StreamingFailedToTransmuxSegment: 'streamingfailedtotransmuxsegment',
StreamingFailedToAppendSegment: 'streamingfailedtoappendsegment',
StreamingCodecsChangeError: 'streamingcodecschangeerror',
} as const;
export const VhsPlaybackUnknownEngineType = 'unknown' as const;
export type VhsPlaybackEngineType =
| (typeof VhsPlaybackEngineType)[keyof typeof VhsPlaybackEngineType]
| typeof VhsPlaybackUnknownEngineType;
export const VhsPlaybackMediaErrorCode = {
Custom: 0,
Aborted: 1,
Network: 2,
Decode: 3,
SourceNotSupported: 4,
Encrypted: 5,
Unknown: 'unknown',
} as const;
export type VhsPlaybackMediaErrorCode =
(typeof VhsPlaybackMediaErrorCode)[keyof typeof VhsPlaybackMediaErrorCode];
export const VhsPlaybackDisposition = {
Terminal: 'terminal',
} as const;
export type VhsPlaybackDisposition =
(typeof VhsPlaybackDisposition)[keyof typeof VhsPlaybackDisposition];
export const VhsPlaybackStage = {
Manifest: 'manifest',
Playlist: 'playlist',
Segment: 'segment',
Unknown: 'unknown',
} as const;
export type VhsPlaybackStage =
(typeof VhsPlaybackStage)[keyof typeof VhsPlaybackStage];
export interface VhsPlaybackEvidence {
readonly engineType: VhsPlaybackEngineType;
readonly mediaErrorCode: VhsPlaybackMediaErrorCode;
readonly disposition: VhsPlaybackDisposition;
readonly stage: VhsPlaybackStage;
readonly httpStatus?: number;
}
export const HlsPlaybackDisposition = {
Fatal: 'fatal',
Recoverable: 'recoverable',
} as const;
export type HlsPlaybackDisposition =
(typeof HlsPlaybackDisposition)[keyof typeof HlsPlaybackDisposition];
export const HlsPlaybackStage = {
Manifest: 'manifest',
Level: 'level',
Segment: 'segment',
Key: 'key',
Media: 'media',
Unknown: 'unknown',
} as const;
export type HlsPlaybackStage =
(typeof HlsPlaybackStage)[keyof typeof HlsPlaybackStage];
export const HlsPlaybackFailure = {
Http: 'http',
Timeout: 'timeout',
Network: 'network',
Access: 'access',
Unknown: 'unknown',
} as const;
export type HlsPlaybackFailure =
(typeof HlsPlaybackFailure)[keyof typeof HlsPlaybackFailure];
export const HlsPlaybackUnknownEngineType = 'unknown' as const;
export type HlsPlaybackEngineType =
ErrorTypes | typeof HlsPlaybackUnknownEngineType;
export interface HlsPlaybackEvidence {
readonly engineType: HlsPlaybackEngineType;
readonly engineDetails: ErrorDetails;
readonly disposition: HlsPlaybackDisposition;
readonly stage: HlsPlaybackStage;
readonly failure: HlsPlaybackFailure;
readonly httpStatus?: number;
}
export const ShakaPlaybackSeverity = {
Recoverable: 'recoverable',
Critical: 'critical',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackSeverity =
(typeof ShakaPlaybackSeverity)[keyof typeof ShakaPlaybackSeverity];
export const ShakaPlaybackCategory = {
Network: 'network',
Text: 'text',
Media: 'media',
Manifest: 'manifest',
Streaming: 'streaming',
Drm: 'drm',
Player: 'player',
Cast: 'cast',
Storage: 'storage',
Ads: 'ads',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackCategory =
(typeof ShakaPlaybackCategory)[keyof typeof ShakaPlaybackCategory];
export const ShakaPlaybackDisposition = {
Terminal: 'terminal',
Recoverable: 'recoverable',
} as const;
export type ShakaPlaybackDisposition =
(typeof ShakaPlaybackDisposition)[keyof typeof ShakaPlaybackDisposition];
export const ShakaPlaybackStage = {
Manifest: 'manifest',
Segment: 'segment',
Media: 'media',
License: 'license',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackStage =
(typeof ShakaPlaybackStage)[keyof typeof ShakaPlaybackStage];
export const ShakaPlaybackFailure = {
Network: 'network',
Drm: 'drm',
Manifest: 'manifest',
Media: 'media',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackFailure =
(typeof ShakaPlaybackFailure)[keyof typeof ShakaPlaybackFailure];
export const ShakaPlaybackUnknownCode = 'unknown' as const;
export type ShakaPlaybackEngineCode = number | typeof ShakaPlaybackUnknownCode;
export interface ShakaPlaybackEvidence {
readonly severity: ShakaPlaybackSeverity;
readonly category: ShakaPlaybackCategory;
readonly engineCode: ShakaPlaybackEngineCode;
readonly disposition: ShakaPlaybackDisposition;
readonly stage: ShakaPlaybackStage;
readonly failure: ShakaPlaybackFailure;
readonly httpStatus?: number;
}
export interface PlaybackDiagnostic {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
readonly sourceUrl: string;
readonly container: string;
readonly mimeType?: string;
readonly player?: InlinePlaybackPlayer;
readonly audioCodecs: readonly string[];
readonly videoCodecs: readonly string[];
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
export interface PlaybackFallbackRequest {
readonly player: ExternalPlayerName;
readonly playback: ResolvedPortalPlayback;
readonly diagnostic: PlaybackDiagnostic;
}
@@ -1,761 +0,0 @@
import { ErrorDetails, ErrorTypes } from 'hls.js';
import type {
NativePlaybackErrorInput,
PlaybackDiagnostic,
PlaybackSourceMetadata,
} from './playback-diagnostics.model';
import * as diagnostics from './playback-diagnostics.util';
import {
PlaybackDiagnosticCode,
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from './playback-diagnostics.util';
interface StructuredHlsEvidenceInput {
readonly engineType: string;
readonly engineDetails: string;
readonly disposition: string;
readonly stage: string;
readonly failure: string;
readonly httpStatus?: number;
}
function classifyStructuredHlsPlaybackIssue(
evidence: StructuredHlsEvidenceInput,
metadata: Parameters<typeof classifyHlsPlaybackIssue>[1]
): ReturnType<typeof classifyHlsPlaybackIssue> {
return classifyHlsPlaybackIssue(evidence as never, metadata);
}
type VhsClassifier = (
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
) => PlaybackDiagnostic;
describe('playback diagnostics', () => {
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'unknown',
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: ['ac-3'],
videoCodecs: ['avc1.64001f'],
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue?.externalFallbackRecommended).toBe(true);
expect(issue?.audioCodecs).toEqual(['ac-3']);
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
});
it('classifies HLS buffer codec errors as unsupported codec fallbacks', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails: ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
disposition: 'fatal',
stage: 'media',
failure: 'unknown',
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'html5',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue?.externalFallbackRecommended).toBe(true);
});
it('classifies native decode and unsupported source errors without using network wording', () => {
const decodeIssue = classifyNativePlaybackIssue(
{ code: 3, message: 'decode failed' },
createPlaybackSourceMetadata({
url: 'https://example.com/movie.mp4',
mimeType: 'video/mp4',
player: 'artplayer',
})
);
const unsupportedSourceIssue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
createPlaybackSourceMetadata({
url: 'https://example.com/movie.mkv',
mimeType: 'video/matroska',
player: 'artplayer',
})
);
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(decodeIssue.externalFallbackRecommended).toBe(true);
expect(unsupportedSourceIssue.code).toBe(
PlaybackDiagnosticCode.UnsupportedContainer
);
expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true);
});
it('classifies MIME-only unsupported containers as container diagnostics', () => {
const issue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
createPlaybackSourceMetadata({
url: 'https://example.com/archive/stream',
mimeType: 'video/x-msvideo',
player: 'html5',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
expect(issue.container).toBe('x-msvideo');
});
it('keeps MPEG-TS MIME-only source failures unknown without codec evidence', () => {
const issue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
createPlaybackSourceMetadata({
url: 'https://example.com/live/stream',
mimeType: 'video/mp2t',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.container).toBe('mp2t');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies native HTTP source failures from a safe status and error type', () => {
const issue = classifyNativePlaybackIssue(
{
code: 4,
message: 'source not supported',
status: 404,
metadata: { errorType: 'networkrequestfailed' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.httpStatus).toBe(404);
expect(issue.nativeErrorType).toBe('networkrequestfailed');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies exact VHS network evidence without using provider text', () => {
const issue = classifyVhsPlaybackIssue(
{
code: 4,
message:
'provider says codec DRM CORS at ' +
'https://provider.example/live.m3u8?token=secret',
metadata: { errorType: 'networkrequestfailed' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
source: 'vhs',
httpStatus: undefined,
nativeErrorMessage: undefined,
externalFallbackRecommended: false,
vhs: {
engineType: 'networkrequestfailed',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
},
})
);
});
it('classifies exact VHS decrypt evidence without inspecting messages', () => {
const issue = classifyVhsPlaybackIssue(
{
code: 3,
message: 'provider network timeout',
metadata: {
errorType: 'streamingfailedtodecryptsegment',
},
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/encrypted.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe('vhs');
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.nativeErrorMessage).toBeUndefined();
expect(issue.vhs).toEqual({
engineType: 'streamingfailedtodecryptsegment',
mediaErrorCode: 3,
disposition: 'terminal',
stage: 'segment',
});
});
it('classifies the public encrypted MediaError code without DRM text', () => {
const issue = classifyVhsPlaybackIssue(
{ code: 5, message: 'provider supplied message' },
createPlaybackSourceMetadata({
url: 'https://example.com/live/encrypted.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.nativeErrorMessage).toBeUndefined();
});
it('keeps generic VHS code three unknown while native code three remains decode evidence', () => {
const metadata = createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
});
const vhsIssue = classifyVhsPlaybackIssue(
{
code: 3,
message:
'Playback cannot continue. No available working or supported playlists.',
},
metadata
);
const nativeIssue = classifyNativePlaybackIssue(
{ code: 3, message: 'native decode failed' },
metadata
);
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(vhsIssue.externalFallbackRecommended).toBe(false);
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
});
it('retains only safe VHS HTTP and allowlisted engine evidence', () => {
const secret = 'vhs-classifier-secret';
const issue = classifyVhsPlaybackIssue(
{
code: 4,
status: 503,
message: `https://provider.example/?token=${secret}`,
metadata: {
errorType: 'networkbadstatus',
requestType: 'hls-key',
headers: { Authorization: secret },
responseBody: secret,
},
} as NativePlaybackErrorInput,
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
})
);
const serializedEvidence = JSON.stringify(issue.vhs);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.httpStatus).toBe(503);
expect(issue.nativeErrorMessage).toBeUndefined();
expect(serializedEvidence).not.toContain(secret);
expect(serializedEvidence).not.toContain('provider.example');
expect(serializedEvidence).not.toContain('hls-key');
expect(serializedEvidence).not.toContain('Authorization');
});
it('keeps unknown VHS metadata unknown despite cause-shaped provider text', () => {
const issue = classifyVhsPlaybackIssue(
{
code: 3,
message: 'CORS codec DRM network timeout',
metadata: { errorType: 'providerCodecCorsDrmError' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.vhs?.engineType).toBe('unknown');
expect(issue.nativeErrorMessage).toBeUndefined();
});
it('keeps native code four HLS source failures unknown without status or container evidence', () => {
const issue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(issue.httpStatus).toBeUndefined();
});
it('does not retain unsafe native status or metadata error types', () => {
const issue = classifyNativePlaybackIssue(
{
code: 4,
message: 'source not supported',
status: 0,
metadata: { errorType: 'request failed: token=secret value' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.httpStatus).toBeUndefined();
expect(issue.nativeErrorType).toBeUndefined();
expect(issue.externalFallbackRecommended).toBe(false);
});
it.each([
{ status: 399, accepted: false },
{ status: 400, accepted: true },
{ status: 599, accepted: true },
{ status: 600, accepted: false },
{ status: 404.5, accepted: false },
])(
'accepts native HTTP status $status only when it is a 4xx or 5xx integer',
({ status, accepted }) => {
const issue = classifyNativePlaybackIssue(
{ code: 4, status },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(
accepted
? PlaybackDiagnosticCode.NetworkError
: PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue.httpStatus).toBe(accepted ? status : undefined);
}
);
it.each([
{ length: 128, accepted: true },
{ length: 129, accepted: false },
])(
'retains native error type identifiers up to $length characters',
({ length, accepted }) => {
const errorType = 'a'.repeat(length);
const issue = classifyNativePlaybackIssue(
{ code: 4, metadata: { errorType } },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue.nativeErrorType).toBe(
accepted ? errorType : undefined
);
}
);
it('classifies HLS network errors without claiming codec incompatibility', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'network',
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('retains structured HLS HTTP and stage evidence', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'http',
httpStatus: 404,
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
})
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 404,
hls: expect.objectContaining({
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
});
it('does not create terminal diagnostics for recoverable HLS events', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.FRAG_LOAD_ERROR,
disposition: 'recoverable',
stage: 'segment',
failure: 'network',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live/index.m3u8',
player: 'videojs',
})
);
expect(issue).toBeNull();
});
it('keeps status-zero HLS failures as network evidence rather than guessing access', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'network',
},
createPlaybackSourceMetadata({
url: 'http://provider.example/live.m3u8',
player: 'videojs',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.httpStatus).toBeUndefined();
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails: ErrorDetails.FRAG_DECRYPT_ERROR,
disposition: 'fatal',
stage: 'segment',
failure: 'unknown',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live.m3u8',
player: 'html5',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue?.externalFallbackRecommended).toBe(true);
});
it('classifies exact HLS key load failures as network errors', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.NETWORK_ERROR,
engineDetails: ErrorDetails.KEY_LOAD_ERROR,
disposition: 'fatal',
stage: 'key',
failure: 'network',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live.m3u8',
player: 'artplayer',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
'classifies exact HLS %s evidence as a media decode error',
(engineType) => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType,
engineDetails: ErrorDetails.FRAG_PARSING_ERROR,
disposition: 'fatal',
stage: 'segment',
failure: 'unknown',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live.m3u8',
player: 'html5',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
}
);
it('keeps unknown structured HLS evidence unknown', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: 'unknown',
engineDetails: ErrorDetails.UNKNOWN,
disposition: 'fatal',
stage: 'unknown',
failure: 'unknown',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live.m3u8?codec=network',
player: 'html5',
})
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('classifies native CORS failures as browser access errors', () => {
const issue = classifyNativePlaybackIssue(
{
code: 2,
message:
'Access to media at https://provider.example/live.m3u8 from origin app://iptvnator has been blocked by CORS policy: No Access-Control-Allow-Origin header is present.',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live.m3u8',
player: 'html5',
})
);
expect(issue.code).toBe('browser-access-error');
expect(issue.externalFallbackRecommended).toBe(true);
});
it('keeps opaque native network failures generic when the browser hides access details', () => {
const issue = classifyNativePlaybackIssue(
{
code: 2,
message: '',
},
createPlaybackSourceMetadata({
url: 'https://provider.example/live.m3u8',
player: 'html5',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
});
it('does not guess mpegts browser access from exception messages', () => {
const secret = 'blocked by CORS policy';
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
msg: secret,
}),
createPlaybackSourceMetadata({
url: 'https://provider.example/live/channel.ts',
mimeType: 'video/mp2t',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(JSON.stringify(issue)).not.toContain(secret);
});
it('classifies mpegts early EOF failures as fallback-actionable media errors', () => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
'NetworkError',
'UnrecoverableEarlyEof',
{ msg: 'Fetch stream meet Early-EOF' }
),
createPlaybackSourceMetadata({
url: 'https://provider.example/movie/123.ts',
mimeType: 'video/mp2t',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.mpegTs?.failure).toBe('truncated-stream');
expect(issue.details).toBeUndefined();
});
it('classifies mpegts codec errors as unsupported codec fallbacks', () => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
'MediaError',
'CodecUnsupported',
undefined
),
createPlaybackSourceMetadata({
url: 'https://example.com/live/channel.ts',
mimeType: 'video/mp2t',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue.externalFallbackRecommended).toBe(true);
});
it('prefers stream extension query metadata over web script path extensions', () => {
const metadata = createPlaybackSourceMetadata({
url: 'http://portal.example/play/live.php?stream=123&extension=ts',
player: 'html5',
});
expect(metadata.extension).toBe('ts');
expect(metadata.container).toBe('ts');
});
it('does not expose web script extensions as media containers', () => {
const metadata = createPlaybackSourceMetadata({
url: 'http://portal.example/play/live.php?stream=123',
player: 'html5',
});
expect(metadata.extension).toBe('');
expect(metadata.container).toBe('');
});
it('uses declared media query metadata without exposing web script extensions', () => {
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?format=m3u8&stream=123'
)
).toBe('m3u8');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?container=m3u8&stream=123'
)
).toBe('m3u8');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?ext=ts&stream=123'
)
).toBe('ts');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?stream=123'
)
).toBe('');
});
it('keeps trusted media path extensions ahead of generic query metadata', () => {
expect(
getPlaybackMediaExtensionFromUrl(
'http://cdn.example/archive/movie.mp4?container=m3u8'
)
).toBe('mp4');
expect(
getPlaybackMediaExtensionFromUrl(
'http://cdn.example/live/channel.m3u8?format=mp4'
)
).toBe('m3u8');
});
it('keeps explicit extension query metadata ahead of trusted media path extensions', () => {
expect(
getPlaybackMediaExtensionFromUrl(
'http://cdn.example/live/channel.ts?extension=m3u8'
)
).toBe('m3u8');
expect(
getPlaybackMediaExtensionFromUrl(
'http://cdn.example/live/channel.m3u8?ext=ts'
)
).toBe('ts');
});
it('does not infer media extensions from generic type/output query metadata', () => {
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?type=m3u8&stream=123'
)
).toBe('');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?output=ts&stream=123'
)
).toBe('');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?format=mpv&stream=123'
)
).toBe('');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.mpv?stream=123'
)
).toBe('');
});
it('detects codecs with limited Chromium browser-player support', () => {
expect(
getLikelyBrowserUnsupportedCodecLabels({
audioCodecs: ['mp4a.40.2', 'ac-3', 'ec-3'],
videoCodecs: ['avc1.64001f', 'hvc1.1.6.L93.B0'],
})
).toEqual(['HEVC', 'AC-3', 'E-AC-3']);
});
});
function classifyVhsPlaybackIssue(
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const classifier = (
diagnostics as unknown as {
readonly classifyVhsPlaybackIssue?: VhsClassifier;
}
).classifyVhsPlaybackIssue;
expect(classifier).toBeDefined();
if (!classifier) {
throw new Error('classifyVhsPlaybackIssue is not exported');
}
return classifier(error, metadata);
}
@@ -1,338 +0,0 @@
import type {
HlsPlaybackEvidence,
MpegTsPlaybackEvidence,
NativePlaybackErrorInput,
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackSourceMetadata,
ShakaPlaybackEvidence,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
VhsPlaybackEvidence,
} from './playback-diagnostics.model';
import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model';
import { HlsPlaybackDisposition } from './playback-diagnostics.model';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackFailure,
} from './mpegts-playback-evidence.model';
import { PlaybackDiagnosticSource as DiagnosticSource } from './playback-diagnostics.model';
import {
VhsPlaybackEngineType,
VhsPlaybackMediaErrorCode,
} from './playback-diagnostics.model';
import { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util';
import { isBrowserAccessFailure } from './playback-error-patterns.util';
import { isLikelyContainerIssue } from './playback-media-source.util';
import { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export * from './playback-diagnostics.model';
export { createHlsPlaybackEvidence } from './hls-playback-evidence.util';
export { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.util';
export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export {
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from './playback-media-source.util';
const SOURCE_NOT_SUPPORTED_CODE = 4;
const DECODE_ERROR_CODE = 3;
const NETWORK_ERROR_CODE = 2;
const NATIVE_ERROR_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const VHS_NETWORK_ERROR_TYPES: ReadonlySet<VhsPlaybackEngineTypeValue> =
new Set([
VhsPlaybackEngineType.NetworkBadStatus,
VhsPlaybackEngineType.NetworkRequestFailed,
VhsPlaybackEngineType.NetworkRequestAborted,
VhsPlaybackEngineType.NetworkRequestTimeout,
VhsPlaybackEngineType.NetworkBodyParserFailed,
]);
export function classifyNativePlaybackIssue(
error: NativePlaybackErrorInput | MediaError | null | undefined,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const nativeErrorCode = error?.code;
const nativeErrorMessage = error?.message || undefined;
const nativeErrorInput = error as
NativePlaybackErrorInput | null | undefined;
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
const nativeErrorType = getNativeErrorType(
nativeErrorInput?.metadata?.errorType
);
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
if (httpStatus !== undefined) {
return createPlaybackDiagnostic({
code: DiagnosticCode.NetworkError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
httpStatus,
nativeErrorType,
});
}
if (nativeErrorCode === NETWORK_ERROR_CODE) {
// Native MediaError details are often opaque for browser security
// failures. Only classify browser access when the runtime exposes a
// concrete CORS/mixed-content/CSP-style message.
return createPlaybackDiagnostic({
code: isBrowserAccessFailure(lowerNativeErrorMessage)
? DiagnosticCode.BrowserAccessError
: DiagnosticCode.NetworkError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
if (nativeErrorCode === DECODE_ERROR_CODE) {
return createPlaybackDiagnostic({
code: DiagnosticCode.MediaDecodeError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) {
return createPlaybackDiagnostic({
code: isLikelyContainerIssue(metadata)
? DiagnosticCode.UnsupportedContainer
: DiagnosticCode.UnknownPlaybackError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
return createPlaybackDiagnostic({
code: DiagnosticCode.UnknownPlaybackError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
export function classifyVhsPlaybackIssue(
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const evidence = createVhsPlaybackEvidence(error);
const code = getVhsPlaybackDiagnosticCode(evidence, metadata);
return createPlaybackDiagnostic({
code,
source: DiagnosticSource.Vhs,
metadata,
nativeErrorCode:
typeof evidence.mediaErrorCode === 'number'
? evidence.mediaErrorCode
: undefined,
httpStatus: evidence.httpStatus,
vhs: evidence,
});
}
export function classifyHlsPlaybackIssue(
evidence: HlsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic | null {
if (evidence.disposition === HlsPlaybackDisposition.Recoverable) {
return null;
}
return createPlaybackDiagnostic({
code: getHlsPlaybackDiagnosticCode(evidence),
source: DiagnosticSource.Hls,
metadata,
httpStatus: evidence.httpStatus,
hls: evidence,
});
}
export function classifyMpegTsPlaybackIssue(
evidence: MpegTsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
return createPlaybackDiagnostic({
code: getMpegTsPlaybackDiagnosticCode(evidence),
source: DiagnosticSource.MpegTs,
metadata,
httpStatus: evidence.httpStatus,
mpegTs: evidence,
});
}
export function classifyUnsupportedHlsManifestCodecs(
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic | null {
if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') {
return null;
}
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
if (MediaSource.isTypeSupported(mimeType)) {
return null;
}
return createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedCodec,
source: DiagnosticSource.Source,
metadata,
details: mimeType,
});
}
export function createPlaybackDiagnostic(options: {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
readonly metadata: PlaybackSourceMetadata;
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
readonly externalFallbackRecommended?: boolean;
}): PlaybackDiagnostic {
const {
code,
source,
metadata,
details,
nativeErrorCode,
nativeErrorMessage,
httpStatus,
nativeErrorType,
vhs,
hls,
mpegTs,
shaka,
} = options;
return {
code,
source,
sourceUrl: metadata.url,
container: metadata.container,
mimeType: metadata.mimeType,
player: metadata.player,
audioCodecs: metadata.audioCodecs,
videoCodecs: metadata.videoCodecs,
details: details || undefined,
nativeErrorCode,
nativeErrorMessage,
httpStatus,
nativeErrorType,
vhs,
hls,
mpegTs,
shaka,
externalFallbackRecommended:
options.externalFallbackRecommended ??
isExternalFallbackRecommended(code),
};
}
function getMpegTsPlaybackDiagnosticCode(
evidence: MpegTsPlaybackEvidence
): PlaybackDiagnosticCode {
switch (evidence.failure) {
case MpegTsPlaybackFailure.Http:
case MpegTsPlaybackFailure.Timeout:
case MpegTsPlaybackFailure.Network:
return DiagnosticCode.NetworkError;
case MpegTsPlaybackFailure.TruncatedStream:
case MpegTsPlaybackFailure.MediaSource:
return DiagnosticCode.MediaDecodeError;
case MpegTsPlaybackFailure.Codec:
return DiagnosticCode.UnsupportedCodec;
case MpegTsPlaybackFailure.Format:
return evidence.engineDetails ===
MpegTsPlaybackEngineDetails.FormatUnsupported
? DiagnosticCode.UnsupportedContainer
: DiagnosticCode.MediaDecodeError;
default:
return DiagnosticCode.UnknownPlaybackError;
}
}
function getVhsPlaybackDiagnosticCode(
evidence: VhsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnosticCode {
if (
evidence.httpStatus !== undefined ||
VHS_NETWORK_ERROR_TYPES.has(evidence.engineType) ||
evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Network
) {
return DiagnosticCode.NetworkError;
}
if (
evidence.engineType ===
VhsPlaybackEngineType.StreamingFailedToDecryptSegment ||
evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Encrypted
) {
return DiagnosticCode.DrmOrEncryption;
}
if (
evidence.mediaErrorCode ===
VhsPlaybackMediaErrorCode.SourceNotSupported &&
isLikelyContainerIssue(metadata)
) {
return DiagnosticCode.UnsupportedContainer;
}
return DiagnosticCode.UnknownPlaybackError;
}
function getNativeHttpStatus(status: unknown): number | undefined {
return typeof status === 'number' &&
Number.isInteger(status) &&
status >= 400 &&
status <= 599
? status
: undefined;
}
function getNativeErrorType(errorType: unknown): string | undefined {
return typeof errorType === 'string' &&
NATIVE_ERROR_TYPE_PATTERN.test(errorType)
? errorType
: undefined;
}
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
return (
code === DiagnosticCode.UnsupportedContainer ||
code === DiagnosticCode.UnsupportedCodec ||
code === DiagnosticCode.MediaDecodeError ||
code === DiagnosticCode.BrowserAccessError ||
code === DiagnosticCode.DrmOrEncryption
);
}
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
}
@@ -1,18 +0,0 @@
export function isBrowserAccessFailure(details: string): boolean {
return (
details.includes('cors') ||
details.includes('cross-origin') ||
details.includes('cross origin') ||
details.includes('access-control') ||
details.includes('access control') ||
details.includes('content security policy') ||
details.includes('mixed content') ||
details.includes('private network access') ||
details.includes('blocked by content security') ||
details.includes('blocked by cors') ||
details.includes('blocked by mixed content') ||
details.includes('not allowed to load local resource') ||
details.includes('err_blocked') ||
details.includes('err_cleartext')
);
}
@@ -1,115 +0,0 @@
import {
UNSUPPORTED_CONTAINER_EXTENSIONS,
getPlaybackMediaExtensionFromUrl,
} from '@iptvnator/shared/m3u-utils';
import type {
PlaybackSourceMetadata,
PlaybackSourceMetadataInput,
} from './playback-diagnostics.model';
// Media-extension normalization lives in @iptvnator/shared/m3u-utils so M3U
// routing (isDashChannel) and engine selection always agree; re-exported here
// to keep the playback-lib import surface stable.
export { getPlaybackMediaExtensionFromUrl };
const UNSUPPORTED_CONTAINER_NAMES = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
'matroska',
'quicktime',
'x-matroska',
'x-msvideo',
]);
const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{
readonly label: string;
readonly pattern: RegExp;
}> = [
{ label: 'HEVC', pattern: /^(hev1|hvc1|hevc|h265)/ },
{ label: 'AC-3', pattern: /^(ac-?3|dac3)$/ },
{ label: 'E-AC-3', pattern: /^(ec-?3|eac-?3|dec3)$/ },
{ label: 'DTS', pattern: /^(dts|dtsc|dtse|dtsh|dtsl)/ },
{ label: 'MPEG-2 Video', pattern: /^(mp2v|mpeg2video)/ },
];
export function createPlaybackSourceMetadata(
input: PlaybackSourceMetadataInput
): PlaybackSourceMetadata {
const extension = getPlaybackMediaExtensionFromUrl(input.url);
const mimeType = input.mimeType?.trim() || undefined;
return {
url: input.url,
extension,
container: extension || inferContainerFromMimeType(mimeType),
mimeType,
player: input.player,
audioCodecs: normalizeCodecs(input.audioCodecs),
videoCodecs: normalizeCodecs(input.videoCodecs),
};
}
export function getLikelyBrowserUnsupportedCodecLabels(
metadata: Pick<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
): string[] {
const codecs = [...metadata.videoCodecs, ...metadata.audioCodecs].map(
normalizeToken
);
return BROWSER_LIMITED_CODEC_PATTERNS.filter(({ pattern }) =>
codecs.some((codec) => pattern.test(codec))
).map(({ label }) => label);
}
export function isLikelyContainerIssue(
metadata: PlaybackSourceMetadata
): boolean {
return (
UNSUPPORTED_CONTAINER_NAMES.has(metadata.extension) ||
UNSUPPORTED_CONTAINER_NAMES.has(metadata.container) ||
metadata.mimeType === 'video/matroska'
);
}
export function mergeCodecMetadata(
metadata: PlaybackSourceMetadata,
codecs: Pick<PlaybackSourceMetadataInput, 'audioCodecs' | 'videoCodecs'>
): PlaybackSourceMetadata {
const audioCodecs = normalizeCodecs([
...metadata.audioCodecs,
...(codecs.audioCodecs ?? []),
]);
const videoCodecs = normalizeCodecs([
...metadata.videoCodecs,
...(codecs.videoCodecs ?? []),
]);
return {
...metadata,
audioCodecs,
videoCodecs,
};
}
function inferContainerFromMimeType(mimeType: string | undefined): string {
if (!mimeType) {
return '';
}
const subtype = mimeType.split(';')[0]?.split('/')[1] ?? '';
return subtype.toLowerCase();
}
function normalizeCodecs(codecs: readonly string[] | undefined): string[] {
return Array.from(
new Set(
(codecs ?? [])
.map((codec) => codec.trim())
.filter((codec) => codec.length > 0)
)
);
}
function normalizeToken(value: string | undefined): string {
return value?.trim().toLowerCase() ?? '';
}
@@ -1,297 +0,0 @@
import { execFileSync } from 'node:child_process';
import type { NativePlaybackErrorInput } from './playback-diagnostics.model';
import * as diagnostics from './playback-diagnostics.util';
interface ExpectedVhsPlaybackEvidence {
readonly engineType: string;
readonly mediaErrorCode: number | string;
readonly disposition: string;
readonly stage: string;
readonly httpStatus?: number;
}
type EvidenceFactory = (
error: NativePlaybackErrorInput
) => ExpectedVhsPlaybackEvidence;
interface VideoJsErrorConstants {
readonly [name: string]: string;
}
describe('VHS playback evidence', () => {
it('matches the installed public videojs.Error identifiers', () => {
expect(Object.values(getEngineTypes()).sort()).toEqual(
Object.values(getInstalledVideoJsErrors()).sort()
);
});
it('extracts only allowlisted fields from a real VHS bad-status shape', () => {
const secret = 'vhs-evidence-secret';
const evidence = createEvidence({
code: 4,
status: 503,
message:
'HLS playlist request error at URL: ' +
`https://provider.example/live.m3u8?token=${secret}`,
metadata: {
errorType: getInstalledVideoJsErrors()['NetworkBadStatus'],
requestType: 'hls-playlist',
uri: `https://provider.example/live.m3u8?token=${secret}`,
headers: { Authorization: `Bearer ${secret}` },
responseText: `provider body ${secret}`,
},
} as NativePlaybackErrorInput);
const serialized = JSON.stringify(evidence);
expect(evidence).toEqual({
engineType: 'networkbadstatus',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 503,
});
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('hls-playlist');
expect(Object.keys(evidence).sort()).toEqual(
[
'disposition',
'engineType',
'httpStatus',
'mediaErrorCode',
'stage',
].sort()
);
});
it.each([
{
engineType: 'streaminghlsplaylistparsererror',
stage: 'playlist',
},
{
engineType: 'streamingdashmanifestparsererror',
stage: 'manifest',
},
{
engineType: 'streamingfailedtoselectnextsegment',
stage: 'segment',
},
{
engineType: 'streamingfailedtodecryptsegment',
stage: 'segment',
},
{
engineType: 'streamingfailedtotransmuxsegment',
stage: 'segment',
},
{
engineType: 'streamingfailedtoappendsegment',
stage: 'segment',
},
{
engineType: 'networkrequestfailed',
stage: 'unknown',
},
{
engineType: 'streamingcontentsteeringparsererror',
stage: 'unknown',
},
{
engineType: 'streamingvttparsererror',
stage: 'unknown',
},
{
engineType: 'streamingcodecschangeerror',
stage: 'unknown',
},
])(
'maps public $engineType evidence only to the proven $stage stage',
({ engineType, stage }) => {
expect(
createEvidence({
code: 3,
metadata: { errorType: engineType },
}).stage
).toBe(stage);
}
);
it.each([
{ code: 0, expected: 0 },
{ code: 5, expected: 5 },
{ code: -1, expected: 'unknown' },
{ code: 6, expected: 'unknown' },
{ code: 3.5, expected: 'unknown' },
])(
'retains MediaError code $code only inside the standard range',
({ code, expected }) => {
expect(createEvidence({ code }).mediaErrorCode).toBe(expected);
}
);
it.each([
{ status: 399, expected: undefined },
{ status: 400, expected: 400 },
{ status: 599, expected: 599 },
{ status: 600, expected: undefined },
{ status: 404.5, expected: undefined },
])(
'retains HTTP status $status only for integer 4xx and 5xx failures',
({ status, expected }) => {
expect(createEvidence({ code: 2, status }).httpStatus).toBe(
expected
);
}
);
it('keeps unknown identifiers unknown without inspecting messages or request types', () => {
const evidence = createEvidence({
code: 3,
status: 0,
message:
'CORS codec DRM failure in hls-key request at provider URL',
metadata: {
errorType: 'providerCorsCodecDrmFailure',
requestType: 'hls-key',
},
} as NativePlaybackErrorInput);
expect(evidence).toEqual({
engineType: 'unknown',
mediaErrorCode: 3,
disposition: 'terminal',
stage: 'unknown',
});
});
it('populates player.error before the installed Video.js error listener runs', () => {
expect(readInstalledVideoJsErrorFromListener()).toEqual({
code: 4,
status: 503,
errorType: 'networkbadstatus',
});
});
});
function createEvidence(
error: NativePlaybackErrorInput
): ExpectedVhsPlaybackEvidence {
const factory = (
diagnostics as unknown as {
readonly createVhsPlaybackEvidence?: EvidenceFactory;
}
).createVhsPlaybackEvidence;
expect(factory).toBeDefined();
if (!factory) {
throw new Error('createVhsPlaybackEvidence is not exported');
}
return factory(error);
}
function getEngineTypes(): VideoJsErrorConstants {
const engineTypes = (
diagnostics as unknown as {
readonly VhsPlaybackEngineType?: VideoJsErrorConstants;
}
).VhsPlaybackEngineType;
expect(engineTypes).toBeDefined();
if (!engineTypes) {
throw new Error('VhsPlaybackEngineType is not exported');
}
return engineTypes;
}
function getInstalledVideoJsErrors(): VideoJsErrorConstants {
const script =
"import videoJs from 'video.js';" +
'process.stdout.write(JSON.stringify(videoJs.Error));';
const output = execFileSync(
process.execPath,
['--input-type=module', '-e', script],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);
return JSON.parse(output) as VideoJsErrorConstants;
}
function readInstalledVideoJsErrorFromListener(): {
readonly code: number;
readonly status: number;
readonly errorType: string;
} {
const script = `
import { createRequire } from 'node:module';
const rootRequire = createRequire(import.meta.url);
const environmentPath = rootRequire.resolve(
'jest-environment-jsdom/package.json'
);
const environmentRequire = createRequire(environmentPath);
const { JSDOM } = environmentRequire('jsdom');
const dom = new JSDOM(
'<!doctype html><html><body></body></html>',
{ url: 'http://localhost/', pretendToBeVisual: true }
);
dom.window.HTMLMediaElement.prototype.load = () => {};
for (const name of [
'window',
'document',
'navigator',
'Element',
'HTMLElement',
'HTMLVideoElement',
'HTMLMediaElement',
'Event',
'CustomEvent',
'Node',
]) {
Object.defineProperty(globalThis, name, {
value: dom.window[name],
configurable: true,
});
}
const { default: videoJs } = await import('video.js');
videoJs.log.level('off');
const video = document.createElement('video');
document.body.append(video);
const player = videoJs(video);
let observed = null;
player.on('error', () => {
observed = player.error();
});
player.error({
code: 4,
status: 503,
metadata: {
errorType: videoJs.Error.NetworkBadStatus,
},
});
process.stdout.write(JSON.stringify({
code: observed.code,
status: observed.status,
errorType: observed.metadata.errorType,
}));
player.dispose();
dom.window.close();
`;
const output = execFileSync(
process.execPath,
['--input-type=module', '-e', script],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);
return JSON.parse(output) as {
readonly code: number;
readonly status: number;
readonly errorType: string;
};
}
@@ -1,76 +0,0 @@
import type {
NativePlaybackErrorInput,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
VhsPlaybackEvidence,
VhsPlaybackMediaErrorCode as VhsPlaybackMediaErrorCodeValue,
VhsPlaybackStage as VhsPlaybackStageValue,
} from './playback-diagnostics.model';
import {
VhsPlaybackDisposition,
VhsPlaybackEngineType,
VhsPlaybackMediaErrorCode,
VhsPlaybackStage,
VhsPlaybackUnknownEngineType,
} from './playback-diagnostics.model';
const ENGINE_TYPES = new Set<string>(Object.values(VhsPlaybackEngineType));
const SEGMENT_ENGINE_TYPES = new Set<VhsPlaybackEngineTypeValue>([
VhsPlaybackEngineType.StreamingFailedToSelectNextSegment,
VhsPlaybackEngineType.StreamingFailedToDecryptSegment,
VhsPlaybackEngineType.StreamingFailedToTransmuxSegment,
VhsPlaybackEngineType.StreamingFailedToAppendSegment,
]);
export function createVhsPlaybackEvidence(
error: NativePlaybackErrorInput
): VhsPlaybackEvidence {
const engineType = getEngineType(error.metadata?.errorType);
const httpStatus = getHttpStatus(error.status);
const evidence: VhsPlaybackEvidence = {
engineType,
mediaErrorCode: getMediaErrorCode(error.code),
disposition: VhsPlaybackDisposition.Terminal,
stage: getStage(engineType),
};
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
}
function getEngineType(value: unknown): VhsPlaybackEngineTypeValue {
return typeof value === 'string' && ENGINE_TYPES.has(value)
? (value as VhsPlaybackEngineTypeValue)
: VhsPlaybackUnknownEngineType;
}
function getMediaErrorCode(value: unknown): VhsPlaybackMediaErrorCodeValue {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= VhsPlaybackMediaErrorCode.Custom &&
value <= VhsPlaybackMediaErrorCode.Encrypted
? (value as VhsPlaybackMediaErrorCodeValue)
: VhsPlaybackMediaErrorCode.Unknown;
}
function getHttpStatus(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= 400 &&
value <= 599
? value
: undefined;
}
function getStage(
engineType: VhsPlaybackEngineTypeValue
): VhsPlaybackStageValue {
if (engineType === VhsPlaybackEngineType.StreamingHlsPlaylistParserError) {
return VhsPlaybackStage.Playlist;
}
if (engineType === VhsPlaybackEngineType.StreamingDashManifestParserError) {
return VhsPlaybackStage.Manifest;
}
if (SEGMENT_ENGINE_TYPES.has(engineType)) {
return VhsPlaybackStage.Segment;
}
return VhsPlaybackStage.Unknown;
}
@@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
@@ -65,12 +66,10 @@ describe('PortalInlinePlayerComponent sources picker', () => {
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
({ PortalInlinePlayerComponent } =
await import('./portal-inline-player.component'));
({ WebPlayerViewComponent } =
await import('../web-player-view/web-player-view.component'));
});
afterEach(() => {
@@ -101,6 +100,7 @@ describe('PortalInlinePlayerComponent sources picker', () => {
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('playbackSessionKey', 'sources-key');
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mkv',
@@ -18,6 +18,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
@@ -41,12 +42,10 @@ describe('PortalInlinePlayerComponent up next rail', () => {
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
({ PortalInlinePlayerComponent } =
await import('./portal-inline-player.component'));
({ WebPlayerViewComponent } =
await import('../web-player-view/web-player-view.component'));
});
afterEach(() => {
@@ -77,9 +76,11 @@ describe('PortalInlinePlayerComponent up next rail', () => {
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('playbackSessionKey', 'up-next-key');
}
const railEl = () => fixture.nativeElement.querySelector('app-up-next-rail');
const railEl = () =>
fixture.nativeElement.querySelector('app-up-next-rail');
const seriesPlayback = {
streamUrl: 'https://example.com/episode.mp4',
@@ -84,6 +84,7 @@
></div>
}
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
@@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -19,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
@@ -27,7 +30,7 @@ class StubWebPlayerViewComponent {
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
@@ -42,12 +45,10 @@ describe('PortalInlinePlayerComponent', () => {
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
({ PortalInlinePlayerComponent } =
await import('./portal-inline-player.component'));
({ WebPlayerViewComponent } =
await import('../web-player-view/web-player-view.component'));
});
beforeEach(async () => {
@@ -66,6 +67,10 @@ describe('PortalInlinePlayerComponent', () => {
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'playbackSessionKey',
'host-owned-session-key'
);
});
afterEach(() => {
@@ -94,13 +99,17 @@ describe('PortalInlinePlayerComponent', () => {
(
component as unknown as {
playbackEnded: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: {
subscribe: (fn: () => void) => void;
};
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).playbackEnded.subscribe(() => events.push('ended'));
(
component as unknown as {
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: {
subscribe: (fn: () => void) => void;
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
@@ -119,6 +128,7 @@ describe('PortalInlinePlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(webPlayer.seriesNavigation()).toBe(seriesNavigation);
expect(webPlayer.playbackSessionKey()).toBe('host-owned-session-key');
webPlayer.playbackEnded.emit();
webPlayer.previousEpisodeRequested.emit();
@@ -127,6 +137,47 @@ describe('PortalInlinePlayerComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
it('forwards the exact resolved playback fallback request unchanged', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.test/live.m3u8',
title: 'Header-bearing live stream',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'mpv',
playback,
diagnostic: {
code: 'network-error',
player: 'videojs',
source: 'hls',
container: '',
mimeType: '',
videoCodecs: [],
audioCodecs: [],
},
};
let forwarded: PlaybackFallbackRequest | undefined;
component.externalFallbackRequested.subscribe(
(value) => (forwarded = value)
);
fixture.componentRef.setInput('playback', playback);
fixture.detectChanges();
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
webPlayer.externalFallbackRequested.emit(request);
expect(forwarded).toBe(request);
expect(forwarded?.playback).toBe(playback);
});
describe('playerMediaTitle', () => {
it('uses the series title with the episode label for episode playback', () => {
fixture.componentRef.setInput('playback', {
@@ -254,6 +305,10 @@ describe('PortalInlinePlayerComponent', () => {
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
fixture.componentRef.setInput(
'playbackSessionKey',
'host-owned-session-key'
);
component = fixture.componentInstance;
});
@@ -308,6 +363,10 @@ describe('PortalInlinePlayerComponent', () => {
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
fixture.componentRef.setInput(
'playbackSessionKey',
'host-owned-session-key'
);
component = fixture.componentInstance;
}
@@ -22,8 +22,8 @@ import {
type VodSourceDescriptor,
type VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
@@ -63,6 +63,7 @@ const RAIL_STAGE_GAP = 18;
},
})
export class PortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
@@ -77,8 +78,7 @@ export class PortalInlinePlayerComponent {
readonly title = computed(() =>
applyChannelNameStrip(
this.playback()?.title,
this.playback()?.isLive &&
this.settingsStore.stripCountryPrefix?.()
this.playback()?.isLive && this.settingsStore.stripCountryPrefix?.()
)
);
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
@@ -149,18 +149,15 @@ export class PortalInlinePlayerComponent {
return { primary, secondary: metadata?.label ?? null };
});
private readonly stageViewport = viewChild<ElementRef<HTMLElement>>(
'stageViewport'
);
private readonly stageViewport =
viewChild<ElementRef<HTMLElement>>('stageViewport');
/**
* Border-box size of the theater stage, written by a ResizeObserver.
* Measuring the border box (not the content box) keeps the value stable
* when the rail modifier toggles the stage's own padding, so the gate
* below cannot oscillate around its threshold.
*/
readonly stageSize = signal<{ width: number; height: number } | null>(
null
);
readonly stageSize = signal<{ width: number; height: number } | null>(null);
/**
* Width the rail would actually get: the stage minus its docked-mode
* padding, the 16:9 player sized to the remaining height, and the flex
@@ -1,223 +0,0 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
import {
classifyShakaPlaybackIssue,
createUnsupportedDrmDiagnostic,
} from './shaka-error-classifier';
interface StructuredShakaDiagnostic {
readonly code: string;
readonly details?: string;
readonly httpStatus?: number;
readonly shaka?: {
readonly severity: string;
readonly category: string;
readonly engineCode: number | string;
readonly disposition: string;
readonly stage: string;
readonly failure: string;
readonly httpStatus?: number;
};
}
type StructuredClassifier = (
error: Record<string, unknown> | null | undefined,
sourceMetadata: typeof metadata,
disposition: 'terminal' | 'recoverable'
) => StructuredShakaDiagnostic | null;
const metadata = createPlaybackSourceMetadata({
url: 'http://example.com/stream.mpd',
mimeType: 'application/dash+xml',
player: InlinePlaybackPlayer.Html5,
});
const classify = classifyShakaPlaybackIssue as StructuredClassifier;
describe('classifyShakaPlaybackIssue', () => {
it.each([
['DRM category/code pair', { severity: 2, category: 6, code: 6001 }],
['manifest key-system code', { severity: 2, category: 4, code: 4008 }],
])('maps exact %s to DrmOrEncryption', (_label, error) => {
const issue = classify(error, metadata, 'terminal');
expect(issue).not.toBeNull();
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue?.shaka?.failure).toBe('drm');
});
it('maps an exact terminal network error and retains only safe status evidence', () => {
const issue = classify(
{
severity: 1,
category: 1,
code: 1001,
message:
'Blocked by CORS at https://provider.example/?token=secret',
data: [
'https://provider.example/manifest.mpd?token=secret',
503,
'provider response secret',
{ Authorization: 'Bearer secret' },
],
},
metadata,
'terminal'
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Shaka,
httpStatus: 503,
details: undefined,
shaka: {
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
})
);
const serialized = JSON.stringify(issue);
expect(serialized).not.toContain('secret');
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('response');
});
it.each([3014, 3015, 3016])(
'maps exact media pipeline code %s to MediaDecodeError',
(code) => {
const issue = classify(
{ severity: 2, category: 3, code },
metadata,
'terminal'
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
}
);
it('maps only the exact DASH unsupported-container code to UnsupportedContainer', () => {
const issue = classify(
{ severity: 2, category: 4, code: 4006 },
metadata,
'terminal'
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
});
it.each([
[
'manifest parsing',
{ severity: 2, category: 4, code: 4001 },
'manifest',
],
[
'ambiguous browser content support',
{ severity: 2, category: 4, code: 4032 },
'media',
],
[
'ambiguous restrictions',
{ severity: 2, category: 4, code: 4012 },
'unknown',
],
[
'generic media parsing',
{ severity: 2, category: 3, code: 3005 },
'media',
],
[
'mismatched category/code pair',
{ severity: 2, category: 1, code: 6001 },
'unknown',
],
[
'mismatched media code pair',
{ severity: 2, category: 1, code: 3014 },
'unknown',
],
[
'mismatched container code pair',
{ severity: 2, category: 3, code: 4006 },
'unknown',
],
[
'provider extension values',
{ severity: 3, category: 11, code: 123456 },
'unknown',
],
])(
'keeps %s as UnknownPlaybackError with structured failure=%s',
(_label, error, failure) => {
const issue = classify(
{
...error,
message:
'CORS codec DRM unsupported container license request',
data: [{ provider: 'secret-payload' }],
},
metadata,
'terminal'
);
expect(issue?.code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue?.shaka?.failure).toBe(failure);
expect(issue?.details).toBeUndefined();
}
);
it('does not turn a recoverable Shaka event into a terminal diagnostic', () => {
const issue = classify(
{ severity: 1, category: 1, code: 1002 },
metadata,
'recoverable'
);
expect(issue).toBeNull();
});
it('creates unknown structured evidence when no public Shaka error is available', () => {
const issue = classify(null, metadata, 'terminal');
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.UnknownPlaybackError,
details: undefined,
shaka: {
severity: 'unknown',
category: 'unknown',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
},
})
);
});
});
describe('createUnsupportedDrmDiagnostic', () => {
it('creates a safe DRM diagnostic without echoing provider license data', () => {
const secret = 'unsupported-drm-secret';
const issue = createUnsupportedDrmDiagnostic(
`https://provider.example/license?token=${secret}`,
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
expect(issue.details).toBe('Unsupported DRM license configuration');
expect(JSON.stringify(issue)).not.toContain(secret);
expect(JSON.stringify(issue)).not.toContain('provider.example');
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
// must not offer them as a fallback.
expect(issue.externalFallbackRecommended).toBe(false);
});
});
@@ -1,109 +0,0 @@
import type {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackSourceMetadata,
ShakaPlaybackDisposition,
ShakaPlaybackEvidence,
} from '../playback-diagnostics/playback-diagnostics.model';
import {
PlaybackDiagnosticCode as DiagnosticCode,
PlaybackDiagnosticSource as DiagnosticSource,
ShakaPlaybackCategory,
ShakaPlaybackDisposition as ShakaDisposition,
ShakaPlaybackFailure,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
import type { ShakaErrorLike } from './shaka-module.types';
export {
SHAKA_DIAGNOSTIC_VERSION,
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
export { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
const MEDIA_DECODE_CODES = new Set<number>([
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
SHAKA_ERROR_CODE.VIDEO_ERROR,
]);
export function classifyShakaPlaybackIssue(
error: Partial<ShakaErrorLike> | null | undefined,
metadata: PlaybackSourceMetadata,
disposition: ShakaPlaybackDisposition
): PlaybackDiagnostic | null {
const evidence = createShakaPlaybackEvidence(error, disposition);
if (evidence.disposition === ShakaDisposition.Recoverable) {
return null;
}
return createPlaybackDiagnostic({
code: getDiagnosticCode(evidence),
source: DiagnosticSource.Shaka,
metadata,
httpStatus: evidence.httpStatus,
shaka: evidence,
});
}
function getDiagnosticCode(
evidence: ShakaPlaybackEvidence
): PlaybackDiagnosticCode {
if (evidence.failure === ShakaPlaybackFailure.Network) {
return DiagnosticCode.NetworkError;
}
if (evidence.failure === ShakaPlaybackFailure.Drm) {
return DiagnosticCode.DrmOrEncryption;
}
if (
evidence.category === ShakaPlaybackCategory.Manifest &&
evidence.engineCode === SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER
) {
return DiagnosticCode.UnsupportedContainer;
}
if (
evidence.category === ShakaPlaybackCategory.Media &&
typeof evidence.engineCode === 'number' &&
MEDIA_DECODE_CODES.has(evidence.engineCode)
) {
return DiagnosticCode.MediaDecodeError;
}
return DiagnosticCode.UnknownPlaybackError;
}
/**
* Diagnostic for `.mpd` channels that declare a DRM system the app cannot
* handle. Provider-supplied license strings are intentionally not retained.
*/
export function createUnsupportedDrmDiagnostic(
_licenseType: string,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
return createPlaybackDiagnostic({
code: DiagnosticCode.DrmOrEncryption,
source: DiagnosticSource.Shaka,
metadata,
details: 'Unsupported DRM license configuration',
// External MPV/VLC cannot receive the KODIPROP license config either,
// so offering them as a fallback would just fail differently.
externalFallbackRecommended: false,
});
}
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
export function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
if (!error || typeof error !== 'object') {
return null;
}
const candidate = error as Partial<ShakaErrorLike>;
return typeof candidate.code === 'number' ||
typeof candidate.category === 'number' ||
typeof candidate.severity === 'number'
? candidate
: null;
}
@@ -1,125 +0,0 @@
/**
* Public Shaka error values audited against the locked 5.2.2 runtime.
*
* Keep the version assertion in the contract spec: a Shaka upgrade must stop
* here for a new audit instead of silently accepting new error layouts.
*/
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.2';
export const SHAKA_ERROR_SEVERITY = {
RECOVERABLE: 1,
CRITICAL: 2,
} as const;
export const SHAKA_ERROR_CATEGORY = {
NETWORK: 1,
TEXT: 2,
MEDIA: 3,
MANIFEST: 4,
STREAMING: 5,
DRM: 6,
PLAYER: 7,
CAST: 8,
STORAGE: 9,
ADS: 10,
} as const;
/**
* Public online-playback codes whose numeric value may cross the evidence
* boundary. Retired gaps and offline/cast/ad-only codes are intentionally
* absent.
*/
export const SHAKA_ERROR_CODE = {
UNSUPPORTED_SCHEME: 1000,
BAD_HTTP_STATUS: 1001,
HTTP_ERROR: 1002,
TIMEOUT: 1003,
MALFORMED_DATA_URI: 1004,
REQUEST_FILTER_ERROR: 1006,
RESPONSE_FILTER_ERROR: 1007,
SEGMENT_MISSING: 1011,
// Text parsing reachable from DASH playback. HLS-only and app-uninvoked
// external-track/src= codes stay outside this boundary.
INVALID_TEXT_HEADER: 2000,
INVALID_TEXT_CUE: 2001,
UNABLE_TO_DETECT_ENCODING: 2003,
BAD_ENCODING: 2004,
INVALID_XML: 2005,
INVALID_MP4_TTML: 2007,
INVALID_MP4_VTT: 2008,
INVALID_MP4_CEA: 2010,
BUFFER_READ_OUT_OF_BOUNDS: 3000,
JS_INTEGER_OVERFLOW: 3001,
EBML_OVERFLOW: 3002,
EBML_BAD_FLOATING_POINT_SIZE: 3003,
MP4_SIDX_WRONG_BOX_TYPE: 3004,
MP4_SIDX_INVALID_TIMESCALE: 3005,
MP4_SIDX_TYPE_NOT_SUPPORTED: 3006,
WEBM_CUES_ELEMENT_MISSING: 3007,
WEBM_EBML_HEADER_ELEMENT_MISSING: 3008,
WEBM_SEGMENT_ELEMENT_MISSING: 3009,
WEBM_INFO_ELEMENT_MISSING: 3010,
WEBM_DURATION_ELEMENT_MISSING: 3011,
WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING: 3012,
WEBM_CUE_TIME_ELEMENT_MISSING: 3013,
MEDIA_SOURCE_OPERATION_FAILED: 3014,
MEDIA_SOURCE_OPERATION_THREW: 3015,
VIDEO_ERROR: 3016,
QUOTA_EXCEEDED_ERROR: 3017,
TRANSMUXING_FAILED: 3018,
CONTENT_TRANSFORMATION_FAILED: 3019,
TRANSMUXING_NO_VIDEO_DATA: 3023,
STREAMING_NOT_ALLOWED: 3024,
BUFFER_WRITE_OUT_OF_BOUNDS: 3025,
UNABLE_TO_GUESS_MANIFEST_TYPE: 4000,
DASH_INVALID_XML: 4001,
DASH_NO_SEGMENT_INFO: 4002,
DASH_EMPTY_ADAPTATION_SET: 4003,
DASH_EMPTY_PERIOD: 4004,
DASH_WEBM_MISSING_INIT: 4005,
DASH_UNSUPPORTED_CONTAINER: 4006,
DASH_PSSH_BAD_ENCODING: 4007,
DASH_NO_COMMON_KEY_SYSTEM: 4008,
DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED: 4009,
DASH_CONFLICTING_KEY_IDS: 4010,
RESTRICTIONS_CANNOT_BE_MET: 4012,
DASH_DUPLICATE_REPRESENTATION_ID: 4018,
DASH_UNSUPPORTED_XLINK_ACTUATE: 4027,
DASH_XLINK_DEPTH_LIMIT: 4028,
CONTENT_UNSUPPORTED_BY_BROWSER: 4032,
NO_VARIANTS: 4036,
PERIOD_FLATTENING_FAILED: 4037,
INCONSISTENT_DRM_ACROSS_PERIODS: 4038,
NO_WEB_CRYPTO_API: 4042,
AES_128_INVALID_IV_LENGTH: 4048,
AES_128_INVALID_KEY_LENGTH: 4049,
DASH_CONFLICTING_AES_128: 4050,
DASH_UNSUPPORTED_AES_128: 4051,
DASH_INVALID_PATCH: 4052,
DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED: 4054,
WEBTRANSPORT_NOT_AVAILABLE: 4056,
WEBTRANSPORT_INITIALIZATION_FAILED: 4057,
DASH_INVALID_JSON: 4061,
DASH_UNSUPPORTED_ESSENTIAL_PROPERTY: 4063,
STREAMING_ENGINE_STARTUP_INVALID_STATE: 5006,
NO_RECOGNIZED_KEY_SYSTEMS: 6000,
REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE: 6001,
FAILED_TO_CREATE_CDM: 6002,
FAILED_TO_ATTACH_TO_VIDEO: 6003,
INVALID_SERVER_CERTIFICATE: 6004,
FAILED_TO_CREATE_SESSION: 6005,
FAILED_TO_GENERATE_LICENSE_REQUEST: 6006,
LICENSE_REQUEST_FAILED: 6007,
LICENSE_RESPONSE_REJECTED: 6008,
ENCRYPTED_CONTENT_WITHOUT_DRM_INFO: 6010,
NO_LICENSE_SERVER_GIVEN: 6012,
OFFLINE_SESSION_REMOVED: 6013,
EXPIRED: 6014,
SERVER_CERTIFICATE_REQUIRED: 6015,
INIT_DATA_TRANSFORM_ERROR: 6016,
SERVER_CERTIFICATE_REQUEST_FAILED: 6017,
MIN_HDCP_VERSION_NOT_MATCH: 6018,
ERROR_CHECKING_HDCP_VERSION: 6019,
MISSING_EME_SUPPORT: 6020,
LOAD_INTERRUPTED: 7000,
} as const;
@@ -1,29 +0,0 @@
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
import type { ShakaErrorLike } from './shaka-module.types';
export function getShakaErrorEventDisposition(
error: Partial<ShakaErrorLike> | null
): (typeof ShakaDisposition)[keyof typeof ShakaDisposition] | null {
if (error?.severity === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
return ShakaDisposition.Recoverable;
}
if (error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL) {
return ShakaDisposition.Terminal;
}
return null;
}
export function isShakaLoadInterrupted(
error: Partial<ShakaErrorLike> | null
): boolean {
return (
error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL &&
error.category === SHAKA_ERROR_CATEGORY.PLAYER &&
error.code === SHAKA_ERROR_CODE.LOAD_INTERRUPTED
);
}
@@ -1,121 +0,0 @@
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
export const SHAKA_PUBLIC_CODES = new Set<number>(
Object.values(SHAKA_ERROR_CODE)
);
export const SHAKA_NETWORK_CODES = new Set<number>([
SHAKA_ERROR_CODE.UNSUPPORTED_SCHEME,
SHAKA_ERROR_CODE.BAD_HTTP_STATUS,
SHAKA_ERROR_CODE.HTTP_ERROR,
SHAKA_ERROR_CODE.TIMEOUT,
SHAKA_ERROR_CODE.MALFORMED_DATA_URI,
SHAKA_ERROR_CODE.REQUEST_FILTER_ERROR,
SHAKA_ERROR_CODE.RESPONSE_FILTER_ERROR,
SHAKA_ERROR_CODE.SEGMENT_MISSING,
]);
export const SHAKA_MEDIA_CODES = new Set<number>([
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
SHAKA_ERROR_CODE.EBML_OVERFLOW,
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
SHAKA_ERROR_CODE.VIDEO_ERROR,
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
]);
export const SHAKA_MANIFEST_DRM_CODES = new Set<number>([
SHAKA_ERROR_CODE.DASH_PSSH_BAD_ENCODING,
SHAKA_ERROR_CODE.DASH_NO_COMMON_KEY_SYSTEM,
SHAKA_ERROR_CODE.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED,
SHAKA_ERROR_CODE.DASH_CONFLICTING_KEY_IDS,
SHAKA_ERROR_CODE.INCONSISTENT_DRM_ACROSS_PERIODS,
SHAKA_ERROR_CODE.NO_WEB_CRYPTO_API,
SHAKA_ERROR_CODE.AES_128_INVALID_IV_LENGTH,
SHAKA_ERROR_CODE.AES_128_INVALID_KEY_LENGTH,
SHAKA_ERROR_CODE.DASH_CONFLICTING_AES_128,
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_AES_128,
SHAKA_ERROR_CODE.DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED,
]);
export const SHAKA_MANIFEST_MEDIA_CODES = new Set<number>([
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER,
SHAKA_ERROR_CODE.CONTENT_UNSUPPORTED_BY_BROWSER,
]);
export const SHAKA_AMBIGUOUS_MANIFEST_CODES = new Set<number>([
SHAKA_ERROR_CODE.RESTRICTIONS_CANNOT_BE_MET,
]);
export const SHAKA_MANIFEST_CODES = new Set<number>(
Object.values(SHAKA_ERROR_CODE).filter(
(code) => code >= 4000 && code < 5000
)
);
export const SHAKA_DRM_CODES = new Set<number>(
Object.values(SHAKA_ERROR_CODE).filter(
(code) => code >= 6000 && code < 7000
)
);
export const SHAKA_SEGMENT_STAGE_CODES = new Set<number>([
SHAKA_ERROR_CODE.SEGMENT_MISSING,
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
SHAKA_ERROR_CODE.EBML_OVERFLOW,
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
]);
export const SHAKA_MEDIA_STAGE_CODES = new Set<number>([
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
SHAKA_ERROR_CODE.VIDEO_ERROR,
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
]);
export const SHAKA_LICENSE_STAGE_CODES = new Set<number>([
SHAKA_ERROR_CODE.FAILED_TO_GENERATE_LICENSE_REQUEST,
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED,
SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN,
SHAKA_ERROR_CODE.EXPIRED,
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
]);
export const SHAKA_NESTED_HTTP_CODES = new Set<number>([
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
]);
@@ -22,12 +22,7 @@ export interface ShakaTextTrackLike {
kind: string | null;
}
export interface ShakaErrorLike {
severity: number;
category: number;
code: number;
data?: readonly unknown[];
}
export type { ShakaErrorLike } from '@iptvnator/playback/util';
export interface ShakaPlayerLike {
attach(mediaElement: HTMLMediaElement): Promise<unknown>;
@@ -1,376 +0,0 @@
import { execFileSync } from 'node:child_process';
import * as shakaDiagnostics from './shaka-error-classifier';
interface ShakaErrorInput {
readonly severity?: unknown;
readonly category?: unknown;
readonly code?: unknown;
readonly message?: unknown;
readonly data?: readonly unknown[];
readonly [key: string]: unknown;
}
interface ExpectedShakaPlaybackEvidence {
readonly severity: string;
readonly category: string;
readonly engineCode: number | string;
readonly disposition: string;
readonly stage: string;
readonly failure: string;
readonly httpStatus?: number;
}
interface InstalledShakaContract {
readonly version: string;
readonly severity: Readonly<Record<string, number>>;
readonly category: Readonly<Record<string, number>>;
readonly code: Readonly<Record<string, number>>;
}
interface ShakaEvidenceExports {
readonly SHAKA_DIAGNOSTIC_VERSION?: string;
readonly SHAKA_ERROR_SEVERITY?: Readonly<Record<string, number>>;
readonly SHAKA_ERROR_CATEGORY?: Readonly<Record<string, number>>;
readonly SHAKA_ERROR_CODE?: Readonly<Record<string, number>>;
readonly createShakaPlaybackEvidence?: (
error: ShakaErrorInput | null | undefined,
disposition: 'terminal' | 'recoverable'
) => ExpectedShakaPlaybackEvidence;
}
const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports;
describe('Shaka playback evidence', () => {
it('matches the installed public Shaka 5.2.2 error contract', () => {
const installed = getInstalledShakaContract();
expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe(
installed.version
);
expect(exportsUnderTest.SHAKA_ERROR_SEVERITY).toEqual(
installed.severity
);
expect(exportsUnderTest.SHAKA_ERROR_CATEGORY).toEqual(
installed.category
);
expect(exportsUnderTest.SHAKA_ERROR_CODE).toBeDefined();
for (const [name, value] of Object.entries(
exportsUnderTest.SHAKA_ERROR_CODE ?? {}
)) {
expect(installed.code[name]).toBe(value);
}
});
it('extracts only the documented status from a real bad-status shape', () => {
const secret = 'shaka-direct-secret';
const evidence = createEvidence(
{
severity: 1,
category: 1,
code: 1001,
message: `request failed at https://user:${secret}@provider.example`,
data: [
`https://provider.example/manifest.mpd?token=${secret}`,
503,
`provider response body ${secret}`,
{ Authorization: `Bearer ${secret}` },
0,
`https://provider.example/final?token=${secret}`,
],
providerPayload: { key: secret },
},
'terminal'
);
const serialized = JSON.stringify(evidence);
expect(evidence).toEqual({
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
});
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('response body');
expect(Object.keys(evidence).sort()).toEqual(
[
'category',
'disposition',
'engineCode',
'failure',
'httpStatus',
'severity',
'stage',
].sort()
);
});
it.each([
{ code: 6007, label: 'license request' },
{ code: 6017, label: 'server certificate request' },
])(
'extracts a nested bad status from the documented $label layout',
({ code }) => {
const secret = 'shaka-nested-secret';
const evidence = createEvidence(
{
severity: 2,
category: 6,
code,
data: [
{
severity: 1,
category: 1,
code: 1001,
data: [
`https://provider.example/license?token=${secret}`,
403,
`license response ${secret}`,
{ Authorization: `Bearer ${secret}` },
],
},
{
licenseRequest: secret,
sessionMetadata: secret,
},
],
},
'terminal'
);
const serialized = JSON.stringify(evidence);
expect(evidence).toEqual({
severity: 'critical',
category: 'drm',
engineCode: code,
disposition: 'terminal',
stage: 'license',
failure: 'drm',
httpStatus: 403,
});
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
}
);
it.each([
{ status: 99, expected: undefined },
{ status: 100, expected: 100 },
{ status: 599, expected: 599 },
{ status: 600, expected: undefined },
{ status: 404.5, expected: undefined },
{ status: '503', expected: undefined },
])(
'retains documented HTTP status $status only inside the protocol range',
({ status, expected }) => {
const evidence = createEvidence(
{
severity: 1,
category: 1,
code: 1001,
data: ['https://provider.example/manifest.mpd', status],
},
'terminal'
);
expect(evidence.httpStatus).toBe(expected);
}
);
it('ignores status-like data on every undocumented layout', () => {
const evidence = createEvidence(
{
severity: 2,
category: 4,
code: 4001,
data: [503, 504, { status: 505 }],
},
'terminal'
);
expect(evidence.httpStatus).toBeUndefined();
});
it('preserves the exact public streaming startup code as unknown failure evidence', () => {
expect(
createEvidence({ severity: 2, category: 5, code: 5006 }, 'terminal')
).toEqual({
severity: 'critical',
category: 'streaming',
engineCode: 5006,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it.each([
['INVALID_TEXT_HEADER', 2000],
['INVALID_TEXT_CUE', 2001],
['UNABLE_TO_DETECT_ENCODING', 2003],
['BAD_ENCODING', 2004],
['INVALID_XML', 2005],
['INVALID_MP4_TTML', 2007],
['INVALID_MP4_VTT', 2008],
['INVALID_MP4_CEA', 2010],
])('preserves the exact public DASH text code %s', (_name, code) => {
expect(
createEvidence({ severity: 2, category: 2, code }, 'terminal')
).toEqual({
severity: 'critical',
category: 'text',
engineCode: code,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it('rejects public text codes from app-uninvoked explicit-track APIs', () => {
expect(
createEvidence({ severity: 2, category: 2, code: 2014 }, 'terminal')
).toEqual({
severity: 'critical',
category: 'text',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it.each([
{
label: 'manifest parsing',
error: { severity: 2, category: 4, code: 4001 },
stage: 'manifest',
failure: 'manifest',
},
{
label: 'segment index parsing',
error: { severity: 2, category: 3, code: 3004 },
stage: 'segment',
failure: 'media',
},
{
label: 'media source operation',
error: { severity: 2, category: 3, code: 3014 },
stage: 'media',
failure: 'media',
},
{
label: 'license response',
error: { severity: 2, category: 6, code: 6008 },
stage: 'license',
failure: 'drm',
},
{
label: 'network without proven request stage',
error: { severity: 2, category: 1, code: 1002 },
stage: 'unknown',
failure: 'network',
},
])(
'maps exact $label evidence without reading messages',
({ error, stage, failure }) => {
const evidence = createEvidence(
{
...error,
message:
'CORS codec manifest license segment provider guess',
},
'terminal'
);
expect(evidence.stage).toBe(stage);
expect(evidence.failure).toBe(failure);
}
);
it.each([
{
label: 'ambiguous restrictions',
error: { severity: 2, category: 4, code: 4012 },
stage: 'manifest',
},
{
label: 'mismatched public pair',
error: { severity: 2, category: 1, code: 6001 },
stage: 'unknown',
},
{
label: 'mismatched media stage pair',
error: { severity: 2, category: 1, code: 3004 },
stage: 'unknown',
},
{
label: 'unknown provider code',
error: { severity: 3, category: 11, code: 123456 },
stage: 'unknown',
},
])('keeps $label failure evidence unknown', ({ error, stage }) => {
const evidence = createEvidence(error, 'terminal');
expect(evidence.failure).toBe('unknown');
expect(evidence.stage).toBe(stage);
});
it('validates severity, category, code, and explicit disposition independently', () => {
expect(
createEvidence(
{ severity: 99, category: 99, code: 999999 },
'recoverable'
)
).toEqual({
severity: 'unknown',
category: 'unknown',
engineCode: 'unknown',
disposition: 'recoverable',
stage: 'unknown',
failure: 'unknown',
});
});
});
function createEvidence(
error: ShakaErrorInput | null | undefined,
disposition: 'terminal' | 'recoverable'
): ExpectedShakaPlaybackEvidence {
const factory = exportsUnderTest.createShakaPlaybackEvidence;
expect(factory).toBeDefined();
if (!factory) {
throw new Error('createShakaPlaybackEvidence is not exported');
}
return factory(error, disposition);
}
function getInstalledShakaContract(): InstalledShakaContract {
const script = `
global.self = globalThis;
import('shaka-player').then((module) => {
const shaka = module.default ?? module;
process.stdout.write(JSON.stringify({
version: shaka.Player.version,
severity: shaka.util.Error.Severity,
category: shaka.util.Error.Category,
code: shaka.util.Error.Code,
}));
}).catch((error) => {
console.error(error);
process.exit(1);
});
`;
return JSON.parse(
execFileSync(process.execPath, ['-e', script], {
cwd: process.cwd(),
encoding: 'utf8',
})
) as InstalledShakaContract;
}
@@ -1,235 +0,0 @@
import {
type ShakaPlaybackCategory as ShakaPlaybackCategoryValue,
type ShakaPlaybackDisposition,
type ShakaPlaybackEvidence,
type ShakaPlaybackFailure as ShakaPlaybackFailureValue,
type ShakaPlaybackStage as ShakaPlaybackStageValue,
ShakaPlaybackCategory,
ShakaPlaybackFailure,
ShakaPlaybackSeverity,
ShakaPlaybackStage,
ShakaPlaybackUnknownCode,
} from '../playback-diagnostics/playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
import {
SHAKA_AMBIGUOUS_MANIFEST_CODES,
SHAKA_DRM_CODES,
SHAKA_LICENSE_STAGE_CODES,
SHAKA_MANIFEST_CODES,
SHAKA_MANIFEST_DRM_CODES,
SHAKA_MANIFEST_MEDIA_CODES,
SHAKA_MEDIA_CODES,
SHAKA_MEDIA_STAGE_CODES,
SHAKA_NESTED_HTTP_CODES,
SHAKA_NETWORK_CODES,
SHAKA_PUBLIC_CODES,
SHAKA_SEGMENT_STAGE_CODES,
} from './shaka-error-mapping';
import type { ShakaErrorLike } from './shaka-module.types';
export function createShakaPlaybackEvidence(
error: Partial<ShakaErrorLike> | null | undefined,
disposition: ShakaPlaybackDisposition
): ShakaPlaybackEvidence {
const category = getCategory(error?.category);
const engineCode = getEngineCode(error?.code);
const httpStatus = getHttpStatus(error);
const evidence: ShakaPlaybackEvidence = {
severity: getSeverity(error?.severity),
category,
engineCode,
disposition,
stage: getStage(category, engineCode),
failure: getFailure(category, engineCode),
};
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
}
function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] {
if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
return ShakaPlaybackSeverity.Recoverable;
}
if (value === SHAKA_ERROR_SEVERITY.CRITICAL) {
return ShakaPlaybackSeverity.Critical;
}
return ShakaPlaybackSeverity.Unknown;
}
function getCategory(value: unknown): ShakaPlaybackCategoryValue {
switch (value) {
case SHAKA_ERROR_CATEGORY.NETWORK:
return ShakaPlaybackCategory.Network;
case SHAKA_ERROR_CATEGORY.TEXT:
return ShakaPlaybackCategory.Text;
case SHAKA_ERROR_CATEGORY.MEDIA:
return ShakaPlaybackCategory.Media;
case SHAKA_ERROR_CATEGORY.MANIFEST:
return ShakaPlaybackCategory.Manifest;
case SHAKA_ERROR_CATEGORY.STREAMING:
return ShakaPlaybackCategory.Streaming;
case SHAKA_ERROR_CATEGORY.DRM:
return ShakaPlaybackCategory.Drm;
case SHAKA_ERROR_CATEGORY.PLAYER:
return ShakaPlaybackCategory.Player;
case SHAKA_ERROR_CATEGORY.CAST:
return ShakaPlaybackCategory.Cast;
case SHAKA_ERROR_CATEGORY.STORAGE:
return ShakaPlaybackCategory.Storage;
case SHAKA_ERROR_CATEGORY.ADS:
return ShakaPlaybackCategory.Ads;
default:
return ShakaPlaybackCategory.Unknown;
}
}
function getEngineCode(value: unknown): ShakaPlaybackEvidence['engineCode'] {
return typeof value === 'number' &&
Number.isInteger(value) &&
SHAKA_PUBLIC_CODES.has(value)
? value
: ShakaPlaybackUnknownCode;
}
function getFailure(
category: ShakaPlaybackCategoryValue,
engineCode: ShakaPlaybackEvidence['engineCode']
): ShakaPlaybackFailureValue {
if (
category === ShakaPlaybackCategory.Network &&
typeof engineCode === 'number' &&
SHAKA_NETWORK_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Network;
}
if (
category === ShakaPlaybackCategory.Drm &&
typeof engineCode === 'number' &&
SHAKA_DRM_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Drm;
}
if (
category === ShakaPlaybackCategory.Media &&
typeof engineCode === 'number' &&
SHAKA_MEDIA_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Media;
}
if (
category === ShakaPlaybackCategory.Manifest &&
typeof engineCode === 'number'
) {
if (SHAKA_MANIFEST_DRM_CODES.has(engineCode)) {
return ShakaPlaybackFailure.Drm;
}
if (SHAKA_MANIFEST_MEDIA_CODES.has(engineCode)) {
return ShakaPlaybackFailure.Media;
}
if (
SHAKA_MANIFEST_CODES.has(engineCode) &&
!SHAKA_AMBIGUOUS_MANIFEST_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Manifest;
}
}
return ShakaPlaybackFailure.Unknown;
}
function getStage(
category: ShakaPlaybackCategoryValue,
engineCode: ShakaPlaybackEvidence['engineCode']
): ShakaPlaybackStageValue {
if (typeof engineCode !== 'number') {
return ShakaPlaybackStage.Unknown;
}
if (
category === ShakaPlaybackCategory.Manifest &&
SHAKA_MANIFEST_CODES.has(engineCode)
) {
return ShakaPlaybackStage.Manifest;
}
if (
(category === ShakaPlaybackCategory.Network &&
engineCode === SHAKA_ERROR_CODE.SEGMENT_MISSING) ||
(category === ShakaPlaybackCategory.Media &&
SHAKA_SEGMENT_STAGE_CODES.has(engineCode))
) {
return ShakaPlaybackStage.Segment;
}
if (
category === ShakaPlaybackCategory.Media &&
SHAKA_MEDIA_STAGE_CODES.has(engineCode)
) {
return ShakaPlaybackStage.Media;
}
if (
category === ShakaPlaybackCategory.Drm &&
SHAKA_LICENSE_STAGE_CODES.has(engineCode)
) {
return ShakaPlaybackStage.License;
}
return ShakaPlaybackStage.Unknown;
}
function getHttpStatus(
error: Partial<ShakaErrorLike> | null | undefined
): number | undefined {
if (
error?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
) {
return asHttpStatus(error.data?.[1]);
}
if (
error?.category !== SHAKA_ERROR_CATEGORY.DRM ||
typeof error.code !== 'number' ||
!SHAKA_NESTED_HTTP_CODES.has(error.code)
) {
return undefined;
}
const nested = asShakaErrorData(error.data?.[0]);
return nested?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
nested.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
? asHttpStatus(nested.data?.[1])
: undefined;
}
function asShakaErrorData(value: unknown): Partial<ShakaErrorLike> | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const candidate = value as {
readonly severity?: unknown;
readonly category?: unknown;
readonly code?: unknown;
readonly data?: unknown;
};
return {
severity:
typeof candidate.severity === 'number'
? candidate.severity
: undefined,
category:
typeof candidate.category === 'number'
? candidate.category
: undefined,
code: typeof candidate.code === 'number' ? candidate.code : undefined,
data: Array.isArray(candidate.data) ? candidate.data : undefined,
};
}
function asHttpStatus(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= 100 &&
value <= 599
? value
: undefined;
}
@@ -4,7 +4,7 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
} from '@iptvnator/playback/util';
import {
FakeShakaPlayer,
createFakeShakaEnvironment,
@@ -116,8 +116,6 @@ describe('ShakaVideoSession', () => {
stage: 'unknown',
failure: 'drm',
});
// Without KODIPROP config the DRM hint may still help externally.
expect(issues[0].externalFallbackRecommended).toBe(true);
// Critical errors end playback: the dead engine must be torn down.
expect(player.destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
@@ -142,7 +140,7 @@ describe('ShakaVideoSession', () => {
expect(session.getPlayer()).toBe(player);
});
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
it('emits structured failures without owning ClearKey recovery policy', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/enc.mpd', {
@@ -160,12 +158,6 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
// MPV/VLC never receive the KODIPROP license config, so they are not
// offered as a fallback for key failures on ClearKey channels.
expect(issues[0].externalFallbackRecommended).toBe(false);
// Non-DRM failures (media/codec/manifest/…) are equally unsolvable
// externally while the stream itself stays encrypted.
session.start(video, 'http://example.com/enc.mpd', {
licenseType: 'clearkey',
supported: true,
@@ -180,7 +172,6 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(2);
expect(issues[1].code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issues[1].externalFallbackRecommended).toBe(false);
});
it('emits a diagnostic and tears the engine down when load rejects', async () => {
@@ -407,6 +398,7 @@ describe('ShakaVideoSession', () => {
});
expect(JSON.stringify(issues[0])).not.toContain(secret);
expect(JSON.stringify(issues[0])).not.toContain('provider.example');
expect(issues[0].runtimeSupport).toBeUndefined();
});
it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => {
@@ -497,7 +489,7 @@ describe('ShakaVideoSession', () => {
expect(player.selectTextTrackCalls).toHaveLength(2);
});
it('keeps external fallback available when the browser cannot run Shaka for clear DASH', async () => {
it('emits an unknown diagnostic when the browser cannot run Shaka for clear DASH', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -517,10 +509,13 @@ describe('ShakaVideoSession', () => {
stage: 'unknown',
failure: 'unknown',
});
expect(issues[0].externalFallbackRecommended).toBe(true);
expect(issues[0]).toHaveProperty(
'runtimeSupport',
'shaka-browser-unsupported'
);
});
it('keeps external fallback unavailable when browser support fails for KODIPROP DRM', async () => {
it('emits the same unknown diagnostic when browser support fails with DRM', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -536,7 +531,10 @@ describe('ShakaVideoSession', () => {
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].externalFallbackRecommended).toBe(false);
expect(issues[0]).toHaveProperty(
'runtimeSupport',
'shaka-browser-unsupported'
);
});
it('destroy tears down the engine and blocks later starts', async () => {
@@ -1,20 +1,17 @@
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type {
InlinePlaybackPlayer,
PlaybackDiagnostic,
PlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.model';
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
import {
ShakaPlaybackDisposition as ShakaDisposition,
asShakaError,
classifyShakaPlaybackIssue,
createPlaybackSourceMetadata,
createShakaBrowserUnsupportedDiagnostic,
createUnsupportedDrmDiagnostic,
} from './shaka-error-classifier';
import {
getShakaErrorEventDisposition,
isShakaLoadInterrupted,
} from './shaka-error-lifecycle';
type InlinePlaybackPlayer,
type PlaybackDiagnostic,
type PlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
import {
loadShakaModule,
@@ -143,18 +140,18 @@ export class ShakaVideoSession {
}
if (!module.Player.isBrowserSupported()) {
this.emitTerminalIfCurrent(
this.emitIfCurrent(
generation,
null,
url,
drm === undefined
createShakaBrowserUnsupportedDiagnostic(
this.createMetadata(url)
)
);
return;
}
const player = new module.Player();
this.player = player;
this.bindPlayerListeners(player, generation, url, drm !== undefined);
this.bindPlayerListeners(player, generation, url);
if (drm?.clearKeys) {
player.configure({ drm: { clearKeys: drm.clearKeys } });
@@ -164,13 +161,7 @@ export class ShakaVideoSession {
await player.attach(video);
await player.load(url);
} catch (error: unknown) {
this.handleLoadFailure(
generation,
player,
url,
error,
drm !== undefined
);
this.handleLoadFailure(generation, player, url, error);
return;
}
@@ -206,8 +197,7 @@ export class ShakaVideoSession {
generation: number,
player: ShakaPlayerLike,
url: string,
error: unknown,
drmProvided: boolean
error: unknown
): void {
const shakaError = asShakaError(error);
if (
@@ -224,36 +214,17 @@ export class ShakaVideoSession {
ShakaDisposition.Terminal
);
if (issue) {
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
this.config.emitPlaybackIssue(issue);
}
// Never leave a non-functional engine attached to the media element
// or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
}
/**
* Channels carrying KODIPROP ClearKey config cannot be handed to MPV/VLC
* at all — external players never receive the license config, so the
* encrypted stream fails there regardless of what broke inline (DRM,
* manifest, codec, network, …). Suppress the fallback hint entirely.
*/
private withoutUnusableDrmFallback(
issue: PlaybackDiagnostic,
drmProvided: boolean
): PlaybackDiagnostic {
if (!drmProvided || !issue.externalFallbackRecommended) {
return issue;
}
return { ...issue, externalFallbackRecommended: false };
}
private bindPlayerListeners(
player: ShakaPlayerLike,
generation: number,
url: string,
drmProvided: boolean
url: string
): void {
const errorListener = (event: Event): void => {
if (this.isStale(generation) || this.player !== player) {
@@ -276,9 +247,7 @@ export class ShakaVideoSession {
return;
}
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
this.config.emitPlaybackIssue(issue);
// Critical errors end playback; never leave the dead engine
// attached or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
@@ -370,8 +339,7 @@ export class ShakaVideoSession {
private emitTerminalIfCurrent(
generation: number,
error: Partial<ShakaErrorLike> | null,
url: string,
externalFallbackRecommended?: boolean
url: string
): void {
const issue = classifyShakaPlaybackIssue(
error,
@@ -379,12 +347,7 @@ export class ShakaVideoSession {
ShakaDisposition.Terminal
);
if (issue) {
this.emitIfCurrent(
generation,
externalFallbackRecommended === undefined
? issue
: { ...issue, externalFallbackRecommended }
);
this.emitIfCurrent(generation, issue);
}
}
@@ -143,7 +143,6 @@ describe('VjsMpegTsSession', () => {
failure: 'http',
httpStatus: 503,
}),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
@@ -167,7 +166,6 @@ describe('VjsMpegTsSession', () => {
expect.objectContaining({
code: 'network-error',
mpegTs: expect.objectContaining({ failure: 'network' }),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
@@ -6,7 +6,7 @@ import {
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
interface VjsDurationPlayer {
duration(value?: number): unknown;
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
import type { VideoJsPlayer } from './vjs-player.types';
@@ -196,7 +196,6 @@ describe('VjsPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.test/archive/movie.mkv',
externalFallbackRecommended: true,
})
);
});
@@ -236,7 +235,6 @@ describe('VjsPlayerComponent', () => {
stage: 'unknown',
httpStatus: 404,
},
externalFallbackRecommended: false,
})
);
});
@@ -272,7 +270,6 @@ describe('VjsPlayerComponent', () => {
disposition: 'terminal',
stage: 'unknown',
},
externalFallbackRecommended: false,
})
);
});
@@ -300,7 +297,6 @@ describe('VjsPlayerComponent', () => {
code: 'media-decode-error',
source: 'native',
nativeErrorMessage: 'Native media decode failed',
externalFallbackRecommended: true,
})
);
});
@@ -23,7 +23,7 @@ import {
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -1,5 +1,5 @@
import type videoJs from 'video.js';
import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model';
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
export type VideoPlayerSource = {
src: string;
@@ -265,6 +265,7 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
(timeUpdate)="onInlineTimeUpdate($event)"
(closed)="closeInlinePlayback()"
@@ -1,5 +1,6 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
@@ -118,6 +119,10 @@ describe('VodDetailsComponent offline playback', () => {
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
fixture.componentRef.setInput(
'playbackSessionKey',
'stalker-host-owned-key'
);
fixture.componentRef.setInput('playbackPosition', playbackPosition);
fixture.componentRef.setInput('externalPlayback', externalPlayback);
fixture.componentRef.setInput('providerOnly', providerOnly);
@@ -128,6 +133,24 @@ describe('VodDetailsComponent offline playback', () => {
await fixture.whenStable();
};
it('passes the host-owned session key unchanged to inline playback', async () => {
await render();
fixture.componentRef.setInput('inlinePlayback', {
streamUrl: 'https://portal.example/movie.mp4',
title: 'Catalog Movie',
});
fixture.detectChanges();
const inlinePlayer = fixture.debugElement.query(
By.css('app-portal-inline-player')
).componentInstance as {
playbackSessionKey(): string;
};
expect(inlinePlayer.playbackSessionKey()).toBe(
'stalker-host-owned-key'
);
});
const completeDownload = (
fileAvailability: DownloadItem['fileAvailability'] = 'available'
) => {
@@ -36,7 +36,7 @@ import {
CrossPortalSimilarService,
DownloadsService,
} from '@iptvnator/services';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { createVodDownloadState } from './vod-download-state.util';
@@ -79,6 +79,7 @@ export class VodDetailsComponent {
/** VOD item with discriminated union type */
readonly item = input.required<VodDetailsItem>();
readonly playbackSessionKey = input.required<string>();
/** Whether this item is in favorites (managed by parent) */
readonly isFavorite = input<boolean>(false);
@@ -0,0 +1,500 @@
import {
InlinePlaybackPlayer,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { PlaybackRecoverySession } from './playback-recovery-session';
describe('PlaybackRecoverySession', () => {
it('tracks failure, switch resume, and a reset across sessions', () => {
const session = new PlaybackRecoverySession();
expect(session.syncSession('movie-a')).toBe(true);
const firstBinding = session.beginPlayback(
InlinePlaybackPlayer.VideoJs
);
expect(session.recordFailure(firstBinding)).toBe(true);
expect([...session.attemptedTargets()]).toEqual([
InlinePlaybackPlayer.VideoJs,
]);
session.recordTimeUpdate({ currentTime: 42, duration: 120 }, false);
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(true);
const replacementBinding = session.beginPlayback(
InlinePlaybackPlayer.Html5
);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.resumeStartTime(0, false)).toBe(42);
const previousAttempts = session.attemptedTargets();
expect(session.syncSession('movie-b')).toBe(true);
expect(session.attemptedTargets()).not.toBe(previousAttempts);
expect(session.attemptedTargets()).toEqual(new Set());
expect(session.temporaryPlayerOverride()).toBeNull();
expect(session.switchPending()).toBe(false);
expect(session.activeBinding()).toBeNull();
expect(session.recordFailure(firstBinding)).toBe(false);
expect(session.resumeStartTime(0, false)).toBe(0);
const newBinding = session.beginPlayback(
InlinePlaybackPlayer.ArtPlayer
);
expect(newBinding.generation).toBe(replacementBinding.generation + 2);
});
it('treats the same exact session key as a no-op', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false);
const attempts = session.attemptedTargets();
expect(session.syncSession('movie-a')).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBe(binding);
expect(session.accepts(binding)).toBe(true);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const switchAttempts = session.attemptedTargets();
expect(session.syncSession('movie-a')).toBe(false);
expect(session.attemptedTargets()).toBe(switchAttempts);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
expect(session.resumeStartTime(5, false)).toBe(24);
expect(session.activeBinding()).toBeNull();
expect(session.accepts(binding)).toBe(false);
});
it('resets only the resume position when the source revision changes', () => {
const session = new PlaybackRecoverySession();
const firstRevision = Symbol();
session.syncSession('channel-a');
expect(session.syncSourceRevision(firstRevision)).toBe(true);
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const attempts = session.attemptedTargets();
expect(session.syncSourceRevision(firstRevision)).toBe(false);
expect(session.resumeStartTime(5, false)).toBe(24);
expect(session.syncSourceRevision(Symbol())).toBe(true);
expect(session.resumeStartTime(5, false)).toBe(5);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
});
it('accepts an empty session key and compares keys exactly', () => {
const session = new PlaybackRecoverySession();
expect(session.syncSession('')).toBe(true);
expect(session.syncSession('')).toBe(false);
expect(session.syncSession(' ')).toBe(true);
});
it('advances the generation whenever playback begins', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
const second = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(second.generation).toBe(first.generation + 1);
expect(session.accepts(first)).toBe(false);
expect(session.accepts(second)).toBe(true);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.ArtPlayer])
);
});
it('preserves session attempts across an alternative source binding', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.recordFailure(first);
session.beginRetry();
const alternative = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.settle(first);
expect(session.accepts(first)).toBe(false);
expect(session.accepts(alternative)).toBe(true);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.VideoJs])
);
session.settle(alternative);
expect(session.switchPending()).toBe(false);
});
it('clears playback binding and prevents retry without resetting session data', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
session.recordTimeUpdate({ currentTime: 17, duration: 80 }, false);
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const binding = session.beginPlayback(InlinePlaybackPlayer.Html5);
session.settle(binding);
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
session.clearPlaybackBinding();
expect(session.activeBinding()).toBeNull();
expect(session.accepts(binding)).toBe(false);
expect(session.beginRetry()).toBe(false);
expect(session.attemptedTargets()).toEqual(
new Set([
InlinePlaybackPlayer.Html5,
InlinePlaybackPlayer.ArtPlayer,
])
);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.resumeStartTime(0, false)).toBe(17);
const nextBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(nextBinding.generation).toBe(binding.generation + 2);
});
it('accepts only a matching active generation and target', () => {
const session = new PlaybackRecoverySession();
expect(
session.accepts({
generation: 0,
target: InlinePlaybackPlayer.VideoJs,
})
).toBe(false);
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(session.accepts({ ...binding })).toBe(true);
expect(
session.accepts({
generation: binding.generation,
target: InlinePlaybackPlayer.Html5,
})
).toBe(false);
});
it('exposes an immutable active binding snapshot', () => {
const session = new PlaybackRecoverySession();
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
const activeBinding = session.activeBinding();
expect(Object.isFrozen(binding)).toBe(true);
expect(activeBinding).toBe(binding);
expect(activeBinding).toEqual({
generation: 1,
target: InlinePlaybackPlayer.VideoJs,
});
expect(Object.keys(activeBinding ?? {})).toEqual([
'generation',
'target',
]);
expect(Object.isFrozen(activeBinding)).toBe(true);
expect('set' in session.attemptedTargets).toBe(false);
expect('set' in session.temporaryPlayerOverride).toBe(false);
expect('set' in session.switchPending).toBe(false);
expect('set' in session.activeBinding).toBe(false);
expect(
Reflect.set(
binding as unknown as Record<string, unknown>,
'target',
InlinePlaybackPlayer.Html5
)
).toBe(false);
expect(session.activeBinding()?.target).toBe(
InlinePlaybackPlayer.VideoJs
);
expect(
session.accepts({
generation: binding.generation,
target: InlinePlaybackPlayer.Html5,
})
).toBe(false);
});
it('copies attempted targets for every inline and external attempt', () => {
const session = new PlaybackRecoverySession();
const initial = session.attemptedTargets();
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
const inline = session.attemptedTargets();
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
const duplicate = session.attemptedTargets();
session.recordExternalAttempt('mpv');
const external = session.attemptedTargets();
expect(initial).toEqual(new Set());
expect(inline).toEqual(new Set([InlinePlaybackPlayer.VideoJs]));
expect(duplicate).toEqual(new Set([InlinePlaybackPlayer.VideoJs]));
expect(external).toEqual(
new Set<PlaybackRecommendationTarget>([
InlinePlaybackPlayer.VideoJs,
'mpv',
])
);
expect(inline).not.toBe(initial);
expect(duplicate).not.toBe(inline);
expect(external).not.toBe(duplicate);
});
it('copies attempts and clears pending for an accepted failure', () => {
const session = new PlaybackRecoverySession();
session.beginPlayback(InlinePlaybackPlayer.Html5);
session.beginRetry();
const binding = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
expect(session.recordFailure(binding)).toBe(true);
expect(session.attemptedTargets()).not.toBe(attempts);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.Html5])
);
expect(session.switchPending()).toBe(false);
});
it('rejects the old binding throughout the switch pre-effect interval', () => {
const session = new PlaybackRecoverySession();
const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(true);
const attempts = session.attemptedTargets();
expect(session.activeBinding()).toBeNull();
expect(session.recordFailure(oldBinding)).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
session.settle(oldBinding);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5);
session.settle(replacement);
expect(session.switchPending()).toBe(false);
});
it('rejects the old binding throughout the retry pre-effect interval', () => {
const session = new PlaybackRecoverySession();
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(session.beginRetry()).toBe(true);
const attempts = session.attemptedTargets();
expect(session.activeBinding()).toBeNull();
expect(session.recordFailure(oldBinding)).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
session.settle(oldBinding);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.temporaryPlayerOverride()).toBeNull();
const replacement = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(session.recordFailure(replacement)).toBe(true);
expect(session.switchPending()).toBe(false);
expect(session.attemptedTargets()).not.toBe(attempts);
expect(session.attemptedTargets()).toEqual(
new Set([
InlinePlaybackPlayer.ArtPlayer,
InlinePlaybackPlayer.VideoJs,
])
);
});
it('leaves all state unchanged for a stale failure', () => {
const session = new PlaybackRecoverySession();
const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const current = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
expect(session.recordFailure(stale)).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.switchPending()).toBe(true);
expect(session.activeBinding()).toBe(current);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
});
it('settles only the matching binding without recording an attempt', () => {
const session = new PlaybackRecoverySession();
const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const current = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
session.settle(stale);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
session.settle(current);
expect(session.switchPending()).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
});
it('records a switch immediately and clears resume only for live playback', () => {
const session = new PlaybackRecoverySession();
session.recordTimeUpdate({ currentTime: 31, duration: 90 }, false);
const attempts = session.attemptedTargets();
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, true)
).toBe(true);
expect(session.attemptedTargets()).not.toBe(attempts);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.Html5])
);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
expect(session.resumeStartTime(7, false)).toBe(7);
});
it('allows only the switch when a switch races a retry', () => {
const session = new PlaybackRecoverySession();
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(true);
const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
expect(session.beginRetry()).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBe(replacement);
expect(session.accepts(replacement)).toBe(true);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
session.settle(replacement);
const bindingAfterRejectedRetry = session.beginPlayback(
InlinePlaybackPlayer.Html5
);
expect(bindingAfterRejectedRetry.generation).toBe(
replacement.generation + 1
);
});
it('allows only the retry when a retry races a switch', () => {
const session = new PlaybackRecoverySession();
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.ArtPlayer, false);
const switched = session.beginPlayback(InlinePlaybackPlayer.ArtPlayer);
session.settle(switched);
expect(session.beginRetry()).toBe(true);
const replacement = session.beginPlayback(
InlinePlaybackPlayer.ArtPlayer
);
const attempts = session.attemptedTargets();
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBe(replacement);
expect(session.accepts(replacement)).toBe(true);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.ArtPlayer
);
expect(session.switchPending()).toBe(true);
session.settle(replacement);
const bindingAfterRejectedSwitch = session.beginPlayback(
InlinePlaybackPlayer.ArtPlayer
);
expect(bindingAfterRejectedSwitch.generation).toBe(
replacement.generation + 1
);
});
it('begins retry only with an active binding and preserves state', () => {
const session = new PlaybackRecoverySession();
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
expect(session.beginRetry()).toBe(false);
const initial = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(initial.generation).toBe(1);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const switched = session.beginPlayback(InlinePlaybackPlayer.Html5);
session.settle(switched);
const attempts = session.attemptedTargets();
expect(session.beginRetry()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBeNull();
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.beginRetry()).toBe(false);
});
it.each([
[{ currentTime: 9, duration: 20 }, true],
[{ currentTime: Number.NaN, duration: 20 }, false],
[{ currentTime: Number.POSITIVE_INFINITY, duration: 20 }, false],
[{ currentTime: Number.NEGATIVE_INFINITY, duration: 20 }, false],
[{ currentTime: -1, duration: 20 }, false],
])('ignores invalid or live time update %#', (event, isLive) => {
const session = new PlaybackRecoverySession();
session.recordTimeUpdate({ currentTime: 13, duration: 40 }, false);
session.recordTimeUpdate(event, isLive);
expect(session.resumeStartTime(3, false)).toBe(13);
});
it('stores any finite non-negative VOD time without interpreting duration', () => {
const session = new PlaybackRecoverySession();
session.recordTimeUpdate(
{ currentTime: 0, duration: Number.NaN },
false
);
expect(session.resumeStartTime(8, false)).toBe(0);
session.recordTimeUpdate({ currentTime: 75, duration: 10 }, false);
expect(session.resumeStartTime(8, false)).toBe(75);
});
it('always returns zero for live and otherwise preserves the host start time', () => {
const session = new PlaybackRecoverySession();
expect(session.resumeStartTime(19, false)).toBe(19);
expect(session.resumeStartTime(19, true)).toBe(0);
session.recordTimeUpdate({ currentTime: 44, duration: 100 }, false);
expect(session.resumeStartTime(19, true)).toBe(0);
expect(session.resumeStartTime(19, false)).toBe(44);
});
it('does not let stale callbacks clear pending for a new session', () => {
const stale = new PlaybackRecoverySession();
stale.syncSession('movie-a');
const oldBinding = stale.beginPlayback(InlinePlaybackPlayer.VideoJs);
stale.syncSession('movie-b');
stale.beginPlayback(InlinePlaybackPlayer.VideoJs);
stale.beginRetry();
stale.settle(oldBinding);
expect(stale.switchPending()).toBe(true);
expect(stale.recordFailure(oldBinding)).toBe(false);
expect(stale.switchPending()).toBe(true);
});
});
@@ -0,0 +1,174 @@
import { signal, type Signal } from '@angular/core';
import type {
InlinePlaybackPlayer,
PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
export interface PlaybackBinding {
readonly generation: number;
readonly target: InlinePlaybackPlayer;
}
export class PlaybackRecoverySession {
private readonly attemptedTargetsState = signal<
ReadonlySet<PlaybackRecommendationTarget>
>(new Set());
private readonly temporaryPlayerOverrideState =
signal<InlinePlaybackPlayer | null>(null);
private readonly switchPendingState = signal(false);
private readonly activeBindingState = signal<PlaybackBinding | null>(null);
private readonly sessionKey = signal<string | null>(null);
private readonly generation = signal(0);
private readonly resumePosition = signal<number | null>(null);
private sourceRevision: symbol | null = null;
public readonly attemptedTargets: Signal<
ReadonlySet<PlaybackRecommendationTarget>
> = this.attemptedTargetsState.asReadonly();
public readonly temporaryPlayerOverride: Signal<InlinePlaybackPlayer | null> =
this.temporaryPlayerOverrideState.asReadonly();
public readonly switchPending: Signal<boolean> =
this.switchPendingState.asReadonly();
public readonly activeBinding: Signal<PlaybackBinding | null> =
this.activeBindingState.asReadonly();
syncSession(key: string): boolean {
if (this.sessionKey() === key) {
return false;
}
this.sessionKey.set(key);
this.attemptedTargetsState.set(new Set());
this.temporaryPlayerOverrideState.set(null);
this.switchPendingState.set(false);
this.resumePosition.set(null);
this.sourceRevision = null;
this.activeBindingState.set(null);
this.generation.update((generation) => generation + 1);
return true;
}
syncSourceRevision(revision: symbol): boolean {
if (this.sourceRevision === revision) {
return false;
}
this.sourceRevision = revision;
this.resumePosition.set(null);
return true;
}
beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding {
const generation = this.generation() + 1;
const binding: PlaybackBinding = Object.freeze({
generation,
target,
});
this.generation.set(generation);
this.activeBindingState.set(binding);
return binding;
}
clearPlaybackBinding(): void {
this.invalidatePlaybackBinding();
}
endPlayback(): void {
this.switchPendingState.set(false);
this.invalidatePlaybackBinding();
}
recordFailure(binding: PlaybackBinding): boolean {
if (!this.accepts(binding)) {
return false;
}
this.recordAttempt(binding.target);
this.switchPendingState.set(false);
return true;
}
recordInlineAttempt(target: InlinePlaybackPlayer): void {
this.recordAttempt(target);
}
recordExternalAttempt(target: ExternalPlayerName): void {
this.recordAttempt(target);
}
recordTimeUpdate(
event: { readonly currentTime: number; readonly duration: number },
isLive: boolean
): void {
if (
!isLive &&
Number.isFinite(event.currentTime) &&
event.currentTime >= 0
) {
this.resumePosition.set(event.currentTime);
}
}
beginPlayerSwitch(target: InlinePlaybackPlayer, isLive: boolean): boolean {
if (this.switchPendingState()) {
return false;
}
this.recordInlineAttempt(target);
if (isLive) {
this.resumePosition.set(null);
}
this.temporaryPlayerOverrideState.set(target);
this.switchPendingState.set(true);
this.invalidatePlaybackBinding();
return true;
}
beginRetry(): boolean {
if (this.switchPendingState() || this.activeBindingState() === null) {
return false;
}
this.switchPendingState.set(true);
this.invalidatePlaybackBinding();
return true;
}
settle(binding: PlaybackBinding): void {
if (this.accepts(binding)) {
this.switchPendingState.set(false);
}
}
resumeStartTime(inputStartTime: number, isLive: boolean): number {
if (isLive) {
return 0;
}
const position = this.resumePosition();
return position !== null && Number.isFinite(position) && position >= 0
? position
: inputStartTime;
}
accepts(binding: PlaybackBinding): boolean {
const activeBinding = this.activeBindingState();
return (
activeBinding !== null &&
activeBinding.generation === binding.generation &&
activeBinding.target === binding.target
);
}
private recordAttempt(target: PlaybackRecommendationTarget): void {
const attempts = new Set(this.attemptedTargetsState());
attempts.add(target);
this.attemptedTargetsState.set(attempts);
}
private invalidatePlaybackBinding(): void {
this.generation.update((generation) => generation + 1);
this.activeBindingState.set(null);
}
}
@@ -0,0 +1,116 @@
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import {
type PlaybackBinding,
PlaybackRecoverySession,
} from './playback-recovery-session';
import type { WebPlayerApplicationToken } from './web-player-application-state';
import {
createVideoJsOptions,
createWebPlayerChannel,
} from './web-player-playback-state';
export interface WebPlayerApplicationHandoff {
readonly channel: ReturnType<typeof createWebPlayerChannel>;
readonly vjsOptions: VideoPlayerOptions;
}
interface WebPlayerApplicationOwnership {
readonly binding: PlaybackBinding;
readonly token: WebPlayerApplicationToken;
}
export class WebPlayerApplicationHandoffCoordinator {
private headerScopeStreamUrl: string | null = null;
private activeOwnership: WebPlayerApplicationOwnership | null = null;
constructor(
private readonly streamHeaders: ElectronStreamHeadersService,
private readonly recoverySession: PlaybackRecoverySession
) {}
apply(
playback: ResolvedPortalPlayback,
isLive: boolean,
reloadToken: number,
binding: PlaybackBinding,
token: WebPlayerApplicationToken,
currentToken: () => WebPlayerApplicationToken,
accept: (handoff: WebPlayerApplicationHandoff) => void
): void {
this.activeOwnership = Object.freeze({ binding, token });
const headerSync = this.streamHeaders.apply(playback);
this.headerScopeStreamUrl = playback.streamUrl;
const acceptSource = (): void => {
const options = createVideoJsOptions({
streamUrl: playback.streamUrl,
isLive,
reloadToken,
});
accept({
channel: createWebPlayerChannel(playback),
vjsOptions: {
...options,
sources: options.sources.map((source) => ({ ...source })),
},
});
};
if (!headerSync) {
acceptSource();
return;
}
const handOff = (): void => {
if (this.ownsCurrentApplication(binding, token, currentToken)) {
acceptSource();
}
};
void headerSync.then(
(stillCurrent) => {
if (stillCurrent) {
handOff();
} else if (
this.ownsCurrentApplication(binding, token, currentToken)
) {
this.recoverySession.settle(binding);
}
},
() => {
if (this.ownsCurrentApplication(binding, token, currentToken)) {
this.recoverySession.settle(binding);
}
}
);
}
owns(binding: PlaybackBinding, token: WebPlayerApplicationToken): boolean {
if (!this.recoverySession.accepts(binding)) {
return false;
}
const active = this.activeOwnership;
return active?.binding === binding && active.token === token;
}
invalidate(): void {
this.activeOwnership = null;
}
private ownsCurrentApplication(
binding: PlaybackBinding,
token: WebPlayerApplicationToken,
currentToken: () => WebPlayerApplicationToken
): boolean {
return this.owns(binding, token) && currentToken() === token;
}
release(): void {
this.invalidate();
this.streamHeaders.clear(this.headerScopeStreamUrl);
this.headerScopeStreamUrl = null;
}
destroy(): void {
this.recoverySession.endPlayback();
this.release();
}
}
@@ -0,0 +1,58 @@
import { computed, type Signal } from '@angular/core';
import type {
ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
resolveWebPlayerIsLive,
resolveWebPlayerPlayback,
} from './web-player-playback-state';
export type WebPlayerApplicationToken = symbol;
export type WebPlayerSourceRevisionToken = symbol;
export function createWebPlayerApplicationState(sources: {
readonly playback: Signal<ResolvedPortalPlayback | null>;
readonly streamUrl: Signal<string>;
readonly title: Signal<string>;
readonly startTime: Signal<number>;
readonly selectedPlayer: Signal<VideoPlayer>;
readonly reloadToken: Signal<number>;
}): {
readonly playback: Signal<ResolvedPortalPlayback>;
readonly isLive: Signal<boolean>;
readonly sourceRevision: Signal<WebPlayerSourceRevisionToken>;
readonly token: Signal<WebPlayerApplicationToken>;
} {
const playback = computed(() => {
const explicit = sources.playback();
return (
explicit ??
resolveWebPlayerPlayback({
playback: null,
streamUrl: sources.streamUrl(),
title: sources.title(),
startTime: sources.startTime(),
})
);
});
const isLive = computed(() => {
const explicit = sources.playback();
return explicit ? resolveWebPlayerIsLive(explicit) : true;
});
const sourceRevision = computed<WebPlayerSourceRevisionToken>(() => {
if (sources.playback() === null) {
void sources.streamUrl();
void sources.startTime();
}
void isLive();
return Symbol();
});
const token = computed<WebPlayerApplicationToken>(() => {
void sourceRevision();
void sources.selectedPlayer();
void sources.reloadToken();
return Symbol();
});
return { playback, isLive, sourceRevision, token };
}
@@ -0,0 +1,175 @@
import { type ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import {
createVideoJsOptions,
createWebPlayerChannel,
resolveWebPlayerIsLive,
resolveWebPlayerMediaTitle,
resolveWebPlayerPlayback,
} from './web-player-playback-state';
describe('web player playback state', () => {
it('preserves an existing resolved playback payload', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://provider.example/movie/7.ts',
title: 'Movie Seven',
startTime: 42,
headers: { Authorization: 'Bearer secret' },
};
expect(
resolveWebPlayerPlayback({
playback,
streamUrl: 'https://fallback.example/stream',
title: 'Fallback',
startTime: 0,
})
).toBe(playback);
});
it('constructs fallback playback and uses the URL for a missing title', () => {
expect(
resolveWebPlayerPlayback({
playback: null,
streamUrl: 'https://example.com/live/1.ts',
title: '',
startTime: 19,
})
).toEqual({
streamUrl: 'https://example.com/live/1.ts',
title: 'https://example.com/live/1.ts',
startTime: 19,
});
});
it('uses explicit live metadata before inferring VOD from content info', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/movie.ts',
title: 'Movie',
isLive: true,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
};
expect(resolveWebPlayerIsLive(playback)).toBe(true);
expect(resolveWebPlayerIsLive({ ...playback, isLive: undefined })).toBe(
false
);
});
it('resolves an explicit media title, playback title, and raw URL fallback', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/movie.ts',
title: 'Movie',
};
expect(
resolveWebPlayerMediaTitle(
{ primary: 'Series', secondary: 'S01E02' },
playback
)
).toEqual({ primary: 'Series', secondary: 'S01E02' });
expect(resolveWebPlayerMediaTitle(null, playback)).toEqual({
primary: 'Movie',
secondary: null,
});
expect(
resolveWebPlayerMediaTitle(null, {
...playback,
title: playback.streamUrl,
})
).toBeNull();
});
it('prefers explicit HTTP metadata when constructing a channel', () => {
expect(
createWebPlayerChannel({
streamUrl: 'https://example.com/live/channel.m3u8',
title: 'Header Locked Channel',
thumbnail: 'https://example.com/logo.png',
userAgent: 'ProviderAgent/1.0',
referer: 'https://provider.example/ref',
origin: 'https://provider.example',
headers: {
'User-Agent': 'IgnoredAgent/1.0',
Referer: 'https://ignored.example/ref',
Origin: 'https://ignored.example',
},
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: { '0011': '2233' },
},
})
).toEqual(
expect.objectContaining({
id: 'https://example.com/live/channel.m3u8',
url: 'https://example.com/live/channel.m3u8',
name: 'Header Locked Channel',
tvg: expect.objectContaining({
name: 'Header Locked Channel',
logo: 'https://example.com/logo.png',
}),
http: {
'user-agent': 'ProviderAgent/1.0',
referrer: 'https://provider.example/ref',
origin: 'https://provider.example',
},
radio: 'false',
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: { '0011': '2233' },
},
})
);
});
it('falls back to case-insensitive HTTP headers', () => {
expect(
createWebPlayerChannel({
streamUrl: 'https://example.com/live/channel.m3u8',
title: '',
headers: {
'uSeR-aGeNt': 'HeaderAgent/1.0',
rEfErEr: 'https://headers.example/ref',
ORIGIN: 'https://headers.example',
},
}).http
).toEqual({
'user-agent': 'HeaderAgent/1.0',
referrer: 'https://headers.example/ref',
origin: 'https://headers.example',
});
});
it.each([
['https://example.com/live/index.m3u8', 'application/x-mpegURL'],
[
'https://example.com/play?extension=m3u8&token=signed',
'application/x-mpegURL',
],
['https://example.com/live/channel.ts', 'video/mp2t'],
['https://example.com/live.php?stream=123', 'video/mp2t'],
['https://example.com/archive/movie.mkv', 'video/matroska'],
[
'https://example.com/play?container=mkv&token=signed',
'video/matroska',
],
['https://example.com/archive/movie.mp4', 'video/mp4'],
])('selects the existing MIME type for %s', (streamUrl, type) => {
expect(
createVideoJsOptions({
streamUrl,
isLive: false,
reloadToken: 3,
})
).toEqual({
isLive: false,
reloadToken: 3,
sources: [{ src: streamUrl, type }],
});
});
});
@@ -0,0 +1,119 @@
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
import type {
Channel,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlayerMediaTitle } from '../player-controls';
export function resolveWebPlayerPlayback(options: {
readonly playback: ResolvedPortalPlayback | null;
readonly streamUrl: string;
readonly title: string;
readonly startTime: number;
}): ResolvedPortalPlayback {
return (
options.playback ?? {
streamUrl: options.streamUrl,
title: options.title || options.streamUrl,
startTime: options.startTime,
}
);
}
export function resolveWebPlayerIsLive(
playback: ResolvedPortalPlayback
): boolean {
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
}
export function resolveWebPlayerMediaTitle(
explicit: PlayerMediaTitle | null,
playback: ResolvedPortalPlayback
): PlayerMediaTitle | null {
if (explicit?.primary?.trim()) {
return explicit;
}
const title = playback.title?.trim();
return !title || title === playback.streamUrl
? null
: { primary: title, secondary: null };
}
export function createWebPlayerChannel(
playback: ResolvedPortalPlayback
): Channel {
const title = playback.title || playback.streamUrl;
return {
id: playback.streamUrl,
url: playback.streamUrl,
name: title,
group: { title: '' },
tvg: {
id: '',
name: title,
url: '',
logo: playback.thumbnail ?? '',
rec: '',
},
http: {
referrer:
playback.referer ??
getHeaderValue(playback.headers, 'Referer') ??
'',
'user-agent':
playback.userAgent ??
getHeaderValue(playback.headers, 'User-Agent') ??
'',
origin:
playback.origin ??
getHeaderValue(playback.headers, 'Origin') ??
'',
},
radio: 'false',
drm: playback.drm,
};
}
export function createVideoJsOptions(options: {
readonly streamUrl: string;
readonly isLive: boolean;
readonly reloadToken: number;
}): {
readonly isLive: boolean;
readonly reloadToken: number;
readonly sources: readonly {
readonly src: string;
readonly type: string;
}[];
} {
const extension = getPlaybackMediaExtensionFromUrl(options.streamUrl);
const type =
extension === 'm3u' || extension === 'm3u8'
? 'application/x-mpegURL'
: extension === 'ts' || !extension
? 'video/mp2t'
: extension === 'mkv'
? 'video/matroska'
: 'video/mp4';
return {
isLive: options.isLive,
reloadToken: options.reloadToken,
sources: [{ src: options.streamUrl, type }],
};
}
function getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
);
return matchingKey ? headers[matchingKey] : undefined;
}
@@ -0,0 +1,82 @@
import {
InlinePlaybackPlayer,
createPlaybackTargetCapabilities,
recommendPlaybackRecovery,
resolvePlaybackSourceKind,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlaybackBinding } from './playback-recovery-session';
export function createWebPlayerRecommendations(options: {
readonly diagnostic: PlaybackDiagnostic | null;
readonly binding: PlaybackBinding | null;
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
readonly managedExternalPlayersAvailable: boolean;
readonly playbackExternallyTransferable: boolean;
readonly isLive: boolean;
readonly alternativeSourceCount: number;
}): readonly PlaybackRecommendation[] {
if (!options.diagnostic || !options.binding) {
return [];
}
const sourceKind = resolvePlaybackSourceKind(options.diagnostic);
return recommendPlaybackRecovery({
diagnostic: options.diagnostic,
activeTarget: options.binding.target,
attemptedTargets: options.attemptedTargets,
targetCapabilities: createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
options.managedExternalPlayersAvailable,
}),
source: {
kind: sourceKind,
isLive: options.isLive,
drm: options.playbackExternallyTransferable
? 'none'
: 'untransferable',
externalTransferable: options.playbackExternallyTransferable,
},
alternativeSourceCount: options.alternativeSourceCount,
});
}
export function isPlaybackExternallyTransferable(
playback: ResolvedPortalPlayback
): boolean {
return playback.drm === undefined;
}
export function toVideoPlayer(target: InlinePlaybackPlayer): VideoPlayer {
switch (target) {
case InlinePlaybackPlayer.VideoJs:
return VideoPlayer.VideoJs;
case InlinePlaybackPlayer.Html5:
return VideoPlayer.Html5Player;
case InlinePlaybackPlayer.ArtPlayer:
return VideoPlayer.ArtPlayer;
}
}
export function toInlinePlaybackPlayer(
player: VideoPlayer
): InlinePlaybackPlayer | null {
switch (player) {
case VideoPlayer.VideoJs:
return InlinePlaybackPlayer.VideoJs;
case VideoPlayer.Html5Player:
return InlinePlaybackPlayer.Html5;
case VideoPlayer.ArtPlayer:
return InlinePlaybackPlayer.ArtPlayer;
case VideoPlayer.EmbeddedMpv:
case VideoPlayer.MPV:
case VideoPlayer.VLC:
return null;
}
}
@@ -1,278 +1,112 @@
@if (selectedPlayer() === 'videojs') {
@defer (on immediate) {
<!-- The source hand-off is deferred until the scoped Electron header
override is configured; mounting the player before `vjsOptions`
exists would fire a source-less (or credential-less) first load. -->
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@for (application of renderedApplications(); track application.token) {
@if (application.binding; as binding) {
@if (binding.target === 'videojs') {
@defer (on immediate) {
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @else if (binding.target === 'html5') {
@defer (on immediate) {
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @else if (binding.target === 'artplayer') {
@defer (on immediate) {
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'html5') {
@defer (on immediate) {
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'artplayer') {
@defer (on immediate) {
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
} @else if (application.embeddedMpv) {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
}
@if (visiblePlaybackDiagnostic(); as issue) {
<section
class="web-player-diagnostic"
data-test-id="playback-diagnostic-banner"
role="status"
>
<div class="web-player-diagnostic__content">
<div class="web-player-diagnostic__badge">
<mat-icon aria-hidden="true">warning</mat-icon>
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
</div>
<h2 class="web-player-diagnostic__headline">
{{ diagnosticHeadlineKey() | translate }}
</h2>
<div class="web-player-diagnostic__description">
{{ getDiagnosticDescriptionKey(issue) | translate }}
</div>
@if (getDiagnosticMeta(issue); as meta) {
<div class="web-player-diagnostic__meta">
{{ meta }}
</div>
}
@if (getDiagnosticCodecHint(issue); as codecHint) {
<div
class="web-player-diagnostic__codec-hint"
data-test-id="playback-codec-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
| translate: { codecs: codecHint }
}}
</div>
}
@if (alternativeSources().length > 0) {
<div
class="web-player-diagnostic__alternatives"
data-test-id="playback-alternative-sources"
>
<h3 class="web-player-diagnostic__alternatives-title">
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</h3>
@for (source of visibleAlternatives(); track source.id) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="alternativeSourceRequested.emit($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: { count: hiddenAlternativeCount() }
}}
</p>
}
</div>
}
@if (canShowExternalFallbackActions()) {
<div
class="web-player-diagnostic__player-actions"
aria-label="External player fallbacks"
>
<button
class="web-player-diagnostic__player-card web-player-diagnostic__player-card--primary"
type="button"
data-test-id="playback-fallback-mpv"
(click)="requestExternalFallback('mpv')"
>
<mat-icon aria-hidden="true">open_in_new</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'
| translate
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV_HINT'
| translate
}}
</span>
</span>
</button>
<button
class="web-player-diagnostic__player-card"
type="button"
data-test-id="playback-fallback-vlc"
(click)="requestExternalFallback('vlc')"
>
<mat-icon aria-hidden="true">open_in_new</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'
| translate
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC_HINT'
| translate
}}
</span>
</span>
</button>
</div>
}
<div class="web-player-diagnostic__utilities">
<button
class="web-player-diagnostic__utility-button"
type="button"
[cdkCopyToClipboard]="resolvedPlayback().streamUrl"
[matTooltip]="resolvedPlayback().streamUrl"
data-test-id="playback-copy-url"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<button
class="web-player-diagnostic__utility-button"
type="button"
data-test-id="playback-retry"
(click)="retryPlayback()"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<details
class="web-player-diagnostic__details"
data-test-id="playback-diagnostic-details"
>
<summary>
<mat-icon aria-hidden="true">expand_more</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
</summary>
<dl>
@for (
detail of getDiagnosticDetails(issue);
track detail.labelKey
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
</div>
}
</dl>
</details>
</div>
</div>
</section>
<app-playback-diagnostic-panel
[diagnostic]="issue"
[recommendations]="recommendations()"
[playback]="resolvedPlayback()"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[playbackExternallyTransferable]="playbackExternallyTransferable()"
[alternativeSources]="alternativeSources()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
(alternativeSourceRequested)="alternativeSourceRequested.emit($event)"
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
/>
}
@@ -22,462 +22,3 @@ app-html-video-player {
height: 100%;
background: #000;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
overflow: auto;
background:
radial-gradient(
circle at 18% 18%,
rgb(255 176 76 / 6%),
transparent 28%
),
rgb(0 0 0 / 96%);
color: #f6f6f6;
}
.web-player-diagnostic__content {
width: min(100%, 720px);
}
.web-player-diagnostic__badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 8px;
padding: 5px 11px;
border: 1px solid rgb(255 174 66 / 42%);
border-radius: 6px;
background: rgb(255 148 35 / 15%);
color: #ffb24c;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.web-player-diagnostic__badge mat-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
font-size: 16px;
}
.web-player-diagnostic__badge span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__headline {
max-width: 660px;
margin: 20px 0 0;
color: #fff;
font-size: 1.65rem;
font-weight: 760;
letter-spacing: 0;
line-height: 1.05;
}
.web-player-diagnostic__description {
max-width: 760px;
margin-top: 12px;
color: rgb(255 255 255 / 73%);
font-size: 1rem;
line-height: 1.48;
}
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 12px;
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
font-weight: 620;
line-height: 1.3;
overflow-wrap: anywhere;
}
// "Try another source" — the alternatives block that turns the error screen
// into a recovery point. The overlay is always dark, so these are literals
// rather than theme tokens, matching the rest of this diagnostic surface.
.web-player-diagnostic__alternatives {
width: min(420px, 100%);
margin: 20px auto 0;
padding: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(22 27 36 / 90%);
text-align: left;
// Scrolls itself rather than growing the whole overlay: otherwise a long
// list pushes the error headline and message off the top of the screen,
// which is precisely the information the user needs first.
max-height: min(46vh, 320px);
overflow-y: auto;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.web-player-diagnostic__player-actions {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__player-card {
display: grid;
min-height: 60px;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
color: #f5f5f5;
cursor: pointer;
text-align: left;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.web-player-diagnostic__player-card:hover {
border-color: rgb(255 255 255 / 28%);
background: rgb(255 255 255 / 6%);
}
.web-player-diagnostic__player-card:active {
transform: translateY(1px);
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
outline: 2px solid #ffb24c;
outline-offset: 3px;
}
.web-player-diagnostic__player-card--primary {
border-color: transparent;
background: #fff;
color: #151515;
}
.web-player-diagnostic__player-card--primary:hover {
border-color: transparent;
background: rgb(255 255 255 / 88%);
}
.web-player-diagnostic__player-card mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.web-player-diagnostic__player-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.web-player-diagnostic__player-label {
overflow: hidden;
font-size: 0.95rem;
font-weight: 760;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__player-hint {
overflow: hidden;
color: currentColor;
font-size: 0.78rem;
line-height: 1.1;
opacity: 0.62;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
margin-top: 22px;
color: rgb(255 255 255 / 45%);
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-weight: 640;
line-height: 1.2;
}
.web-player-diagnostic__utility-button:hover,
.web-player-diagnostic__details summary:hover {
color: rgb(255 255 255 / 76%);
}
.web-player-diagnostic__utility-button mat-icon,
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__divider {
width: 1px;
height: 18px;
margin-top: 3px;
background: rgb(255 255 255 / 16%);
}
.web-player-diagnostic__details {
min-width: 0;
max-width: 100%;
}
.web-player-diagnostic__details summary {
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details[open] {
flex-basis: 100%;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 7px 14px;
max-width: min(100%, 640px);
margin: 12px 0 0;
padding: 12px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
line-height: 1.3;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: rgb(255 255 255 / 48%);
font-weight: 700;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
@container (max-width: 760px) {
.web-player-diagnostic {
align-items: flex-start;
padding: 22px 16px;
}
.web-player-diagnostic__badge {
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: normal;
}
.web-player-diagnostic__headline {
margin-top: 16px;
font-size: 1.48rem;
}
.web-player-diagnostic__utilities {
gap: 10px 12px;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 520px) {
.web-player-diagnostic {
align-items: flex-start;
padding: clamp(14px, 5cqi, 22px);
}
.web-player-diagnostic__content {
width: 100%;
}
.web-player-diagnostic__badge {
gap: 6px;
padding: 4px 8px;
font-size: 0.7rem;
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: nowrap;
}
.web-player-diagnostic__headline {
margin-top: 14px;
font-size: 1.35rem;
line-height: 1.08;
}
.web-player-diagnostic__description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.38;
}
.web-player-diagnostic__meta {
margin-top: 8px;
font-size: 0.74rem;
}
.web-player-diagnostic__codec-hint {
margin-top: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__player-actions {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
.web-player-diagnostic__player-card {
min-height: 52px;
padding: 10px 12px;
}
.web-player-diagnostic__utilities {
display: grid;
gap: 7px;
margin-top: 16px;
}
.web-player-diagnostic__divider {
display: none;
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
width: fit-content;
min-height: 22px;
font-size: 0.82rem;
}
.web-player-diagnostic__details[open] {
flex-basis: auto;
}
.web-player-diagnostic__details dl {
max-height: 260px;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
margin-top: 8px;
padding: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 340px) {
.web-player-diagnostic {
padding: 12px;
}
.web-player-diagnostic__headline {
font-size: 1.22rem;
}
.web-player-diagnostic__description {
font-size: 0.84rem;
}
.web-player-diagnostic__player-label {
font-size: 0.88rem;
}
}
@@ -29,7 +29,8 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -85,6 +86,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
PlaybackDiagnosticPanelComponent,
ClipboardModule,
MatButtonModule,
MatIconModule,
@@ -96,6 +98,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -124,6 +127,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
fixture.destroy();
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -197,11 +201,11 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
expect(artPlayer.showCaptions()).toBe(true);
expect(artPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(artPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
emitPlaybackIssue(null);
fixture.detectChanges();
expect(artPlayer.interactionEnabled()).toBe(true);
});
@@ -209,7 +213,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
const htmlPlayer = await renderHtmlPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(false);
@@ -219,43 +223,49 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
it('re-enables HTML5 interaction after retrying or clearing the issue', async () => {
const htmlPlayer = await renderHtmlPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(htmlPlayer.interactionEnabled()).toBe(false);
component.retryPlayback();
fixture.detectChanges();
const retriedHtmlPlayer = fixture.debugElement.query(
By.directive(StubHtmlVideoPlayerComponent)
).componentInstance as StubHtmlVideoPlayerComponent;
expect(component.playbackInteractionEnabled()).toBe(true);
expect(htmlPlayer.interactionEnabled()).toBe(true);
expect(retriedHtmlPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(htmlPlayer.interactionEnabled()).toBe(false);
expect(retriedHtmlPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
emitPlaybackIssue(null);
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(true);
expect(htmlPlayer.interactionEnabled()).toBe(true);
expect(retriedHtmlPlayer.interactionEnabled()).toBe(true);
});
it('disables and restores Video.js interaction around diagnostics', async () => {
const vjsPlayer = await renderVjsPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(false);
component.retryPlayback();
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(true);
const retriedVjsPlayer = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(false);
expect(retriedVjsPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
emitPlaybackIssue(null);
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(true);
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
});
it.each([
@@ -269,17 +279,20 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
'uses the resolved value for Video.js effect and retry: %s',
(_label, metadata, expected) => {
const streamUrl = 'https://example.com/video.ts';
const setVjsOptions = jest.spyOn(component, 'setVjsOptions');
setPlayback(metadata, streamUrl);
fixture.detectChanges();
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
expect(component.vjsOptions).toEqual(
expect.objectContaining({ isLive: expected, reloadToken: 0 })
);
setVjsOptions.mockClear();
component.retryPlayback();
fixture.detectChanges();
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
expect(component.vjsOptions).toEqual(
expect.objectContaining({ isLive: expected, reloadToken: 1 })
);
}
);
@@ -336,6 +349,16 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
return fixture.debugElement.query(By.directive(StubArtPlayerComponent))
.componentInstance as StubArtPlayerComponent;
}
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
fixture.detectChanges();
const binding = component.activeBinding();
expect(binding).not.toBeNull();
if (!binding) {
throw new Error('Expected an active inline playback binding');
}
component.handlePlaybackIssue(issue, binding);
}
});
function createVodContentInfo() {
@@ -356,6 +379,5 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
player: 'html5',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
};
}
@@ -21,7 +21,15 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey as resolveDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -141,6 +149,7 @@ describe('WebPlayerViewComponent', () => {
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
PlaybackDiagnosticPanelComponent,
// Real, not stubbed: the point of the test below is
// that this row's Check action reaches the host.
VodSourceRowComponent,
@@ -156,6 +165,7 @@ describe('WebPlayerViewComponent', () => {
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -236,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
]);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const check = fixture.debugElement.query(
@@ -258,7 +268,7 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -295,7 +305,7 @@ describe('WebPlayerViewComponent', () => {
const issue = createHttpDiagnostic();
fixture.detectChanges();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -307,8 +317,8 @@ describe('WebPlayerViewComponent', () => {
expect(banner.nativeElement.textContent).toContain('HTTP 404');
expect(mpvButton).toBeNull();
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
@@ -318,13 +328,57 @@ describe('WebPlayerViewComponent', () => {
);
});
it.each([
['browser-access', createBrowserAccessDiagnostic],
['unsupported-codec', createUnsupportedCodecDiagnostic],
])(
'uses neutral %s guidance when ClearKey playback cannot be transferred',
(_diagnostic, createDiagnostic) => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
fixture.componentRef.setInput('playback', {
streamUrl: 'https://provider.example/protected.mpd',
title: 'Protected stream',
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
fixture.detectChanges();
emitPlaybackIssue(createDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
By.css('[data-test-id="playback-diagnostic-banner"]')
);
expect(banner.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE'
);
expect(banner.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION'
);
expect(
banner.query(
By.css(
'[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]'
)
)
).toBeNull();
}
);
it('renders only sanitized structured HLS evidence in technical details', () => {
const issue = createStructuredHlsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -345,10 +399,10 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured VHS evidence in technical details', () => {
const issue = createStructuredVhsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -374,10 +428,10 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured Shaka evidence in technical details', () => {
const issue = createStructuredShakaDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -404,13 +458,13 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured mpegts evidence in technical details', () => {
const issue = createStructuredMpegTsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(details).toEqual(
expect.arrayContaining([
{
@@ -432,59 +486,6 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('response body');
});
it('keeps query-declared HLS streams on the HLS mime type', () => {
const streamUrl =
'https://example.com/play?extension=m3u8&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'application/x-mpegURL',
},
]);
});
it('uses the Matroska mime type for MKV paths', () => {
const streamUrl = 'https://example.com/archive/movie.mkv';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('uses the Matroska mime type for query-declared MKV streams', () => {
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/mp2t',
},
]);
});
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
const streamUrl = 'https://example.com/movie/123.ts';
fixture.componentRef.setInput('playback', {
@@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => {
const pendingSettings = new Subject<unknown>();
storageMap.get.mockReturnValue(pendingSettings.asObservable());
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -627,11 +629,11 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
component.playbackDiagnostic.set(createUnsupportedCodecDiagnostic());
fixture.detectChanges();
component.requestExternalFallback('mpv');
component.requestRecommendedPlayer('mpv');
expect(component.playbackDiagnostic()).toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(
fixture.debugElement.query(
By.directive(StubEmbeddedMpvPlayerComponent)
@@ -796,10 +798,10 @@ describe('WebPlayerViewComponent', () => {
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticTitleKey(issue)).toBe(
expect(getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
);
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
expect(getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
@@ -808,14 +810,14 @@ describe('WebPlayerViewComponent', () => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
expect(getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
});
it('uses an inline recovery headline when external fallback actions are unavailable', () => {
fixture.detectChanges();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -838,7 +840,7 @@ describe('WebPlayerViewComponent', () => {
fixture.detectChanges();
const issue = createUnsupportedCodecDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = fixture.debugElement.query(
@@ -851,8 +853,8 @@ describe('WebPlayerViewComponent', () => {
expect(details.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
@@ -888,7 +890,7 @@ describe('WebPlayerViewComponent', () => {
expect.objectContaining({ reloadToken: 0 })
);
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const retryButton = fixture.debugElement.query(
@@ -902,9 +904,12 @@ describe('WebPlayerViewComponent', () => {
retryButton.nativeElement.click();
fixture.detectChanges();
const retriedPlayer = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(component.playbackDiagnostic()).toBeNull();
expect(player.options()).toEqual(
expect(retriedPlayer.options()).toEqual(
expect.objectContaining({ reloadToken: 1 })
);
expect(
@@ -1029,6 +1034,24 @@ describe('WebPlayerViewComponent', () => {
);
});
});
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
fixture.detectChanges();
const binding = component.activeBinding();
expect(binding).not.toBeNull();
if (!binding) {
throw new Error('Expected an active inline playback binding');
}
component.handlePlaybackIssue(issue, binding);
}
function getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return resolveDiagnosticDescriptionKey(
issue,
runtimeCapabilities.supportsManagedExternalPlayers,
true
);
}
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -1041,7 +1064,6 @@ function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: true,
};
}
@@ -1056,7 +1078,6 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'blocked by CORS policy',
externalFallbackRecommended: true,
};
}
@@ -1071,7 +1092,6 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
details: 'manifestIncompatibleCodecsError',
externalFallbackRecommended: true,
};
}
@@ -1086,7 +1106,6 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
externalFallbackRecommended: false,
};
}
@@ -1104,7 +1123,6 @@ function createHttpDiagnostic(): PlaybackDiagnostic {
nativeErrorMessage: 'source not supported',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
externalFallbackRecommended: false,
};
}
@@ -1128,7 +1146,6 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -1154,7 +1171,6 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
stage: 'unknown',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1182,7 +1198,6 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
failure: 'network',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1209,6 +1224,5 @@ function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -12,50 +12,52 @@ import {
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import {
Channel,
ResolvedPortalPlayback,
Settings,
type PlaybackDiagnostic,
type PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
STORE_KEY,
VideoPlayer,
type Channel,
type ResolvedPortalPlayback,
type Settings,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
/** How many recovery options the error screen shows before it stops helping. */
const ERROR_SCREEN_ALTERNATIVES = 5;
import { ArtPlayerComponent } from '../art-player/art-player.component';
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
} from '../player-controls';
import {
type PlaybackDiagnostic,
type PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './web-player-view-diagnostics.utils';
type PlaybackBinding,
PlaybackRecoverySession,
} from './playback-recovery-session';
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
import {
createWebPlayerApplicationState,
type WebPlayerApplicationToken,
type WebPlayerSourceRevisionToken,
} from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import {
createWebPlayerRecommendations,
isPlaybackExternallyTransferable,
toInlinePlaybackPlayer,
toVideoPlayer,
} from './web-player-recovery-policy';
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
@@ -64,24 +66,25 @@ function resolveWebPlayerSharedControls(): boolean {
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
}
interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
}
@Component({
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
styleUrls: ['./web-player-view.component.scss'],
host: {
class: 'web-player-view',
},
host: { class: 'web-player-view' },
imports: [
ArtPlayerComponent,
ClipboardModule,
EmbeddedMpvPlayerComponent,
HtmlVideoPlayerComponent,
MatButtonModule,
MatIconModule,
MatTooltipModule,
TranslatePipe,
PlaybackDiagnosticPanelComponent,
VjsPlayerComponent,
VodSourceRowComponent,
],
providers: [
{
@@ -92,307 +95,310 @@ function resolveWebPlayerSharedControls(): boolean {
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent implements OnDestroy {
storage = inject(StorageMap);
private readonly storage = inject(StorageMap);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly recoverySession = new PlaybackRecoverySession();
private readonly applicationHandoff =
new WebPlayerApplicationHandoffCoordinator(
inject(ElectronStreamHeadersService),
this.recoverySession
);
readonly streamUrl = input.required<string>();
readonly playbackSessionKey = input.required<string>();
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly startTime = input(0);
readonly volume = input(1);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
streamUrl = input.required<string>();
title = input('');
playback = input<ResolvedPortalPlayback | null>(null);
startTime = input<number>(0);
volume = input<number>(1);
playerOverride = input<VideoPlayer | null>(null);
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
/** Display-ready title lines for the fullscreen overlay; hosts with richer
* context (e.g. series name + episode label) pass it explicitly. */
mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/**
* Alternative sources offered on the error screen, turning a dead end into
* a recovery point. Empty (the default) keeps the overlay exactly as it
* was for every non-multi-source host.
*/
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
readonly alternativeSourceRequested = output<string>();
/** The row's Check action, which has to reach the host that can probe. */
readonly sourceCheckRequested = output<string>();
/**
* A playback failure the host may be able to recover from by switching
* source. Emitted alongside showing the overlay, never instead of it: if
* the host does nothing, the user still sees the honest error.
*/
readonly playbackFailed = output<PlaybackDiagnosticCode>();
/**
* An error screen is a recovery point, not a catalogue. Offering fifty
* options here is worse than offering the best few — the full list stays
* one click away behind the player's own sources button.
*/
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
);
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal<
Settings | undefined
>;
/**
* Subtitle preference for the built-in web players. Read from the settings
* store instead of an input so every host (M3U, Xtream, Stalker, portal
* detail pages) gets it without having to wire it through — the missing
* bindings were why the setting looked like a no-op (#1155).
*/
readonly settings = toSignal(
this.storage.get(STORE_KEY.Settings)
) as Signal<Settings | undefined>;
readonly showCaptions = computed(
() => this.settingsStore.showCaptions?.() ?? false
);
channel!: Channel;
vjsOptions!: {
isLive: boolean;
reloadToken: number;
sources: { src: string; type: string }[];
};
readonly reloadToken = signal(0);
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
readonly recoveryPending = this.recoverySession.switchPending;
readonly activeBinding = this.recoverySession.activeBinding;
channel: Channel | undefined;
vjsOptions: VideoPlayerOptions | undefined;
readonly selectedPlayer = computed<VideoPlayer>(() => {
const temporary = this.recoverySession.temporaryPlayerOverride();
return temporary
? toVideoPlayer(temporary)
: (this.playerOverride() ??
this.settings()?.player ??
VideoPlayer.VideoJs);
});
private readonly applicationState = createWebPlayerApplicationState({
playback: this.playback,
streamUrl: this.streamUrl,
title: this.title,
startTime: this.startTime,
selectedPlayer: this.selectedPlayer,
reloadToken: this.reloadToken,
});
readonly resolvedPlayback = this.applicationState.playback;
readonly resolvedIsLive = this.applicationState.isLive;
readonly playbackSourceRevisionToken = this.applicationState.sourceRevision;
readonly playbackApplicationToken = this.applicationState.token;
// Diagnostic ownership follows raw intent so an old action disappears;
// the application effect then clears its backing state before handoff.
// Keep this token opaque: it must never retain playback payload fields.
private readonly playbackDiagnosticIntentToken =
computed<WebPlayerApplicationToken>(() => {
if (this.playback() === null) {
void this.streamUrl();
void this.startTime();
}
void this.selectedPlayer();
void this.reloadToken();
return Symbol();
});
private readonly playbackDiagnosticOwnerToken =
signal<WebPlayerApplicationToken | null>(null);
readonly effectiveStartTime = computed(() =>
this.recoverySession.resumeStartTime(
this.startTime(),
this.resolvedIsLive()
)
);
readonly visiblePlaybackDiagnostic = computed(() =>
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
this.selectedPlayer() === VideoPlayer.EmbeddedMpv ||
this.playbackDiagnosticOwnerToken() !==
this.playbackDiagnosticIntentToken()
? null
: this.playbackDiagnostic()
);
readonly playbackInteractionEnabled = computed(
() => this.visiblePlaybackDiagnostic() === null
);
readonly canShowExternalFallbackActions = computed(
() =>
this.runtime.supportsManagedExternalPlayers &&
!!this.visiblePlaybackDiagnostic()?.externalFallbackRecommended
readonly resolvedMediaTitle = computed(() =>
resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback())
);
readonly diagnosticHeadlineKey = computed(() =>
this.canShowExternalFallbackActions()
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
readonly playbackExternallyTransferable = computed(() =>
isPlaybackExternallyTransferable(this.resolvedPlayback())
);
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
const playback = this.playback();
if (playback) {
return playback;
readonly recordingFolder = computed(
() => this.settings()?.recordingFolder ?? ''
);
get supportsManagedExternalPlayers(): boolean {
return this.runtime.supportsManagedExternalPlayers;
}
readonly recommendations = computed(() => {
const binding = this.activeBinding();
const token = this.playbackApplicationToken();
return createWebPlayerRecommendations({
diagnostic: this.visiblePlaybackDiagnostic(),
binding:
binding && this.applicationHandoff.owns(binding, token)
? binding
: null,
attemptedTargets: this.recoverySession.attemptedTargets(),
managedExternalPlayersAvailable:
this.runtime.supportsManagedExternalPlayers,
playbackExternallyTransferable:
this.playbackExternallyTransferable(),
isLive: this.resolvedIsLive(),
alternativeSourceCount: this.alternativeSources().length,
});
});
readonly renderedApplications = computed<
readonly PlaybackApplicationOwnership[]
>(() => {
const binding = this.activeBinding();
const embeddedMpv =
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
if (!binding && !embeddedMpv) {
return [];
}
return {
streamUrl: this.streamUrl(),
title: this.title() || this.streamUrl(),
startTime: this.startTime(),
};
return [
Object.freeze({
binding,
embeddedMpv,
isLive: this.resolvedIsLive(),
sourceRevision: this.playbackSourceRevisionToken(),
token: this.playbackApplicationToken(),
}),
];
});
readonly resolvedIsLive = computed(() => {
const playback = this.resolvedPlayback();
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
});
readonly selectedPlayer = computed(
() =>
this.playerOverride() ??
this.settings()?.player ??
VideoPlayer.VideoJs
);
readonly resolvedMediaTitle = computed<PlayerMediaTitle | null>(() => {
const explicit = this.mediaTitle();
if (explicit?.primary?.trim()) {
return explicit;
}
const playback = this.resolvedPlayback();
const title = playback.title?.trim();
// resolvedPlayback() falls back to the stream URL as title; a raw URL
// is not a watchable overlay title.
if (!title || title === playback.streamUrl) {
return null;
}
return { primary: title, secondary: null };
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
/** Stream URL the currently configured Electron header override belongs to. */
private headerScopeStreamUrl: string | null = null;
private readonly streamHeaders = inject(ElectronStreamHeadersService);
constructor() {
effect(() => {
// Track player changes so stale browser diagnostics are cleared on switch.
this.selectedPlayer();
const playback = this.resolvedPlayback();
const isLive = this.resolvedIsLive();
this.playbackDiagnostic.set(null);
this.applyPlayback(playback, isLive);
// Session sync may clear a temporary player override. Run it before
// tracking intent so that reset is folded into this application.
this.syncRecoverySession();
void this.playbackDiagnosticIntentToken();
const sourceRevision = this.playbackSourceRevisionToken();
untracked(() =>
this.recoverySession.syncSourceRevision(sourceRevision)
);
const token = this.playbackApplicationToken();
const playback = untracked(this.resolvedPlayback);
const isLive = untracked(this.resolvedIsLive);
const selectedPlayer = untracked(this.selectedPlayer);
const reloadToken = untracked(this.reloadToken);
const target = toInlinePlaybackPlayer(selectedPlayer);
this.channel = undefined;
this.vjsOptions = undefined;
this.clearPlaybackDiagnostic();
if (target === null) {
this.applicationHandoff.release();
this.recoverySession.clearPlaybackBinding();
return;
}
const binding = untracked(() =>
this.recoverySession.beginPlayback(target)
);
this.applicationHandoff.apply(
playback,
isLive,
reloadToken,
binding,
token,
() => this.playbackApplicationToken(),
(handoff) => {
this.channel = handoff.channel;
this.vjsOptions = handoff.vjsOptions;
}
);
});
}
ngOnDestroy(): void {
// Portal credentials must not outlive the playback session that
// needed them: dropping the scoped override here keeps only the
// playlist-level (unscoped) User-Agent/Referer defaults active. The
// service no-ops if a newer consumer already owns the override slot.
this.streamHeaders.clear(this.headerScopeStreamUrl);
this.applicationHandoff.destroy();
}
/**
* Configures the scoped Electron request headers BEFORE the stream source
* is handed to a player, so the very first media request already carries
* them — an auth-gated portal stream answers 403 without its
* Cookie/Authorization, and several engines treat that first failure as
* fatal. In the PWA there is no header bridge and the source applies
* synchronously, exactly as before.
*/
private applyPlayback(
playback: ResolvedPortalPlayback,
isLive: boolean
handlePlaybackIssue(
issue: PlaybackDiagnostic | null,
binding: PlaybackBinding
): void {
const headerSync = this.streamHeaders.apply(playback);
this.headerScopeStreamUrl = playback.streamUrl;
const handOff = (): void => {
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, isLive);
};
if (!headerSync) {
handOff();
this.syncRecoverySession();
if (!this.recoverySession.accepts(binding)) {
return;
}
void headerSync.then((stillCurrent) => {
if (stillCurrent) {
handOff();
}
});
}
setVjsOptions(streamUrl: string, isLive = true) {
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
const mimeType =
extension === 'm3u' || extension === 'm3u8'
? 'application/x-mpegURL'
: extension === 'ts' || !extension
? 'video/mp2t'
: extension === 'mkv'
? 'video/matroska'
: 'video/mp4';
this.vjsOptions = {
isLive,
reloadToken: untracked(() => this.reloadToken()),
sources: [{ src: streamUrl, type: mimeType }],
};
}
setChannel(playbackOrUrl: ResolvedPortalPlayback | string) {
const playback =
typeof playbackOrUrl === 'string'
? {
streamUrl: playbackOrUrl,
title: playbackOrUrl,
}
: playbackOrUrl;
this.channel = {
id: playback.streamUrl,
url: playback.streamUrl,
name: playback.title || playback.streamUrl,
group: { title: '' },
tvg: {
id: '',
name: playback.title || playback.streamUrl,
url: '',
logo: playback.thumbnail ?? '',
rec: '',
},
http: {
referrer:
playback.referer ??
this.getHeaderValue(playback.headers, 'Referer') ??
'',
'user-agent':
playback.userAgent ??
this.getHeaderValue(playback.headers, 'User-Agent') ??
'',
origin:
playback.origin ??
this.getHeaderValue(playback.headers, 'Origin') ??
'',
},
radio: 'false',
drm: playback.drm,
};
}
handlePlaybackIssue(issue: PlaybackDiagnostic | null): void {
if (this.selectedPlayer() === VideoPlayer.EmbeddedMpv) {
this.playbackDiagnostic.set(null);
if (
!this.applicationHandoff.owns(
binding,
this.playbackApplicationToken()
)
) {
this.applicationHandoff.invalidate();
this.recoverySession.clearPlaybackBinding();
this.clearPlaybackDiagnostic();
return;
}
if (!issue) {
this.recoverySession.settle(binding);
this.clearPlaybackDiagnostic();
return;
}
if (!this.recoverySession.recordFailure(binding)) {
return;
}
this.playbackDiagnosticOwnerToken.set(
this.playbackDiagnosticIntentToken()
);
this.playbackDiagnostic.set(issue);
if (issue) {
this.playbackFailed.emit(issue.code);
}
this.playbackFailed.emit(issue.code);
}
requestExternalFallback(player: ExternalPlayerName): void {
handleTimeUpdate(
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
): void {
if (!this.ownsPlaybackApplication(ownership)) {
return;
}
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
this.timeUpdate.emit(event);
}
requestRecommendedPlayer(target: PlaybackRecommendationTarget): void {
const diagnostic = this.visiblePlaybackDiagnostic();
if (!diagnostic) {
return;
}
this.externalFallbackRequested.emit({
player,
playback: this.resolvedPlayback(),
diagnostic,
});
const available = this.recommendations().some(
(item) => item.action === 'player' && item.target === target
);
if (!available) {
if (target !== 'mpv' && target !== 'vlc') {
this.recoverySession.recordInlineAttempt(target);
}
return;
}
if (target === 'mpv' || target === 'vlc') {
this.recoverySession.recordExternalAttempt(target);
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
});
return;
}
this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive());
}
retryPlayback(): void {
const playback = this.resolvedPlayback();
this.playbackDiagnostic.set(null);
this.reloadToken.update((value) => value + 1);
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
}
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
issue,
this.runtime.supportsManagedExternalPlayers
);
}
private getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
if (!this.recoverySession.beginRetry()) {
return;
}
this.reloadToken.update((value) => value + 1);
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
private syncRecoverySession(): void {
const sessionKey = this.playbackSessionKey();
if (untracked(() => this.recoverySession.syncSession(sessionKey))) {
this.clearPlaybackDiagnostic();
}
}
private clearPlaybackDiagnostic(): void {
this.playbackDiagnosticOwnerToken.set(null);
this.playbackDiagnostic.set(null);
}
private ownsPlaybackApplication(
ownership: PlaybackApplicationOwnership
): boolean {
if (
ownership.token !== this.playbackApplicationToken() ||
ownership.sourceRevision !== this.playbackSourceRevisionToken()
) {
return false;
}
if (ownership.binding) {
return (
!ownership.embeddedMpv &&
this.applicationHandoff.owns(ownership.binding, ownership.token)
);
}
return (
ownership.embeddedMpv &&
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
);
return matchingKey ? headers[matchingKey] : undefined;
}
}
@@ -1,5 +1,5 @@
import { Component, input, output } from '@angular/core';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
/**
* Player stand-ins for WebPlayerViewComponent specs. They mirror the real
@@ -7,7 +7,10 @@ import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagno
* without pulling in video.js, ArtPlayer or the Electron bridge.
*/
@Component({ selector: 'app-vjs-player', template: '' })
@Component({
selector: 'app-vjs-player',
template: '<div data-test-id="stub-vjs-player"></div>',
})
export class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -23,7 +26,10 @@ export class StubVjsPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-html-video-player', template: '' })
@Component({
selector: 'app-html-video-player',
template: '<div data-test-id="stub-html-player"></div>',
})
export class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -40,7 +46,10 @@ export class StubHtmlVideoPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-art-player', template: '' })
@Component({
selector: 'app-art-player',
template: '<div data-test-id="stub-art-player"></div>',
})
export class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -57,7 +66,10 @@ export class StubArtPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-embedded-mpv-player', template: '' })
@Component({
selector: 'app-embedded-mpv-player',
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
})
export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -0,0 +1,71 @@
import { isBrowserMediaTypeSupported } from './browser-media-type-support';
describe('isBrowserMediaTypeSupported', () => {
let mediaSourceDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
});
afterEach(() => {
restoreMediaSource(mediaSourceDescriptor);
});
it('returns undefined when MediaSource is unavailable', () => {
Reflect.deleteProperty(globalThis, 'MediaSource');
expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined();
});
it.each([undefined, null, true, {}])(
'returns undefined when isTypeSupported is not callable: %p',
(isTypeSupported) => {
setMediaSource({ isTypeSupported });
expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined();
}
);
it('returns undefined when the browser capability probe throws', () => {
const providerMimeType =
'video/mp4; codecs="provider-controlled-codec"';
setMediaSource({
isTypeSupported: () => {
throw new Error(providerMimeType);
},
});
expect(isBrowserMediaTypeSupported(providerMimeType)).toBeUndefined();
});
it.each([true, false])(
'returns the browser capability result %s',
(supportResult) => {
const isTypeSupported = jest.fn(() => supportResult);
setMediaSource({ isTypeSupported });
expect(isBrowserMediaTypeSupported('video/mp4')).toBe(
supportResult
);
expect(isTypeSupported).toHaveBeenCalledWith('video/mp4');
}
);
});
function setMediaSource(value: unknown): void {
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value,
});
}
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -0,0 +1,16 @@
export function isBrowserMediaTypeSupported(
mimeType: string
): boolean | undefined {
if (typeof MediaSource === 'undefined') {
return undefined;
}
try {
const probe: unknown = MediaSource.isTypeSupported;
return typeof probe === 'function'
? probe.call(MediaSource, mimeType)
: undefined;
} catch {
return undefined;
}
}
@@ -1,4 +1,4 @@
import { InlinePlaybackPlayer } from '../playback-diagnostics/playback-diagnostics.model';
import { InlinePlaybackPlayer } from '@iptvnator/playback/util';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks as flush,