mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): structure Video.js diagnostics (#1317)
This commit is contained in:
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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user