From 9a50e7385b1bf61a5427033361219660af23ac97 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:15:49 +0200 Subject: [PATCH] fix(playback): structure Video.js diagnostics (#1317) --- ...playback-structured-videojs-diagnostics.md | 9 + docs/architecture/embedded-inline-playback.md | 42 +- ...7-31-structured-videojs-vhs-diagnostics.md | 518 ++++++++++++++++++ ...ructured-videojs-vhs-diagnostics-design.md | 298 ++++++++++ .../playback-diagnostics.model.ts | 66 ++- .../playback-diagnostics.util.spec.ts | 256 +++++++-- .../playback-diagnostics.util.ts | 73 ++- .../vhs-playback-evidence.util.spec.ts | 297 ++++++++++ .../vhs-playback-evidence.util.ts | 76 +++ .../vjs-player/vjs-player.component.spec.ts | 108 +++- .../lib/vjs-player/vjs-player.component.ts | 21 +- .../lib/vjs-player/vjs-player.types.spec.ts | 47 ++ .../src/lib/vjs-player/vjs-player.types.ts | 11 + .../web-player-view-diagnostics.utils.ts | 18 +- .../web-player-view.component.spec.ts | 58 +- 15 files changed, 1837 insertions(+), 61 deletions(-) create mode 100644 .changes/playback-structured-videojs-diagnostics.md create mode 100644 docs/superpowers/plans/2026-07-31-structured-videojs-vhs-diagnostics.md create mode 100644 docs/superpowers/specs/2026-07-31-structured-videojs-vhs-diagnostics-design.md create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts diff --git a/.changes/playback-structured-videojs-diagnostics.md b/.changes/playback-structured-videojs-diagnostics.md new file mode 100644 index 000000000..e8370df30 --- /dev/null +++ b/.changes/playback-structured-videojs-diagnostics.md @@ -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. diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3900dd612..cfd18c01a 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-31-structured-videojs-vhs-diagnostics.md b/docs/superpowers/plans/2026-07-31-structured-videojs-vhs-diagnostics.md new file mode 100644 index 000000000..a65336b01 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-structured-videojs-vhs-diagnostics.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-31-structured-videojs-vhs-diagnostics-design.md b/docs/superpowers/specs/2026-07-31-structured-videojs-vhs-diagnostics-design.md new file mode 100644 index 000000000..eedca5d03 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-structured-videojs-vhs-diagnostics-design.md @@ -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. diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts index f1136c352..54c164b0b 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -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; } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts index d8a8a2592..535ad86ff 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts @@ -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); +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index 37ee8dbd8..6512a7f0f 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -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 = + 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) && diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts new file mode 100644 index 000000000..15a3fe8f2 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts @@ -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( + '', + { 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; + }; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts new file mode 100644 index 000000000..cb1215e8d --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts @@ -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(Object.values(VhsPlaybackEngineType)); +const SEGMENT_ENGINE_TYPES = new Set([ + 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; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index b6fdac918..c5c37e6f5 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -204,6 +204,7 @@ describe('VjsPlayerComponent', () => { it('preserves Video.js HTTP error context in a playback diagnostic', () => { const issues: Array = []; 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 = []; + 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 = []; + 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 = []; + 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(), diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 9911dd61a..b7cf69e15 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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 + ) ); }; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts index 9bfa1600e..c546ea1f9 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts @@ -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); +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts index a6a2d25c3..32361a32f 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts @@ -97,3 +97,14 @@ export function getVideoJsTechVideo( return null; } } + +export function hasActiveVhsSourceHandler( + player: Pick +): boolean { + try { + const vhs = player.tech({ IWillNotUseThisInPlugins: true })?.vhs; + return typeof vhs === 'object' && vhs !== null; + } catch { + return false; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts index b66075ca6..aec1c0e20 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -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': diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 5bb8138c8..8b275e075 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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, + }; +}