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:
4gray authored and GitHub committed 2026-07-31 07:33:05 +02:00
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.
+23 -2
View File
@@ -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(
@@ -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) => {
@@ -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,
};
}