> = {
+ 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([]);
+ }
+}
From 93e759e1dac10b297cc599bb90b4accf964a5e66 Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Tue, 8 Sep 2026 08:59:00 +0200
Subject: [PATCH 2/2] fix(m3u): accept standard Base64 ClearKey values (#1575)
* fix(m3u): accept standard Base64 ClearKey values
* refactor(release): move M3U fixture generation out of capture driver
---
.changes/m3u-clearkey-base64.md | 6 ++
AGENTS.md | 4 +-
CLAUDE.md | 4 +-
.../src/dash-clearkey.e2e.ts | 13 ++-
apps/web-e2e/src/dash-clearkey.e2e.ts | 17 +++-
docs/architecture/m3u-playlist-module.md | 7 +-
.../m3u-utils/src/lib/kodiprop.utils.spec.ts | 90 ++++++++++++++++++-
.../m3u-utils/src/lib/kodiprop.utils.ts | 20 ++---
tools/release/capture-app-driver.ts | 32 +------
tools/release/capture-fixtures.ts | 35 +++++++-
tools/release/project.json | 2 +-
11 files changed, 178 insertions(+), 52 deletions(-)
create mode 100644 .changes/m3u-clearkey-base64.md
diff --git a/.changes/m3u-clearkey-base64.md b/.changes/m3u-clearkey-base64.md
new file mode 100644
index 000000000..245c4f153
--- /dev/null
+++ b/.changes/m3u-clearkey-base64.md
@@ -0,0 +1,6 @@
+---
+type: fix
+area: m3u
+---
+
+ClearKey channels with ordinary Base64 keys now play when imported or refreshed, including JSON playlist exports that use + and / characters. Refresh an existing playlist once to apply the fix.
diff --git a/AGENTS.md b/AGENTS.md
index fd2fb995c..c429d321e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -562,7 +562,9 @@ Key files:
- DASH (`.mpd`) sources play through a lazily imported Shaka Player source
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
ArtPlayer components; ClearKey keys come from KODIPROP-derived
- `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via
+ `Channel.drm` (hex, Base64URL or ordinary Base64, strictly 128-bit key/KID;
+ refresh replaces cached unsupported parser results), and the shared bridge
+ exposes Shaka audio/text tracks via
source kind `shaka`. The DOM-free Shaka `5.2.4` diagnostic boundary lives in
`libs/playback/util`; it version-locks public severity/category/code evidence,
ignores recoverable error events,
diff --git a/CLAUDE.md b/CLAUDE.md
index 006c9ad52..6cef6d394 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1070,7 +1070,9 @@ app as a real argument, so it is not an option.
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in
`libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all
- import paths). DASH channels always play inline: `isDashChannel()` bypasses
+ import paths; hex, Base64URL or ordinary Base64, strictly 128-bit key/KID;
+ refresh replaces cached unsupported parser results). DASH channels always
+ play inline: `isDashChannel()` bypasses
the external-player setting (radio precedent) and routes Video.js/MPV/VLC/
embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps
ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
index 7bbf9c4b6..37184339a 100644
--- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
+++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
@@ -153,11 +153,22 @@ async function startDashFixtureServer(): Promise {
}
function buildDashPlaylist(origin: string): string {
+ // Regression for #1466: ordinary Base64 includes + and / in this key.
+ const license = JSON.stringify({
+ keys: [
+ {
+ kty: 'oct',
+ kid: Buffer.from(CLEARKEY_KID, 'hex').toString('base64'),
+ k: Buffer.from(CLEARKEY_KEY, 'hex').toString('base64'),
+ },
+ ],
+ type: 'temporary',
+ });
return [
'#EXTM3U',
'#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
- `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`,
+ `#KODIPROP:inputstream.adaptive.license_key=${license}`,
`${origin}/clearkey.mpd`,
'#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH',
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts
index e2ad28d2f..49f60313c 100644
--- a/apps/web-e2e/src/dash-clearkey.e2e.ts
+++ b/apps/web-e2e/src/dash-clearkey.e2e.ts
@@ -16,12 +16,27 @@ const FIXTURE_HOST = 'https://dash-fixture.local';
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
+// Regression for #1466: accept standard Base64 JSON without padding in PWA.
+const CLEARKEY_LICENSE = JSON.stringify({
+ keys: [
+ {
+ kty: 'oct',
+ kid: Buffer.from(CLEARKEY_KID, 'hex')
+ .toString('base64')
+ .replace(/=+$/, ''),
+ k: Buffer.from(CLEARKEY_KEY, 'hex')
+ .toString('base64')
+ .replace(/=+$/, ''),
+ },
+ ],
+ type: 'temporary',
+});
const DASH_PLAYLIST = [
'#EXTM3U',
'#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
- `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`,
+ `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_LICENSE}`,
`${FIXTURE_HOST}/clearkey.mpd`,
'#EXTINF:-1 tvg-id="clear-dash" group-title="DASH",Clear DASH',
`${FIXTURE_HOST}/clear.mpd`,
diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md
index 8f6da45fa..172ed7944 100644
--- a/docs/architecture/m3u-playlist-module.md
+++ b/docs/architecture/m3u-playlist-module.md
@@ -1313,7 +1313,12 @@ player in settings.
`inputstream.adaptive.license_type`, `license_key`, and the combined
`drm_legacy` property. ClearKey key formats: `kid:key` hex (single or
comma-separated), the W3C ClearKey license JSON, and a plain `{kid: key}`
- JSON map. Unsupported license types (Widevine, PlayReady, license-server
+ JSON map. Key components also accept Base64URL and ordinary Base64 (`+`/`/`),
+ with or without padding; decoded keys and KIDs must remain exactly 128 bits.
+ This includes JSON exports that use ordinary Base64 instead of the W3C
+ Base64URL alphabet. Refresh an already imported source to replace a cached
+ `drm.supported: false` result after a parser compatibility update.
+ Unsupported license types (Widevine, PlayReady, license-server
URLs, malformed values) are preserved as `supported: false` — never a
throw.
3. The typed result lands on `Channel.drm` (`ChannelDrm` in
diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts
index 2864ca1e0..61fa9cfb5 100644
--- a/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts
+++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts
@@ -90,6 +90,92 @@ describe('kodiprop.utils', () => {
expect(drm?.clearKeys).toEqual({ [KID_HEX]: KEY_HEX });
});
+ describe.each(['atob', 'Buffer'])('%s decoder', (decoder) => {
+ const atobDescriptor = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'atob'
+ );
+
+ beforeEach(() => {
+ if (decoder === 'Buffer') {
+ Object.defineProperty(globalThis, 'atob', {
+ configurable: true,
+ value: undefined,
+ });
+ }
+ });
+
+ afterEach(() => {
+ if (atobDescriptor) {
+ Object.defineProperty(globalThis, 'atob', atobDescriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, 'atob');
+ }
+ });
+
+ it.each([true, false])(
+ 'accepts standard Base64 JSON keys (padding=%s)',
+ (padding) => {
+ // Synthetic bytes exercise both + and /, as in #1466.
+ const kidHex = 'fb'.repeat(16);
+ const keyHex = 'ff'.repeat(16);
+ const encode = (hex: string): string => {
+ const value = Buffer.from(hex, 'hex').toString(
+ 'base64'
+ );
+ return padding ? value : value.replace(/=+$/, '');
+ };
+ const license = JSON.stringify({
+ keys: [
+ {
+ kty: 'oct',
+ kid: encode(kidHex),
+ k: encode(keyHex),
+ },
+ ],
+ type: 'temporary',
+ });
+
+ expect(
+ extractDrmFromRaw(
+ rawWith(
+ '#KODIPROP:inputstream.adaptive.license_type=clearkey',
+ `#KODIPROP:inputstream.adaptive.license_key=${license}`
+ )
+ )
+ ).toEqual({
+ licenseType: 'clearkey',
+ supported: true,
+ clearKeys: { [kidHex]: keyHex },
+ });
+ }
+ );
+
+ it.each([
+ Buffer.alloc(15, 255).toString('base64'),
+ Buffer.alloc(17, 255).toString('base64'),
+ `${KEY_B64}!`,
+ `${KEY_B64.slice(0, 10)} ${KEY_B64.slice(10)}`,
+ `${KEY_B64}===`,
+ `${KEY_B64.slice(0, 10)}=${KEY_B64.slice(10)}`,
+ `${KID_HEX.slice(0, 16)} corrupted ${KID_HEX.slice(16)}`,
+ ])('rejects invalid key components: %s', (invalid) => {
+ for (const entry of [
+ { kid: invalid, k: KEY_B64 },
+ { kid: KID_B64, k: invalid },
+ ]) {
+ expect(
+ extractDrmFromRaw(
+ rawWith(
+ '#KODIPROP:inputstream.adaptive.license_type=clearkey',
+ `#KODIPROP:inputstream.adaptive.license_key=${JSON.stringify({ keys: [entry] })}`
+ )
+ )
+ ).toEqual({ licenseType: 'clearkey', supported: false });
+ }
+ });
+ });
+
it('parses the drm_legacy combined property', () => {
const drm = extractDrmFromRaw(
rawWith(
@@ -163,9 +249,7 @@ describe('kodiprop.utils', () => {
it('ignores unrelated KODIPROP properties', () => {
expect(
extractDrmFromRaw(
- rawWith(
- '#KODIPROP:inputstream.adaptive.manifest_type=mpd'
- )
+ rawWith('#KODIPROP:inputstream.adaptive.manifest_type=mpd')
)
).toBeUndefined();
});
diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts
index d2bdae1ae..e1e16a15b 100644
--- a/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts
+++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts
@@ -3,10 +3,9 @@ import { ChannelDrm, ChannelDrmClearKeys } from '@iptvnator/shared/interfaces';
/**
* `#KODIPROP:` DRM extraction.
*
- * The playlist parser does not understand `#KODIPROP:` lines, but it appends
- * every unknown line between `#EXTINF` and the stream URL to `item.raw`
- * (the dominant Kodi/TiviMate layout). This module post-processes that raw
- * block into a typed {@link ChannelDrm} value.
+ * The playlist parser preserves `#KODIPROP:` lines before `#EXTINF` or
+ * between it and the stream URL in `item.raw`. This module post-processes
+ * that raw block into a typed {@link ChannelDrm} value.
*
* Supported properties:
* - `inputstream.adaptive.license_type` + `inputstream.adaptive.license_key`
@@ -81,9 +80,7 @@ export function extractDrmFromRaw(
};
}
-function collectKodipropValues(
- raw: string | undefined
-): Map {
+function collectKodipropValues(raw: string | undefined): Map {
const props = new Map();
if (!raw) {
return props;
@@ -220,7 +217,8 @@ function parseClearKeyMap(
/**
* Normalizes a 128-bit key component to 32 lowercase hex chars. Accepts plain
- * or dashed (UUID-style) hex and base64url (the W3C license encoding).
+ * or dashed (UUID-style) hex, base64url (the W3C license encoding), and
+ * standard Base64 used by some playlist exporters.
*/
function normalizeKeyComponent(value: string | undefined): string | undefined {
const compact = value?.trim().replace(/-/g, '').toLowerCase();
@@ -232,14 +230,14 @@ function normalizeKeyComponent(value: string | undefined): string | undefined {
return compact;
}
- const fromBase64 = base64UrlToHex(value?.trim() ?? '');
+ const fromBase64 = base64ToHex(value?.trim() ?? '');
return fromBase64 && HEX_128_BIT_PATTERN.test(fromBase64)
? fromBase64
: undefined;
}
-function base64UrlToHex(value: string): string | undefined {
- if (!/^[A-Za-z0-9_-]+={0,2}$/.test(value)) {
+function base64ToHex(value: string): string | undefined {
+ if (!/^[A-Za-z0-9_+/-]+={0,2}$/.test(value)) {
return undefined;
}
diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts
index cfbe7dd0e..bc58924ce 100644
--- a/tools/release/capture-app-driver.ts
+++ b/tools/release/capture-app-driver.ts
@@ -6,7 +6,7 @@
*/
import { spawn, type ChildProcess } from 'node:child_process';
-import { mkdirSync, writeFileSync } from 'node:fs';
+import { mkdirSync } from 'node:fs';
import path from 'node:path';
import {
_electron as electron,
@@ -15,7 +15,6 @@ import {
} from '@playwright/test';
import {
- M3U_FIXTURE_TITLE,
STALKER_FIXTURE_MAC,
STALKER_FIXTURE_PORTAL_URL,
STALKER_FIXTURE_TITLE,
@@ -38,6 +37,7 @@ export {
M3U_FIXTURE_TITLE,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
+ writeM3uFixture,
} from './capture-fixtures';
/**
@@ -66,34 +66,6 @@ const STALKER_MOCK_FIXTURE_CATEGORIES = ['Newsroom', 'Culture & Docs'];
/* Fixtures */
/* ------------------------------------------------------------------ */
-/** Entirely synthetic channels; streams and logos point at the mock. */
-export function writeM3uFixture(dataDir: string): string {
- const channels = [
- ['Newsroom', 'Aurora Local', 'aurora-local'],
- ['Newsroom', 'Civic Pulse', 'civic-pulse'],
- ['Sports', 'Fieldside One', 'fieldside-one'],
- ['Sports', 'Motion Arena', 'motion-arena'],
- ['Kids', 'Horizon Kids', 'horizon-kids'],
- ['Kids', 'Story Lantern', 'story-lantern'],
- ['Culture', 'Atlas Culture', 'atlas-culture'],
- ['Culture', 'Night Music', 'night-music'],
- ];
- const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`;
- const lines = ['#EXTM3U'];
-
- channels.forEach(([group, title, slug], index) => {
- lines.push(
- `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`,
- stream
- );
- });
-
- const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`);
- writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
-
- return filePath;
-}
-
export async function ensureXtreamMockServer(
workspaceRoot: string
): Promise {
diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts
index cfda0f827..f6c52760c 100644
--- a/tools/release/capture-fixtures.ts
+++ b/tools/release/capture-fixtures.ts
@@ -1,6 +1,6 @@
/**
- * Fixture identities shared by the capture driver (seeding) and the named
- * setup actions (guide shots that re-enter the add-playlist dialog). Kept in
+ * Fixture identities and M3U generation shared by the capture driver and
+ * the named setup actions (guide shots that re-enter the add-playlist dialog). Kept in
* a leaf module so capture-navigation.ts can import them without pulling in
* the driver, which itself imports the navigation module.
*
@@ -10,6 +10,9 @@
* instead of a `get.php?username=…` link.
*/
+import { writeFileSync } from 'node:fs';
+import path from 'node:path';
+
import { FICTIONAL_STALKER_MAC } from './screenshot-guards.mjs';
export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211';
@@ -73,3 +76,31 @@ export const AUTO_DETECT_FIXTURE_MESSAGE = [
'',
'Use these details in any Xtream Codes compatible player.',
].join('\n');
+
+/** Entirely synthetic channels; streams and logos point at the mock. */
+export function writeM3uFixture(dataDir: string): string {
+ const channels = [
+ ['Newsroom', 'Aurora Local', 'aurora-local'],
+ ['Newsroom', 'Civic Pulse', 'civic-pulse'],
+ ['Sports', 'Fieldside One', 'fieldside-one'],
+ ['Sports', 'Motion Arena', 'motion-arena'],
+ ['Kids', 'Horizon Kids', 'horizon-kids'],
+ ['Kids', 'Story Lantern', 'story-lantern'],
+ ['Culture', 'Atlas Culture', 'atlas-culture'],
+ ['Culture', 'Night Music', 'night-music'],
+ ];
+ const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`;
+ const lines = ['#EXTM3U'];
+
+ channels.forEach(([group, title, slug], index) => {
+ lines.push(
+ `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`,
+ stream
+ );
+ });
+
+ const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`);
+ writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
+
+ return filePath;
+}
diff --git a/tools/release/project.json b/tools/release/project.json
index 7d2f7e24f..6b436d17c 100644
--- a/tools/release/project.json
+++ b/tools/release/project.json
@@ -41,7 +41,7 @@
"{workspaceRoot}/tools/eslint-rules/**/*",
"{workspaceRoot}/tools/eslint/**/*"
],
- "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-navigation-helpers.ts\" \"tools/release/capture-navigation-setup-actions.ts\" \"tools/release/capture-navigation-portal-actions.ts\" \"tools/release/capture-navigation-download-actions.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\""
+ "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-fixtures.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-navigation-helpers.ts\" \"tools/release/capture-navigation-setup-actions.ts\" \"tools/release/capture-navigation-portal-actions.ts\" \"tools/release/capture-navigation-download-actions.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\""
}
},
"tags": [