mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
+184
@@ -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>
|
||||
+530
@@ -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;
|
||||
}
|
||||
}
|
||||
+619
@@ -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');
|
||||
});
|
||||
});
|
||||
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -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);
|
||||
});
|
||||
});
|
||||
+19
-2
@@ -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
|
||||
+162
@@ -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');
|
||||
});
|
||||
});
|
||||
+148
@@ -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()"
|
||||
|
||||
+68
-9
@@ -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)"
|
||||
/>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
-21
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user