mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
refactor(playback): extract diagnostic utilities
This commit is contained in:
1 parent
7b704834e7
commit
2668fe0dd5
60 files changed
+820
-77
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+1
-1
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+33
@@ -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(
|
||||
{
|
||||
+9
-10
@@ -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;
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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
-4
@@ -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,
|
||||
File renamed without changes.
+2
-2
@@ -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
|
||||
File renamed without changes.
@@ -0,0 +1,6 @@
|
||||
export interface ShakaErrorLike {
|
||||
readonly severity: number;
|
||||
readonly category: number;
|
||||
readonly code: number;
|
||||
readonly data?: readonly unknown[];
|
||||
}
|
||||
File renamed without changes.
+2
-2
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
@@ -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}`;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user