fix(playback): structure Video.js diagnostics (#1317)

This commit is contained in:
4gray authored and GitHub committed 2026-07-31 09:15:49 +02:00
1 parent 46c7713841
commit 9a50e7385b
15 files changed
+1837 -61

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: playback
---
The default web player now reports safer, more accurate streaming errors:
confirmed network and encrypted-segment failures keep structured details,
while ambiguous Video.js errors remain unknown instead of suggesting the
wrong cause.
+41 -1
View File
@@ -335,6 +335,38 @@ 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.
Video.js `8.23.9`, the default web player, runs HTTP streaming through bundled
VHS `3.17.5`. Its terminal `Player#error` crosses a separate allowlisted
boundary built only from the public Video.js `MediaError` code/status,
`metadata.errorType`, and the documented `player.tech().vhs` runtime property.
The engine type must exactly match an installed `videojs.Error` value;
unrecognized values remain unknown. Exact network identifiers, HTTP 4xx/5xx
status, and standard network code 2 produce `network-error`; exact
`streamingfailedtodecryptsegment` or standard encrypted code 5 produce
`drm-or-encryption`. A generic VHS code 3 remains
`unknown-playback-error` because VHS also assigns code 3 to terminal internal
objects and strings that do not establish a decode cause. Non-VHS native code
3 keeps its standard media-decode meaning.
VHS stage evidence is derived only where the public engine identifier names
the operation: HLS playlist parsing is `playlist`, DASH manifest parsing is
`manifest`, and select/decrypt/transmux/append segment errors are `segment`;
everything else is `unknown`. In particular, IPTVnator does not read internal
`requestType` values to guess manifest, playlist, segment, or key stages. VHS
handles retries, rendition exclusions/re-inclusions, segment timeout recovery,
and request aborts before a final player error; IPTVnator observes only the
public terminal event and does not subscribe to undocumented recovery events
or private loaders.
Video.js/VHS error messages, request or response URLs, headers, xhr objects,
response text/bodies, credentials, request types, and arbitrary metadata are
neither retained nor rendered. Technical details contain only the sanitized
stage, exact/unknown engine type, standard/unknown media error code, terminal
disposition, and validated HTTP status. The active playback URL remains
available only through the pre-existing playback metadata used by Retry, Copy
URL, and explicit external-player actions; it is never copied from VHS error
metadata.
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
@@ -360,7 +392,15 @@ they do not include provider-supplied channel names or source URLs.
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, 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.
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 Video.js/VHS and 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,518 @@
# Structured Video.js/VHS 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:** Add a safe, exact-value Video.js/VHS evidence contract for the
default web player without depending on private VHS internals.
**Architecture:** Read the public Video.js `MediaError` inside the existing
`Player#error` listener, detect active VHS through the documented
`player.tech().vhs` runtime property, and sanitize the error into a small
allowlisted evidence value. Classify only confirmed public values, keep
generic non-VHS Video.js errors on the native path, and render only the
sanitized evidence.
**Tech Stack:** Angular 21, TypeScript 5.9, Video.js 8.23.9, VHS 3.17.5, Jest
through Nx, Markdown architecture and release-note documentation.
---
### Task 0: Establish The Evidence And Baseline
**Files:**
- Verify: `package.json`
- Verify: `pnpm-lock.yaml`
- Verify:
`node_modules/.pnpm/video.js@8.23.9/node_modules/video.js/dist/types/media-error.d.ts`
- Verify:
`node_modules/.pnpm/@videojs+http-streaming@3.17.5_video.js@8.23.9/node_modules/@videojs/http-streaming/README.md`
- Verify:
`node_modules/.pnpm/@videojs+http-streaming@3.17.5_video.js@8.23.9/node_modules/@videojs/http-streaming/src/videojs-http-streaming.js`
- [x] **Step 1: Install locked dependencies**
Run:
```bash
pnpm install --frozen-lockfile
```
Expected: exit 0 without changing `pnpm-lock.yaml`.
- [x] **Step 2: Verify Nx workspace discovery**
Run:
```bash
pnpm nx show projects
```
Expected: exit 0 and output containing `ui-playback`, `web`, and `web-e2e`.
- [x] **Step 3: Run the affected-project baseline**
Run:
```bash
pnpm nx test ui-playback
```
Expected: 85 suites and 765 tests pass before implementation.
- [x] **Step 4: Audit exact installed and upstream versions**
Confirm:
```text
video.js = 8.23.9
@videojs/http-streaming = 3.17.5
Video.js v8.23.9 tag = 81b3cb429fae8dd00659ac5d3b0b1d2d20a283cb
VHS v3.17.5 tag = a9f9d7ac0264b373f14da1bb2f2e7fe8f2775c4f
```
Read the public Video.js `MediaError` and player error API, the VHS README
runtime properties/events, and the tagged upstream error/recovery tests.
Reject private request/loaders and undocumented retry/exclusion events from
the production design.
### Task 1: Drive The VHS Evidence Boundary From Failing Tests
**Files:**
- Create:
`libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts`
- Create:
`libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts`
- Modify:
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts`
- Modify:
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts`
- Modify:
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts`
- [ ] **Step 1: Write the failing allowlist and sanitizer tests**
In `vhs-playback-evidence.util.spec.ts`, import the actual installed Video.js
runtime and the wished-for boundary:
```typescript
import videoJs from 'video.js';
import {
VhsPlaybackEngineType,
createVhsPlaybackEvidence,
} from './vhs-playback-evidence.util';
it('matches the installed public videojs.Error identifiers', () => {
expect(Object.values(VhsPlaybackEngineType).sort()).toEqual(
Object.values(videoJs.Error).sort()
);
});
```
Add installed-runtime-shaped inputs for:
```typescript
const unsafeError = {
code: 4,
status: 503,
message:
'HLS playlist request error at URL: ' +
'https://provider.example/live.m3u8?token=secret',
metadata: {
errorType: videoJs.Error.NetworkBadStatus,
requestType: 'hls-playlist',
uri: 'https://provider.example/live.m3u8?token=secret',
headers: { Authorization: 'Bearer secret' },
responseText: 'provider body secret',
},
};
```
Expect only:
```typescript
{
engineType: 'networkbadstatus',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 503,
}
```
Serialize the evidence and prove it contains none of the URL, token, header,
body, message, request type, or arbitrary metadata sentinels.
Cover:
- every public `videojs.Error` value;
- unknown and malformed error types;
- standard code boundaries 0/5 and invalid values;
- HTTP boundaries 399/400/599/600 and non-integers;
- exact HLS playlist, DASH manifest, and segment-operation stage mappings.
- [ ] **Step 2: Write failing classifier tests**
In `playback-diagnostics.util.spec.ts`, add wished-for
`classifyVhsPlaybackIssue` cases:
```typescript
expect(classifyVhsPlaybackIssue(networkError, metadata)).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
httpStatus: 503,
externalFallbackRecommended: false,
})
);
```
Add regressions proving:
- exact network types classify as `network-error` without message matching;
- exact `streamingfailedtodecryptsegment` classifies as
`drm-or-encryption`;
- standard code 5 classifies as `drm-or-encryption`;
- generic VHS code 3 stays `unknown-playback-error`;
- unknown provider values and misleading messages stay unknown;
- the diagnostic contains no `nativeErrorMessage`;
- the top-level status and structured evidence are retained.
Keep the existing generic native code-3 expectation 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/vhs-playback-evidence.util.spec.ts \
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts \
--runInBand
```
Expected: FAIL because the VHS evidence model, extractor, source, and
classifier do not exist.
- [ ] **Step 4: Add the minimal evidence model**
In `playback-diagnostics.model.ts`, add const objects and extracted types:
```typescript
export const VhsPlaybackDisposition = {
Terminal: 'terminal',
} as const;
export const VhsPlaybackStage = {
Manifest: 'manifest',
Playlist: 'playlist',
Segment: 'segment',
Unknown: 'unknown',
} as const;
export const VhsPlaybackMediaErrorCode = {
Custom: 0,
Aborted: 1,
Network: 2,
Decode: 3,
SourceNotSupported: 4,
Encrypted: 5,
Unknown: 'unknown',
} as const;
```
Define `VhsPlaybackEngineType`, including an `unknown` fallback, and
`VhsPlaybackEvidence` with engine type, validated media error code, terminal
disposition, stage, and optional status. Add:
```typescript
readonly vhs?: VhsPlaybackEvidence;
```
to `PlaybackDiagnostic`, and add `Vhs: 'vhs'` to
`PlaybackDiagnosticSource`.
- [ ] **Step 5: Implement the allowlisted extractor**
In `vhs-playback-evidence.util.ts`:
- define one const object containing the exact installed public
`videojs.Error` strings;
- validate `metadata.errorType` through a readonly set;
- validate only standard error codes 0 through 5;
- validate only integer HTTP status 400 through 599;
- map only exact public parser/segment identifiers to stages;
- return a fresh object containing only the evidence fields;
- never read the message or any metadata key other than `errorType`.
- [ ] **Step 6: Implement exact classification**
In `playback-diagnostics.util.ts`, add:
```typescript
export function classifyVhsPlaybackIssue(
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic
```
Create evidence once, then classify by validated HTTP status, exact network
types, standard network code, exact decrypt type, standard encrypted code, or
known unsupported container. Keep all remaining evidence unknown. Store the
evidence on the diagnostic, copy its status into the existing `httpStatus`,
copy its validated code into `nativeErrorCode`, and do not copy the error
message or arbitrary metadata.
- [ ] **Step 7: Run focused tests to verify GREEN**
Run the command from step 3.
Expected: PASS for allowlisting, privacy, exact classification, code-3
unknown behavior, and unchanged native tests.
### Task 2: Route Only Active VHS Errors Through The Boundary
**Files:**
- Modify:
`libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts`
- Modify:
`libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts`
- Modify:
`libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts`
- Modify:
`libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts`
- [ ] **Step 1: Write failing active-VHS detection tests**
In `vjs-player.types.spec.ts`, add expectations for a wished-for
`hasActiveVhsSourceHandler` helper:
```typescript
expect(hasActiveVhsSourceHandler(playerWithVhs)).toBe(true);
expect(hasActiveVhsSourceHandler(playerWithoutVhs)).toBe(false);
expect(hasActiveVhsSourceHandler(throwingPlayer)).toBe(false);
```
The helper may inspect only the documented `player.tech().vhs` property.
- [ ] **Step 2: Write failing component routing regressions**
Extend the player harness with an optional `vhs` object on the current tech.
Add tests proving:
- active VHS + real network error shape emits a structured VHS network
diagnostic;
- the unsafe VHS message and metadata are absent;
- active VHS + generic code 3 emits unknown;
- no VHS + native code 3 retains `media-decode-error`;
- a populated `player.error()` still wins over `video.error`.
- [ ] **Step 3: Run the focused component 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/vjs-player/vjs-player.types.spec.ts \
libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts \
--runInBand
```
Expected: FAIL because active VHS detection and routing do not exist.
- [ ] **Step 4: Implement the public runtime check and routing**
Add a guarded helper in `vjs-player.types.ts` that returns true only when
`player.tech()?.vhs` is a non-null object. In
`VjsPlayerComponent.handleVideoJsError`, call
`classifyVhsPlaybackIssue` only when that helper is true and
`player.error()` returned an error. Otherwise keep
`classifyNativePlaybackIssue(playerError ?? video.error, metadata)`.
Do not add xhr hooks, VHS loader access, retry listeners, or private fields.
- [ ] **Step 5: Run the focused component tests to verify GREEN**
Run the command from step 3.
Expected: PASS.
### Task 3: Render Only Structured VHS Details
**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: Write the failing safe rendering regression**
Create a VHS diagnostic carrying sanitized evidence plus provider-secret
sentinels in fields that must not be rendered. Expect:
```typescript
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=unknown · type=networkbadstatus · code=4 · ' +
'disposition=terminal · HTTP 503',
}
```
Prove the rendered details do not contain the provider URL, token, headers,
message, response body, or arbitrary metadata.
- [ ] **Step 2: Run the focused view test 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 has no VHS evidence branch.
- [ ] **Step 3: Implement deterministic VHS formatting**
In `formatDiagnosticErrorDetails`, add a VHS branch before generic native
formatting. Build the summary only from evidence stage, engine type, media
error code, disposition, and optional status. Add `vhs` to the diagnostic
source formatter as `Video.js / VHS`.
- [ ] **Step 4: Run the focused view test to verify GREEN**
Run the command from step 2.
Expected: PASS and the existing HLS summary remains unchanged.
### Task 4: Document, Release-Note, And Validate
**Files:**
- Modify: `docs/architecture/embedded-inline-playback.md`
- Create: `.changes/playback-structured-videojs-diagnostics.md`
- Verify: `AGENTS.md`
- Verify: `CLAUDE.md`
- [ ] **Step 1: Update the canonical diagnostic contract**
Document:
- Video.js 8.23.9 / VHS 3.17.5 public evidence boundary;
- exact allowlist and conservative classification;
- safe stage mapping;
- terminal `Player#error` semantics and recoverable VHS suppression;
- rejected private request/loader fields and unsafe payloads;
- active-VHS code 3 remaining unknown.
No `AGENTS.md` or `CLAUDE.md` change is expected because neither currently
describes this diagnostic boundary. Re-check both after the runtime diff.
- [ ] **Step 2: Add the release note**
Create:
```markdown
---
type: fix
area: playback
---
The default web player now reports safer, more accurate streaming errors:
confirmed network and encrypted-segment failures keep structured details,
while ambiguous Video.js errors remain unknown instead of suggesting the
wrong cause.
```
- [ ] **Step 3: Run targeted and affected validation**
Run:
```bash
pnpm nx test ui-playback
pnpm nx lint ui-playback
pnpm nx typecheck web
pnpm run i18n:validate
pnpm run release:notes:validate
```
If `web:typecheck` is not the actual target name, inspect
`pnpm nx show project web` and run the repository's declared typecheck target.
Expected: all commands exit 0.
- [ ] **Step 4: Complete the test-impact pass**
Use:
```bash
pnpm nx show projects --withTarget test
pnpm nx show projects --withTarget e2e
```
Record that `ui-playback` unit/component tests, lint, web typecheck, i18n, and
release-note validation cover the changed boundary. E2E is skipped unless the
implementation changes workflow, routing, playback lifecycle, or player
recovery behavior.
### Task 5: Independent Review, Final Verification, And Ready PR
**Files:**
- Review: complete diff from `origin/master...HEAD`
- [ ] **Step 1: Run an independent local Codex review**
Provide the reviewer with the user constraints, exact installed versions, and
the full diff. Ask only for actionable P0/P1/P2 correctness, privacy, public
API stability, event-ordering, regression, test, and scope findings.
- [ ] **Step 2: Fix every valid P0/P1/P2 finding with TDD**
For each finding, add or adjust a failing regression test first, verify RED,
apply the minimal fix, and verify GREEN. Reject incorrect findings with
specific source/test evidence.
- [ ] **Step 3: Repeat the independent review**
Run the same full-diff review again. Expected: no actionable P0/P1/P2
findings.
- [ ] **Step 4: Run fresh full validation**
Repeat every command from Task 4 step 3 after the final review fix. Inspect
the complete output and confirm zero failures.
- [ ] **Step 5: Inspect final scope and documentation**
Run:
```bash
git status --short
git diff --check origin/master...HEAD
git diff --stat origin/master...HEAD
git diff origin/master...HEAD
```
Confirm:
- no hls.js contract changes unless a real regression required one;
- no Shaka/mpegts redesign;
- no private VHS production access;
- no unsafe error payload retention/rendering;
- docs and one release note are present;
- no unrelated files changed.
- [ ] **Step 6: Commit, push, and create the ready PR**
Use a conventional `fix(playback): ...` commit for runtime behavior. Push
`agent/structured-vhs-diagnostics` and create a non-draft PR with the evidence
summary, privacy boundary, tests, validation, E2E rationale, and review result.
@@ -0,0 +1,298 @@
# Structured Video.js/VHS Diagnostics
## Context
PR #1314 made generic native playback diagnostics preserve Video.js HTTP
status and `metadata.errorType`, and stopped treating an ambiguous
`MediaError` code 4 as codec evidence. PR #1316 added a separate structured
hls.js boundary for the HTML5 and ArtPlayer engines. Video.js remains the
default web player, but its HLS/DASH implementation is VHS rather than hls.js,
so the hls.js evidence contract does not apply to it.
The locked workspace installs:
- Video.js `8.23.9`;
- bundled `@videojs/http-streaming` (VHS) `3.17.5`;
- an unrelated Video.js `7.21.7` / VHS `2.16.3` pair nested under the legacy
aspect-ratio plugin.
The application imports the root Video.js `8.23.9` build. Its package declares
VHS `^3.17.5`, and pnpm resolves that dependency to `3.17.5`.
The audit compared the installed sources with the exact upstream tags:
- Video.js `v8.23.9`, commit
`81b3cb429fae8dd00659ac5d3b0b1d2d20a283cb`;
- VHS `v3.17.5`, commit
`a9f9d7ac0264b373f14da1bb2f2e7fe8f2775c4f`.
The installed VHS `error-codes.js`, `videojs-http-streaming.js`, and
`playlist-controller.js` match the tagged sources.
## Goals
- Add a minimal allowlisted evidence boundary for terminal Video.js/VHS
errors.
- Retain only public Video.js `MediaError` fields and exact public
`videojs.Error` identifiers.
- Classify only exact confirmed values; unknown values remain unknown.
- Keep recoverable VHS playlist/segment handling from becoming a terminal
IPTVnator diagnostic.
- Avoid retaining or rendering VHS/provider URLs, headers, xhr objects,
response bodies, messages, credentials, or arbitrary metadata.
- Correct the misleading case where VHS promotes a generic internal object or
string to code 3 even though no media-decode cause was established.
- Keep generic native Video.js playback behavior unchanged when VHS is not the
active source handler.
## Non-goals
- Changing the hls.js evidence contract from PR #1316.
- A Shaka or mpegts.js diagnostic redesign.
- Inferring CORS, mixed content, CSP, private-network access, codec, DRM, or a
request stage from messages or indirect signals.
- Reading VHS playlist loaders, segment loaders, request objects, or other
private implementation state.
- Listening to undocumented VHS retry/exclusion events in production.
- Diagnostic history, persistence, correlation, stream probes, automatic
failover, or a cross-player recommendation matrix.
## Approaches Considered
### Public Video.js error boundary (selected)
Read `player.error()` inside the public `Player#error` listener. When the
documented `player.tech().vhs` runtime property is present, sanitize the error
into a small `VhsPlaybackEvidence` object. Validate `metadata.errorType`
against the exact values exported by `videojs.Error`, validate the standard
`MediaError` code and HTTP status, and derive a stage only where the public
engine identifier itself names that stage.
This approach adds structured evidence without depending on VHS loaders or
request objects. It also lets generic non-VHS Video.js errors continue through
the existing native classifier.
### Observe VHS xhr hooks
VHS documents `vhs.xhr.onRequest` and `onResponse`, but request hooks would
require correlating mutable request objects with a later terminal error. They
also expose URLs, headers, response objects, and provider payloads at exactly
the boundary that must stay sanitized. Request completion is not terminal
playback disposition: VHS may retry, exclude a rendition, or recover. This
approach is rejected.
### Read VHS loaders and retry events
The installed implementation carries precise `requestType`, xhr, playlist,
segment, and key context internally. Those objects and their event ordering
are not part of the documented stable error API. Depending on
`playlistController_`, loader error objects, `retryplaylist`, or
`excludeplaylist` would violate the scope constraint and make updates to VHS
risky. This approach is rejected.
### Stop after the audit
Stopping would be correct if the public API exposed nothing beyond PR #1314.
The audit found a safe increment: `videojs.Error` is a public exact-value
allowlist, `player.error()` is populated before `Player#error`, and active VHS
can be detected through a documented runtime property. That is enough to
structure evidence, suppress unsafe message/metadata retention, and avoid a
false generic code-3 decode diagnosis.
## Stable Public Evidence
Video.js `8.23.9` documents and types these public fields:
- `player.error()` returns the current Video.js `MediaError`;
- `MediaError.code` carries standard codes 0 through 5;
- `MediaError.status` is an optional plugin-supplied status;
- `MediaError.metadata.errorType` is expected to align with
`videojs.Error`;
- `Player#error` is emitted after `player.error_` has been replaced with the
new `MediaError`;
- `player.tech().vhs` is a documented VHS runtime property while VHS is in
use.
Video.js `8.23.9` publicly exports these exact `videojs.Error` values:
- `networkbadstatus`;
- `networkrequestfailed`;
- `networkrequestaborted`;
- `networkrequesttimeout`;
- `networkbodyparserfailed`;
- `streaminghlsplaylistparsererror`;
- `streamingdashmanifestparsererror`;
- `streamingcontentsteeringparsererror`;
- `streamingvttparsererror`;
- `streamingfailedtoselectnextsegment`;
- `streamingfailedtodecryptsegment`;
- `streamingfailedtotransmuxsegment`;
- `streamingfailedtoappendsegment`;
- `streamingcodecschangeerror`.
The allowlist is intentionally version-locked. A regression test compares it
with the installed `videojs.Error` export so a dependency update requires a
new audit instead of silently accepting new engine values.
## Rejected Evidence
VHS `3.17.5` internally adds fields such as `requestType`, `uri`, `headers`,
`error`, xhr objects, response text, playlist objects, and segment context.
Those values can contain credentials or provider data and are not required by
the public Video.js `ErrorMetadata` contract. The boundary must not read or
copy them.
The following are also rejected:
- error `message`, because VHS messages embed request URLs;
- `responseText`, response data, and response bodies;
- request/response headers;
- arbitrary metadata keys or provider objects;
- `requestType`, because its propagation through `player.error()` is an
implementation detail rather than a documented stable contract;
- status zero as CORS or browser-access evidence;
- message fragments as codec, DRM, network, access, or stage evidence.
The existing `PlaybackDiagnostic.sourceUrl` remains available to the
pre-existing Retry, Copy URL, and explicit external-player workflows. No URL
from the Video.js/VHS error object is copied into evidence or technical
details.
## Evidence Contract
`VhsPlaybackEvidence` contains:
- `engineType`: one exact installed `videojs.Error` value, otherwise
`unknown`;
- `mediaErrorCode`: a validated standard `MediaError` code 0 through 5,
otherwise `unknown`;
- `disposition`: `terminal`;
- `stage`: `manifest`, `playlist`, `segment`, or `unknown`;
- optional `httpStatus`: an integer from 400 through 599 copied only from the
public top-level `MediaError.status`.
There is no recoverable evidence object. IPTVnator creates this boundary only
from the public `Player#error` event after Video.js has stored the final error.
Recoverable VHS handling remains inside VHS and produces no terminal
diagnostic.
## Stage Mapping
Stage is derived only from exact public engine identifiers:
- `streaminghlsplaylistparsererror` → `playlist`;
- `streamingdashmanifestparsererror` → `manifest`;
- `streamingfailedtoselectnextsegment` → `segment`;
- `streamingfailedtodecryptsegment` → `segment`;
- `streamingfailedtotransmuxsegment` → `segment`;
- `streamingfailedtoappendsegment` → `segment`;
- all network identifiers, content-steering/VTT parser errors, codec-change
errors, and unrecognized values → `unknown`.
Network errors remain stage-unknown even if internal metadata happens to carry
`requestType: hls-playlist`, `hls-segment`, or `hls-key`. The public error
contract does not guarantee those values.
## Classification
`classifyVhsPlaybackIssue` uses this precedence:
1. A validated HTTP 4xx/5xx status produces `network-error`.
2. Exact public network error types produce `network-error`.
3. Standard `MediaError` code 2 produces `network-error`.
4. Exact `streamingfailedtodecryptsegment` produces
`drm-or-encryption`.
5. Standard `MediaError` code 5 produces `drm-or-encryption`.
6. A known browser-incompatible source container may still produce
`unsupported-container`.
7. Everything else produces `unknown-playback-error`.
Generic VHS code 3 does not produce `media-decode-error`. VHS assigns code 3
to object errors without a code and to string errors before calling
`player.error()`, including the terminal “no available playlists” path.
Therefore code 3 is not sufficient decode evidence on the VHS path.
The boundary does not classify:
- codec incompatibility from codec-change, transmux, append, or message text;
- DRM from key-request/load failure;
- browser access from status zero or generic request failure;
- media decode from code 3 alone.
Generic Video.js playback without active VHS continues to use the existing
native classifier, so a native code 3 remains a media-decode diagnostic.
## Runtime And Event Ordering
VHS `3.17.5` handles playlist and segment failures before the public terminal
error:
- a failed rendition can be excluded and another selected;
- a single finite-exclusion rendition is retried;
- previously excluded renditions can be re-included;
- segment timeouts can trigger ABR recovery;
- aborted segment requests are ignored as non-errors;
- only an unrecoverable playlist-controller error reaches
`player.error(...)`.
Video.js `8.23.9` constructs and stores the `MediaError`, then synchronously
fires `Player#error`. The component therefore reads the final public error
inside its existing listener and marks it terminal. IPTVnator does not
subscribe to internal VHS recovery events.
## Component Flow
`VjsPlayerComponent` keeps one `Player#error` listener:
1. Read `player.error()` before falling back to the native video error.
2. Detect active VHS through the documented `player.tech().vhs` runtime
property.
3. With active VHS and a Video.js error, call
`classifyVhsPlaybackIssue`.
4. Otherwise keep the existing `classifyNativePlaybackIssue` path.
5. Emit exactly one terminal diagnostic.
The component does not inspect `playlistController_`, loaders, xhr hooks,
request types, retry events, or error messages.
## User Interface
Add one deterministic Video.js/VHS technical-detail summary:
`stage=unknown · type=networkbadstatus · code=4 · disposition=terminal · HTTP 503`
The summary is built only from `VhsPlaybackEvidence`. It never includes the
Video.js message or arbitrary metadata. Existing diagnostic headings,
descriptions, HTTP badge, Retry, Copy URL, and explicit MPV/VLC actions remain
unchanged; no new translation keys or layout changes are needed.
## Testing
Use test-driven development:
- Compare the allowlist with the actual installed `videojs.Error` export.
- Exercise real Video.js public event ordering by setting an error on a real
Video.js player and reading `player.error()` from its `error` listener.
- Use installed-runtime-shaped VHS errors for 5xx, request failure, timeout,
playlist parsing, segment decrypt, generic code 3, and unknown provider
metadata.
- Prove URLs, headers, response data, messages, credentials, request types,
and arbitrary metadata do not survive the boundary or rendered details.
- Prove only exact public network/decrypt values affect classification.
- Prove a generic active-VHS code 3 stays unknown while non-VHS native code 3
remains a media-decode diagnostic.
- Prove the component routes active VHS errors through the structured boundary
and leaves generic Video.js errors on the native path.
- Prove technical details render only the sanitized VHS evidence.
Run the complete `ui-playback` unit target, its lint target, the web typecheck,
i18n validation, release-note validation, and the repository test-impact pass.
No E2E is required because the playback workflow, controls, routing, and
integration lifecycle are unchanged; the change is confined to the existing
terminal error boundary and technical details.
## Documentation And Release Note
Update `docs/architecture/embedded-inline-playback.md`, the canonical browser
playback diagnostic contract. Add a `fix(playback)` release note because users
receive more accurate default-player diagnoses and safer technical details.
@@ -20,6 +20,7 @@ export type PlaybackDiagnosticCode =
export const PlaybackDiagnosticSource = {
Source: 'source',
Native: 'native',
Vhs: 'vhs',
Hls: 'hls',
MpegTs: 'mpegts',
Shaka: 'shaka',
@@ -66,6 +67,67 @@ export interface NativePlaybackErrorInput {
readonly metadata?: NativePlaybackErrorMetadataInput;
}
export const VhsPlaybackEngineType = {
NetworkBadStatus: 'networkbadstatus',
NetworkRequestFailed: 'networkrequestfailed',
NetworkRequestAborted: 'networkrequestaborted',
NetworkRequestTimeout: 'networkrequesttimeout',
NetworkBodyParserFailed: 'networkbodyparserfailed',
StreamingHlsPlaylistParserError: 'streaminghlsplaylistparsererror',
StreamingDashManifestParserError: 'streamingdashmanifestparsererror',
StreamingContentSteeringParserError: 'streamingcontentsteeringparsererror',
StreamingVttParserError: 'streamingvttparsererror',
StreamingFailedToSelectNextSegment: 'streamingfailedtoselectnextsegment',
StreamingFailedToDecryptSegment: 'streamingfailedtodecryptsegment',
StreamingFailedToTransmuxSegment: 'streamingfailedtotransmuxsegment',
StreamingFailedToAppendSegment: 'streamingfailedtoappendsegment',
StreamingCodecsChangeError: 'streamingcodecschangeerror',
} as const;
export const VhsPlaybackUnknownEngineType = 'unknown' as const;
export type VhsPlaybackEngineType =
| (typeof VhsPlaybackEngineType)[keyof typeof VhsPlaybackEngineType]
| typeof VhsPlaybackUnknownEngineType;
export const VhsPlaybackMediaErrorCode = {
Custom: 0,
Aborted: 1,
Network: 2,
Decode: 3,
SourceNotSupported: 4,
Encrypted: 5,
Unknown: 'unknown',
} as const;
export type VhsPlaybackMediaErrorCode =
(typeof VhsPlaybackMediaErrorCode)[keyof typeof VhsPlaybackMediaErrorCode];
export const VhsPlaybackDisposition = {
Terminal: 'terminal',
} as const;
export type VhsPlaybackDisposition =
(typeof VhsPlaybackDisposition)[keyof typeof VhsPlaybackDisposition];
export const VhsPlaybackStage = {
Manifest: 'manifest',
Playlist: 'playlist',
Segment: 'segment',
Unknown: 'unknown',
} as const;
export type VhsPlaybackStage =
(typeof VhsPlaybackStage)[keyof typeof VhsPlaybackStage];
export interface VhsPlaybackEvidence {
readonly engineType: VhsPlaybackEngineType;
readonly mediaErrorCode: VhsPlaybackMediaErrorCode;
readonly disposition: VhsPlaybackDisposition;
readonly stage: VhsPlaybackStage;
readonly httpStatus?: number;
}
export const HlsPlaybackDisposition = {
Fatal: 'fatal',
Recoverable: 'recoverable',
@@ -100,8 +162,7 @@ export type HlsPlaybackFailure =
export const HlsPlaybackUnknownEngineType = 'unknown' as const;
export type HlsPlaybackEngineType =
| ErrorTypes
| typeof HlsPlaybackUnknownEngineType;
ErrorTypes | typeof HlsPlaybackUnknownEngineType;
export interface HlsPlaybackEvidence {
readonly engineType: HlsPlaybackEngineType;
@@ -133,6 +194,7 @@ export interface PlaybackDiagnostic {
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
@@ -1,4 +1,10 @@
import { ErrorDetails, ErrorTypes } from 'hls.js';
import type {
NativePlaybackErrorInput,
PlaybackDiagnostic,
PlaybackSourceMetadata,
} from './playback-diagnostics.model';
import * as diagnostics from './playback-diagnostics.util';
import {
PlaybackDiagnosticCode,
classifyHlsPlaybackIssue,
@@ -25,13 +31,17 @@ function classifyStructuredHlsPlaybackIssue(
return classifyHlsPlaybackIssue(evidence as never, metadata);
}
type VhsClassifier = (
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
) => PlaybackDiagnostic;
describe('playback diagnostics', () => {
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails:
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
engineDetails: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'unknown',
@@ -55,8 +65,7 @@ describe('playback diagnostics', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails:
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
engineDetails: ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
disposition: 'fatal',
stage: 'media',
failure: 'unknown',
@@ -146,6 +155,150 @@ describe('playback diagnostics', () => {
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies exact VHS network evidence without using provider text', () => {
const issue = classifyVhsPlaybackIssue(
{
code: 4,
message:
'provider says codec DRM CORS at ' +
'https://provider.example/live.m3u8?token=secret',
metadata: { errorType: 'networkrequestfailed' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
source: 'vhs',
httpStatus: undefined,
nativeErrorMessage: undefined,
externalFallbackRecommended: false,
vhs: {
engineType: 'networkrequestfailed',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
},
})
);
});
it('classifies exact VHS decrypt evidence without inspecting messages', () => {
const issue = classifyVhsPlaybackIssue(
{
code: 3,
message: 'provider network timeout',
metadata: {
errorType: 'streamingfailedtodecryptsegment',
},
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/encrypted.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe('vhs');
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.nativeErrorMessage).toBeUndefined();
expect(issue.vhs).toEqual({
engineType: 'streamingfailedtodecryptsegment',
mediaErrorCode: 3,
disposition: 'terminal',
stage: 'segment',
});
});
it('classifies the public encrypted MediaError code without DRM text', () => {
const issue = classifyVhsPlaybackIssue(
{ code: 5, message: 'provider supplied message' },
createPlaybackSourceMetadata({
url: 'https://example.com/live/encrypted.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.nativeErrorMessage).toBeUndefined();
});
it('keeps generic VHS code three unknown while native code three remains decode evidence', () => {
const metadata = createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
});
const vhsIssue = classifyVhsPlaybackIssue(
{
code: 3,
message:
'Playback cannot continue. No available working or supported playlists.',
},
metadata
);
const nativeIssue = classifyNativePlaybackIssue(
{ code: 3, message: 'native decode failed' },
metadata
);
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(vhsIssue.externalFallbackRecommended).toBe(false);
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
});
it('retains only safe VHS HTTP and allowlisted engine evidence', () => {
const secret = 'vhs-classifier-secret';
const issue = classifyVhsPlaybackIssue(
{
code: 4,
status: 503,
message: `https://provider.example/?token=${secret}`,
metadata: {
errorType: 'networkbadstatus',
requestType: 'hls-key',
headers: { Authorization: secret },
responseBody: secret,
},
} as NativePlaybackErrorInput,
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
})
);
const serializedEvidence = JSON.stringify(issue.vhs);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.httpStatus).toBe(503);
expect(issue.nativeErrorMessage).toBeUndefined();
expect(serializedEvidence).not.toContain(secret);
expect(serializedEvidence).not.toContain('provider.example');
expect(serializedEvidence).not.toContain('hls-key');
expect(serializedEvidence).not.toContain('Authorization');
});
it('keeps unknown VHS metadata unknown despite cause-shaped provider text', () => {
const issue = classifyVhsPlaybackIssue(
{
code: 3,
message: 'CORS codec DRM network timeout',
metadata: { errorType: 'providerCodecCorsDrmError' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.vhs?.engineType).toBe('unknown');
expect(issue.nativeErrorMessage).toBeUndefined();
});
it('keeps native code four HLS source failures unknown without status or container evidence', () => {
const issue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
@@ -186,45 +339,49 @@ describe('playback diagnostics', () => {
{ status: 599, accepted: true },
{ status: 600, accepted: false },
{ status: 404.5, accepted: false },
])('accepts native HTTP status $status only when it is a 4xx or 5xx integer', ({
status,
accepted,
}) => {
const issue = classifyNativePlaybackIssue(
{ code: 4, status },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
])(
'accepts native HTTP status $status only when it is a 4xx or 5xx integer',
({ status, accepted }) => {
const issue = classifyNativePlaybackIssue(
{ code: 4, status },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(
accepted
? PlaybackDiagnosticCode.NetworkError
: PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue.httpStatus).toBe(accepted ? status : undefined);
});
expect(issue.code).toBe(
accepted
? PlaybackDiagnosticCode.NetworkError
: PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue.httpStatus).toBe(accepted ? status : undefined);
}
);
it.each([
{ length: 128, accepted: true },
{ length: 129, accepted: false },
])('retains native error type identifiers up to $length characters', ({
length,
accepted,
}) => {
const errorType = 'a'.repeat(length);
const issue = classifyNativePlaybackIssue(
{ code: 4, metadata: { errorType } },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
])(
'retains native error type identifiers up to $length characters',
({ length, accepted }) => {
const errorType = 'a'.repeat(length);
const issue = classifyNativePlaybackIssue(
{ code: 4, metadata: { errorType } },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.nativeErrorType).toBe(accepted ? errorType : undefined);
});
expect(issue.code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue.nativeErrorType).toBe(
accepted ? errorType : undefined
);
}
);
it('classifies HLS network errors without claiming codec incompatibility', () => {
const issue = classifyStructuredHlsPlaybackIssue(
@@ -371,9 +528,7 @@ describe('playback diagnostics', () => {
})
);
expect(issue?.code).toBe(
PlaybackDiagnosticCode.MediaDecodeError
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
}
);
@@ -392,9 +547,7 @@ describe('playback diagnostics', () => {
})
);
expect(issue?.code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
@@ -586,3 +739,20 @@ describe('playback diagnostics', () => {
).toEqual(['HEVC', 'AC-3', 'E-AC-3']);
});
});
function classifyVhsPlaybackIssue(
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const classifier = (
diagnostics as unknown as {
readonly classifyVhsPlaybackIssue?: VhsClassifier;
}
).classifyVhsPlaybackIssue;
expect(classifier).toBeDefined();
if (!classifier) {
throw new Error('classifyVhsPlaybackIssue is not exported');
}
return classifier(error, metadata);
}
@@ -6,10 +6,16 @@ import type {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackSourceMetadata,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
VhsPlaybackEvidence,
} 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 {
VhsPlaybackEngineType,
VhsPlaybackMediaErrorCode,
} from './playback-diagnostics.model';
import { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util';
import {
isBrowserAccessFailure,
@@ -18,9 +24,11 @@ import {
normalizeErrorDetails,
} from './playback-error-patterns.util';
import { isLikelyContainerIssue } from './playback-media-source.util';
import { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export * from './playback-diagnostics.model';
export { createHlsPlaybackEvidence } from './hls-playback-evidence.util';
export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export {
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
@@ -31,6 +39,14 @@ const SOURCE_NOT_SUPPORTED_CODE = 4;
const DECODE_ERROR_CODE = 3;
const NETWORK_ERROR_CODE = 2;
const NATIVE_ERROR_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const VHS_NETWORK_ERROR_TYPES: ReadonlySet<VhsPlaybackEngineTypeValue> =
new Set([
VhsPlaybackEngineType.NetworkBadStatus,
VhsPlaybackEngineType.NetworkRequestFailed,
VhsPlaybackEngineType.NetworkRequestAborted,
VhsPlaybackEngineType.NetworkRequestTimeout,
VhsPlaybackEngineType.NetworkBodyParserFailed,
]);
export function classifyNativePlaybackIssue(
error: NativePlaybackErrorInput | MediaError | null | undefined,
@@ -38,7 +54,8 @@ export function classifyNativePlaybackIssue(
): PlaybackDiagnostic {
const nativeErrorCode = error?.code;
const nativeErrorMessage = error?.message || undefined;
const nativeErrorInput = error as NativePlaybackErrorInput | null | undefined;
const nativeErrorInput = error as
NativePlaybackErrorInput | null | undefined;
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
const nativeErrorType = getNativeErrorType(
nativeErrorInput?.metadata?.errorType
@@ -107,6 +124,26 @@ export function classifyNativePlaybackIssue(
});
}
export function classifyVhsPlaybackIssue(
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const evidence = createVhsPlaybackEvidence(error);
const code = getVhsPlaybackDiagnosticCode(evidence, metadata);
return createPlaybackDiagnostic({
code,
source: DiagnosticSource.Vhs,
metadata,
nativeErrorCode:
typeof evidence.mediaErrorCode === 'number'
? evidence.mediaErrorCode
: undefined,
httpStatus: evidence.httpStatus,
vhs: evidence,
});
}
export function classifyHlsPlaybackIssue(
evidence: HlsPlaybackEvidence,
metadata: PlaybackSourceMetadata
@@ -218,6 +255,7 @@ export function createPlaybackDiagnostic(options: {
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
@@ -232,6 +270,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorMessage,
httpStatus,
nativeErrorType,
vhs,
hls,
} = options;
@@ -249,6 +288,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorMessage,
httpStatus,
nativeErrorType,
vhs,
hls,
externalFallbackRecommended:
options.externalFallbackRecommended ??
@@ -256,6 +296,37 @@ export function createPlaybackDiagnostic(options: {
};
}
function getVhsPlaybackDiagnosticCode(
evidence: VhsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnosticCode {
if (
evidence.httpStatus !== undefined ||
VHS_NETWORK_ERROR_TYPES.has(evidence.engineType) ||
evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Network
) {
return DiagnosticCode.NetworkError;
}
if (
evidence.engineType ===
VhsPlaybackEngineType.StreamingFailedToDecryptSegment ||
evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Encrypted
) {
return DiagnosticCode.DrmOrEncryption;
}
if (
evidence.mediaErrorCode ===
VhsPlaybackMediaErrorCode.SourceNotSupported &&
isLikelyContainerIssue(metadata)
) {
return DiagnosticCode.UnsupportedContainer;
}
return DiagnosticCode.UnknownPlaybackError;
}
function getNativeHttpStatus(status: unknown): number | undefined {
return typeof status === 'number' &&
Number.isInteger(status) &&
@@ -0,0 +1,297 @@
import { execFileSync } from 'node:child_process';
import type { NativePlaybackErrorInput } from './playback-diagnostics.model';
import * as diagnostics from './playback-diagnostics.util';
interface ExpectedVhsPlaybackEvidence {
readonly engineType: string;
readonly mediaErrorCode: number | string;
readonly disposition: string;
readonly stage: string;
readonly httpStatus?: number;
}
type EvidenceFactory = (
error: NativePlaybackErrorInput
) => ExpectedVhsPlaybackEvidence;
interface VideoJsErrorConstants {
readonly [name: string]: string;
}
describe('VHS playback evidence', () => {
it('matches the installed public videojs.Error identifiers', () => {
expect(Object.values(getEngineTypes()).sort()).toEqual(
Object.values(getInstalledVideoJsErrors()).sort()
);
});
it('extracts only allowlisted fields from a real VHS bad-status shape', () => {
const secret = 'vhs-evidence-secret';
const evidence = createEvidence({
code: 4,
status: 503,
message:
'HLS playlist request error at URL: ' +
`https://provider.example/live.m3u8?token=${secret}`,
metadata: {
errorType: getInstalledVideoJsErrors()['NetworkBadStatus'],
requestType: 'hls-playlist',
uri: `https://provider.example/live.m3u8?token=${secret}`,
headers: { Authorization: `Bearer ${secret}` },
responseText: `provider body ${secret}`,
},
} as NativePlaybackErrorInput);
const serialized = JSON.stringify(evidence);
expect(evidence).toEqual({
engineType: 'networkbadstatus',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 503,
});
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('hls-playlist');
expect(Object.keys(evidence).sort()).toEqual(
[
'disposition',
'engineType',
'httpStatus',
'mediaErrorCode',
'stage',
].sort()
);
});
it.each([
{
engineType: 'streaminghlsplaylistparsererror',
stage: 'playlist',
},
{
engineType: 'streamingdashmanifestparsererror',
stage: 'manifest',
},
{
engineType: 'streamingfailedtoselectnextsegment',
stage: 'segment',
},
{
engineType: 'streamingfailedtodecryptsegment',
stage: 'segment',
},
{
engineType: 'streamingfailedtotransmuxsegment',
stage: 'segment',
},
{
engineType: 'streamingfailedtoappendsegment',
stage: 'segment',
},
{
engineType: 'networkrequestfailed',
stage: 'unknown',
},
{
engineType: 'streamingcontentsteeringparsererror',
stage: 'unknown',
},
{
engineType: 'streamingvttparsererror',
stage: 'unknown',
},
{
engineType: 'streamingcodecschangeerror',
stage: 'unknown',
},
])(
'maps public $engineType evidence only to the proven $stage stage',
({ engineType, stage }) => {
expect(
createEvidence({
code: 3,
metadata: { errorType: engineType },
}).stage
).toBe(stage);
}
);
it.each([
{ code: 0, expected: 0 },
{ code: 5, expected: 5 },
{ code: -1, expected: 'unknown' },
{ code: 6, expected: 'unknown' },
{ code: 3.5, expected: 'unknown' },
])(
'retains MediaError code $code only inside the standard range',
({ code, expected }) => {
expect(createEvidence({ code }).mediaErrorCode).toBe(expected);
}
);
it.each([
{ status: 399, expected: undefined },
{ status: 400, expected: 400 },
{ status: 599, expected: 599 },
{ status: 600, expected: undefined },
{ status: 404.5, expected: undefined },
])(
'retains HTTP status $status only for integer 4xx and 5xx failures',
({ status, expected }) => {
expect(createEvidence({ code: 2, status }).httpStatus).toBe(
expected
);
}
);
it('keeps unknown identifiers unknown without inspecting messages or request types', () => {
const evidence = createEvidence({
code: 3,
status: 0,
message:
'CORS codec DRM failure in hls-key request at provider URL',
metadata: {
errorType: 'providerCorsCodecDrmFailure',
requestType: 'hls-key',
},
} as NativePlaybackErrorInput);
expect(evidence).toEqual({
engineType: 'unknown',
mediaErrorCode: 3,
disposition: 'terminal',
stage: 'unknown',
});
});
it('populates player.error before the installed Video.js error listener runs', () => {
expect(readInstalledVideoJsErrorFromListener()).toEqual({
code: 4,
status: 503,
errorType: 'networkbadstatus',
});
});
});
function createEvidence(
error: NativePlaybackErrorInput
): ExpectedVhsPlaybackEvidence {
const factory = (
diagnostics as unknown as {
readonly createVhsPlaybackEvidence?: EvidenceFactory;
}
).createVhsPlaybackEvidence;
expect(factory).toBeDefined();
if (!factory) {
throw new Error('createVhsPlaybackEvidence is not exported');
}
return factory(error);
}
function getEngineTypes(): VideoJsErrorConstants {
const engineTypes = (
diagnostics as unknown as {
readonly VhsPlaybackEngineType?: VideoJsErrorConstants;
}
).VhsPlaybackEngineType;
expect(engineTypes).toBeDefined();
if (!engineTypes) {
throw new Error('VhsPlaybackEngineType is not exported');
}
return engineTypes;
}
function getInstalledVideoJsErrors(): VideoJsErrorConstants {
const script =
"import videoJs from 'video.js';" +
'process.stdout.write(JSON.stringify(videoJs.Error));';
const output = execFileSync(
process.execPath,
['--input-type=module', '-e', script],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);
return JSON.parse(output) as VideoJsErrorConstants;
}
function readInstalledVideoJsErrorFromListener(): {
readonly code: number;
readonly status: number;
readonly errorType: string;
} {
const script = `
import { createRequire } from 'node:module';
const rootRequire = createRequire(import.meta.url);
const environmentPath = rootRequire.resolve(
'jest-environment-jsdom/package.json'
);
const environmentRequire = createRequire(environmentPath);
const { JSDOM } = environmentRequire('jsdom');
const dom = new JSDOM(
'<!doctype html><html><body></body></html>',
{ url: 'http://localhost/', pretendToBeVisual: true }
);
dom.window.HTMLMediaElement.prototype.load = () => {};
for (const name of [
'window',
'document',
'navigator',
'Element',
'HTMLElement',
'HTMLVideoElement',
'HTMLMediaElement',
'Event',
'CustomEvent',
'Node',
]) {
Object.defineProperty(globalThis, name, {
value: dom.window[name],
configurable: true,
});
}
const { default: videoJs } = await import('video.js');
videoJs.log.level('off');
const video = document.createElement('video');
document.body.append(video);
const player = videoJs(video);
let observed = null;
player.on('error', () => {
observed = player.error();
});
player.error({
code: 4,
status: 503,
metadata: {
errorType: videoJs.Error.NetworkBadStatus,
},
});
process.stdout.write(JSON.stringify({
code: observed.code,
status: observed.status,
errorType: observed.metadata.errorType,
}));
player.dispose();
dom.window.close();
`;
const output = execFileSync(
process.execPath,
['--input-type=module', '-e', script],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);
return JSON.parse(output) as {
readonly code: number;
readonly status: number;
readonly errorType: string;
};
}
@@ -0,0 +1,76 @@
import type {
NativePlaybackErrorInput,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
VhsPlaybackEvidence,
VhsPlaybackMediaErrorCode as VhsPlaybackMediaErrorCodeValue,
VhsPlaybackStage as VhsPlaybackStageValue,
} from './playback-diagnostics.model';
import {
VhsPlaybackDisposition,
VhsPlaybackEngineType,
VhsPlaybackMediaErrorCode,
VhsPlaybackStage,
VhsPlaybackUnknownEngineType,
} from './playback-diagnostics.model';
const ENGINE_TYPES = new Set<string>(Object.values(VhsPlaybackEngineType));
const SEGMENT_ENGINE_TYPES = new Set<VhsPlaybackEngineTypeValue>([
VhsPlaybackEngineType.StreamingFailedToSelectNextSegment,
VhsPlaybackEngineType.StreamingFailedToDecryptSegment,
VhsPlaybackEngineType.StreamingFailedToTransmuxSegment,
VhsPlaybackEngineType.StreamingFailedToAppendSegment,
]);
export function createVhsPlaybackEvidence(
error: NativePlaybackErrorInput
): VhsPlaybackEvidence {
const engineType = getEngineType(error.metadata?.errorType);
const httpStatus = getHttpStatus(error.status);
const evidence: VhsPlaybackEvidence = {
engineType,
mediaErrorCode: getMediaErrorCode(error.code),
disposition: VhsPlaybackDisposition.Terminal,
stage: getStage(engineType),
};
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
}
function getEngineType(value: unknown): VhsPlaybackEngineTypeValue {
return typeof value === 'string' && ENGINE_TYPES.has(value)
? (value as VhsPlaybackEngineTypeValue)
: VhsPlaybackUnknownEngineType;
}
function getMediaErrorCode(value: unknown): VhsPlaybackMediaErrorCodeValue {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= VhsPlaybackMediaErrorCode.Custom &&
value <= VhsPlaybackMediaErrorCode.Encrypted
? (value as VhsPlaybackMediaErrorCodeValue)
: VhsPlaybackMediaErrorCode.Unknown;
}
function getHttpStatus(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= 400 &&
value <= 599
? value
: undefined;
}
function getStage(
engineType: VhsPlaybackEngineTypeValue
): VhsPlaybackStageValue {
if (engineType === VhsPlaybackEngineType.StreamingHlsPlaylistParserError) {
return VhsPlaybackStage.Playlist;
}
if (engineType === VhsPlaybackEngineType.StreamingDashManifestParserError) {
return VhsPlaybackStage.Manifest;
}
if (SEGMENT_ENGINE_TYPES.has(engineType)) {
return VhsPlaybackStage.Segment;
}
return VhsPlaybackStage.Unknown;
}
@@ -204,6 +204,7 @@ describe('VjsPlayerComponent', () => {
it('preserves Video.js HTTP error context in a playback diagnostic', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
harness.vhsActive = true;
render({
sources: [
{
@@ -224,15 +225,112 @@ describe('VjsPlayerComponent', () => {
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'network-error',
source: 'native',
source: 'vhs',
sourceUrl: 'https://example.test/missing/playlist.m3u8',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
nativeErrorMessage: undefined,
vhs: {
engineType: 'networkrequestfailed',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 404,
},
externalFallbackRecommended: false,
})
);
});
it('keeps a generic terminal VHS code three unknown', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
harness.vhsActive = true;
render({
sources: [
{
src: 'https://example.test/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
});
harness.currentError = {
code: 3,
message:
'Playback cannot continue. No available working or supported playlists.',
};
harness.emit('error');
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'unknown-playback-error',
source: 'vhs',
nativeErrorMessage: undefined,
vhs: {
engineType: 'unknown',
mediaErrorCode: 3,
disposition: 'terminal',
stage: 'unknown',
},
externalFallbackRecommended: false,
})
);
});
it('keeps non-VHS native code three as media decode evidence', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
render({
sources: [
{
src: 'https://example.test/archive/movie.mp4',
type: 'video/mp4',
},
],
});
harness.currentError = {
code: 3,
message: 'Native media decode failed',
};
harness.emit('error');
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'media-decode-error',
source: 'native',
nativeErrorMessage: 'Native media decode failed',
externalFallbackRecommended: true,
})
);
});
it('does not publish a diagnostic before the terminal VHS player error', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
harness.vhsActive = true;
render({
sources: [
{
src: 'https://example.test/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
});
harness.currentError = {
code: 2,
metadata: { errorType: 'networkrequestfailed' },
};
harness.emit('retryplaylist');
expect(issues).toEqual([]);
harness.emit('error');
expect(issues.at(-1)?.vhs?.disposition).toBe('terminal');
});
it('rebinds native ended handling after playerreset', () => {
const events: string[] = [];
component.playbackEnded.subscribe(() => events.push('ended'));
@@ -312,6 +410,7 @@ function createPlayerHarness() {
const harness = {
currentVideo: document.createElement('video'),
currentError: null as NativePlaybackErrorInput | null,
vhsActive: false,
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
@@ -355,7 +454,10 @@ function createPlayerHarness() {
paused: jest.fn(() => harness.paused),
reset: harness.reset,
src: harness.src,
tech: jest.fn(() => ({ el: () => harness.currentVideo })),
tech: jest.fn(() => ({
el: () => harness.currentVideo,
...(harness.vhsActive ? { vhs: {} } : {}),
})),
volume: harness.volume,
dispose: jest.fn(),
qualitySelectorHls: jest.fn(),
@@ -21,6 +21,7 @@ import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
import {
@@ -48,6 +49,7 @@ import {
type VideoPlayerOptions,
type VideoPlayerSource,
getVideoJsTechVideo,
hasActiveVhsSourceHandler,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
@@ -239,16 +241,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
typeof this.player.error === 'function'
? this.player.error()
: null;
const metadata = createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
});
this.mpegTsSession.syncDuration();
this.playbackIssue.emit(
classifyNativePlaybackIssue(
playerError ?? video.error,
createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
})
)
playerError && hasActiveVhsSourceHandler(this.player)
? classifyVhsPlaybackIssue(playerError, metadata)
: classifyNativePlaybackIssue(
playerError ?? video.error,
metadata
)
);
};
@@ -1,4 +1,5 @@
import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types';
import * as playerTypes from './vjs-player.types';
describe('getVideoJsTechVideo', () => {
it('returns the current Video.js Tech video element', () => {
@@ -28,3 +29,49 @@ describe('getVideoJsTechVideo', () => {
).toBeNull();
});
});
describe('hasActiveVhsSourceHandler', () => {
it('detects the documented Video.js Tech VHS runtime property', () => {
const tech = jest.fn(() => ({ vhs: {} }));
expect(
hasActiveVhsSourceHandler({
tech,
} as unknown as VideoJsPlayer)
).toBe(true);
expect(tech).toHaveBeenCalledWith({
IWillNotUseThisInPlugins: true,
});
expect(
hasActiveVhsSourceHandler({
tech: () => ({ el: () => document.createElement('video') }),
} as unknown as VideoJsPlayer)
).toBe(false);
});
it('fails closed when Tech access is transiently unavailable', () => {
expect(
hasActiveVhsSourceHandler({
tech: () => {
throw new Error('Tech unavailable');
},
} as unknown as VideoJsPlayer)
).toBe(false);
});
});
function hasActiveVhsSourceHandler(player: VideoJsPlayer): boolean {
const helper = (
playerTypes as unknown as {
readonly hasActiveVhsSourceHandler?: (
candidate: VideoJsPlayer
) => boolean;
}
).hasActiveVhsSourceHandler;
expect(helper).toBeDefined();
if (!helper) {
throw new Error('hasActiveVhsSourceHandler is not exported');
}
return helper(player);
}
@@ -97,3 +97,14 @@ export function getVideoJsTechVideo(
return null;
}
}
export function hasActiveVhsSourceHandler(
player: Pick<VideoJsPlayer, 'tech'>
): boolean {
try {
const vhs = player.tech({ IWillNotUseThisInPlugins: true })?.vhs;
return typeof vhs === 'object' && vhs !== null;
} catch {
return false;
}
}
@@ -82,7 +82,7 @@ export function getDiagnosticDetails(
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
value: issue.vhs ? '' : (issue.nativeErrorMessage ?? ''),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
@@ -92,6 +92,20 @@ export function getDiagnosticDetails(
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.vhs) {
return [
`stage=${issue.vhs.stage}`,
`type=${issue.vhs.engineType}`,
`code=${issue.vhs.mediaErrorCode}`,
`disposition=${issue.vhs.disposition}`,
issue.vhs.httpStatus === undefined
? ''
: `HTTP ${issue.vhs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.hls) {
return [
`stage=${issue.hls.stage}`,
@@ -151,6 +165,8 @@ function formatPlayer(player: PlaybackDiagnostic['player']): string {
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
switch (source) {
case 'vhs':
return 'Video.js / VHS';
case 'hls':
return 'HLS.js';
case 'mpegts':
@@ -342,6 +342,35 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('provider.example');
});
it('renders only sanitized structured VHS evidence in technical details', () => {
const issue = createStructuredVhsDiagnostic();
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_SOURCE',
value: 'Video.js / VHS',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=unknown · type=networkbadstatus · code=4 · ' +
'disposition=terminal · HTTP 503',
},
])
);
expect(renderedDetails).not.toContain('vhs-render-secret');
expect(renderedDetails).not.toContain('provider.example');
expect(renderedDetails).not.toContain('Authorization');
expect(renderedDetails).not.toContain('response body');
});
it('keeps query-declared HLS streams on the HLS mime type', () => {
const streamUrl =
'https://example.com/play?extension=m3u8&token=signed';
@@ -370,8 +399,7 @@ describe('WebPlayerViewComponent', () => {
});
it('uses the Matroska mime type for query-declared MKV streams', () => {
const streamUrl =
'https://example.com/play?container=mkv&token=signed';
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
component.setVjsOptions(streamUrl);
@@ -926,3 +954,29 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: false,
};
}
function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://provider.example/live.m3u8?token=vhs-render-secret',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
details: 'Authorization response body vhs-render-secret',
nativeErrorCode: 4,
nativeErrorMessage:
'https://provider.example/error?token=vhs-render-secret',
httpStatus: 503,
vhs: {
engineType: 'networkbadstatus',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}