mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(playback): structure HLS diagnostics (#1316)
* docs(playback): design structured HLS diagnostics * docs(playback): plan structured HLS diagnostics * fix(playback): structure HLS diagnostics * docs(playback): document structured HLS evidence * fix(playback): keep HLS startup logs private
This commit is contained in:
1 parent
bf13849d69
commit
99d167993d
22 files changed
+1828
-204
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
HLS failures now use confirmed player evidence such as the failed stage, timeout, and HTTP status. Recoverable retries stay silent, and technical details no longer include raw provider error payloads.
|
||||
@@ -335,11 +335,32 @@ Video.js HTTP error is `network-error` and shows its status. Because an HTTP
|
||||
status is server/network evidence rather than decoding evidence, external
|
||||
decoding is not presented as a likely fix.
|
||||
|
||||
`network-error` is reserved for provider/network loading failures. Browser security failures such as CORS, mixed content, Content Security Policy, and private-network-access blocks are classified as `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
|
||||
HLS.js errors cross one shared sanitizer boundary before HTML5 or ArtPlayer can
|
||||
emit a diagnostic. The boundary retains only allowlisted engine `type` and
|
||||
`details` identifiers, the final fatal/recoverable disposition, a stage derived
|
||||
from exact details (`manifest`, `level`, `segment`, `key`, `media`, or
|
||||
`unknown`), a structured failure kind, and a validated `response.code`.
|
||||
Recoverable events return no terminal diagnostic. Exact codec, decrypt/key
|
||||
system, network/timeout/HTTP, and media/mux evidence select the corresponding
|
||||
diagnostic; insufficient evidence stays `unknown-playback-error`.
|
||||
|
||||
HLS.js does not expose a reliable structured CORS, mixed-content, CSP, or
|
||||
private-network-access cause. Status zero and generic fetch failures therefore
|
||||
remain network/unknown evidence instead of becoming browser-access guesses.
|
||||
Error URLs, request context and headers, loader/network objects, response
|
||||
URL/text/body, error messages, reasons, and arbitrary provider payloads are
|
||||
neither retained nor rendered. Technical details show only the sanitized stage,
|
||||
failure, engine type/details, disposition, and HTTP status. The active playback
|
||||
URL remains available to the pre-existing retry, copy, and explicit
|
||||
external-player workflows; it is not copied from the HLS error payload into
|
||||
the evidence or technical details. HLS startup development logs are event-only:
|
||||
they do not include provider-supplied channel names or source URLs.
|
||||
|
||||
`network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
|
||||
|
||||
mpegts.js `Early-EOF` failures on MPEG-TS streams are classified as `media-decode-error` instead of generic `network-error`. These failures usually mean the fetch stream ended before mpegts.js expected a complete transport stream, and external players may still handle the same URL more tolerant of short reads or malformed TS boundaries.
|
||||
|
||||
The diagnostic surface covers the inline player viewport when playback fails, with a compact warning badge, a native-player fallback headline, and player-card actions for configured external players. It exposes technical details on demand: diagnostic code, reporting player/source, detected container/MIME, video/audio codecs, native browser error fields, and raw HLS/mpegts details. HLS manifest codec metadata also drives a concise browser-support hint for codecs that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2 video.
|
||||
The diagnostic surface covers the inline player viewport when playback fails, with a compact warning badge, a native-player fallback headline, and player-card actions for configured external players. It exposes technical details on demand: diagnostic code, reporting player/source, detected container/MIME, video/audio codecs, native browser error fields, sanitized structured HLS evidence, and existing mpegts details. HLS manifest codec metadata also drives a concise browser-support hint for codecs that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2 video.
|
||||
|
||||
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
|
||||
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
# Structured HLS Diagnostics Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace broad HLS error-string heuristics with a safe structured
|
||||
hls.js 1.6.16 evidence contract shared by HTML5 and ArtPlayer.
|
||||
|
||||
**Architecture:** Sanitize `ErrorData` at one boundary into an allowlisted
|
||||
`HlsPlaybackEvidence` object, classify only that object, and return no terminal
|
||||
diagnostic for recoverable events. Preserve the evidence on
|
||||
`PlaybackDiagnostic` for deterministic technical details without retaining
|
||||
engine messages, URLs, headers, response bodies, credentials, or arbitrary
|
||||
provider payloads.
|
||||
|
||||
**Tech Stack:** Angular 21, TypeScript 5.9, hls.js 1.6.16, Jest through Nx,
|
||||
ngx-translate JSON catalogs, Markdown release notes.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Confirm The Clean Baseline
|
||||
|
||||
**Files:**
|
||||
- Verify only: `package.json`
|
||||
- Verify only: `pnpm-lock.yaml`
|
||||
- Verify only: `libs/ui/playback/project.json`
|
||||
|
||||
- [ ] **Step 1: Install locked dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Expected: exit 0 without changing `pnpm-lock.yaml`.
|
||||
|
||||
- [ ] **Step 2: Verify Nx workspace discovery**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx show projects
|
||||
```
|
||||
|
||||
Expected: exit 0 and output containing `ui-playback`, `web`, and `web-e2e`.
|
||||
|
||||
- [ ] **Step 3: Run the affected project baseline**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test ui-playback --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: 84 suites and 741 tests pass before implementation.
|
||||
|
||||
### Task 1: Drive The Evidence Contract From Failing Tests
|
||||
|
||||
**Files:**
|
||||
- Create:
|
||||
`libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts`
|
||||
- Create:
|
||||
`libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts`
|
||||
|
||||
- [ ] **Step 1: Add extractor regressions before the extractor exists**
|
||||
|
||||
In `hls-playback-evidence.util.spec.ts`, import hls.js 1.6.16 `ErrorData`,
|
||||
`ErrorDetails`, and `ErrorTypes`, then exercise the wished-for export through
|
||||
the existing diagnostics module namespace. Use a helper returning a complete
|
||||
`ErrorData`:
|
||||
|
||||
```typescript
|
||||
function createErrorData(overrides: Partial<ErrorData> = {}): ErrorData {
|
||||
return {
|
||||
type: ErrorTypes.OTHER_ERROR,
|
||||
details: ErrorDetails.UNKNOWN,
|
||||
error: new Error('provider payload must not survive'),
|
||||
fatal: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Cover these exact shapes:
|
||||
|
||||
- manifest 404:
|
||||
`networkError`, `manifestLoadError`, `fatal: true`,
|
||||
`response.code: 404`;
|
||||
- level timeout:
|
||||
`networkError`, `levelLoadTimeOut`, `fatal: true`;
|
||||
- recoverable fragment load:
|
||||
`networkError`, `fragLoadError`, `fatal: false`;
|
||||
- key load:
|
||||
`networkError`, `keyLoadError`;
|
||||
- fatal fragment decrypt:
|
||||
`mediaError`, `fragDecryptError`;
|
||||
- buffer codec:
|
||||
`mediaError`, `bufferAddCodecError`;
|
||||
- unknown runtime type/detail values;
|
||||
- response status boundaries 99/100/599/600 and non-integers;
|
||||
- a payload containing credential-shaped URLs, headers, response text/data,
|
||||
context, `networkDetails`, `error`, and `reason`.
|
||||
|
||||
Expected evidence examples:
|
||||
|
||||
```typescript
|
||||
expect(createEvidence(manifest404)).toEqual({
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
});
|
||||
|
||||
expect(createEvidence(recoverableFragment)).toEqual({
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.FRAG_LOAD_ERROR,
|
||||
disposition: 'recoverable',
|
||||
stage: 'segment',
|
||||
failure: 'network',
|
||||
});
|
||||
```
|
||||
|
||||
Serialize the unsafe-shape evidence and assert it contains none of the secret,
|
||||
URL, header, text, body, or provider-message sentinels.
|
||||
|
||||
- [ ] **Step 2: Replace broad HLS classifier cases with structured expectations**
|
||||
|
||||
In `playback-diagnostics.util.spec.ts`, make
|
||||
`classifyHlsPlaybackIssue` consume the wished-for evidence shape. Cover:
|
||||
|
||||
```typescript
|
||||
expect(
|
||||
classifyHlsPlaybackIssue(recoverableFragmentEvidence, metadata)
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
classifyHlsPlaybackIssue(fatalManifest404Evidence, metadata)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
httpStatus: 404,
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Add exact codec, decrypt/DRM, media/mux, and unknown cases. Add misleading
|
||||
provider-string-shaped values to the source metadata URL and prove they cannot
|
||||
change classification, since the classifier receives no message payload.
|
||||
|
||||
Remove HLS expectations that arbitrary error objects, CORS phrases, or
|
||||
provider messages appear in `details`. Keep native and mpegts.js coverage
|
||||
unchanged.
|
||||
|
||||
- [ ] **Step 3: Run the focused tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--experimental-vm-modules \
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--config jest.web-esm.workspace.ts \
|
||||
--runTestsByPath \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts \
|
||||
--runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because no structured evidence creator exists and the current
|
||||
classifier still accepts broad strings and always returns a diagnostic.
|
||||
|
||||
- [ ] **Step 4: Add the evidence model**
|
||||
|
||||
In `playback-diagnostics.model.ts`, replace `HlsPlaybackErrorInput` with const
|
||||
objects and extracted types:
|
||||
|
||||
```typescript
|
||||
export const HlsPlaybackDisposition = {
|
||||
Fatal: 'fatal',
|
||||
Recoverable: 'recoverable',
|
||||
} as const;
|
||||
|
||||
export const HlsPlaybackStage = {
|
||||
Manifest: 'manifest',
|
||||
Level: 'level',
|
||||
Segment: 'segment',
|
||||
Key: 'key',
|
||||
Media: 'media',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export const HlsPlaybackFailure = {
|
||||
Http: 'http',
|
||||
Timeout: 'timeout',
|
||||
Network: 'network',
|
||||
Access: 'access',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
```
|
||||
|
||||
Define `HlsPlaybackEvidence` with allowlisted engine type/detail types,
|
||||
disposition, stage, failure, and optional status. Add optional
|
||||
`hls?: HlsPlaybackEvidence` to `PlaybackDiagnostic`.
|
||||
|
||||
- [ ] **Step 5: Implement exact extraction**
|
||||
|
||||
In `hls-playback-evidence.util.ts`:
|
||||
|
||||
- validate `type` against `Object.values(ErrorTypes)`, otherwise `unknown`;
|
||||
- validate `details` against `Object.values(ErrorDetails)`, otherwise
|
||||
`ErrorDetails.UNKNOWN`;
|
||||
- validate only `response.code` as an integer from 100 through 599;
|
||||
- map stage through readonly sets of exact `ErrorDetails`;
|
||||
- map timeout and network failure through readonly exact sets;
|
||||
- prefer timeout, then 4xx/5xx HTTP failure, then network, else unknown;
|
||||
- return only the six evidence keys and optional `httpStatus`.
|
||||
|
||||
Do not read any other `ErrorData` property.
|
||||
|
||||
- [ ] **Step 6: Implement exact classification**
|
||||
|
||||
In `playback-diagnostics.util.ts`:
|
||||
|
||||
- change the HLS classifier input to `HlsPlaybackEvidence`;
|
||||
- return `null` for `Recoverable`;
|
||||
- use exact detail sets for incompatible/add-codec and decrypt evidence;
|
||||
- use exact key-system type/details for DRM;
|
||||
- use exact network type/failure for network diagnostics;
|
||||
- use exact media/mux types for decode diagnostics;
|
||||
- use unknown otherwise;
|
||||
- retain the evidence and HTTP status through `createPlaybackDiagnostic`.
|
||||
|
||||
Remove `HlsPlaybackErrorInput` from `playback-error-patterns.util.ts`.
|
||||
`normalizeErrorDetails`, `isNetworkFailure`, `isCodecFailure`, and
|
||||
`isDrmOrEncryptionFailure` remain only where mpegts.js still needs them, or are
|
||||
deleted when HLS was their only consumer. Do not redesign mpegts.js behavior.
|
||||
|
||||
- [ ] **Step 7: Run focused tests to verify GREEN**
|
||||
|
||||
Run the focused command from step 3.
|
||||
|
||||
Expected: PASS for exact real shapes, unknown values, privacy exclusions,
|
||||
recoverable suppression, and all unchanged native/mpegts behavior.
|
||||
|
||||
- [ ] **Step 8: Commit the evidence contract**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts
|
||||
git commit -m "fix(playback): classify HLS errors from structured evidence"
|
||||
```
|
||||
|
||||
Expected: one contract/classifier commit with its RED/GREEN regressions.
|
||||
|
||||
### Task 2: Route HTML5 And ArtPlayer Through The Boundary
|
||||
|
||||
**Files:**
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/art-player/art-player-source-session.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/art-player/art-player.component.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write adapter regressions**
|
||||
|
||||
Update HTML5 and ArtPlayer tests first:
|
||||
|
||||
- fatal manifest 404 emits `network-error`, stage `manifest`, failure `http`,
|
||||
disposition `fatal`, and `httpStatus: 404`;
|
||||
- recoverable fragment error emits nothing;
|
||||
- payload sentinels placed in `error`, `reason`, response URL/text/data,
|
||||
request headers, and `networkDetails` do not appear in the emitted
|
||||
diagnostic JSON;
|
||||
- stale ArtPlayer HLS callbacks remain ignored after a source change.
|
||||
|
||||
Use hls.js-shaped values, not prose-only test objects.
|
||||
|
||||
- [ ] **Step 2: Run adapter tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--experimental-vm-modules \
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--config jest.web-esm.workspace.ts \
|
||||
--runTestsByPath \
|
||||
libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player.component.spec.ts \
|
||||
--runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because adapters still build broad HLS input and retain
|
||||
arbitrary errors.
|
||||
|
||||
- [ ] **Step 3: Use the shared extractor/classifier**
|
||||
|
||||
In both adapters:
|
||||
|
||||
```typescript
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
createHlsPlaybackEvidence(data),
|
||||
sourceMetadata
|
||||
);
|
||||
if (issue) {
|
||||
emitPlaybackIssue(issue);
|
||||
}
|
||||
```
|
||||
|
||||
Remove duplicated `if (!data.fatal)` gates and all copying of `data.error`
|
||||
messages or objects.
|
||||
|
||||
- [ ] **Step 4: Run adapter tests to verify GREEN**
|
||||
|
||||
Run the focused command from step 2.
|
||||
|
||||
Expected: PASS for HTML5, ArtPlayer, recoverable suppression, privacy
|
||||
exclusions, and stale-session guards.
|
||||
|
||||
- [ ] **Step 5: Commit adapter integration**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add \
|
||||
libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts \
|
||||
libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player-source-session.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player.component.spec.ts
|
||||
git commit -m "fix(playback): sanitize HLS adapter errors"
|
||||
```
|
||||
|
||||
Expected: one adapter-boundary commit.
|
||||
|
||||
### Task 3: Render Only Sanitized HLS Evidence
|
||||
|
||||
**Files:**
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts`
|
||||
- Modify:
|
||||
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Add the rendered-detail regression**
|
||||
|
||||
Create a diagnostic carrying structured manifest-404 evidence and expect its
|
||||
technical error-details row to equal:
|
||||
|
||||
```text
|
||||
stage=manifest · failure=http · type=networkError · details=manifestLoadError · disposition=fatal · HTTP 404
|
||||
```
|
||||
|
||||
Include credential-shaped source/provider sentinels in fields that the HLS
|
||||
extractor rejects and assert the banner/details text does not contain them.
|
||||
Keep the existing direct native `HTTP 404 · networkrequestfailed` expectation.
|
||||
|
||||
- [ ] **Step 2: Run the view spec to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--experimental-vm-modules \
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--config jest.web-esm.workspace.ts \
|
||||
--runTestsByPath \
|
||||
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts \
|
||||
--runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because the formatter does not render structured HLS evidence.
|
||||
|
||||
- [ ] **Step 3: Format HLS evidence deterministically**
|
||||
|
||||
In `formatDiagnosticErrorDetails`, prefer `issue.hls` and format only:
|
||||
|
||||
```typescript
|
||||
[
|
||||
`stage=${evidence.stage}`,
|
||||
`failure=${evidence.failure}`,
|
||||
`type=${evidence.engineType}`,
|
||||
`details=${evidence.engineDetails}`,
|
||||
`disposition=${evidence.disposition}`,
|
||||
evidence.httpStatus === undefined ? '' : `HTTP ${evidence.httpStatus}`,
|
||||
]
|
||||
```
|
||||
|
||||
Filter empty values and join with ` · `. Leave native, mpegts.js, and Shaka
|
||||
formatting unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the view spec to verify GREEN**
|
||||
|
||||
Run the focused command from step 2.
|
||||
|
||||
Expected: PASS with safe deterministic evidence and unchanged native HTTP
|
||||
rendering.
|
||||
|
||||
- [ ] **Step 5: Commit the UI formatting**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add \
|
||||
libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts \
|
||||
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
|
||||
git commit -m "fix(playback): show safe HLS diagnostic evidence"
|
||||
```
|
||||
|
||||
Expected: one focused UI/test commit without new translation keys.
|
||||
|
||||
### Task 4: Update Canonical Documentation And Release Notes
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/embedded-inline-playback.md`
|
||||
- Create: `.changes/playback-structured-hls-diagnostics.md`
|
||||
- Verify only: `CLAUDE.md`
|
||||
- Verify only: `AGENTS.md`
|
||||
|
||||
- [ ] **Step 1: Update the canonical diagnostic contract**
|
||||
|
||||
Replace the statement that technical details expose raw HLS information with:
|
||||
|
||||
- exact hls.js fields retained;
|
||||
- stage/failure/disposition mapping;
|
||||
- recoverable events suppressed;
|
||||
- status zero/access ambiguity stays unknown/network;
|
||||
- unsafe HLS fields are neither retained nor rendered;
|
||||
- HTML5 and ArtPlayer share the same boundary.
|
||||
|
||||
Do not add history, probes, failover, Shaka, or mpegts redesign material.
|
||||
|
||||
- [ ] **Step 2: Assess living root docs**
|
||||
|
||||
Check the shared-player descriptions in `CLAUDE.md` and `AGENTS.md`. Update
|
||||
both only if their existing claims become stale. If neither describes HLS error
|
||||
payload details, leave both unchanged and record that assessment.
|
||||
|
||||
- [ ] **Step 3: Add the user-facing release note**
|
||||
|
||||
Create `.changes/playback-structured-hls-diagnostics.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
HLS failures now use confirmed player evidence such as the failed stage, timeout, and HTTP status. Recoverable retries stay silent, and technical details no longer include raw provider error payloads.
|
||||
```
|
||||
|
||||
Keep the body below 400 characters.
|
||||
|
||||
- [ ] **Step 4: Validate Markdown and release-note format**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
pnpm run release:notes:validate
|
||||
```
|
||||
|
||||
Expected: both commands exit 0.
|
||||
|
||||
- [ ] **Step 5: Commit docs and release note**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add \
|
||||
docs/architecture/embedded-inline-playback.md \
|
||||
.changes/playback-structured-hls-diagnostics.md
|
||||
git commit -m "docs(playback): document structured HLS evidence"
|
||||
```
|
||||
|
||||
Add `CLAUDE.md` and `AGENTS.md` only if step 2 required synchronized changes.
|
||||
|
||||
### Task 5: Complete The Test-Impact Pass
|
||||
|
||||
**Files:**
|
||||
- Verify only: all files changed from `origin/master`
|
||||
|
||||
- [ ] **Step 1: Run focused RED/GREEN suites together**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--experimental-vm-modules \
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--config jest.web-esm.workspace.ts \
|
||||
--runTestsByPath \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts \
|
||||
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts \
|
||||
libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts \
|
||||
libs/ui/playback/src/lib/art-player/art-player.component.spec.ts \
|
||||
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts \
|
||||
--runInBand
|
||||
```
|
||||
|
||||
Expected: all focused suites pass.
|
||||
|
||||
- [ ] **Step 2: Run the affected Nx project tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test ui-playback --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: all `ui-playback` suites pass with the new regressions.
|
||||
|
||||
- [ ] **Step 3: Run lint and typechecks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx lint ui-playback --skip-nx-cache
|
||||
pnpm exec tsc -p libs/ui/playback/tsconfig.lib.json --noEmit
|
||||
pnpm run typecheck:web
|
||||
```
|
||||
|
||||
Expected: all commands exit 0.
|
||||
|
||||
- [ ] **Step 4: Run repository validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:check
|
||||
pnpm run release:notes:validate
|
||||
git diff --check origin/master...HEAD
|
||||
```
|
||||
|
||||
Expected: translations remain aligned, the release note is valid, and the diff
|
||||
has no whitespace errors.
|
||||
|
||||
- [ ] **Step 5: Record the E2E decision**
|
||||
|
||||
No E2E is required: this PR does not change navigation, control interactions,
|
||||
playback engine selection, or retry/fallback workflows. Unit/component tests
|
||||
exercise the real hls.js public shape, both adapters, terminal suppression,
|
||||
classification, and rendered technical details.
|
||||
|
||||
### Task 6: Run Independent Local Review And Revalidation
|
||||
|
||||
**Files:**
|
||||
- Review only: `origin/master...HEAD`
|
||||
|
||||
- [ ] **Step 1: Inspect the complete diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
git diff --stat origin/master...HEAD
|
||||
git diff --check origin/master...HEAD
|
||||
```
|
||||
|
||||
Expected: only the focused design/plan, HLS contract, tests, two adapters,
|
||||
minimal diagnostic formatting, canonical doc, and release note are present.
|
||||
|
||||
- [ ] **Step 2: Dispatch an independent reviewer**
|
||||
|
||||
Give a fresh reviewer only the requirements, design path, base SHA, head SHA,
|
||||
and full diff. Require actionable P0/P1/P2 findings with exact file/line
|
||||
references, including:
|
||||
|
||||
- incorrect hls.js 1.6.16 shape assumptions;
|
||||
- recoverable-to-terminal regressions;
|
||||
- unsafe data retention or rendering;
|
||||
- broad string inference;
|
||||
- classification precedence errors;
|
||||
- HTML5/ArtPlayer divergence;
|
||||
- missing tests, type safety, or scope creep.
|
||||
|
||||
The reviewer must not modify files.
|
||||
|
||||
- [ ] **Step 3: Resolve findings with TDD**
|
||||
|
||||
For each valid finding, add or update the closest regression first, observe the
|
||||
expected failure, apply the smallest fix, and return the focused suite to
|
||||
green. Reject invalid findings only with concrete code/type/test evidence.
|
||||
|
||||
- [ ] **Step 4: Repeat review after fixes**
|
||||
|
||||
If any file changed, re-run the same independent review against the new
|
||||
`origin/master...HEAD` range until no actionable P0/P1/P2 remains.
|
||||
|
||||
- [ ] **Step 5: Re-run final validation**
|
||||
|
||||
Repeat every command in Task 5 after the last review change.
|
||||
|
||||
Expected: all tests and validations exit 0 on the exact commit range that will
|
||||
be pushed.
|
||||
|
||||
### Task 7: Publish The Ready Pull Request
|
||||
|
||||
**Files:**
|
||||
- Verify only: `.github/pull_request_template.md` if present
|
||||
|
||||
- [ ] **Step 1: Commit any final reviewed fixes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff
|
||||
git add -u
|
||||
git commit -m "fix(playback): address structured HLS review findings"
|
||||
```
|
||||
|
||||
Create this commit only when review produced verified changes.
|
||||
|
||||
- [ ] **Step 2: Inspect final history and scope**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git log --oneline origin/master..HEAD
|
||||
git diff --stat origin/master...HEAD
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
Expected: a clean focused branch.
|
||||
|
||||
- [ ] **Step 3: Push the branch**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git push -u origin agent/structured-hls-diagnostics
|
||||
```
|
||||
|
||||
Expected: push succeeds without force.
|
||||
|
||||
- [ ] **Step 4: Create a ready PR**
|
||||
|
||||
Create a non-draft PR targeting `master`:
|
||||
|
||||
```text
|
||||
fix(playback): structure HLS diagnostics
|
||||
```
|
||||
|
||||
The body summarizes:
|
||||
|
||||
- exact hls.js evidence and recoverable suppression;
|
||||
- shared HTML5/ArtPlayer sanitizer;
|
||||
- safe technical details without raw provider payloads;
|
||||
- tests and validation commands;
|
||||
- independent review status.
|
||||
|
||||
Link a relevant open issue only if one was found or created. Do not reopen or
|
||||
close #1159, which PR #1314 already resolved.
|
||||
|
||||
- [ ] **Step 5: Verify PR state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,title,state,isDraft,baseRefName,headRefName,url,body
|
||||
```
|
||||
|
||||
Expected: `OPEN`, `isDraft: false`, base `master`, head
|
||||
`agent/structured-hls-diagnostics`.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Structured HLS Diagnostics
|
||||
|
||||
## Context
|
||||
|
||||
PR #1314 made native media diagnostics evidence-based, but the HLS path still
|
||||
normalizes `type`, `details`, `message`, and an arbitrary `error` payload into
|
||||
one string. `classifyHlsPlaybackIssue` then searches that string for broad
|
||||
fragments such as `network`, `status`, and `codec`. This can misclassify a
|
||||
provider message, and serializing arbitrary values can retain URLs,
|
||||
credentials, response content, loader context, or other provider data.
|
||||
|
||||
IPTVnator currently installs hls.js 1.6.16. Its public `ErrorData` supplies
|
||||
structured `type`, `details`, `fatal`, and an optional `response.code`.
|
||||
Specific `ErrorDetails` values also identify the manifest, level, segment, key,
|
||||
or media stage. The same payload contains unsafe or unbounded fields, including
|
||||
`error`, `reason`, `networkDetails`, `context`, `loader`, `url`, and response
|
||||
URL/text/data, which are not needed for classification.
|
||||
|
||||
Both the HTML5 player and ArtPlayer subscribe to `Hls.Events.ERROR`. They
|
||||
currently ignore `fatal: false` before calling the classifier, but each adapter
|
||||
builds the broad string input independently.
|
||||
|
||||
## Goals
|
||||
|
||||
- Create one structured HLS evidence boundary for the installed hls.js error
|
||||
shape.
|
||||
- Retain only allowlisted engine type/detail identifiers, the final
|
||||
fatal/recoverable disposition, a stage derived from exact details, a
|
||||
structured failure kind, and a validated HTTP status when present.
|
||||
- Classify HLS failures from exact engine values rather than substring
|
||||
heuristics.
|
||||
- Keep insufficient evidence explicitly unknown.
|
||||
- Ensure recoverable hls.js events never become terminal playback diagnostics.
|
||||
- Use the same extraction and classification path in HTML5 and ArtPlayer.
|
||||
- Show only the sanitized evidence in technical diagnostic details.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Diagnostic history, persistence, correlation, or telemetry.
|
||||
- Additional manifest or stream probes.
|
||||
- Automatic player failover.
|
||||
- A Shaka or mpegts.js diagnostic redesign.
|
||||
- A cross-player confidence or likelihood matrix.
|
||||
- Inferring browser access failures from status zero, `TypeError`, or error
|
||||
message text.
|
||||
- Changing hls.js retry, level-switch, or recovery behavior.
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
### Boundary sanitizer (selected)
|
||||
|
||||
Convert hls.js `ErrorData` into a small `HlsPlaybackEvidence` value at the
|
||||
adapter boundary. The classifier accepts only that value. This centralizes the
|
||||
allowlist, makes unsafe fields structurally unavailable to classification and
|
||||
UI code, and gives HTML5 and ArtPlayer identical behavior.
|
||||
|
||||
### Pass `ErrorData` directly
|
||||
|
||||
This would preserve the strongest TypeScript relationship with hls.js, but it
|
||||
would leave every consumer able to read or render URLs, loader state, headers,
|
||||
response payloads, and arbitrary errors. It also makes privacy depend on each
|
||||
future call site remembering the same exclusions.
|
||||
|
||||
### Keep the broad input and tighten string tests
|
||||
|
||||
Exact comparisons could improve classification, but the adapters would still
|
||||
copy arbitrary messages and objects into diagnostics. This would not establish
|
||||
the requested safe evidence contract.
|
||||
|
||||
## Evidence Contract
|
||||
|
||||
`HlsPlaybackEvidence` contains:
|
||||
|
||||
- `engineType`: one of the installed hls.js `ErrorTypes` values, otherwise
|
||||
`unknown`;
|
||||
- `engineDetails`: one of the installed hls.js `ErrorDetails` values,
|
||||
including its existing `unknown` value;
|
||||
- `disposition`: `fatal` when `ErrorData.fatal === true`, otherwise
|
||||
`recoverable`;
|
||||
- `stage`: `manifest`, `level`, `segment`, `key`, `media`, or `unknown`;
|
||||
- `failure`: `http`, `timeout`, `network`, `access`, or `unknown`;
|
||||
- optional `httpStatus`: an integer from 100 through 599 copied only from
|
||||
`ErrorData.response.code`.
|
||||
|
||||
The `access` value is part of the contract but hls.js 1.6.16 does not expose a
|
||||
reliable structured CORS, mixed-content, CSP, or private-network-access code.
|
||||
Status zero and generic fetch failures can also mean DNS, offline, connection,
|
||||
or abort failures. The extractor therefore does not emit `access` for current
|
||||
runtime shapes; it uses `network` or `unknown` and never inspects message text.
|
||||
|
||||
The extractor does not copy:
|
||||
|
||||
- `ErrorData.url`;
|
||||
- `context`, including request URLs and headers;
|
||||
- `networkDetails` or loader instances;
|
||||
- `response.url`, `response.text`, or `response.data`;
|
||||
- `error`, deprecated `err`, `reason`, events, fragments, or arbitrary values.
|
||||
|
||||
The existing playback metadata still carries the active source URL for the
|
||||
pre-existing Retry, Copy URL, and explicit external-player workflows. No HLS
|
||||
error URL is copied into evidence or shown in technical details.
|
||||
|
||||
## Stage Mapping
|
||||
|
||||
Stages are derived only from exact `ErrorDetails` values:
|
||||
|
||||
- `manifest`: manifest load, timeout, parsing, and incompatible-codec details;
|
||||
- `level`: level load, timeout, parsing, empty, and switch details, plus
|
||||
audio/subtitle track playlist load details;
|
||||
- `segment`: fragment load, timeout, parsing, decrypt, and gap details;
|
||||
- `key`: key load/timeout and key-system details;
|
||||
- `media`: buffer, mux/remux, attach-media, and playback-stall details;
|
||||
- `unknown`: internal, aborted, interstitial, unrecognized, or otherwise
|
||||
unmapped details.
|
||||
|
||||
This mapping describes the engine stage, not root-cause likelihood. For
|
||||
example, a segment-stage timeout is still classified as a network timeout.
|
||||
|
||||
## Failure Mapping
|
||||
|
||||
Failure kind uses this precedence:
|
||||
|
||||
1. Exact timeout details produce `timeout`.
|
||||
2. A validated 4xx or 5xx `response.code` produces `http`.
|
||||
3. Exact network load-error details, or the exact `networkError` engine type,
|
||||
produce `network`.
|
||||
4. No current hls.js 1.6.16 field independently proves `access`.
|
||||
5. Everything else is `unknown`.
|
||||
|
||||
A 1xx, 2xx, or 3xx status remains retained as factual response metadata but
|
||||
does not by itself produce an HTTP-failure kind.
|
||||
|
||||
## Classification
|
||||
|
||||
`classifyHlsPlaybackIssue` returns `PlaybackDiagnostic | null`:
|
||||
|
||||
1. `recoverable` returns `null` before classification.
|
||||
2. Exact incompatible/add-codec details produce `unsupported-codec`.
|
||||
3. Exact key-system or fragment-decrypt evidence produces
|
||||
`drm-or-encryption`.
|
||||
4. Exact network type, load/timeout details, or HTTP failure evidence produces
|
||||
`network-error`.
|
||||
5. Exact media or mux type produces `media-decode-error`.
|
||||
6. Everything else produces `unknown-playback-error`.
|
||||
|
||||
The HLS path does not emit `browser-access-error` without structured access
|
||||
evidence. It also does not classify `keyLoadError` as DRM: failure to fetch an
|
||||
encryption key is network evidence, while `fragDecryptError` and key-system
|
||||
details are encryption/DRM evidence.
|
||||
|
||||
The normalized `PlaybackDiagnostic` retains the sanitized HLS evidence and
|
||||
copies its validated HTTP status into the existing top-level `httpStatus`
|
||||
field. External fallback recommendations continue to derive from the final
|
||||
diagnostic code.
|
||||
|
||||
## Adapter Flow
|
||||
|
||||
HTML5 and ArtPlayer both use:
|
||||
|
||||
1. `createHlsPlaybackEvidence(data)`;
|
||||
2. `classifyHlsPlaybackIssue(evidence, sourceMetadata)`;
|
||||
3. emit only when the classifier returns a diagnostic.
|
||||
|
||||
The fatal gate therefore lives in the shared classifier rather than being
|
||||
duplicated in the two adapters. hls.js registers its final error-controller
|
||||
listener during construction, before IPTVnator subscribes, so the application
|
||||
observes the final `fatal` value after retry/alternate handling has had a
|
||||
chance to update it.
|
||||
|
||||
## User Interface
|
||||
|
||||
The existing technical “Error details” row renders a deterministic, safe HLS
|
||||
summary made only from the evidence fields, for example:
|
||||
|
||||
`stage=manifest · failure=http · type=networkError · details=manifestLoadError · disposition=fatal · HTTP 404`
|
||||
|
||||
No HLS error message, URL, header, body, response text, or arbitrary provider
|
||||
payload appears. HLS startup development logs are event-only and omit
|
||||
provider-supplied channel names and source URLs. Existing title/description and
|
||||
HTTP metadata rendering remain unchanged, so no new translation keys or layout
|
||||
changes are required.
|
||||
|
||||
## Testing
|
||||
|
||||
Use test-driven development:
|
||||
|
||||
- Add extractor tests using hls.js 1.6.16-shaped manifest, level, segment, key,
|
||||
media, HTTP, timeout, and unknown payloads.
|
||||
- Prove unrecognized runtime values become `unknown`.
|
||||
- Prove response URLs/text/data, request context/headers, error messages,
|
||||
network details, and credential-shaped payloads do not appear in evidence or
|
||||
rendered details.
|
||||
- Add classifier tests for exact codec, DRM, network, media, and unknown
|
||||
values.
|
||||
- Prove misleading strings cannot override structured engine values.
|
||||
- Prove recoverable events return `null`.
|
||||
- Update HTML5 and ArtPlayer integration tests to cover fatal HTTP evidence and
|
||||
recoverable suppression through the shared path.
|
||||
- Add a focused view test for the deterministic safe HLS summary.
|
||||
|
||||
Run the complete `ui-playback` unit target, its lint target, the web typecheck,
|
||||
i18n drift validation, and release-note validation. No E2E is required because
|
||||
the player workflow and diagnostic controls are unchanged; adapter, classifier,
|
||||
and rendered-detail behavior are covered at their closest unit/component
|
||||
boundaries.
|
||||
|
||||
## Documentation And Release Note
|
||||
|
||||
Update `docs/architecture/embedded-inline-playback.md`, the canonical playback
|
||||
diagnostic description, to replace raw HLS details with the structured
|
||||
contract. Add a `fix(playback)` release note because users receive more accurate
|
||||
HLS diagnoses and safer technical details.
|
||||
@@ -96,8 +96,14 @@ export class MockMpegTsPlayer {
|
||||
|
||||
export const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer());
|
||||
|
||||
const actualHlsModule =
|
||||
jest.requireActual<typeof import('hls.js')>('hls.js');
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({
|
||||
...actualHlsModule,
|
||||
default: MockHls,
|
||||
ErrorDetails: actualHlsModule.ErrorDetails,
|
||||
ErrorTypes: actualHlsModule.ErrorTypes,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
|
||||
@@ -206,6 +206,56 @@ describe('ArtPlayerSourceSession', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports only structured evidence for a fatal HLS manifest HTTP failure', () => {
|
||||
const emitted: PlaybackDiagnostic[] = [];
|
||||
const { session, player, video } = createSession({
|
||||
sharedControls: true,
|
||||
emitPlaybackIssue: (issue) => emitted.push(issue),
|
||||
});
|
||||
const secret = 'art-hls-secret-sentinel';
|
||||
session.attach(player);
|
||||
session.customType['m3u8']?.(
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
);
|
||||
|
||||
hlsInstances[0].emit(MockHls.Events.ERROR, null, {
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError',
|
||||
fatal: true,
|
||||
error: new Error(`provider message ${secret}`),
|
||||
reason: `provider reason ${secret}`,
|
||||
response: {
|
||||
code: 503,
|
||||
url: `https://provider.example/error?token=${secret}`,
|
||||
text: secret,
|
||||
data: { body: secret },
|
||||
},
|
||||
context: {
|
||||
url: `https://provider.example/context?token=${secret}`,
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'network-error',
|
||||
source: 'hls',
|
||||
httpStatus: 503,
|
||||
hls: {
|
||||
engineType: 'networkError',
|
||||
engineDetails: 'manifestLoadError',
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 503,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(emitted[0].hls)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('ignores a delayed customType callback after its player was destroyed', () => {
|
||||
const { session, player, video } = createSession({
|
||||
sharedControls: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createHlsPlaybackEvidence,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import type { WebVideoControlsAdapter } from '../player-controls';
|
||||
@@ -312,22 +313,15 @@ export class ArtPlayerSourceSession {
|
||||
}
|
||||
|
||||
private handleHlsError(url: string, data: ErrorData): void {
|
||||
if (!data.fatal) {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
createHlsPlaybackEvidence(data),
|
||||
this.createSourceMetadata(url, 'application/x-mpegURL')
|
||||
);
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.emitPlaybackIssue(
|
||||
classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: data.type,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
message: data.error?.message,
|
||||
error: data.error,
|
||||
},
|
||||
this.createSourceMetadata(url, 'application/x-mpegURL')
|
||||
)
|
||||
);
|
||||
this.config.emitPlaybackIssue(issue);
|
||||
}
|
||||
|
||||
private createSourceMetadata(
|
||||
|
||||
@@ -75,12 +75,18 @@ class MockHls {
|
||||
static isSupported = jest.fn(() => true);
|
||||
}
|
||||
|
||||
const actualHlsModule =
|
||||
jest.requireActual<typeof import('hls.js')>('hls.js');
|
||||
|
||||
jest.unstable_mockModule('artplayer', () => ({
|
||||
default: MockArtplayer,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({
|
||||
...actualHlsModule,
|
||||
default: MockHls,
|
||||
ErrorDetails: actualHlsModule.ErrorDetails,
|
||||
ErrorTypes: actualHlsModule.ErrorTypes,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
|
||||
@@ -14,12 +14,18 @@ import {
|
||||
resetArtPlayerSpecFixtures,
|
||||
} from './art-player.component.spec-fixtures';
|
||||
|
||||
const actualHlsModule =
|
||||
jest.requireActual<typeof import('hls.js')>('hls.js');
|
||||
|
||||
jest.unstable_mockModule('artplayer', () => ({
|
||||
default: MockArtplayer,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({
|
||||
...actualHlsModule,
|
||||
default: MockHls,
|
||||
ErrorDetails: actualHlsModule.ErrorDetails,
|
||||
ErrorTypes: actualHlsModule.ErrorTypes,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createHlsPlaybackEvidence,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
@@ -55,22 +56,15 @@ export function emitFatalHlsPlaybackError(
|
||||
data: ErrorData,
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
|
||||
): void {
|
||||
if (!data.fatal) {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
createHlsPlaybackEvidence(data),
|
||||
createHtml5SourceMetadata(url, 'application/x-mpegURL')
|
||||
);
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitPlaybackIssue(
|
||||
classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: data.type,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
message: data.error?.message,
|
||||
error: data.error,
|
||||
},
|
||||
createHtml5SourceMetadata(url, 'application/x-mpegURL')
|
||||
)
|
||||
);
|
||||
emitPlaybackIssue(issue);
|
||||
}
|
||||
|
||||
export function emitMpegTsPlaybackError(
|
||||
|
||||
+21
@@ -72,6 +72,27 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
||||
expect(bindIndex).toBeLessThan(loadIndex);
|
||||
});
|
||||
|
||||
it('does not write HLS provider data to development logs', () => {
|
||||
const secret = 'hls-dev-log-secret';
|
||||
const debug = jest
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => undefined);
|
||||
jest.replaceProperty(process.env, 'NODE_ENV', 'development');
|
||||
|
||||
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
|
||||
channel: {
|
||||
...TEST_CHANNEL,
|
||||
name: `Provider channel ${secret}`,
|
||||
url: `https://user:${secret}@provider.example/live.m3u8?token=${secret}`,
|
||||
epgParams: `&epg-token=${secret}`,
|
||||
},
|
||||
});
|
||||
|
||||
const serializedLogs = JSON.stringify(debug.mock.calls);
|
||||
expect(serializedLogs).not.toContain(secret);
|
||||
expect(serializedLogs).not.toContain('provider.example');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'passes authoritative isLive=%s to raw MPEG-TS playback',
|
||||
(isLive) => {
|
||||
|
||||
+9
-1
@@ -111,7 +111,15 @@ export class MockMpegTsPlayer {
|
||||
export const mpegTsCreatePlayer = jest.fn(() => new MockMpegTsPlayer());
|
||||
export const mpegTsIsSupported = jest.fn(() => false);
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({ default: MockHls }));
|
||||
const actualHlsModule =
|
||||
jest.requireActual<typeof import('hls.js')>('hls.js');
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({
|
||||
...actualHlsModule,
|
||||
default: MockHls,
|
||||
ErrorDetails: actualHlsModule.ErrorDetails,
|
||||
ErrorTypes: actualHlsModule.ErrorTypes,
|
||||
}));
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
default: {
|
||||
Events: { ERROR: 'error' },
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
PlayerControlsComponent,
|
||||
WebVideoControlsAdapter,
|
||||
@@ -240,19 +241,11 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
|
||||
(
|
||||
component as unknown as {
|
||||
handleHlsError: (
|
||||
url: string,
|
||||
data: {
|
||||
type: string;
|
||||
details: string;
|
||||
fatal: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
) => void;
|
||||
handleHlsError: (url: string, data: ErrorData) => void;
|
||||
}
|
||||
).handleHlsError('https://example.com/live/playlist.m3u8', {
|
||||
type: 'networkError',
|
||||
details: 'fragLoadError',
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.FRAG_LOAD_ERROR,
|
||||
fatal: false,
|
||||
error: new Error('segment retry'),
|
||||
});
|
||||
@@ -260,36 +253,51 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps raw HLS error object context in emitted playback issue details', () => {
|
||||
const issues: Array<{ details?: string }> = [];
|
||||
it('emits only structured HLS evidence for a fatal manifest HTTP failure', () => {
|
||||
const issues: Array<{
|
||||
readonly code?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly hls?: unknown;
|
||||
}> = [];
|
||||
component.playbackIssue.subscribe((issue) => {
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
const secret = 'html-hls-secret-sentinel';
|
||||
|
||||
(
|
||||
component as unknown as {
|
||||
handleHlsError: (
|
||||
url: string,
|
||||
data: {
|
||||
type: string;
|
||||
details: string;
|
||||
fatal: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
) => void;
|
||||
handleHlsError: (url: string, data: ErrorData) => void;
|
||||
}
|
||||
).handleHlsError('https://example.com/live/playlist.m3u8', {
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError',
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
fatal: true,
|
||||
error: {
|
||||
context: 'xhr setup failed',
|
||||
status: 0,
|
||||
error: new Error(`provider message ${secret}`),
|
||||
reason: `provider reason ${secret}`,
|
||||
response: {
|
||||
code: 404,
|
||||
url: `https://provider.example/error?token=${secret}`,
|
||||
text: secret,
|
||||
data: { body: secret },
|
||||
},
|
||||
networkDetails: { responseText: secret },
|
||||
});
|
||||
|
||||
expect(issues[0].details).toContain('xhr setup failed');
|
||||
expect(issues[0].details).toContain('"status":0');
|
||||
expect(issues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'network-error',
|
||||
httpStatus: 404,
|
||||
hls: {
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(issues[0].hls)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
|
||||
|
||||
@@ -257,7 +257,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
Hls &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
debugHtmlPlayer('Switching channel to:', channel.name, url);
|
||||
debugHtmlPlayer('Starting HLS playback');
|
||||
const hls = new Hls();
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ErrorDetails,
|
||||
ErrorTypes,
|
||||
type ErrorData,
|
||||
type LoaderResponse,
|
||||
} from 'hls.js';
|
||||
import * as diagnostics from './playback-diagnostics.util';
|
||||
|
||||
interface ExpectedHlsPlaybackEvidence {
|
||||
readonly engineType: string;
|
||||
readonly engineDetails: string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly failure: string;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
type EvidenceFactory = (data: ErrorData) => ExpectedHlsPlaybackEvidence;
|
||||
|
||||
describe('HLS playback evidence', () => {
|
||||
it('extracts a fatal manifest HTTP failure from the hls.js response shape', () => {
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
response: createResponse(404),
|
||||
})
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts exact timeout and level-stage evidence', () => {
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.LEVEL_LOAD_TIMEOUT,
|
||||
})
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.LEVEL_LOAD_TIMEOUT,
|
||||
disposition: 'fatal',
|
||||
stage: 'level',
|
||||
failure: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a non-fatal fragment load event as recoverable network evidence', () => {
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.FRAG_LOAD_ERROR,
|
||||
fatal: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.FRAG_LOAD_ERROR,
|
||||
disposition: 'recoverable',
|
||||
stage: 'segment',
|
||||
failure: 'network',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
details: ErrorDetails.MANIFEST_PARSING_ERROR,
|
||||
stage: 'manifest',
|
||||
},
|
||||
{ details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR, stage: 'level' },
|
||||
{ details: ErrorDetails.FRAG_DECRYPT_ERROR, stage: 'segment' },
|
||||
{ details: ErrorDetails.KEY_LOAD_ERROR, stage: 'key' },
|
||||
{ details: ErrorDetails.BUFFER_ADD_CODEC_ERROR, stage: 'media' },
|
||||
{ details: ErrorDetails.INTERNAL_EXCEPTION, stage: 'unknown' },
|
||||
])('maps $details to the exact $stage stage', ({ details, stage }) => {
|
||||
expect(createEvidence(createErrorData({ details })).stage).toBe(stage);
|
||||
});
|
||||
|
||||
it('keeps a successful response status as evidence without calling it an HTTP failure', () => {
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.MANIFEST_PARSING_ERROR,
|
||||
response: createResponse(200),
|
||||
})
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBe(200);
|
||||
expect(evidence.failure).toBe('network');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 99, expected: undefined },
|
||||
{ status: 100, expected: 100 },
|
||||
{ status: 599, expected: 599 },
|
||||
{ status: 600, expected: undefined },
|
||||
{ status: 404.5, expected: undefined },
|
||||
])(
|
||||
'retains HTTP status $status only inside the protocol range',
|
||||
({ status, expected }) => {
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
response: createResponse(status),
|
||||
})
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses unknown for unrecognized runtime engine identifiers', () => {
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
type: 'providerNetworkCodecError' as ErrorTypes,
|
||||
details: 'providerStatusCodecFailure' as ErrorDetails,
|
||||
})
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
engineType: 'unknown',
|
||||
engineDetails: ErrorDetails.UNKNOWN,
|
||||
disposition: 'fatal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops URLs, headers, bodies, credentials, and arbitrary provider payloads', () => {
|
||||
const secret = 'evidence-secret-sentinel';
|
||||
const evidence = createEvidence(
|
||||
createErrorData({
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
error: new Error(`provider message ${secret}`),
|
||||
reason: `provider reason ${secret}`,
|
||||
url: `https://user:${secret}@provider.example/manifest.m3u8`,
|
||||
response: {
|
||||
code: 503,
|
||||
url: `https://provider.example/error?token=${secret}`,
|
||||
text: `response text ${secret}`,
|
||||
data: { body: secret },
|
||||
},
|
||||
context: {
|
||||
url: `https://provider.example/context?token=${secret}`,
|
||||
responseType: 'text',
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
} as ErrorData['context'],
|
||||
networkDetails: {
|
||||
responseURL: `https://provider.example/xhr?token=${secret}`,
|
||||
responseText: secret,
|
||||
},
|
||||
})
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
expect(evidence.httpStatus).toBe(503);
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('response text');
|
||||
expect(Object.keys(evidence).sort()).toEqual(
|
||||
[
|
||||
'disposition',
|
||||
'engineDetails',
|
||||
'engineType',
|
||||
'failure',
|
||||
'httpStatus',
|
||||
'stage',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createEvidence(data: ErrorData): ExpectedHlsPlaybackEvidence {
|
||||
const factory = (
|
||||
diagnostics as unknown as {
|
||||
readonly createHlsPlaybackEvidence?: EvidenceFactory;
|
||||
}
|
||||
).createHlsPlaybackEvidence;
|
||||
|
||||
expect(factory).toBeDefined();
|
||||
if (!factory) {
|
||||
throw new Error('createHlsPlaybackEvidence is not exported');
|
||||
}
|
||||
return factory(data);
|
||||
}
|
||||
|
||||
function createErrorData(overrides: Partial<ErrorData> = {}): ErrorData {
|
||||
return {
|
||||
type: ErrorTypes.OTHER_ERROR,
|
||||
details: ErrorDetails.UNKNOWN,
|
||||
error: new Error('provider payload must not survive'),
|
||||
fatal: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createResponse(code: number): LoaderResponse {
|
||||
return {
|
||||
url: 'https://provider.example/response-must-not-survive',
|
||||
code,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
ErrorDetails,
|
||||
ErrorTypes,
|
||||
type ErrorData,
|
||||
} from 'hls.js';
|
||||
import {
|
||||
type HlsPlaybackEngineType,
|
||||
type HlsPlaybackEvidence,
|
||||
HlsPlaybackDisposition,
|
||||
HlsPlaybackFailure,
|
||||
HlsPlaybackStage,
|
||||
HlsPlaybackUnknownEngineType,
|
||||
type PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticCode as DiagnosticCode,
|
||||
} from './playback-diagnostics.model';
|
||||
|
||||
const ENGINE_TYPES = new Set<string>(Object.values(ErrorTypes));
|
||||
const ENGINE_DETAILS = new Set<string>(Object.values(ErrorDetails));
|
||||
|
||||
const MANIFEST_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
ErrorDetails.MANIFEST_LOAD_TIMEOUT,
|
||||
ErrorDetails.MANIFEST_PARSING_ERROR,
|
||||
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
|
||||
]);
|
||||
|
||||
const LEVEL_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.LEVEL_EMPTY_ERROR,
|
||||
ErrorDetails.LEVEL_LOAD_ERROR,
|
||||
ErrorDetails.LEVEL_LOAD_TIMEOUT,
|
||||
ErrorDetails.LEVEL_PARSING_ERROR,
|
||||
ErrorDetails.LEVEL_SWITCH_ERROR,
|
||||
ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
|
||||
ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT,
|
||||
ErrorDetails.SUBTITLE_LOAD_ERROR,
|
||||
ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT,
|
||||
]);
|
||||
|
||||
const SEGMENT_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.FRAG_LOAD_ERROR,
|
||||
ErrorDetails.FRAG_LOAD_TIMEOUT,
|
||||
ErrorDetails.FRAG_DECRYPT_ERROR,
|
||||
ErrorDetails.FRAG_PARSING_ERROR,
|
||||
ErrorDetails.FRAG_GAP,
|
||||
]);
|
||||
|
||||
const KEY_SYSTEM_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.KEY_SYSTEM_NO_KEYS,
|
||||
ErrorDetails.KEY_SYSTEM_NO_ACCESS,
|
||||
ErrorDetails.KEY_SYSTEM_NO_SESSION,
|
||||
ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE,
|
||||
ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
|
||||
ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED,
|
||||
ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED,
|
||||
ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED,
|
||||
ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED,
|
||||
ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR,
|
||||
ErrorDetails.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR,
|
||||
ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR,
|
||||
ErrorDetails.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR,
|
||||
]);
|
||||
|
||||
const KEY_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.KEY_LOAD_ERROR,
|
||||
ErrorDetails.KEY_LOAD_TIMEOUT,
|
||||
...KEY_SYSTEM_DETAILS,
|
||||
]);
|
||||
|
||||
const MEDIA_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.REMUX_ALLOC_ERROR,
|
||||
ErrorDetails.BUFFER_ADD_CODEC_ERROR,
|
||||
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
|
||||
ErrorDetails.BUFFER_APPEND_ERROR,
|
||||
ErrorDetails.BUFFER_APPENDING_ERROR,
|
||||
ErrorDetails.BUFFER_STALLED_ERROR,
|
||||
ErrorDetails.BUFFER_FULL_ERROR,
|
||||
ErrorDetails.BUFFER_SEEK_OVER_HOLE,
|
||||
ErrorDetails.BUFFER_NUDGE_ON_STALL,
|
||||
ErrorDetails.ATTACH_MEDIA_ERROR,
|
||||
]);
|
||||
|
||||
const TIMEOUT_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.MANIFEST_LOAD_TIMEOUT,
|
||||
ErrorDetails.LEVEL_LOAD_TIMEOUT,
|
||||
ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT,
|
||||
ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT,
|
||||
ErrorDetails.FRAG_LOAD_TIMEOUT,
|
||||
ErrorDetails.KEY_LOAD_TIMEOUT,
|
||||
ErrorDetails.ASSET_LIST_LOAD_TIMEOUT,
|
||||
]);
|
||||
|
||||
const NETWORK_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
ErrorDetails.LEVEL_LOAD_ERROR,
|
||||
ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
|
||||
ErrorDetails.SUBTITLE_LOAD_ERROR,
|
||||
ErrorDetails.FRAG_LOAD_ERROR,
|
||||
ErrorDetails.KEY_LOAD_ERROR,
|
||||
ErrorDetails.ASSET_LIST_LOAD_ERROR,
|
||||
]);
|
||||
|
||||
const CODEC_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
|
||||
ErrorDetails.BUFFER_ADD_CODEC_ERROR,
|
||||
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
|
||||
]);
|
||||
|
||||
const DRM_DETAILS = new Set<ErrorDetails>([
|
||||
ErrorDetails.FRAG_DECRYPT_ERROR,
|
||||
...KEY_SYSTEM_DETAILS,
|
||||
]);
|
||||
|
||||
export function createHlsPlaybackEvidence(
|
||||
data: ErrorData
|
||||
): HlsPlaybackEvidence {
|
||||
const engineType = getEngineType(data.type);
|
||||
const engineDetails = getEngineDetails(data.details);
|
||||
const httpStatus = getHttpStatus(data.response?.code);
|
||||
const evidence: HlsPlaybackEvidence = {
|
||||
engineType,
|
||||
engineDetails,
|
||||
disposition:
|
||||
data.fatal === true
|
||||
? HlsPlaybackDisposition.Fatal
|
||||
: HlsPlaybackDisposition.Recoverable,
|
||||
stage: getStage(engineDetails),
|
||||
failure: getFailure(engineType, engineDetails, httpStatus),
|
||||
};
|
||||
|
||||
return httpStatus === undefined
|
||||
? evidence
|
||||
: { ...evidence, httpStatus };
|
||||
}
|
||||
|
||||
export function getHlsPlaybackDiagnosticCode(
|
||||
evidence: HlsPlaybackEvidence
|
||||
): PlaybackDiagnosticCode {
|
||||
if (CODEC_DETAILS.has(evidence.engineDetails)) {
|
||||
return DiagnosticCode.UnsupportedCodec;
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.engineType === ErrorTypes.KEY_SYSTEM_ERROR ||
|
||||
DRM_DETAILS.has(evidence.engineDetails)
|
||||
) {
|
||||
return DiagnosticCode.DrmOrEncryption;
|
||||
}
|
||||
|
||||
if (evidence.failure === HlsPlaybackFailure.Access) {
|
||||
return DiagnosticCode.BrowserAccessError;
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.engineType === ErrorTypes.NETWORK_ERROR ||
|
||||
evidence.failure === HlsPlaybackFailure.Http ||
|
||||
evidence.failure === HlsPlaybackFailure.Timeout ||
|
||||
evidence.failure === HlsPlaybackFailure.Network
|
||||
) {
|
||||
return DiagnosticCode.NetworkError;
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.engineType === ErrorTypes.MEDIA_ERROR ||
|
||||
evidence.engineType === ErrorTypes.MUX_ERROR
|
||||
) {
|
||||
return DiagnosticCode.MediaDecodeError;
|
||||
}
|
||||
|
||||
return DiagnosticCode.UnknownPlaybackError;
|
||||
}
|
||||
|
||||
function getEngineType(value: unknown): HlsPlaybackEngineType {
|
||||
return typeof value === 'string' && ENGINE_TYPES.has(value)
|
||||
? (value as ErrorTypes)
|
||||
: HlsPlaybackUnknownEngineType;
|
||||
}
|
||||
|
||||
function getEngineDetails(value: unknown): ErrorDetails {
|
||||
return typeof value === 'string' && ENGINE_DETAILS.has(value)
|
||||
? (value as ErrorDetails)
|
||||
: ErrorDetails.UNKNOWN;
|
||||
}
|
||||
|
||||
function getHttpStatus(value: unknown): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 100 &&
|
||||
value <= 599
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getStage(details: ErrorDetails): HlsPlaybackEvidence['stage'] {
|
||||
if (MANIFEST_DETAILS.has(details)) {
|
||||
return HlsPlaybackStage.Manifest;
|
||||
}
|
||||
if (LEVEL_DETAILS.has(details)) {
|
||||
return HlsPlaybackStage.Level;
|
||||
}
|
||||
if (SEGMENT_DETAILS.has(details)) {
|
||||
return HlsPlaybackStage.Segment;
|
||||
}
|
||||
if (KEY_DETAILS.has(details)) {
|
||||
return HlsPlaybackStage.Key;
|
||||
}
|
||||
if (MEDIA_DETAILS.has(details)) {
|
||||
return HlsPlaybackStage.Media;
|
||||
}
|
||||
return HlsPlaybackStage.Unknown;
|
||||
}
|
||||
|
||||
function getFailure(
|
||||
engineType: HlsPlaybackEngineType,
|
||||
details: ErrorDetails,
|
||||
httpStatus: number | undefined
|
||||
): HlsPlaybackEvidence['failure'] {
|
||||
if (TIMEOUT_DETAILS.has(details)) {
|
||||
return HlsPlaybackFailure.Timeout;
|
||||
}
|
||||
if (
|
||||
httpStatus !== undefined &&
|
||||
httpStatus >= 400 &&
|
||||
httpStatus <= 599
|
||||
) {
|
||||
return HlsPlaybackFailure.Http;
|
||||
}
|
||||
if (
|
||||
engineType === ErrorTypes.NETWORK_ERROR ||
|
||||
NETWORK_DETAILS.has(details)
|
||||
) {
|
||||
return HlsPlaybackFailure.Network;
|
||||
}
|
||||
return HlsPlaybackFailure.Unknown;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ExternalPlayerName,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
|
||||
export const PlaybackDiagnosticCode = {
|
||||
UnsupportedContainer: 'unsupported-container',
|
||||
@@ -65,14 +66,50 @@ export interface NativePlaybackErrorInput {
|
||||
readonly metadata?: NativePlaybackErrorMetadataInput;
|
||||
}
|
||||
|
||||
export interface HlsPlaybackErrorInput {
|
||||
readonly type?: string;
|
||||
readonly details?: string;
|
||||
readonly fatal?: boolean;
|
||||
readonly message?: string;
|
||||
readonly error?: unknown;
|
||||
readonly audioCodecs?: readonly string[];
|
||||
readonly videoCodecs?: readonly string[];
|
||||
export const HlsPlaybackDisposition = {
|
||||
Fatal: 'fatal',
|
||||
Recoverable: 'recoverable',
|
||||
} as const;
|
||||
|
||||
export type HlsPlaybackDisposition =
|
||||
(typeof HlsPlaybackDisposition)[keyof typeof HlsPlaybackDisposition];
|
||||
|
||||
export const HlsPlaybackStage = {
|
||||
Manifest: 'manifest',
|
||||
Level: 'level',
|
||||
Segment: 'segment',
|
||||
Key: 'key',
|
||||
Media: 'media',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type HlsPlaybackStage =
|
||||
(typeof HlsPlaybackStage)[keyof typeof HlsPlaybackStage];
|
||||
|
||||
export const HlsPlaybackFailure = {
|
||||
Http: 'http',
|
||||
Timeout: 'timeout',
|
||||
Network: 'network',
|
||||
Access: 'access',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type HlsPlaybackFailure =
|
||||
(typeof HlsPlaybackFailure)[keyof typeof HlsPlaybackFailure];
|
||||
|
||||
export const HlsPlaybackUnknownEngineType = 'unknown' as const;
|
||||
|
||||
export type HlsPlaybackEngineType =
|
||||
| ErrorTypes
|
||||
| typeof HlsPlaybackUnknownEngineType;
|
||||
|
||||
export interface HlsPlaybackEvidence {
|
||||
readonly engineType: HlsPlaybackEngineType;
|
||||
readonly engineDetails: ErrorDetails;
|
||||
readonly disposition: HlsPlaybackDisposition;
|
||||
readonly stage: HlsPlaybackStage;
|
||||
readonly failure: HlsPlaybackFailure;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
export interface MpegTsPlaybackErrorInput {
|
||||
@@ -96,6 +133,7 @@ export interface PlaybackDiagnostic {
|
||||
readonly nativeErrorMessage?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly nativeErrorType?: string;
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly externalFallbackRecommended: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
import {
|
||||
PlaybackDiagnosticCode,
|
||||
classifyHlsPlaybackIssue,
|
||||
@@ -8,13 +9,32 @@ import {
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from './playback-diagnostics.util';
|
||||
|
||||
interface StructuredHlsEvidenceInput {
|
||||
readonly engineType: string;
|
||||
readonly engineDetails: string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly failure: string;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
function classifyStructuredHlsPlaybackIssue(
|
||||
evidence: StructuredHlsEvidenceInput,
|
||||
metadata: Parameters<typeof classifyHlsPlaybackIssue>[1]
|
||||
): ReturnType<typeof classifyHlsPlaybackIssue> {
|
||||
return classifyHlsPlaybackIssue(evidence as never, metadata);
|
||||
}
|
||||
|
||||
describe('playback diagnostics', () => {
|
||||
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
type: 'mediaError',
|
||||
details: 'manifestIncompatibleCodecsError',
|
||||
fatal: true,
|
||||
engineType: ErrorTypes.MEDIA_ERROR,
|
||||
engineDetails:
|
||||
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
@@ -25,18 +45,21 @@ describe('playback diagnostics', () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
expect(issue.audioCodecs).toEqual(['ac-3']);
|
||||
expect(issue.videoCodecs).toEqual(['avc1.64001f']);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
||||
expect(issue?.audioCodecs).toEqual(['ac-3']);
|
||||
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
|
||||
});
|
||||
|
||||
it('classifies HLS buffer codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
type: 'mediaError',
|
||||
details: 'bufferIncompatibleCodecsError',
|
||||
fatal: true,
|
||||
engineType: ErrorTypes.MEDIA_ERROR,
|
||||
engineDetails:
|
||||
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'media',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
@@ -44,8 +67,8 @@ describe('playback diagnostics', () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies native decode and unsupported source errors without using network wording', () => {
|
||||
@@ -204,11 +227,13 @@ describe('playback diagnostics', () => {
|
||||
});
|
||||
|
||||
it('classifies HLS network errors without claiming codec incompatibility', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError',
|
||||
fatal: true,
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'network',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
@@ -216,16 +241,51 @@ describe('playback diagnostics', () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat provider-side blocked messages as browser access errors', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
it('retains structured HLS HTTP and stage evidence', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError Request blocked by rate limiter',
|
||||
fatal: true,
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
httpStatus: 404,
|
||||
hls: expect.objectContaining({
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
}),
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create terminal diagnostics for recoverable HLS events', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.FRAG_LOAD_ERROR,
|
||||
disposition: 'recoverable',
|
||||
stage: 'segment',
|
||||
failure: 'network',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live/index.m3u8',
|
||||
@@ -233,65 +293,109 @@ describe('playback diagnostics', () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
expect(issue).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps raw HLS error object context in diagnostic details', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
it('keeps status-zero HLS failures as network evidence rather than guessing access', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError',
|
||||
fatal: true,
|
||||
error: {
|
||||
context: 'xhr setup failed',
|
||||
status: 0,
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'network',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'http://provider.example/live.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue?.httpStatus).toBeUndefined();
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: ErrorTypes.MEDIA_ERROR,
|
||||
engineDetails: ErrorDetails.FRAG_DECRYPT_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'segment',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies exact HLS key load failures as network errors', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.KEY_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'key',
|
||||
failure: 'network',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live.m3u8',
|
||||
player: 'artplayer',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
|
||||
'classifies exact HLS %s evidence as a media decode error',
|
||||
(engineType) => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType,
|
||||
engineDetails: ErrorDetails.FRAG_PARSING_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'segment',
|
||||
failure: 'unknown',
|
||||
},
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.details).toContain('xhr setup failed');
|
||||
expect(issue.details).toContain('"status":0');
|
||||
});
|
||||
expect(issue?.code).toBe(
|
||||
PlaybackDiagnosticCode.MediaDecodeError
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('classifies HLS browser access blocks separately from provider network failures', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
it('keeps unknown structured HLS evidence unknown', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
type: 'networkError',
|
||||
details:
|
||||
'manifestLoadError Mixed Content: The page at https://app.example was loaded over HTTPS, but requested an insecure stream http://provider.example/live.m3u8. This request has been blocked.',
|
||||
fatal: true,
|
||||
engineType: 'unknown',
|
||||
engineDetails: ErrorDetails.UNKNOWN,
|
||||
disposition: 'fatal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'http://provider.example/live.m3u8',
|
||||
player: 'videojs',
|
||||
url: 'https://provider.example/live.m3u8?codec=network',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe('browser-access-error');
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies browser security policy blocks as browser access errors', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: 'networkError',
|
||||
details:
|
||||
'manifestLoadError Refused to connect because it violates the following Content Security Policy directive: "connect-src"',
|
||||
fatal: true,
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'http://provider.example/live.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
expect(issue?.code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies native CORS failures as browser access errors', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
HlsPlaybackErrorInput,
|
||||
HlsPlaybackEvidence,
|
||||
MpegTsPlaybackErrorInput,
|
||||
NativePlaybackErrorInput,
|
||||
PlaybackDiagnostic,
|
||||
@@ -8,21 +8,19 @@ import type {
|
||||
PlaybackSourceMetadata,
|
||||
} from './playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model';
|
||||
import { HlsPlaybackDisposition } from './playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticSource as DiagnosticSource } from './playback-diagnostics.model';
|
||||
import { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util';
|
||||
import {
|
||||
isBrowserAccessFailure,
|
||||
isCodecFailure,
|
||||
isDrmOrEncryptionFailure,
|
||||
isEarlyEofFailure,
|
||||
isNetworkFailure,
|
||||
normalizeErrorDetails,
|
||||
} from './playback-error-patterns.util';
|
||||
import {
|
||||
isLikelyContainerIssue,
|
||||
mergeCodecMetadata,
|
||||
} from './playback-media-source.util';
|
||||
import { isLikelyContainerIssue } from './playback-media-source.util';
|
||||
|
||||
export * from './playback-diagnostics.model';
|
||||
export { createHlsPlaybackEvidence } from './hls-playback-evidence.util';
|
||||
export {
|
||||
createPlaybackSourceMetadata,
|
||||
getLikelyBrowserUnsupportedCodecLabels,
|
||||
@@ -110,60 +108,19 @@ export function classifyNativePlaybackIssue(
|
||||
}
|
||||
|
||||
export function classifyHlsPlaybackIssue(
|
||||
error: HlsPlaybackErrorInput,
|
||||
evidence: HlsPlaybackEvidence,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const details = normalizeErrorDetails(error);
|
||||
const lowerDetails = details.toLowerCase();
|
||||
const lowerType = (error.type ?? '').toLowerCase();
|
||||
const mergedMetadata = mergeCodecMetadata(metadata, {
|
||||
audioCodecs: error.audioCodecs,
|
||||
videoCodecs: error.videoCodecs,
|
||||
});
|
||||
|
||||
if (isNetworkFailure(lowerType, lowerDetails)) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerDetails)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDrmOrEncryptionFailure(lowerDetails)) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCodecFailure(lowerDetails)) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedCodec,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowerType.includes('media') || lowerType.includes('mux')) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
): PlaybackDiagnostic | null {
|
||||
if (evidence.disposition === HlsPlaybackDisposition.Recoverable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
code: getHlsPlaybackDiagnosticCode(evidence),
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
metadata,
|
||||
httpStatus: evidence.httpStatus,
|
||||
hls: evidence,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,6 +218,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
readonly nativeErrorMessage?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly nativeErrorType?: string;
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
/** Overrides the code-derived recommendation, e.g. when external players
|
||||
* are known to be unable to handle the stream either. */
|
||||
readonly externalFallbackRecommended?: boolean;
|
||||
@@ -274,6 +232,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
nativeErrorMessage,
|
||||
httpStatus,
|
||||
nativeErrorType,
|
||||
hls,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
@@ -290,6 +249,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
nativeErrorMessage,
|
||||
httpStatus,
|
||||
nativeErrorType,
|
||||
hls,
|
||||
externalFallbackRecommended:
|
||||
options.externalFallbackRecommended ??
|
||||
isExternalFallbackRecommended(code),
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import type {
|
||||
HlsPlaybackErrorInput,
|
||||
MpegTsPlaybackErrorInput,
|
||||
} from './playback-diagnostics.model';
|
||||
import type { MpegTsPlaybackErrorInput } from './playback-diagnostics.model';
|
||||
|
||||
export function normalizeErrorDetails(
|
||||
error: HlsPlaybackErrorInput | MpegTsPlaybackErrorInput
|
||||
error: MpegTsPlaybackErrorInput
|
||||
): string {
|
||||
const hlsError = 'error' in error ? error.error : undefined;
|
||||
const mpegTsInfo = 'info' in error ? error.info : undefined;
|
||||
const errorMessage = normalizeErrorPayload(hlsError);
|
||||
const info = normalizeErrorPayload(mpegTsInfo);
|
||||
const info = normalizeErrorPayload(error.info);
|
||||
|
||||
return [error.details, error.message, errorMessage, info]
|
||||
return [error.details, error.message, info]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
@@ -92,6 +92,21 @@ export function getDiagnosticDetails(
|
||||
}
|
||||
|
||||
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
|
||||
if (issue.hls) {
|
||||
return [
|
||||
`stage=${issue.hls.stage}`,
|
||||
`failure=${issue.hls.failure}`,
|
||||
`type=${issue.hls.engineType}`,
|
||||
`details=${issue.hls.engineDetails}`,
|
||||
`disposition=${issue.hls.disposition}`,
|
||||
issue.hls.httpStatus === undefined
|
||||
? ''
|
||||
: `HTTP ${issue.hls.httpStatus}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return [
|
||||
issue.httpStatus !== undefined ? `HTTP ${issue.httpStatus}` : '',
|
||||
issue.nativeErrorType ?? '',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
import {
|
||||
PlaybackDiagnostic,
|
||||
@@ -317,6 +318,30 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only sanitized structured HLS evidence in technical details', () => {
|
||||
const issue = createStructuredHlsDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = component.getDiagnosticDetails(issue);
|
||||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||||
|
||||
expect(details).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||||
value:
|
||||
'stage=manifest · failure=http · ' +
|
||||
'type=networkError · details=manifestLoadError · ' +
|
||||
'disposition=fatal · HTTP 404',
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(renderedDetails).not.toContain('diagnostic-url-secret');
|
||||
expect(renderedDetails).not.toContain('provider.example');
|
||||
});
|
||||
|
||||
it('keeps query-declared HLS streams on the HLS mime type', () => {
|
||||
const streamUrl =
|
||||
'https://example.com/play?extension=m3u8&token=signed';
|
||||
@@ -877,3 +902,27 @@ function createHttpDiagnostic(): PlaybackDiagnostic {
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Hls,
|
||||
sourceUrl:
|
||||
'https://provider.example/live.m3u8?token=diagnostic-url-secret',
|
||||
container: 'm3u8',
|
||||
mimeType: 'application/x-mpegURL',
|
||||
player: 'html5',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
httpStatus: 404,
|
||||
hls: {
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user