refactor(playback): extract diagnostic utilities

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent 7b704834e7
commit 2668fe0dd5
60 files changed
+820 -77

No files matched your search

+7 -5
View File
@@ -223,8 +223,9 @@ Key files:
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
source kind `shaka`. The Shaka `5.2.2` diagnostic boundary version-locks
public severity/category/code evidence, ignores recoverable error events,
source kind `shaka`. The DOM-free Shaka `5.2.2` diagnostic boundary lives in
`libs/playback/util`; it version-locks public severity/category/code evidence,
ignores recoverable error events,
treats rejected loads as terminal lifecycle outcomes, preserves exact public
DASH text-parser category/code evidence with unknown stage/failure, and never
retains or renders raw messages or `error.data`. A failed browser-support
@@ -232,9 +233,10 @@ Key files:
DRM still suppresses it. See the CLAUDE.md "Video Players" feature entry and
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- mpegts.js `1.8.0` errors from HTML5, Video.js, and ArtPlayer cross one
version-locked structured evidence boundary. Only exact public type/detail
pairs, pair-derived stage/failure, terminal disposition, and the validated
HTTP 4xx/5xx status slot are retained; raw messages and arbitrary `info`
version-locked structured evidence boundary in `libs/playback/util`. Only
exact public type/detail pairs, pair-derived stage/failure, terminal
disposition, and the validated HTTP 4xx/5xx status slot are retained; raw
messages and arbitrary `info`
never reach diagnostics. This is a sibling of `PlayerController`, not part
of the controls contract.
- The built-in HTML5/hls.js player is the second guarded consumer.
+6 -4
View File
@@ -758,8 +758,9 @@ app as a real argument, so it is not an option.
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- mpegts.js `1.8.0` errors from all three built-in players cross one
version-locked structured evidence boundary. It retains only exact public
type/detail pairs, pair-derived stage/failure, terminal disposition, and a
version-locked structured evidence boundary in `libs/playback/util`. It
retains only exact public type/detail pairs, pair-derived stage/failure,
terminal disposition, and a
validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach
stored or rendered diagnostics. HTTP/network failures avoid false decoder
recommendations, while exact format, codec, truncated-stream, and
@@ -776,8 +777,9 @@ app as a real argument, so it is not an option.
ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
need the castLabs Electron fork) surface a DRM playback diagnostic instead
of crashing. ClearKey EME works in stock Electron. Engine:
`libs/ui/playback/src/lib/shaka-engine/`. Its Shaka `5.2.2` diagnostic
boundary version-locks public severity/category/code evidence, ignores
`libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.2`
diagnostic boundary lives in `libs/playback/util`; it version-locks public
severity/category/code evidence, ignores
recoverable error events, treats rejected loads as terminal lifecycle
outcomes, preserves exact public DASH text-parser category/code evidence with
unknown stage/failure, and never retains or renders raw messages or
+3 -2
View File
@@ -994,8 +994,9 @@ player in settings.
- `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the
engine: lazy `import('shaka-player')` on first use (the module is a separate
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
queue + generation guard against channel-switch races, and a Shaka `5.2.2`
public-error boundary. The boundary version-locks its allowlisted
queue + generation guard against channel-switch races. The DOM-free Shaka
`5.2.2` public-error boundary lives in `libs/playback/util`; it version-locks
its allowlisted
severity/category/code values, emits only structured sanitized
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
and treats a rejected load as terminal even if its final retry error retains
@@ -73,6 +73,11 @@ in `libs/portal/shared/util`, while reusable collection views stay in
`libs/portal/shared/ui`. Existing injectable or stateful services in a `util`
path are legacy debt, not precedent for new placement.
Playback follows the same split: browser and Angular player integration stays
in `libs/ui/playback`, while DOM-free diagnostic contracts and classifiers live
in `libs/playback/util` and receive browser capability checks as explicit
probes.
## Project Tags
Every Nx project keeps one tag from each family in `project.json`:
+28
View File
@@ -0,0 +1,28 @@
{
"name": "playback-util",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playback/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:util"],
"targets": {
"test": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"command": [
"node",
"./tools/testing/run-web-esm-lib-tests.mjs",
"libs/playback/util/src"
],
"env": {
"NODE_OPTIONS": "--experimental-vm-modules"
},
"forwardAllArgs": true
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './lib/diagnostics/playback-diagnostics.util';
export * from './lib/diagnostics/shaka-error-classifier';
export * from './lib/diagnostics/shaka-error-contract';
export * from './lib/diagnostics/shaka-error-lifecycle';
export type * from './lib/diagnostics/shaka-error.types';
@@ -154,7 +154,7 @@ describe('HLS playback evidence', () => {
url: `https://provider.example/context?token=${secret}`,
responseType: 'text',
headers: { Authorization: `Bearer ${secret}` },
} as ErrorData['context'],
} as unknown as ErrorData['context'],
networkDetails: {
responseURL: `https://provider.example/xhr?token=${secret}`,
responseText: secret,
@@ -10,6 +10,7 @@ import {
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
@@ -38,6 +39,38 @@ type VhsClassifier = (
) => PlaybackDiagnostic;
describe('playback diagnostics', () => {
it('classifies manifest codecs only when the explicit support probe rejects them', () => {
const metadata = createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'html5',
audioCodecs: ['ac-3'],
videoCodecs: ['avc1.64001f'],
});
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
() => false
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
});
it.each([true, undefined])(
'does not classify manifest codecs when the support probe returns %s',
(supportResult) => {
const issue = classifyUnsupportedHlsManifestCodecs(
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'html5',
videoCodecs: ['avc1.64001f'],
}),
() => supportResult
);
expect(issue).toBeNull();
}
);
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
@@ -50,17 +50,13 @@ const VHS_NETWORK_ERROR_TYPES: ReadonlySet<VhsPlaybackEngineTypeValue> =
]);
export function classifyNativePlaybackIssue(
error: NativePlaybackErrorInput | MediaError | null | undefined,
error: NativePlaybackErrorInput | null | undefined,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const nativeErrorCode = error?.code;
const nativeErrorMessage = error?.message || undefined;
const nativeErrorInput = error as
NativePlaybackErrorInput | null | undefined;
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
const nativeErrorType = getNativeErrorType(
nativeErrorInput?.metadata?.errorType
);
const httpStatus = getNativeHttpStatus(error?.status);
const nativeErrorType = getNativeErrorType(error?.metadata?.errorType);
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
if (httpStatus !== undefined) {
@@ -175,17 +171,20 @@ export function classifyMpegTsPlaybackIssue(
});
}
export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined;
export function classifyUnsupportedHlsManifestCodecs(
metadata: PlaybackSourceMetadata
metadata: PlaybackSourceMetadata,
isTypeSupported: MediaTypeSupportProbe
): PlaybackDiagnostic | null {
if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') {
if (!hasCodecs(metadata)) {
return null;
}
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
if (MediaSource.isTypeSupported(mimeType)) {
if (isTypeSupported(mimeType) !== false) {
return null;
}
@@ -2,8 +2,8 @@ import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
} from './playback-diagnostics.model';
import { createPlaybackSourceMetadata } from './playback-diagnostics.util';
import {
classifyShakaPlaybackIssue,
createUnsupportedDrmDiagnostic,
@@ -4,18 +4,18 @@ import type {
PlaybackSourceMetadata,
ShakaPlaybackDisposition,
ShakaPlaybackEvidence,
} from '../playback-diagnostics/playback-diagnostics.model';
} from './playback-diagnostics.model';
import {
PlaybackDiagnosticCode as DiagnosticCode,
PlaybackDiagnosticSource as DiagnosticSource,
ShakaPlaybackCategory,
ShakaPlaybackDisposition as ShakaDisposition,
ShakaPlaybackFailure,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
} from './playback-diagnostics.model';
import { createPlaybackDiagnostic } from './playback-diagnostics.util';
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
import type { ShakaErrorLike } from './shaka-module.types';
import type { ShakaErrorLike } from './shaka-error.types';
export {
SHAKA_DIAGNOSTIC_VERSION,
@@ -1,10 +1,10 @@
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import { ShakaPlaybackDisposition as ShakaDisposition } from './playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
import type { ShakaErrorLike } from './shaka-module.types';
import type { ShakaErrorLike } from './shaka-error.types';
export function getShakaErrorEventDisposition(
error: Partial<ShakaErrorLike> | null
@@ -0,0 +1,6 @@
export interface ShakaErrorLike {
readonly severity: number;
readonly category: number;
readonly code: number;
readonly data?: readonly unknown[];
}
@@ -9,7 +9,7 @@ import {
ShakaPlaybackSeverity,
ShakaPlaybackStage,
ShakaPlaybackUnknownCode,
} from '../playback-diagnostics/playback-diagnostics.model';
} from './playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
@@ -29,7 +29,7 @@ import {
SHAKA_PUBLIC_CODES,
SHAKA_SEGMENT_STAGE_CODES,
} from './shaka-error-mapping';
import type { ShakaErrorLike } from './shaka-module.types';
import type { ShakaErrorLike } from './shaka-error.types';
export function createShakaPlaybackEvidence(
error: Partial<ShakaErrorLike> | null | undefined,
@@ -0,0 +1,347 @@
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import ts from 'typescript';
const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src');
const FORBIDDEN_IMPORT_PREFIXES = [
'@angular/',
'@iptvnator/ui/',
'electron',
'@ngx-pwa/',
] as const;
const FORBIDDEN_GLOBALS = new Set([
'window',
'document',
'localStorage',
'sessionStorage',
'MediaSource',
'MediaError',
'HTMLMediaElement',
'HTMLElement',
'navigator',
'indexedDB',
]);
describe('playback util boundary', () => {
it('keeps production sources independent of UI and browser globals', () => {
const violations = collectProductionSources(
PROJECT_SOURCE_ROOT
).flatMap(findBoundaryViolations);
expect(violations).toEqual([]);
});
it.each([
['import declaration', "import '@angular/core';", '@angular/core'],
[
're-export declaration',
"export * from '@iptvnator/ui/components';",
'@iptvnator/ui/components',
],
[
'import type node',
"type AngularType = import('@angular/core').Type;",
'@angular/core',
],
[
'dynamic import',
"void import('@ngx-pwa/service-worker');",
'@ngx-pwa/service-worker',
],
[
'import-equals declaration',
"import Electron = require('electron'); void Electron;",
'electron',
],
])('rejects forbidden sources in a %s', (_label, source, moduleName) => {
const violations = findFixtureViolations(source);
expect(violations).toEqual([expect.stringContaining(moduleName)]);
});
it('allows non-forbidden sources in every supported import form', () => {
const violations = findFixtureViolations(`
import './imported';
export * from './exported';
type LocalType = import('./typed').Type;
void import('./dynamic');
import Local = require('./required');
void Local;
`);
expect(violations).toEqual([]);
});
it.each([
['direct identifier', 'void navigator;', 'navigator'],
['direct storage identifier', 'void indexedDB;', 'indexedDB'],
[
'globalThis property access',
'void globalThis.MediaSource;',
'MediaSource',
],
['self property access', 'void self.indexedDB;', 'indexedDB'],
[
'globalThis element access',
"void globalThis['localStorage'];",
'localStorage',
],
[
'self element access',
"void self['sessionStorage'];",
'sessionStorage',
],
[
'globalThis destructuring',
'const { navigator } = globalThis;',
'navigator',
],
[
'self destructuring',
'const { indexedDB: database } = self; void database;',
'indexedDB',
],
])(
'rejects forbidden browser globals through %s',
(_label, source, name) => {
const violations = findFixtureViolations(source);
expect(violations).toEqual([expect.stringContaining(name)]);
}
);
it.each([
['property access', 'void source.navigator;'],
['element access', "void source['indexedDB'];"],
['destructuring', 'const { navigator } = source;'],
[
'property declaration',
'const config = { MediaSource: source }; void config;',
],
['shadowed self', 'const self = source; void self.MediaSource;'],
[
'local forbidden-name binding',
'const navigator = source.navigator; void navigator;',
],
])('allows ordinary local %s names', (_label, source) => {
const violations = findFixtureViolations(source);
expect(violations).toEqual([]);
});
});
function findFixtureViolations(source: string): string[] {
const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-'));
const filePath = join(directory, 'fixture.ts');
try {
writeFileSync(filePath, `${source}\nexport {};`);
return findBoundaryViolations(filePath);
} finally {
rmSync(directory, { force: true, recursive: true });
}
}
function collectProductionSources(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
return collectProductionSources(path);
}
return entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.spec.ts') &&
!entry.name.endsWith('.test.ts')
? [path]
: [];
});
}
function findBoundaryViolations(filePath: string): string[] {
const program = ts.createProgram({
rootNames: [filePath],
options: {
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'],
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
noEmit: true,
noResolve: true,
skipLibCheck: true,
target: ts.ScriptTarget.ESNext,
},
});
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) {
throw new Error(`Unable to parse ${filePath}`);
}
const checker = program.getTypeChecker();
const violations: string[] = [];
sourceFile.forEachChild(function visit(node): void {
const moduleSpecifier = getExternalModuleSpecifier(node);
if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) {
violations.push(formatViolation(sourceFile, node, moduleSpecifier));
}
for (const globalName of getForbiddenGlobalPropertyAccesses(
node,
checker,
sourceFile
)) {
violations.push(formatViolation(sourceFile, node, globalName));
}
if (
ts.isIdentifier(node) &&
FORBIDDEN_GLOBALS.has(node.text) &&
isAmbientIdentifierReference(node, checker, sourceFile)
) {
violations.push(formatViolation(sourceFile, node, node.text));
}
node.forEachChild(visit);
});
return violations;
}
function getExternalModuleSpecifier(node: ts.Node): string | undefined {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier
) {
return getStaticString(node.moduleSpecifier);
}
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
return getStaticString(node.argument.literal);
}
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword
) {
return getStaticString(node.arguments[0]);
}
if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference)
) {
return getStaticString(node.moduleReference.expression);
}
return undefined;
}
function getStaticString(node?: ts.Node): string | undefined {
return node &&
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
? node.text
: undefined;
}
function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean {
return FORBIDDEN_IMPORT_PREFIXES.some((prefix) =>
moduleSpecifier.startsWith(prefix)
);
}
function getForbiddenGlobalPropertyAccesses(
node: ts.Node,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): string[] {
if (
ts.isPropertyAccessExpression(node) &&
isAmbientGlobalRoot(node.expression, checker, sourceFile) &&
FORBIDDEN_GLOBALS.has(node.name.text)
) {
return [node.name.text];
}
if (
ts.isElementAccessExpression(node) &&
isAmbientGlobalRoot(node.expression, checker, sourceFile)
) {
const propertyName = getStaticString(node.argumentExpression);
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
? [propertyName]
: [];
}
if (
ts.isVariableDeclaration(node) &&
ts.isObjectBindingPattern(node.name) &&
node.initializer &&
isAmbientGlobalRoot(node.initializer, checker, sourceFile)
) {
return node.name.elements.flatMap((element) => {
const propertyName = getBindingPropertyName(element);
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
? [propertyName]
: [];
});
}
return [];
}
function getBindingPropertyName(
element: ts.BindingElement
): string | undefined {
const property = element.propertyName ?? element.name;
return ts.isIdentifier(property) || ts.isStringLiteral(property)
? property.text
: undefined;
}
function isAmbientGlobalRoot(
expression: ts.Expression,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): boolean {
return (
ts.isIdentifier(expression) &&
(expression.text === 'globalThis' || expression.text === 'self') &&
isAmbientIdentifier(expression, checker, sourceFile)
);
}
function isAmbientIdentifierReference(
identifier: ts.Identifier,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): boolean {
return (
!isPropertyName(identifier) &&
isAmbientIdentifier(identifier, checker, sourceFile)
);
}
function isAmbientIdentifier(
identifier: ts.Identifier,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): boolean {
const symbol = checker.getSymbolAtLocation(identifier);
return !symbol?.declarations?.some(
(declaration) => declaration.getSourceFile() === sourceFile
);
}
function isPropertyName(identifier: ts.Identifier): boolean {
const { parent } = identifier;
return (
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
(ts.isBindingElement(parent) &&
(parent.name === identifier ||
parent.propertyName === identifier)) ||
((ts.isPropertyAssignment(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isPropertySignature(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isMethodSignature(parent) ||
ts.isEnumMember(parent)) &&
parent.name === identifier)
);
}
function formatViolation(
sourceFile: ts.SourceFile,
node: ts.Node,
detail: string
): string {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`;
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "ESNext",
"target": "ESNext",
"esModuleInterop": true,
"types": ["jest", "node"],
"moduleResolution": "bundler"
},
"include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}
+1 -1
View File
@@ -5,7 +5,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
export * from './lib/embedded-mpv-player/embedded-mpv-session-controller';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/playback-diagnostics/playback-diagnostics.util';
export * from '@iptvnator/playback/util';
export * from './lib/player-controls';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
@@ -1,4 +1,4 @@
import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-diagnostics.util';
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
/**
* ArtPlayer option overrides for the legacy and shared-control surfaces.
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
} from '@iptvnator/playback/util';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks,
@@ -1,6 +1,6 @@
import type Artplayer from 'artplayer';
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session';
@@ -1,4 +1,4 @@
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
MockHls,
MockMpegTsPlayer,
@@ -90,6 +90,84 @@ describe('ArtPlayerSourceSession', () => {
expect(attach).not.toHaveBeenCalled();
});
it('uses browser media-type support for HLS manifest codec diagnostics', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: { isTypeSupported: jest.fn(() => false) },
});
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
try {
hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, {
levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }],
});
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(emitted).toEqual([
expect.objectContaining({
code: 'unsupported-codec',
source: 'source',
}),
]);
});
it('keeps HLS manifest handling alive when the browser support probe throws', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: {
isTypeSupported: () => {
throw new Error('provider-controlled-codec');
},
},
});
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
try {
expect(() =>
hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, {
levels: [
{
audioCodec: 'provider-controlled-codec',
videoCodec: 'avc1.64001f',
},
],
})
).not.toThrow();
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(emitted).toEqual([]);
});
it('uses authoritative VOD metadata for shared MPEG-TS and catches autoplay rejection', async () => {
const { session, player, video, adapter } = createSession({
sharedControls: true,
@@ -299,3 +377,11 @@ describe('ArtPlayerSourceSession', () => {
expect(refresh).toHaveBeenCalled();
});
});
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -12,7 +12,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
import type { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
@@ -307,7 +308,10 @@ export class ArtPlayerSourceSession {
.map((level) => level.videoCodec)
.filter((codec): codec is string => Boolean(codec))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
isBrowserMediaTypeSupported
);
if (issue) {
this.config.emitPlaybackIssue(issue);
}
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
export interface ArtPlayerVideoSessionConfig {
player: Artplayer;
@@ -13,7 +13,7 @@ import {
} from '@angular/core';
import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -9,7 +9,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
/**
* HTML5-player diagnostics glue extracted from the component: builds source
@@ -46,7 +47,10 @@ export function emitUnsupportedHlsManifestCodecs(
.map((level) => level.videoCodec)
.filter((codec): codec is string => Boolean(codec))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
isBrowserMediaTypeSupported
);
if (issue) {
emitPlaybackIssue(issue);
}
@@ -4,7 +4,12 @@ import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js';
import {
ErrorDetails,
ErrorTypes,
type ErrorData,
type ManifestParsedData,
} from 'hls.js';
import {
PlayerControlsComponent,
WebVideoControlsAdapter,
@@ -203,6 +208,86 @@ describe('HtmlVideoPlayerComponent', () => {
]);
});
it('uses browser media-type support for HLS manifest codec diagnostics', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: { isTypeSupported: jest.fn(() => false) },
});
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
try {
(
component as unknown as {
handleHlsManifestParsed: (
url: string,
data: ManifestParsedData
) => void;
}
).handleHlsManifestParsed(
'https://example.com/live/playlist.m3u8',
{
levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }],
} as ManifestParsedData
);
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(issues).toEqual([
expect.objectContaining({
code: 'unsupported-codec',
source: 'source',
}),
]);
});
it('keeps HLS manifest handling alive when the browser support probe throws', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: {
isTypeSupported: () => {
throw new Error('provider-controlled-codec');
},
},
});
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
try {
expect(() =>
(
component as unknown as {
handleHlsManifestParsed: (
url: string,
data: ManifestParsedData
) => void;
}
).handleHlsManifestParsed(
'https://example.com/live/playlist.m3u8',
{
levels: [
{
audioCodec: 'provider-controlled-codec',
videoCodec: 'avc1.64001f',
},
],
} as ManifestParsedData
)
).not.toThrow();
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(issues).toEqual([]);
});
it('does not emit a playback issue when HLS.js reports a recoverable error', () => {
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => {
@@ -355,3 +440,11 @@ describe('HtmlVideoPlayerComponent', () => {
expect(events).toEqual(['previous']);
});
});
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -21,7 +21,7 @@ import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -22,8 +22,8 @@ import {
type VodSourceDescriptor,
type VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
@@ -22,12 +22,7 @@ export interface ShakaTextTrackLike {
kind: string | null;
}
export interface ShakaErrorLike {
severity: number;
category: number;
code: number;
data?: readonly unknown[];
}
export type { ShakaErrorLike } from '@iptvnator/playback/util';
export interface ShakaPlayerLike {
attach(mediaElement: HTMLMediaElement): Promise<unknown>;
@@ -4,7 +4,7 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
} from '@iptvnator/playback/util';
import {
FakeShakaPlayer,
createFakeShakaEnvironment,
@@ -1,20 +1,16 @@
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type {
InlinePlaybackPlayer,
PlaybackDiagnostic,
PlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.model';
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
import {
ShakaPlaybackDisposition as ShakaDisposition,
asShakaError,
classifyShakaPlaybackIssue,
createPlaybackSourceMetadata,
createUnsupportedDrmDiagnostic,
} from './shaka-error-classifier';
import {
getShakaErrorEventDisposition,
isShakaLoadInterrupted,
} from './shaka-error-lifecycle';
type InlinePlaybackPlayer,
type PlaybackDiagnostic,
type PlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
import {
loadShakaModule,
@@ -6,7 +6,7 @@ import {
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
interface VjsDurationPlayer {
duration(value?: number): unknown;
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
import type { VideoJsPlayer } from './vjs-player.types';
@@ -23,7 +23,7 @@ import {
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -1,5 +1,5 @@
import type videoJs from 'video.js';
import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model';
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
export type VideoPlayerSource = {
src: string;
@@ -36,7 +36,7 @@ import {
CrossPortalSimilarService,
DownloadsService,
} from '@iptvnator/services';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { createVodDownloadState } from './vod-download-state.util';
@@ -2,7 +2,7 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
@@ -29,7 +29,7 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -21,7 +21,7 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -45,7 +45,7 @@ import {
type PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
@@ -1,5 +1,5 @@
import { Component, input, output } from '@angular/core';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
/**
* Player stand-ins for WebPlayerViewComponent specs. They mirror the real
@@ -0,0 +1,71 @@
import { isBrowserMediaTypeSupported } from './browser-media-type-support';
describe('isBrowserMediaTypeSupported', () => {
let mediaSourceDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
});
afterEach(() => {
restoreMediaSource(mediaSourceDescriptor);
});
it('returns undefined when MediaSource is unavailable', () => {
Reflect.deleteProperty(globalThis, 'MediaSource');
expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined();
});
it.each([undefined, null, true, {}])(
'returns undefined when isTypeSupported is not callable: %p',
(isTypeSupported) => {
setMediaSource({ isTypeSupported });
expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined();
}
);
it('returns undefined when the browser capability probe throws', () => {
const providerMimeType =
'video/mp4; codecs="provider-controlled-codec"';
setMediaSource({
isTypeSupported: () => {
throw new Error(providerMimeType);
},
});
expect(isBrowserMediaTypeSupported(providerMimeType)).toBeUndefined();
});
it.each([true, false])(
'returns the browser capability result %s',
(supportResult) => {
const isTypeSupported = jest.fn(() => supportResult);
setMediaSource({ isTypeSupported });
expect(isBrowserMediaTypeSupported('video/mp4')).toBe(
supportResult
);
expect(isTypeSupported).toHaveBeenCalledWith('video/mp4');
}
);
});
function setMediaSource(value: unknown): void {
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value,
});
}
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -0,0 +1,16 @@
export function isBrowserMediaTypeSupported(
mimeType: string
): boolean | undefined {
if (typeof MediaSource === 'undefined') {
return undefined;
}
try {
const probe: unknown = MediaSource.isTypeSupported;
return typeof probe === 'function'
? probe.call(MediaSource, mimeType)
: undefined;
} catch {
return undefined;
}
}
@@ -1,4 +1,4 @@
import { InlinePlaybackPlayer } from '../playback-diagnostics/playback-diagnostics.model';
import { InlinePlaybackPlayer } from '@iptvnator/playback/util';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks as flush,
+1
View File
@@ -32,6 +32,7 @@
"libs/portal/shared/util/src/index.ts"
],
"@iptvnator/epg/data-access": ["libs/epg/data-access/src/index.ts"],
"@iptvnator/playback/util": ["libs/playback/util/src/index.ts"],
"@iptvnator/playlist/shared/ui": [
"libs/playlist/shared/ui/src/index.ts"
],