> = {
+ manifest: 'MANIFEST',
+ playlist: 'PLAYLIST',
+ level: 'PLAYLIST',
+ segment: 'SEGMENT',
+ key: 'KEY',
+ license: 'LICENSE',
+ media: 'MEDIA',
+ loader: 'LOADER',
+ demux: 'DEMUX',
+ 'media-source': 'MEDIA',
+};
+
+export function getDiagnosticEvidenceDetails(
+ issue: PlaybackDiagnostic
+): PlaybackDiagnosticDetail[] {
+ const stage =
+ issue.shaka?.stage ??
+ issue.hls?.stage ??
+ issue.mpegTs?.stage ??
+ issue.vhs?.stage;
+ const stageKey = stage ? STAGE_KEYS[stage] : undefined;
+ const drm = (issue.drmSystems ?? [])
+ .map((system) => DRM_LABELS[system])
+ .filter(Boolean);
+ return [
+ ...(stage && stageKey
+ ? [
+ {
+ labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_STAGE',
+ value: stage,
+ valueKey: `PLAYBACK_DIAGNOSTICS.STAGE_${stageKey}`,
+ },
+ ]
+ : []),
+ ...(drm.length
+ ? [
+ {
+ labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_DRM_SYSTEMS',
+ value: drm.join(', '),
+ },
+ ]
+ : []),
+ ];
+}
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html
index 012bae4e5..0845cb289 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html
@@ -172,6 +172,23 @@
+
+
+ {{ copyStatusKey() ? (copyStatusKey() | translate) : '' }}
+
}
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts
index aa866cb82..2025edf23 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts
@@ -1,3 +1,4 @@
+import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -498,6 +499,39 @@ describe('PlaybackDiagnosticPanelComponent', () => {
expect(checkedSources).toEqual(['playlist-8:xtream:8']);
});
+ it('copies a safe report and reports success or failure for the current diagnostic', () => {
+ fixture.detectChanges();
+ const button = fixture.debugElement.query(
+ By.css('[data-test-id="playback-copy-diagnostic"]')
+ );
+ expect(button).not.toBeNull();
+ const copy = button.injector.get(CdkCopyToClipboard);
+ expect(JSON.parse(copy.text)).toMatchObject({
+ app: 'IPTVnator',
+ code: DIAGNOSTIC.code,
+ });
+ expect(copy.text).not.toContain('example.com');
+ copy.copied.emit(false);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain(
+ 'PLAYBACK_DIAGNOSTICS.COPY_FAILED'
+ );
+ copy.copied.emit(true);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain(
+ 'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
+ );
+ fixture.componentRef.setInput('diagnostic', {
+ ...DIAGNOSTIC,
+ code: PlaybackDiagnosticCode.NetworkError,
+ });
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).not.toContain(
+ 'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
+ );
+ expect(JSON.parse(copy.text).code).toBe('network-error');
+ });
+
it('always renders Copy URL and Technical details utilities', () => {
fixture.detectChanges();
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts
index a67075a03..ff1bc5f6e 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts
@@ -1,8 +1,13 @@
+import { DOCUMENT } from '@angular/common';
+import packageJson from '@package';
+import { createDiagnosticReport } from './playback-diagnostic-report.util';
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
+ inject,
+ signal,
input,
output,
} from '@angular/core';
@@ -78,6 +83,30 @@ export class PlaybackDiagnosticPanelComponent {
readonly alternativeSourceRequested = output();
readonly sourceCheckRequested = output();
+ private readonly document = inject(DOCUMENT);
+ private readonly copyResult = signal<{
+ issue: PlaybackDiagnostic;
+ success: boolean;
+ } | null>(null);
+ readonly diagnosticReport = computed(() =>
+ createDiagnosticReport(
+ this.diagnostic(),
+ packageJson.version,
+ this.document.defaultView?.navigator.userAgent ?? ''
+ )
+ );
+ readonly copyStatusKey = computed(() => {
+ const result = this.copyResult();
+ if (!result || result.issue !== this.diagnostic()) return '';
+ return result.success
+ ? 'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
+ : 'PLAYBACK_DIAGNOSTICS.COPY_FAILED';
+ });
+
+ onReportCopied(success: boolean): void {
+ this.copyResult.set({ issue: this.diagnostic(), success });
+ }
+
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts
new file mode 100644
index 000000000..c56d65407
--- /dev/null
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts
@@ -0,0 +1,93 @@
+import { type PlaybackDiagnostic } from '@iptvnator/playback/util';
+import { createDiagnosticReport } from './playback-diagnostic-report.util';
+
+const issue: PlaybackDiagnostic = {
+ code: 'network-error',
+ source: 'shaka',
+ player: 'html5',
+ sourceUrl: 'https://user:secret@private.test/live.mpd?token=secret',
+ container: 'mpd',
+ audioCodecs: ['opus'],
+ videoCodecs: ['avc1.640028'],
+ drmSystems: ['widevine'],
+ httpStatus: 403,
+ nativeErrorMessage: 'secret',
+ nativeErrorType: 'secret',
+ details: 'secret',
+ shaka: {
+ category: 'network',
+ severity: 'critical',
+ engineCode: 1001,
+ stage: 'segment',
+ failure: 'network',
+ disposition: 'terminal',
+ },
+};
+
+describe('safe diagnostic report', () => {
+ it('copies bounded facts without raw URLs, messages or user agent', () => {
+ const report = createDiagnosticReport(
+ issue,
+ '1.0.0',
+ 'Mozilla/5.0 (Macintosh) Electron/42 secret'
+ );
+ expect(JSON.parse(report)).toMatchObject({
+ app: 'IPTVnator',
+ appVersion: '1.0.0',
+ os: 'macOS',
+ runtime: 'electron',
+ code: 'network-error',
+ engine: 'shaka',
+ player: 'html5',
+ stage: 'segment',
+ engineCode: 1001,
+ httpStatus: 403,
+ drmSystems: ['widevine'],
+ videoCodecs: ['avc1.640028'],
+ });
+ for (const secret of [
+ 'secret',
+ 'private.test',
+ 'Mozilla',
+ 'sourceUrl',
+ 'nativeErrorMessage',
+ ])
+ expect(report).not.toContain(secret);
+ });
+ it('rejects unexpected strings even when a caller breaks the types', () => {
+ const report = createDiagnosticReport(
+ {
+ ...issue,
+ code: 'secret',
+ source: 'secret',
+ player: 'secret',
+ container: 'secret',
+ videoCodecs: ['secret'],
+ audioCodecs: ['https://secret'],
+ drmSystems: ['secret'],
+ httpStatus: Infinity,
+ nativeErrorCode: NaN,
+ shaka: {
+ ...issue.shaka,
+ stage: 'secret',
+ engineCode: 'secret',
+ },
+ } as unknown as PlaybackDiagnostic,
+ 'secret',
+ 'secret'
+ );
+ expect(report).not.toContain('secret');
+ expect(report).not.toContain('null');
+ });
+ it('does not carry another engine’s evidence', () => {
+ expect(
+ JSON.parse(
+ createDiagnosticReport(
+ { ...issue, source: 'native' },
+ '1.0.0',
+ 'Linux'
+ )
+ )
+ ).not.toHaveProperty('stage');
+ });
+});
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts
new file mode 100644
index 000000000..964b9b873
--- /dev/null
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts
@@ -0,0 +1,119 @@
+import {
+ collectPlaybackCodecs,
+ InlinePlaybackPlayer,
+ PlaybackDiagnosticCode,
+ PlaybackDiagnosticSource,
+ PlaybackDrmSystem,
+ PlaybackRuntimeSupport,
+ SHAKA_ERROR_CODE,
+ type PlaybackDiagnostic,
+} from '@iptvnator/playback/util';
+import { getDiagnosticEvidence } from './playback-diagnostic-summary.util';
+
+function allowed(
+ value: unknown,
+ choices: readonly T[]
+): T | undefined {
+ return choices.find((choice) => choice === value);
+}
+function integer(value: unknown, min: number, max: number): number | undefined {
+ return typeof value === 'number' &&
+ Number.isInteger(value) &&
+ value >= min &&
+ value <= max
+ ? value
+ : undefined;
+}
+function operatingSystem(agent: string): string {
+ if (/Android/i.test(agent)) return 'Android';
+ if (/iPhone|iPad|iPod/i.test(agent)) return 'iOS';
+ if (/Windows/i.test(agent)) return 'Windows';
+ if (/Macintosh|Mac OS X/i.test(agent)) return 'macOS';
+ if (/CrOS/i.test(agent)) return 'ChromeOS';
+ if (/Linux/i.test(agent)) return 'Linux';
+ return 'unknown';
+}
+
+/** Explicit allowlist: never serialize the diagnostic, source, or raw error. */
+export function createDiagnosticReport(
+ issue: PlaybackDiagnostic,
+ appVersion: string,
+ userAgent: string
+): string {
+ const evidence = getDiagnosticEvidence(issue);
+ const codecs = collectPlaybackCodecs([
+ ...issue.videoCodecs.slice(0, 16).map((videoCodec) => ({ videoCodec })),
+ ...issue.audioCodecs.slice(0, 16).map((audioCodec) => ({ audioCodec })),
+ ]);
+ const shakaCode =
+ issue.source === 'shaka' ? issue.shaka?.engineCode : undefined;
+ return JSON.stringify(
+ {
+ schemaVersion: 1,
+ app: 'IPTVnator',
+ appVersion:
+ /^\d{1,4}\.\d{1,4}\.\d{1,4}(?:-(?:alpha|beta|rc)\.?\d{1,5})?$/.test(
+ appVersion
+ )
+ ? appVersion
+ : 'unknown',
+ os: operatingSystem(userAgent),
+ runtime: /Electron\//i.test(userAgent) ? 'electron' : 'browser',
+ code: allowed(issue.code, Object.values(PlaybackDiagnosticCode)),
+ engine: allowed(
+ issue.source,
+ Object.values(PlaybackDiagnosticSource)
+ ),
+ player: allowed(issue.player, Object.values(InlinePlaybackPlayer)),
+ runtimeSupport: allowed(
+ issue.runtimeSupport,
+ Object.values(PlaybackRuntimeSupport)
+ ),
+ stage: allowed(evidence?.stage, [
+ 'manifest',
+ 'playlist',
+ 'level',
+ 'segment',
+ 'key',
+ 'license',
+ 'media',
+ 'loader',
+ 'demux',
+ 'media-source',
+ ]),
+ engineCode: Object.values(SHAKA_ERROR_CODE).find(
+ (code) => code === shakaCode
+ ),
+ nativeErrorCode: integer(issue.nativeErrorCode, 0, 99999),
+ httpStatus: integer(
+ evidence?.httpStatus ?? issue.httpStatus,
+ 100,
+ 599
+ ),
+ container: allowed(issue.container, [
+ 'mpd',
+ 'dash',
+ 'hls',
+ 'm3u8',
+ 'mp4',
+ 'webm',
+ 'matroska',
+ 'mpegts',
+ 'mpeg-ts',
+ 'ts',
+ 'avi',
+ 'mov',
+ 'flv',
+ 'ogg',
+ 'mp3',
+ 'aac',
+ ]),
+ ...codecs,
+ drmSystems: Object.values(PlaybackDrmSystem).filter((system) =>
+ issue.drmSystems?.includes(system)
+ ),
+ },
+ null,
+ 2
+ );
+}
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts
new file mode 100644
index 000000000..985487db8
--- /dev/null
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts
@@ -0,0 +1,68 @@
+import {
+ createUnsupportedDrmDiagnostic,
+ createPlaybackSourceMetadata,
+ classifyShakaPlaybackIssue,
+ type PlaybackDiagnostic,
+} from '@iptvnator/playback/util';
+import { getDiagnosticDescriptionKey } from './playback-diagnostic-view.util';
+
+const metadata = createPlaybackSourceMetadata({
+ url: 'https://private.test/live.mpd',
+});
+function shaka(code: number, category = 6): PlaybackDiagnostic {
+ const result = classifyShakaPlaybackIssue(
+ { code, category, severity: 2 },
+ metadata,
+ 'terminal'
+ );
+ if (!result) throw new Error('Expected terminal diagnostic');
+ return result;
+}
+const description = (issue: PlaybackDiagnostic) =>
+ getDiagnosticDescriptionKey(issue, false, false);
+
+describe('evidence-backed diagnostic summaries', () => {
+ it('explains an explicitly unsupported app DRM configuration', () => {
+ expect(
+ description(
+ createUnsupportedDrmDiagnostic('com.widevine.alpha', metadata)
+ )
+ ).toBe('PLAYBACK_DIAGNOSTICS.DRM_CONFIGURATION_UNSUPPORTED');
+ });
+ it.each([
+ [6001, 'DRM_CONFIGURATION_UNAVAILABLE'],
+ [6007, 'LICENSE_REQUEST_FAILED'],
+ [6008, 'LICENSE_RESPONSE_REJECTED'],
+ [6012, 'LICENSE_SERVER_MISSING'],
+ ])('explains Shaka DRM code %s', (code, key) => {
+ expect(description(shaka(code))).toBe(`PLAYBACK_DIAGNOSTICS.${key}`);
+ });
+ it('does not infer unsupported DRM from the manifest or arbitrary message', () => {
+ expect(
+ description({
+ ...shaka(6002),
+ drmSystems: ['widevine'],
+ details: 'Unsupported DRM license configuration',
+ })
+ ).toBe('PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION');
+ });
+ it('explains access refusal only for a confirmed segment request', () => {
+ const result = classifyShakaPlaybackIssue(
+ {
+ code: 1001,
+ category: 1,
+ severity: 2,
+ data: ['secret', 403, 'body', {}, 1],
+ },
+ metadata,
+ 'terminal'
+ );
+ if (!result) throw new Error('Expected terminal diagnostic');
+ expect(description(result)).toBe(
+ 'PLAYBACK_DIAGNOSTICS.SEGMENT_ACCESS_DENIED'
+ );
+ expect(description({ ...result, shaka: undefined })).toBe(
+ 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.DESCRIPTION'
+ );
+ });
+});
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts
new file mode 100644
index 000000000..127c6b9a1
--- /dev/null
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts
@@ -0,0 +1,60 @@
+import {
+ PlaybackRuntimeSupport,
+ SHAKA_ERROR_CODE,
+ type PlaybackDiagnostic,
+} from '@iptvnator/playback/util';
+
+/** Read evidence only from the engine that owns this diagnostic. */
+export function getDiagnosticEvidence(issue: PlaybackDiagnostic) {
+ switch (issue.source) {
+ case 'shaka':
+ return issue.shaka;
+ case 'hls':
+ return issue.hls;
+ case 'vhs':
+ return issue.vhs;
+ case 'mpegts':
+ return issue.mpegTs;
+ default:
+ return undefined;
+ }
+}
+
+/** Source DRM declarations alone never select a failure explanation. */
+export function getDiagnosticSummaryKey(
+ issue: PlaybackDiagnostic
+): string | undefined {
+ const prefix = 'PLAYBACK_DIAGNOSTICS.';
+ if (
+ issue.code === 'drm-or-encryption' &&
+ issue.runtimeSupport ===
+ PlaybackRuntimeSupport.DrmConfigurationUnsupported
+ ) {
+ return prefix + 'DRM_CONFIGURATION_UNSUPPORTED';
+ }
+ if (issue.source === 'shaka' && issue.shaka?.category === 'drm') {
+ switch (issue.shaka.engineCode) {
+ case SHAKA_ERROR_CODE.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE:
+ return prefix + 'DRM_CONFIGURATION_UNAVAILABLE';
+ case SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED:
+ return prefix + 'LICENSE_REQUEST_FAILED';
+ case SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED:
+ return prefix + 'LICENSE_RESPONSE_REJECTED';
+ case SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN:
+ return prefix + 'LICENSE_SERVER_MISSING';
+ }
+ }
+ const evidence = getDiagnosticEvidence(issue);
+ const status = evidence?.httpStatus ?? issue.httpStatus;
+ if (
+ issue.code === 'network-error' &&
+ evidence?.stage === 'segment' &&
+ (status === 401 || status === 403)
+ ) {
+ return prefix + 'SEGMENT_ACCESS_DENIED';
+ }
+ if (issue.code === 'network-error' && evidence?.stage === 'license') {
+ return prefix + 'LICENSE_REQUEST_FAILED';
+ }
+ return undefined;
+}
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts
index c2aff28e2..7256dbe7e 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts
@@ -12,6 +12,39 @@ import {
} from './playback-diagnostic-view.util';
describe('playback diagnostic view formatters', () => {
+ it('shows separate source DRM and failure-stage facts', () => {
+ const details = getDiagnosticDetails({
+ code: PlaybackDiagnosticCode.NetworkError,
+ source: PlaybackDiagnosticSource.Shaka,
+ sourceUrl: 'https://example.test/live.mpd',
+ container: 'mpd',
+ audioCodecs: [],
+ videoCodecs: [],
+ drmSystems: ['widevine', 'playready'],
+ shaka: {
+ severity: 'critical',
+ category: 'network',
+ engineCode: 1001,
+ disposition: 'terminal',
+ stage: 'segment',
+ failure: 'network',
+ httpStatus: 403,
+ },
+ });
+ expect(details).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_STAGE',
+ value: 'segment',
+ }),
+ {
+ labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_DRM_SYSTEMS',
+ value: 'Widevine, PlayReady',
+ },
+ ])
+ );
+ });
+
const issue: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedCodec,
source: PlaybackDiagnosticSource.Native,
@@ -47,8 +80,7 @@ describe('playback diagnostic view formatters', () => {
{
diagnostic: 'browser access',
code: PlaybackDiagnosticCode.BrowserAccessError,
- desktopKey:
- 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
+ desktopKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION',
},
{
@@ -62,8 +94,7 @@ describe('playback diagnostic view formatters', () => {
code: PlaybackDiagnosticCode.UnsupportedContainer,
desktopKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
- pwaKey:
- 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
+ pwaKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
},
{
diagnostic: 'media decode',
@@ -85,18 +116,18 @@ describe('playback diagnostic view formatters', () => {
supportsManagedExternalPlayers: false,
expected: pwaKey,
},
- ])('preserves transferable $runtime copy', ({
- supportsManagedExternalPlayers,
- expected,
- }) => {
- expect(
- getDiagnosticDescriptionKey(
- diagnosticIssue,
- supportsManagedExternalPlayers,
- true
- )
- ).toBe(expected);
- });
+ ])(
+ 'preserves transferable $runtime copy',
+ ({ supportsManagedExternalPlayers, expected }) => {
+ expect(
+ getDiagnosticDescriptionKey(
+ diagnosticIssue,
+ supportsManagedExternalPlayers,
+ true
+ )
+ ).toBe(expected);
+ }
+ );
it.each([
['desktop', true],
@@ -131,9 +162,12 @@ describe('playback diagnostic view formatters', () => {
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);
- });
+ ])(
+ 'preserves neutral %s copy for protected playback',
+ (_label, code, expected) => {
+ expect(
+ getDiagnosticDescriptionKey({ ...issue, code }, true, false)
+ ).toBe(expected);
+ }
+ );
});
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts
index 9afebe81e..85abcf985 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts
@@ -3,6 +3,8 @@ import {
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '@iptvnator/playback/util';
+import { getDiagnosticSummaryKey } from './playback-diagnostic-summary.util';
+import { getDiagnosticEvidenceDetails } from './playback-diagnostic-evidence-view.util';
const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
PlaybackDiagnostic['code']
@@ -16,6 +18,7 @@ const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
+ readonly valueKey?: string;
};
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
@@ -27,6 +30,8 @@ export function getDiagnosticDescriptionKey(
supportsManagedExternalPlayers: boolean,
playbackExternallyTransferable: boolean
): string {
+ const summary = getDiagnosticSummaryKey(issue);
+ if (summary) return summary;
if (
!playbackExternallyTransferable &&
UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES.has(issue.code)
@@ -77,6 +82,7 @@ export function getDiagnosticDetails(
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: formatDiagnosticSource(issue.source),
},
+ ...getDiagnosticEvidenceDetails(issue),
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts
new file mode 100644
index 000000000..d92e19e4a
--- /dev/null
+++ b/libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts
@@ -0,0 +1,103 @@
+import {
+ collectPlaybackCodecs,
+ getPlaybackDrmSystem,
+ SHAKA_REQUEST_TYPE,
+ type PlaybackDrmSystem,
+} from '@iptvnator/playback/util';
+import type {
+ ShakaNetworkingEngineLike,
+ ShakaResponseFilter,
+} from './shaka-module.types';
+
+const DASH_NAMESPACE = 'urn:mpeg:dash:schema:mpd:2011';
+const MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
+
+/** Observes existing responses only; owns no requests, URLs, keys or XML. */
+export class ShakaManifestMetadata {
+ private active = true;
+ private codecs = collectPlaybackCodecs([]);
+ private drmSystems: PlaybackDrmSystem[] = [];
+ private readonly configuredSystem: PlaybackDrmSystem | undefined;
+
+ constructor(
+ private readonly network: ShakaNetworkingEngineLike | null,
+ licenseType?: string
+ ) {
+ this.configuredSystem = getPlaybackDrmSystem(licenseType);
+ network?.registerResponseFilter(this.onResponse);
+ }
+
+ read() {
+ return {
+ ...this.codecs,
+ drmSystems: [
+ ...new Set([
+ ...this.drmSystems,
+ ...(this.configuredSystem ? [this.configuredSystem] : []),
+ ]),
+ ],
+ };
+ }
+
+ destroy(): void {
+ this.active = false;
+ this.network?.unregisterResponseFilter(this.onResponse);
+ }
+
+ private readonly onResponse: ShakaResponseFilter = (type, response) => {
+ if (!this.active || type !== SHAKA_REQUEST_TYPE.MANIFEST) return;
+ try {
+ if (response.data.byteLength > MAX_MANIFEST_BYTES) return;
+ const xml = new TextDecoder().decode(response.data);
+ if (/ ({
+ audioCodec: element.getAttribute('codecs'),
+ videoCodec: element.getAttribute('codecs'),
+ }))
+ );
+ this.drmSystems = [
+ ...new Set(
+ Array.from(
+ document.getElementsByTagNameNS(
+ DASH_NAMESPACE,
+ 'ContentProtection'
+ )
+ )
+ .slice(0, 256)
+ .map((element) =>
+ getPlaybackDrmSystem(
+ element.getAttribute('schemeIdUri')
+ )
+ )
+ .filter(
+ (system): system is PlaybackDrmSystem =>
+ system !== undefined
+ )
+ ),
+ ];
+ } catch {
+ // Metadata is optional. Never turn inspection failure into a
+ // network-filter failure or interfere with Shaka's own parser.
+ }
+ };
+}
diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts
index 0156d6013..3c1428d79 100644
--- a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts
+++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts
@@ -40,7 +40,18 @@ export interface ShakaVariantTrackLike {
export type { ShakaErrorLike } from '@iptvnator/playback/util';
+export type ShakaResponseFilter = (
+ type: number,
+ response: { data: BufferSource }
+) => void;
+
+export interface ShakaNetworkingEngineLike {
+ registerResponseFilter(filter: ShakaResponseFilter): void;
+ unregisterResponseFilter(filter: ShakaResponseFilter): void;
+}
+
export interface ShakaPlayerLike {
+ getNetworkingEngine?(): ShakaNetworkingEngineLike | null;
attach(mediaElement: HTMLMediaElement): Promise;
configure(config: Record): boolean;
load(assetUri: string): Promise;
diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts
new file mode 100644
index 000000000..da163e832
--- /dev/null
+++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts
@@ -0,0 +1,162 @@
+import {
+ InlinePlaybackPlayer,
+ type PlaybackDiagnostic,
+} from '@iptvnator/playback/util';
+import {
+ createFakeShakaEnvironment,
+ flushShakaMicrotasks as flush,
+} from './shaka-player-test-double';
+import { ShakaVideoSession } from './shaka-video-session';
+
+type Filter = (type: number, response: { data: BufferSource }) => void;
+const manifest = `
+
+
+
+
+
+
+
+ https://secret.test/?token=secret
+`;
+
+describe('Shaka diagnostic source evidence', () => {
+ function setup() {
+ const filters = new Set();
+ const issues: PlaybackDiagnostic[] = [];
+ const environment = createFakeShakaEnvironment({
+ onCreate: (player) =>
+ Object.assign(player, {
+ getNetworkingEngine: () => ({
+ registerResponseFilter: (filter: Filter) =>
+ filters.add(filter),
+ unregisterResponseFilter: (filter: Filter) =>
+ filters.delete(filter),
+ }),
+ }),
+ });
+ const session = new ShakaVideoSession({
+ player: InlinePlaybackPlayer.Html5,
+ emitPlaybackIssue: (issue) => issues.push(issue),
+ loadShaka: environment.loader,
+ });
+ const respond = (xml: string, type = 0) => {
+ const response = { data: new TextEncoder().encode(xml) };
+ for (const filter of filters) filter(type, response);
+ expect(new TextDecoder().decode(response.data)).toBe(xml);
+ };
+ return { filters, issues, environment, session, respond };
+ }
+
+ it('keeps advertised codecs and DRM on a terminal startup failure', async () => {
+ const { filters, issues, environment, session, respond } = setup();
+ session.start(
+ document.createElement('video'),
+ 'https://example.test/live.mpd'
+ );
+ await flush();
+ respond(manifest);
+ environment.instances[0].dispatch('error', {
+ severity: 2,
+ category: 6,
+ code: 6001,
+ });
+ expect(issues[0]).toMatchObject({
+ code: 'drm-or-encryption',
+ videoCodecs: ['avc1.64001e'],
+ audioCodecs: ['mp4a.40.29'],
+ drmSystems: ['widevine', 'playready'],
+ });
+ expect(JSON.stringify(issues)).not.toContain('secret');
+ expect(filters.size).toBe(0);
+ });
+
+ it('does not let old response callbacks enrich the next channel', async () => {
+ const { filters, issues, environment, session, respond } = setup();
+ const video = document.createElement('video');
+ session.start(video, 'https://example.test/first.mpd');
+ await flush();
+ const oldFilters = [...filters];
+ respond(manifest);
+ session.start(video, 'https://example.test/second.mpd');
+ await flush();
+ for (const filter of oldFilters)
+ filter(0, { data: new TextEncoder().encode(manifest) });
+ environment.instances[1].dispatch('error', {
+ severity: 2,
+ category: 1,
+ code: 1003,
+ });
+ expect(issues[0].audioCodecs).toEqual([]);
+ expect(issues[0].videoCodecs).toEqual([]);
+ expect(issues[0]).not.toHaveProperty('drmSystems', [
+ 'widevine',
+ 'playready',
+ ]);
+ });
+
+ it('retains a configured ClearKey name without exposing keys', async () => {
+ const { session, issues, environment } = setup();
+ session.start(
+ document.createElement('video'),
+ 'https://example.test/live.mpd',
+ {
+ licenseType: 'clearkey',
+ supported: true,
+ clearKeys: { 'private-key-id': 'private-content-key' },
+ }
+ );
+ await flush();
+ environment.instances[0].dispatch('error', {
+ severity: 2,
+ category: 6,
+ code: 6007,
+ });
+ expect(issues[0]).toHaveProperty('drmSystems', ['clearkey']);
+ expect(JSON.stringify(issues)).not.toContain('private-');
+ });
+
+ it.each([
+ ['malformed', '', 0],
+ [
+ 'DTD-bearing',
+ ']>' + manifest,
+ 0,
+ ],
+ ['non-manifest response', manifest, 1],
+ ['oversized', manifest + ' '.repeat(2 * 1024 * 1024), 0],
+ ['non-DASH XML', manifest.replaceAll('MPD', 'other'), 0],
+ ])(
+ 'ignores %s without interfering with playback',
+ async (_name, xml, type) => {
+ const { issues, environment, session, respond } = setup();
+ session.start(
+ document.createElement('video'),
+ 'https://example.test/live.mpd'
+ );
+ await flush();
+ expect(() => respond(xml, type)).not.toThrow();
+ environment.instances[0].dispatch('error', {
+ severity: 2,
+ category: 4,
+ code: 4001,
+ });
+ expect(issues[0].videoCodecs).toEqual([]);
+ expect(issues[0].code).toBe('unknown-playback-error');
+ }
+ );
+
+ it('reports only a known configured DRM name, never its raw license value', () => {
+ const { session, issues } = setup();
+ session.start(
+ document.createElement('video'),
+ 'https://example.test/live.mpd',
+ {
+ licenseType: 'com.widevine.alpha',
+ supported: false,
+ }
+ );
+ expect(issues[0]).toHaveProperty('drmSystems', ['widevine']);
+ expect(JSON.stringify(issues)).not.toContain('com.widevine.alpha');
+ });
+});
diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts
index 5443d3f77..ca5fecb8b 100644
--- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts
+++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts
@@ -13,6 +13,7 @@ import {
type PlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
+import { ShakaManifestMetadata } from './shaka-manifest-metadata';
import {
loadShakaModule,
type ShakaErrorLike,
@@ -54,6 +55,7 @@ const PLAYER_REFRESH_EVENTS = [
export class ShakaVideoSession {
private module: ShakaModuleLike | null = null;
private player: ShakaPlayerLike | null = null;
+ private manifestMetadata: ShakaManifestMetadata | null = null;
private playerErrorListener: ((event: Event) => void) | null = null;
private playerRefreshListener: (() => void) | null = null;
private readonly refreshListeners = new Set<() => void>();
@@ -151,6 +153,10 @@ export class ShakaVideoSession {
const player = new module.Player();
this.player = player;
+ this.manifestMetadata = new ShakaManifestMetadata(
+ player.getNetworkingEngine?.() ?? null,
+ drm?.licenseType
+ );
this.bindPlayerListeners(player, generation, url);
if (drm?.clearKeys) {
@@ -274,6 +280,8 @@ export class ShakaVideoSession {
* {@link pendingTeardown} before attaching to the media element.
*/
private beginPlayerTeardown(): void {
+ this.manifestMetadata?.destroy();
+ this.manifestMetadata = null;
const player = this.player;
this.player = null;
this.textSuppression.reset();
@@ -316,6 +324,7 @@ export class ShakaVideoSession {
private createMetadata(url: string): PlaybackSourceMetadata {
return createPlaybackSourceMetadata({
+ ...this.manifestMetadata?.read(),
url,
mimeType: DASH_MIME_TYPE,
player: this.config.player,
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts
index f9194cd40..85989ff99 100644
--- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts
+++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts
@@ -113,26 +113,30 @@ describe('VjsMpegTsSession', () => {
it('emits structured HTTP evidence with Video.js source metadata', () => {
const mpegTsPlayer = createMpegTsPlayer();
+ Object.assign(mpegTsPlayer, {
+ mediaInfo: {
+ audioCodec: 'aac',
+ videoCodec: 'h264',
+ url: 'private-media-info-url',
+ },
+ });
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
const secret = 'vjs-mpegts-secret';
session.start('https://example.test/live/stream.ts', video);
- mpegTsPlayer.emit(
- 'error',
- 'NetworkError',
- 'HttpStatusCodeInvalid',
- {
- code: 503,
- msg: `Service Unavailable ${secret}`,
- headers: { Authorization: secret },
- }
- );
+ mpegTsPlayer.emit('error', 'NetworkError', 'HttpStatusCodeInvalid', {
+ code: 503,
+ msg: `Service Unavailable ${secret}`,
+ headers: { Authorization: secret },
+ });
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'network-error',
+ audioCodecs: ['aac'],
+ videoCodecs: ['h264'],
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
@@ -145,6 +149,9 @@ describe('VjsMpegTsSession', () => {
}),
})
);
+ expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
+ 'private-media-info-url'
+ );
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
secret
);
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts
index 08b0347a6..033fc83c9 100644
--- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts
+++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts
@@ -3,6 +3,7 @@ import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
+ collectPlaybackCodecs,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
@@ -62,6 +63,7 @@ export class VjsMpegTsSession {
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, info),
createPlaybackSourceMetadata({
+ ...collectPlaybackCodecs([engine.mediaInfo ?? {}]),
url,
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts b/libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts
new file mode 100644
index 000000000..868669dae
--- /dev/null
+++ b/libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts
@@ -0,0 +1,29 @@
+import {
+ InlinePlaybackPlayer,
+ classifyNativePlaybackIssue,
+ classifyVhsPlaybackIssue,
+ createPlaybackSourceMetadata,
+} from '@iptvnator/playback/util';
+import {
+ getVideoJsPlaybackCodecs,
+ hasActiveVhsSourceHandler,
+ type VideoJsPlayer,
+ type VideoPlayerSource,
+} from './vjs-player.types';
+
+export function createVideoJsPlaybackDiagnostic(
+ player: VideoJsPlayer,
+ source: VideoPlayerSource | null | undefined,
+ video: HTMLVideoElement
+) {
+ const error = typeof player.error === 'function' ? player.error() : null;
+ const metadata = createPlaybackSourceMetadata({
+ ...getVideoJsPlaybackCodecs(player),
+ url: source?.src ?? video.currentSrc ?? '',
+ mimeType: source?.type,
+ player: InlinePlaybackPlayer.VideoJs,
+ });
+ return error && hasActiveVhsSourceHandler(player)
+ ? classifyVhsPlaybackIssue(error, metadata)
+ : classifyNativePlaybackIssue(error ?? video.error, metadata);
+}
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts
index 5756d95cd..e2a6bc4a0 100644
--- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts
+++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts
@@ -17,13 +17,8 @@ import { createDevLogger } from '@iptvnator/shared/interfaces';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
import 'videojs-quality-selector-hls';
-import {
- InlinePlaybackPlayer,
- type PlaybackDiagnostic,
- classifyNativePlaybackIssue,
- classifyVhsPlaybackIssue,
- createPlaybackSourceMetadata,
-} from '@iptvnator/playback/util';
+import { type PlaybackDiagnostic } from '@iptvnator/playback/util';
+import { createVideoJsPlaybackDiagnostic } from './vjs-playback-diagnostics';
import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
@@ -52,7 +47,6 @@ import {
type VideoPlayerOptions,
type VideoPlayerSource,
getVideoJsTechVideo,
- hasActiveVhsSourceHandler,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
@@ -266,23 +260,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.videoSession.video() ??
getVideoJsTechVideo(this.player) ??
this.target().nativeElement;
- const playerError =
- typeof this.player.error === 'function'
- ? this.player.error()
- : null;
- const metadata = createPlaybackSourceMetadata({
- url: source?.src ?? video.currentSrc ?? '',
- mimeType: source?.type,
- player: InlinePlaybackPlayer.VideoJs,
- });
this.mpegTsSession.syncDuration();
this.playbackIssue.emit(
- playerError && hasActiveVhsSourceHandler(this.player)
- ? classifyVhsPlaybackIssue(playerError, metadata)
- : classifyNativePlaybackIssue(
- playerError ?? video.error,
- metadata
- )
+ createVideoJsPlaybackDiagnostic(this.player, source, video)
);
};
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts
index c546ea1f9..24baa0313 100644
--- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts
+++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts
@@ -2,6 +2,44 @@ import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types';
import * as playerTypes from './vjs-player.types';
describe('getVideoJsTechVideo', () => {
+ it('reads safe codecs from the public VHS representations API', () => {
+ const read = (
+ playerTypes as unknown as {
+ getVideoJsPlaybackCodecs: (player: unknown) => unknown;
+ }
+ ).getVideoJsPlaybackCodecs;
+ expect(read).toBeDefined();
+ expect(
+ read({
+ tech: () => ({
+ vhs: {
+ representations: () => [
+ {
+ codecs: {
+ video: 'avc1.64001e',
+ audio: 'mp4a.40.2',
+ },
+ uri: 'secret-url',
+ },
+ {
+ codecs: {
+ video: 'https://secret.test',
+ audio: 'secret',
+ },
+ },
+ ],
+ },
+ }),
+ })
+ ).toEqual({ videoCodecs: ['avc1.64001e'], audioCodecs: ['mp4a.40.2'] });
+ expect(
+ read({
+ tech: () => {
+ throw new Error('disposed');
+ },
+ })
+ ).toEqual({ videoCodecs: [], audioCodecs: [] });
+ });
it('returns the current Video.js Tech video element', () => {
const video = document.createElement('video');
const tech = jest.fn(() => ({ el: () => video }));
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts
index e6d5ca89d..ba1ea4218 100644
--- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts
+++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts
@@ -1,5 +1,6 @@
import type videoJs from 'video.js';
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
+import { collectPlaybackCodecs } from '@iptvnator/playback/util';
export type VideoPlayerSource = {
src: string;
@@ -62,14 +63,16 @@ export type VideoJsQualityLevel = {
enabled: boolean;
};
-export type VideoJsQualityLevelList =
- VideoJsTrackList & {
- selectedIndex?: number;
- };
+export type VideoJsQualityLevelList = VideoJsTrackList & {
+ selectedIndex?: number;
+};
export type VideoJsTech = {
el?: () => Element | null;
vhs?: {
+ representations?: () => {
+ codecs?: { audio?: unknown; video?: unknown };
+ }[];
playlists?: {
main?: { mediaGroups?: { AUDIO?: Record } };
master?: { mediaGroups?: { AUDIO?: Record } };
@@ -125,3 +128,20 @@ export function hasActiveVhsSourceHandler(
return false;
}
}
+
+export function getVideoJsPlaybackCodecs(player: Pick) {
+ try {
+ const variants =
+ player
+ .tech({ IWillNotUseThisInPlugins: true })
+ ?.vhs?.representations?.() ?? [];
+ return collectPlaybackCodecs(
+ variants.slice(0, 256).map((variant) => ({
+ audioCodec: variant.codecs?.audio,
+ videoCodec: variant.codecs?.video,
+ }))
+ );
+ } catch {
+ return collectPlaybackCodecs([]);
+ }
+}