fix(playback): structure MPEG-TS diagnostics (#1327)

* docs(playback): design structured mpegts diagnostics

* docs(playback): plan structured mpegts diagnostics

* fix(playback): structure mpegts error evidence

* fix(playback): structure HTML5 mpegts errors

* fix(playback): share mpegts evidence across players

* fix(playback): render structured mpegts evidence

* docs(playback): document structured mpegts diagnostics

* chore(playback): keep diagnostics lint clean

* docs(playback): complete mpegts diagnostics plan
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 17:13:16 +02:00
1 parent 9bb1811984
commit 0c59aace71
23 files changed
+1886 -189

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: playback
issues: [1159]
---
MPEG-TS playback errors now show exact engine evidence, including HTTP status,
without exposing provider response details. Format, codec, truncated-stream,
and MediaSource failures now produce more accurate fallback guidance.
+6
View File
@@ -218,6 +218,12 @@ Key files:
preflight stays unknown but keeps external fallback for clear DASH; KODIPROP
DRM still suppresses it. See the CLAUDE.md "Video Players" feature entry and
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- mpegts.js `1.8.0` errors from HTML5, Video.js, and ArtPlayer cross one
version-locked structured evidence boundary. Only exact public type/detail
pairs, pair-derived stage/failure, terminal disposition, and the validated
HTTP 4xx/5xx status slot are retained; raw messages and arbitrary `info`
never reach diagnostics. This is a sibling of `PlayerController`, not part
of the controls contract.
- The built-in HTML5/hls.js player is the second guarded consumer.
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
+8
View File
@@ -723,6 +723,14 @@ app as a real argument, so it is not an option.
**Video Players**:
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- mpegts.js `1.8.0` errors from all three built-in players cross one
version-locked structured evidence boundary. It retains only exact public
type/detail pairs, pair-derived stage/failure, terminal disposition, and a
validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach
stored or rendered diagnostics. HTTP/network failures avoid false decoder
recommendations, while exact format, codec, truncated-stream, and
MediaSource failures retain actionable fallback guidance. This diagnostic
layer remains separate from the shared `PlayerController` controls contract.
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
+24 -2
View File
@@ -432,14 +432,36 @@ configuration.
`network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
mpegts.js `Early-EOF` failures on MPEG-TS streams are classified as `media-decode-error` instead of generic `network-error`. These failures usually mean the fetch stream ended before mpegts.js expected a complete transport stream, and external players may still handle the same URL more tolerant of short reads or malformed TS boundaries.
mpegts.js `1.8.0` errors cross one shared structured boundary before the HTML5,
Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare
the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted
contract. Evidence retains only an exact type/detail pair, terminal
disposition, a pair-derived stage and failure, and a validated HTTP 4xx/5xx
status from the top-level `info.code` slot of
`NetworkError + HttpStatusCodeInvalid`.
Exact public pairs classify HTTP/timeout/exception as network failures,
`FormatUnsupported` as an unsupported container, `CodecUnsupported` as an
unsupported codec, and `FormatError`/`MediaMSEError` as media failures.
`UnrecoverableEarlyEof` remains a fallback-actionable `media-decode-error`:
mpegts.js has already exhausted its internal finite-source early-EOF recovery,
and another demuxer may tolerate the truncated transport stream. Mismatched or
unknown pairs fail closed to `unknown-playback-error`.
Arbitrary `info`, messages, URLs, headers, bodies, credentials, and provider
objects are neither retained nor rendered. A generic `Exception` does not
prove CORS, mixed content, CSP, or private-network access, so mpegts.js no
longer creates `browser-access-error` from message text. HTTP and other network
failures do not claim an external decoder will fix the provider response;
container, codec, truncated-stream, format, and MediaSource failures retain
the existing explicit MPV/VLC fallback behavior.
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, HLS, and Shaka evidence, and existing mpegts details. HLS
structured Video.js/VHS, HLS, Shaka, and mpegts.js evidence. 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.
@@ -0,0 +1,783 @@
# Structured mpegts.js Diagnostics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace mpegts.js message heuristics with a version-locked,
allowlisted evidence boundary shared by HTML5, Video.js, and ArtPlayer.
**Architecture:** Normalize only mpegts.js 1.8.0 public error type/detail
constants and the exact HTTP-status slot into `MpegTsPlaybackEvidence`. The
shared classifier maps internally consistent evidence to existing diagnostic
codes, and the existing viewport renders only structured evidence while the
three engine owners keep their current source and lifecycle behavior.
**Tech Stack:** Angular 21, TypeScript 5.9, mpegts.js 1.8.0, Jest through Nx,
Markdown architecture and release-note documentation.
---
### Task 0: Confirm The Isolated Baseline And Public Runtime
**Files:**
- Verify: `package.json`
- Verify: `pnpm-lock.yaml`
- Verify: `node_modules/mpegts.js/src/player/player-errors.js`
- Verify: `node_modules/mpegts.js/src/io/loader.js`
- Verify: `node_modules/mpegts.js/src/core/transmuxing-controller.js`
- Verify: `node_modules/mpegts.js/d.ts/mpegts.d.ts`
- [x] **Step 1: Create the isolated feature branch from current master**
Run:
```bash
git switch -c agent/structure-mpegts-diagnostics origin/master
```
Expected: branch starts at `9f4e11d6d`, the merge of PR #1318.
- [x] **Step 2: Verify dependency and Nx availability**
Run:
```bash
test -d node_modules
pnpm nx show projects --withTarget test
```
Expected: exit 0 and output containing `ui-playback`.
- [x] **Step 3: Run the affected-project baseline**
Run:
```bash
pnpm nx test ui-playback
```
Expected: 87 suites and 849 tests pass before implementation.
- [x] **Step 4: Audit the installed public contract**
Confirm:
```text
mpegts.js version = 1.8.0
ErrorTypes = NetworkError, MediaError, OtherError
ErrorDetails = Exception, HttpStatusCodeInvalid, ConnectingTimeout,
UnrecoverableEarlyEof, MediaMSEError, FormatError,
FormatUnsupported, CodecUnsupported
HttpStatusCodeInvalid info = { code: HTTP status, msg: status text }
recoverable EarlyEof is handled internally before the public player error
```
### Task 1: Drive The Public Evidence Boundary From Failing Tests
**Files:**
- Create:
`libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts`
- Create:
`libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts`
- Create:
`libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts`
- [x] **Step 1: Write the failing installed-runtime contract test**
Create `mpegts-playback-evidence.util.spec.ts` and assert the real installed
runtime before using any mocks:
```typescript
import mpegts from 'mpegts.js';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
} from './mpegts-playback-evidence.model';
import {
MPEGTS_DIAGNOSTIC_VERSION,
createMpegTsPlaybackEvidence,
} from './mpegts-playback-evidence.util';
describe('mpegts.js playback evidence', () => {
it('locks the accepted public contract to mpegts.js 1.8.0', () => {
expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION);
expect(mpegts.ErrorTypes).toEqual({
NETWORK_ERROR: MpegTsPlaybackEngineType.Network,
MEDIA_ERROR: MpegTsPlaybackEngineType.Media,
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
});
expect(mpegts.ErrorDetails).toEqual({
NETWORK_EXCEPTION:
MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_STATUS_CODE_INVALID:
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
NETWORK_TIMEOUT:
MpegTsPlaybackEngineDetails.ConnectingTimeout,
NETWORK_UNRECOVERABLE_EARLY_EOF:
MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof,
MEDIA_MSE_ERROR: MpegTsPlaybackEngineDetails.MediaMseError,
MEDIA_FORMAT_ERROR: MpegTsPlaybackEngineDetails.FormatError,
MEDIA_FORMAT_UNSUPPORTED:
MpegTsPlaybackEngineDetails.FormatUnsupported,
MEDIA_CODEC_UNSUPPORTED:
MpegTsPlaybackEngineDetails.CodecUnsupported,
});
});
});
```
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/mpegts-playback-evidence.util.spec.ts \
--runInBand
```
Expected: FAIL because the model and evidence modules do not exist.
- [x] **Step 2: Add the focused evidence model**
Create `mpegts-playback-evidence.model.ts` with const-derived unions:
```typescript
export const MpegTsPlaybackEngineType = {
Network: 'NetworkError',
Media: 'MediaError',
Other: 'OtherError',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineType =
(typeof MpegTsPlaybackEngineType)[keyof typeof MpegTsPlaybackEngineType];
export const MpegTsPlaybackEngineDetails = {
NetworkException: 'Exception',
HttpStatusCodeInvalid: 'HttpStatusCodeInvalid',
ConnectingTimeout: 'ConnectingTimeout',
UnrecoverableEarlyEof: 'UnrecoverableEarlyEof',
MediaMseError: 'MediaMSEError',
FormatError: 'FormatError',
FormatUnsupported: 'FormatUnsupported',
CodecUnsupported: 'CodecUnsupported',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineDetails =
(typeof MpegTsPlaybackEngineDetails)[keyof typeof MpegTsPlaybackEngineDetails];
export const MpegTsPlaybackDisposition = { Terminal: 'terminal' } as const;
export type MpegTsPlaybackDisposition =
(typeof MpegTsPlaybackDisposition)[keyof typeof MpegTsPlaybackDisposition];
export const MpegTsPlaybackStage = {
Loader: 'loader',
Demux: 'demux',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackStage =
(typeof MpegTsPlaybackStage)[keyof typeof MpegTsPlaybackStage];
export const MpegTsPlaybackFailure = {
Http: 'http',
Timeout: 'timeout',
Network: 'network',
TruncatedStream: 'truncated-stream',
Format: 'format',
Codec: 'codec',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackFailure =
(typeof MpegTsPlaybackFailure)[keyof typeof MpegTsPlaybackFailure];
export interface MpegTsPlaybackEvidence {
readonly engineType: MpegTsPlaybackEngineType;
readonly engineDetails: MpegTsPlaybackEngineDetails;
readonly disposition: MpegTsPlaybackDisposition;
readonly stage: MpegTsPlaybackStage;
readonly failure: MpegTsPlaybackFailure;
readonly httpStatus?: number;
}
```
Use const-derived aliases for disposition, stage, and failure rather than
duplicating string unions if TypeScript reports drift.
- [x] **Step 3: Add failing sanitization and mapping cases**
Extend the spec with a table covering every consistent pair and expected
stage/failure. Add explicit cases proving:
```typescript
expect(
createMpegTsPlaybackEvidence('NetworkError', 'HttpStatusCodeInvalid', {
code: 404,
msg: 'Not Found secret',
url: 'https://provider.example/live.ts?token=secret',
})
).toEqual({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
disposition: 'terminal',
stage: 'loader',
failure: 'http',
httpStatus: 404,
});
expect(
createMpegTsPlaybackEvidence('OtherError', 'CodecUnsupported', {
code: 503,
})
).toEqual({
engineType: 'OtherError',
engineDetails: 'CodecUnsupported',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
```
Also assert status strings, 399, 600, nested `code`, lowercase constants,
unknown objects, and circular payloads do not add `httpStatus` or throw.
Run the focused spec. Expected: FAIL because the factory is missing.
- [x] **Step 4: Implement the minimal sanitizer**
Create `mpegts-playback-evidence.util.ts` with:
```typescript
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.0';
export function createMpegTsPlaybackEvidence(
type: unknown,
details: unknown,
info: unknown
): MpegTsPlaybackEvidence {
const engineType = normalizeEngineType(type);
const engineDetails = normalizeEngineDetails(details);
const { stage, failure } = deriveMpegTsStageAndFailure(
engineType,
engineDetails
);
const httpStatus =
engineType === MpegTsPlaybackEngineType.Network &&
engineDetails ===
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid
? readHttpStatus(info)
: undefined;
return {
engineType,
engineDetails,
disposition: MpegTsPlaybackDisposition.Terminal,
stage,
failure,
...(httpStatus === undefined ? {} : { httpStatus }),
};
}
```
Implement exact `switch` statements for type/detail normalization and the
eight accepted pairs. `readHttpStatus` reads only top-level `code` and accepts
integers 400–599. Do not stringify, clone, enumerate, or inspect any message or
unknown property.
- [x] **Step 5: Run the evidence spec green**
Run the focused Jest command from Step 1.
Expected: PASS with the runtime contract and sanitizer cases green.
- [x] **Step 6: Commit the evidence boundary**
```bash
git add -- \
libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts \
libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts \
libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts
git commit -m "fix(playback): sanitize mpegts error evidence"
```
### Task 2: Replace Heuristic Classification With Structured Evidence
**Files:**
- 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-error-patterns.util.ts`
- Modify:
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts`
- Test:
`libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts`
- [x] **Step 1: Write failing classifier tests**
Add table-driven expectations that pass evidence, not raw errors:
```typescript
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code: 404, msg: 'secret' }
),
createPlaybackSourceMetadata({
url: 'https://example.test/missing.ts',
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
})
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.MpegTs,
httpStatus: 404,
mpegTs: expect.objectContaining({ failure: 'http' }),
externalFallbackRecommended: false,
})
);
expect(issue.details).toBeUndefined();
```
Cover timeout/exception → network, early EOF/format/MSE → media decode,
unsupported format → container, codec → codec, and inconsistent/other/unknown
→ unknown. Verify only decode/container/codec outcomes recommend fallback.
Run the evidence and diagnostics specs. Expected: FAIL because the classifier
still accepts raw `MpegTsPlaybackErrorInput` and emits `details`.
- [x] **Step 2: Wire evidence into the shared diagnostic model**
In `playback-diagnostics.model.ts`:
```typescript
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
export * from './mpegts-playback-evidence.model';
```
Remove `MpegTsPlaybackErrorInput` and add:
```typescript
readonly mpegTs?: MpegTsPlaybackEvidence;
```
to `PlaybackDiagnostic`.
- [x] **Step 3: Implement evidence-only classification**
Change the signature to:
```typescript
export function classifyMpegTsPlaybackIssue(
evidence: MpegTsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic
```
Map the exact failures from the approved design, pass
`httpStatus: evidence.httpStatus` and `mpegTs: evidence` to
`createPlaybackDiagnostic`, and add `mpegTs` to that factory's options,
destructuring, and result. Export `createMpegTsPlaybackEvidence` from the
diagnostics facade.
- [x] **Step 4: Remove obsolete mpegts text helpers**
Delete `normalizeErrorDetails`, `isNetworkFailure`, `isEarlyEofFailure`, and
their serialization helpers from `playback-error-patterns.util.ts`. Keep
`isBrowserAccessFailure` for native browser errors. Remove other now-unused
text helpers only when `rg` proves they have no callers.
Run:
```bash
rg -n "normalizeErrorDetails|isNetworkFailure|isEarlyEofFailure|MpegTsPlaybackErrorInput" \
libs/ui/playback/src/lib
```
Expected: no matches.
- [x] **Step 5: Run classifier tests green**
Run both focused specs by path.
Expected: PASS with no raw message or arbitrary payload retained.
- [x] **Step 6: Commit structured classification**
```bash
git add -- \
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts \
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts \
libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts \
libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts \
libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts
git commit -m "fix(playback): classify exact mpegts failures"
```
### Task 3: Route HTML5 Through The Shared Boundary
**Files:**
- Modify:
`libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts`
- Modify:
`libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts`
- Modify:
`libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts`
- [x] **Step 1: Make the mpegts fixture emit registered events**
Give `MockMpegTsPlayer` a listener map and `emit(event, ...args)` helper, matching
the existing VJS and ArtPlayer test doubles. Preserve lifecycle logging for
`on`, `off`, attachment, and load.
- [x] **Step 2: Write the failing HTML5 HTTP regression**
Start a `.ts` source, emit:
```typescript
engine.emit('error', 'NetworkError', 'HttpStatusCodeInvalid', {
code: 404,
msg: 'Not Found html-secret',
url: 'https://provider.example/error?token=html-secret',
});
```
Assert the component emits HTML5 source metadata, `network-error`, HTTP 404,
structured `mpegTs` evidence, no raw `details`, and no external fallback.
Run the focused sources spec. Expected: FAIL because the HTML5 helper still
passes a raw error object to the classifier.
- [x] **Step 3: Normalize before classification**
Import `createMpegTsPlaybackEvidence` in
`html-video-player-diagnostics.ts` and change:
```typescript
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(error.type, error.details, error.info),
createHtml5SourceMetadata(url, 'video/mp2t')
)
```
Accept unknown callback values so normalization owns validation.
- [x] **Step 4: Run the HTML5 regression green and commit**
Run the focused sources spec, then:
```bash
git add -- \
libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts \
libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts \
libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts
git commit -m "fix(playback): structure HTML5 mpegts errors"
```
### Task 4: Route Video.js And ArtPlayer Through The Same Boundary
**Files:**
- Modify: `libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts`
- Modify: `libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts`
- Modify: `libs/ui/playback/src/lib/art-player/art-player-source-session.ts`
- Modify: `libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts`
- Modify: `libs/ui/playback/src/lib/art-player/art-player.component.spec.ts`
- [x] **Step 1: Replace the misleading VJS CORS regression**
Change the existing synthetic `FetchError`/CORS case to the real public event:
```typescript
mpegTsPlayer.emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{ code: 503, msg: 'provider secret', headers: { Authorization: 'secret' } }
);
```
Expect `network-error`, HTTP 503, `player=videojs`, exact mpegts evidence, no
fallback, and serialized output without either secret. Add a generic
`NetworkError + Exception` case proving message text that says CORS remains a
generic network diagnostic.
Run the VJS session spec. Expected: FAIL under the raw classifier contract.
- [x] **Step 2: Normalize in VjsMpegTsSession**
Import the factory and call:
```typescript
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, info),
createPlaybackSourceMetadata({
url,
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
})
)
```
Keep duration sync, listener cleanup, play, and teardown unchanged.
- [x] **Step 3: Add the failing ArtPlayer structured regression**
Emit `MediaError + CodecUnsupported` with a secret-bearing `info` object.
Assert `unsupported-codec`, `player=artplayer`, structured evidence, fallback
enabled, and no secret in the emitted issue. Update old lowercase/message-based
fixtures to exact public constants.
Run the ArtPlayer source-session and component specs. Expected: FAIL until the
session uses the factory.
- [x] **Step 4: Normalize in ArtPlayerSourceSession**
Import `createMpegTsPlaybackEvidence` and pass the callback arguments through
it before the shared classifier. Change the stored listener parameter types to
`unknown` so the sanitizer, not the adapter, owns validation. Preserve the
destroyed/current-engine guard.
- [x] **Step 5: Run all three engine-owner specs green and commit**
Run HTML5 sources, VJS session, ArtPlayer source-session, and ArtPlayer
component specs by path. Then:
```bash
git add -- \
libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts \
libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts \
libs/ui/playback/src/lib/art-player/art-player-source-session.ts \
libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts \
libs/ui/playback/src/lib/art-player/art-player.component.spec.ts
git commit -m "fix(playback): share mpegts evidence across players"
```
### Task 5: Render Only Structured mpegts.js 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`
- [x] **Step 1: Write the failing rendering and privacy regression**
Add `createStructuredMpegTsDiagnostic()` with structured HTTP 404 evidence plus
legacy `details` and native message fields containing sentinels. Assert:
```typescript
expect(component.getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=loader · failure=http · type=NetworkError · ' +
'details=HttpStatusCodeInvalid · disposition=terminal · HTTP 404',
},
])
);
expect(renderedDetails).not.toContain('mpegts-render-secret');
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
```
Run the component spec. Expected: FAIL because mpegts evidence is not formatted.
- [x] **Step 2: Add the structured formatter branch**
Before VHS/HLS legacy formatting, add:
```typescript
if (issue.mpegTs) {
return [
`stage=${issue.mpegTs.stage}`,
`failure=${issue.mpegTs.failure}`,
`type=${issue.mpegTs.engineType}`,
`details=${issue.mpegTs.engineDetails}`,
`disposition=${issue.mpegTs.disposition}`,
issue.mpegTs.httpStatus === undefined
? ''
: `HTTP ${issue.mpegTs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
```
Also suppress the native-message row when `issue.mpegTs` exists, matching the
HLS/VHS/Shaka structured boundaries.
- [x] **Step 3: Run the view regression green and commit**
```bash
git add -- \
libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
git commit -m "fix(playback): render structured mpegts evidence"
```
### Task 6: Update Canonical Documentation And Release Note
**Files:**
- Modify: `docs/architecture/embedded-inline-playback.md`
- Modify: `AGENTS.md`
- Modify: `CLAUDE.md`
- Create: `.changes/playback-structured-mpegts-diagnostics.md`
- [x] **Step 1: Update the canonical diagnostic contract**
Replace the current early-EOF-only mpegts paragraph with the 1.8.0 public
boundary: exact type/detail pairs, terminal player events, validated HTTP
status, classification mapping, rejected raw fields, all three owners, and the
absence of message-based browser-access guesses.
- [x] **Step 2: Keep repository guidance current**
Add the same concise contract to the mpegts ownership summaries in `AGENTS.md`
and `CLAUDE.md`: all three owners use the shared version-locked evidence
boundary; raw payloads do not cross it; shared controls remain unchanged.
- [x] **Step 3: Add the user-facing release note**
Create:
```markdown
---
type: fix
area: playback
issues: [1159]
---
MPEG-TS playback errors now show exact engine evidence, including HTTP status,
without exposing provider response details. Format, codec, truncated-stream,
and MediaSource failures now produce more accurate fallback guidance.
```
Keep the body below 400 characters.
- [x] **Step 4: Validate docs and note, then commit**
Run:
```bash
git diff --check
pnpm run release:notes:validate
```
Expected: exit 0. Then:
```bash
git add -- \
docs/architecture/embedded-inline-playback.md \
AGENTS.md CLAUDE.md \
.changes/playback-structured-mpegts-diagnostics.md
git commit -m "docs(playback): document structured mpegts diagnostics"
```
### Task 7: Complete Validation And Local P1/P2 Review
**Files:**
- Verify all files changed since `origin/master`
- [x] **Step 1: Run focused regression tests**
Run all new and changed spec files by path with the web ESM Jest config.
Expected: every focused suite passes.
- [x] **Step 2: Run the affected validation ladder**
```bash
pnpm nx test ui-playback
pnpm nx lint ui-playback
pnpm run typecheck:web
pnpm run i18n:check
pnpm run release:notes:validate
```
Expected: all commands exit 0. No new E2E is required because source routing,
player lifecycle, actions, and UI layout do not change; engine events, all
three adapters, classification, privacy, and rendered output have deterministic
unit/integration coverage.
- [x] **Step 3: Inspect size, scope, and accidental raw-data retention**
```bash
git diff --check origin/master...HEAD
git diff --stat origin/master...HEAD
rg -n "JSON\.stringify|info\.msg|error\.message|normalizeErrorDetails|isNetworkFailure|isEarlyEofFailure" \
libs/ui/playback/src/lib/playback-diagnostics \
libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts \
libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts \
libs/ui/playback/src/lib/art-player/art-player-source-session.ts
```
Expected: no mpegts error serialization/message inference; any unrelated
matches are inspected and justified.
- [x] **Step 4: Perform the requested local Codex review**
Review `origin/master...HEAD` specifically for P1/P2 defects:
- false classification from mismatched type/detail pairs;
- unvalidated or duplicated HTTP status;
- raw provider data escaping through top-level or rendered fields;
- different behavior among HTML5, Video.js, and ArtPlayer;
- missing stale-listener/teardown guards;
- incorrect fallback behavior;
- version-lock drift;
- missing tests or stale canonical docs.
Fix every confirmed P1/P2 finding through a new failing regression first, rerun
the focused and affected validation ladders, and commit the fixes. Repeat until
the review is clean.
- [x] **Step 5: Verify the final branch state**
```bash
git status --short --branch
git log --oneline origin/master..HEAD
git diff --check origin/master...HEAD
```
Expected: clean worktree, focused commits only, and no whitespace errors.
### Task 8: Publish The Pull Request After Explicit Authorization
**Files:**
- Inspect: all changes in `origin/master...HEAD`
- [ ] **Step 1: Confirm final staged and branch scope**
Do not stage unrelated files. Verify the exact branch and commits before push.
- [ ] **Step 2: Push only after user authorization**
```bash
git push -u origin agent/structure-mpegts-diagnostics
```
- [ ] **Step 3: Create one ready PR only after user authorization**
Use base `master`, head `agent/structure-mpegts-diagnostics`, and summarize:
- version-locked public mpegts.js evidence;
- exact HTTP/format/codec/EOF/MSE classification;
- shared HTML5, Video.js, and ArtPlayer integration;
- privacy boundary and future recommendation-layer compatibility;
- tests and validation run.
Link issue #1159 and include the release note. Do not create multiple or draft
PRs unless the user requests that state.
@@ -0,0 +1,301 @@
# Structured mpegts.js Diagnostics
## Context
PR #1314 stopped treating ambiguous native media errors as codec evidence and
retained explicit HTTP status. PR #1316 added an allowlisted hls.js boundary,
PR #1317 added one for Video.js/VHS, and PR #1318 added one for Shaka Player.
mpegts.js is now the remaining browser source engine whose diagnostic path
serializes arbitrary error payloads and classifies them with substring
heuristics.
The locked workspace installs mpegts.js `1.8.0`. Its public player error event
has three arguments: error type, error detail, and an information value. The
runtime exports exact `ErrorTypes` and `ErrorDetails` constants. The current
IPTVnator classifier instead lowercases the arguments, serializes arbitrary
`info`, and searches the resulting text for network, access, codec, format,
MSE, and early-EOF fragments. This can misdiagnose failures, retain provider
text, and miss reliable facts such as the HTTP status carried by
`HttpStatusCodeInvalid`.
All three built-in web players own a mpegts.js integration:
- `HtmlVideoPlayerComponent` for the built-in HTML5 player;
- `VjsMpegTsSession` for Video.js;
- `ArtPlayerSourceSession` for ArtPlayer.
They must cross the same evidence boundary so the selected player does not
change the diagnosis for an identical engine event.
## Goals
- Add one minimal, allowlisted `MpegTsPlaybackEvidence` boundary shared by all
three mpegts.js owners.
- Version-lock the accepted public constants to installed mpegts.js `1.8.0`.
- Classify only exact, internally consistent public type/detail pairs.
- Surface a validated HTTP 4xx/5xx status for
`NetworkError + HttpStatusCodeInvalid`.
- Preserve the existing useful distinction for unrecoverable early EOF while
replacing text inference with the exact public detail.
- Prevent arbitrary `info`, messages, URLs, headers, bodies, credentials, and
provider objects from reaching stored or rendered diagnostics.
- Shape the evidence so a later player-neutral recommendation layer can use it
without coupling diagnostics to `PlayerController`.
## Non-goals
- Building the cross-player recommendation matrix.
- Moving recommendations, diagnostics, or fallback policy into
`PlayerController`.
- Automatic failover, player preference changes, retries beyond those already
owned by mpegts.js, probes, persistence, correlation, or diagnostic history.
- Adding new top-level diagnostic codes or changing diagnostic layout and
translations.
- Inferring CORS, mixed content, Content Security Policy, private-network
access, codec, format, or pipeline stage from message text.
- Inspecting mpegts.js loaders, demuxers, workers, or MediaSource internals at
runtime.
- Changing source selection, live/VOD mode, playback controls, duration
correction, or engine cleanup.
## Approaches Considered
### Public allowlisted evidence boundary (selected)
Normalize the public error-event arguments immediately into a small evidence
object. Accept only exact mpegts.js `1.8.0` type/detail constants, derive stage
and failure from consistent pairs, and read only a validated numeric status
from the documented network error-info slot. Classification and rendering then
receive the sanitized evidence rather than the raw event.
This is the strongest stable contract available. It fixes HTTP diagnostics,
removes unsafe serialization, keeps all three players consistent, and gives a
future recommendation layer explicit facts instead of prose.
### Exact constants plus message heuristics
Keep the public constants but continue reading `info.msg` to infer browser
access or more specific causes. This could preserve a few synthetic CORS cases,
but browser fetch exceptions commonly expose only generic text such as
`Failed to fetch`. Message text is not a stable public taxonomy and may contain
provider data. This approach is rejected.
### Add HTTP status to the existing classifier
Special-case `HttpStatusCodeInvalid` while leaving the rest of the substring
classifier unchanged. This would address issue #1159 narrowly but retain the
privacy and accuracy problems for every other mpegts.js failure. It would also
leave no coherent evidence contract for later recommendations. This approach
is rejected.
## Version-Locked Public Contract
The production allowlist accepts the exact public constants exported by
mpegts.js `1.8.0`.
Error types:
- `NetworkError`;
- `MediaError`;
- `OtherError`.
Error details:
- `Exception`;
- `HttpStatusCodeInvalid`;
- `ConnectingTimeout`;
- `UnrecoverableEarlyEof`;
- `MediaMSEError`;
- `FormatError`;
- `FormatUnsupported`;
- `CodecUnsupported`.
The package also exports loader-only `EarlyEof`, but it is not a public player
`ErrorDetails` value. mpegts.js attempts its finite-source reconnect internally
and exposes only `UnrecoverableEarlyEof` through the player error event when
recovery cannot complete. IPTVnator must not create a terminal diagnostic for
the internal recoverable value.
A contract test compares the allowlist and package version with the real
installed runtime. A dependency upgrade must fail that test and trigger a new
audit before changed or additional constants are accepted.
## Evidence Contract
`MpegTsPlaybackEvidence` contains:
- `engineType`: exact `NetworkError`, `MediaError`, or `OtherError`, otherwise
`unknown`;
- `engineDetails`: one exact public mpegts.js detail, otherwise `unknown`;
- `disposition`: `terminal`;
- `stage`: `loader`, `demux`, `media-source`, or `unknown`;
- `failure`: `http`, `timeout`, `network`, `truncated-stream`, `format`,
`codec`, `media-source`, or `unknown`;
- optional `httpStatus`: an integer from 400 through 599.
Stage and failure names are app-owned and stable. Exact engine type and detail
remain available separately so technical output still identifies the public
mpegts.js condition.
The evidence object does not retain:
- `info.msg` or any other message;
- arbitrary or serialized `info` values;
- request, response, redirect, or source URLs;
- headers, response text, bodies, events, exceptions, or loader objects;
- provider metadata, tokens, cookies, credentials, or unknown properties.
The existing `PlaybackDiagnostic.sourceUrl` remains the active source metadata
used by Retry, Copy URL, and explicit external-player actions. No URL is copied
from an engine error.
## Sanitization And HTTP Status
`createMpegTsPlaybackEvidence(type, details, info)` validates exact,
case-sensitive public values. Unknown strings, differently cased variants,
numbers, objects, and missing values become `unknown`; message fragments never
upgrade them.
The boundary reads `info.code` only for the exact
`NetworkError + HttpStatusCodeInvalid` pair. It retains the value only when it
is an integer from 400 through 599. Status-like fields on any other pair,
inside nested objects, or supplied as strings are ignored. No other `info`
property is read or copied.
The validated status is mirrored to the top-level diagnostic so the existing
metadata badge shows `HTTP 404` or `HTTP 5xx` without a UI redesign.
## Stage And Failure Mapping
Only internally consistent public type/detail pairs receive a stage or failure:
| Public type | Public detail | Stage | Failure |
| --- | --- | --- | --- |
| `NetworkError` | `HttpStatusCodeInvalid` | `loader` | `http` |
| `NetworkError` | `ConnectingTimeout` | `loader` | `timeout` |
| `NetworkError` | `Exception` | `loader` | `network` |
| `NetworkError` | `UnrecoverableEarlyEof` | `loader` | `truncated-stream` |
| `MediaError` | `FormatError` | `demux` | `format` |
| `MediaError` | `FormatUnsupported` | `demux` | `format` |
| `MediaError` | `CodecUnsupported` | `demux` | `codec` |
| `MediaError` | `MediaMSEError` | `media-source` | `media-source` |
`OtherError`, unknown values, and mismatched pairs keep both fields unknown.
For example, `OtherError + CodecUnsupported` must not become codec evidence.
This fail-closed rule prevents a future engine change or malformed event from
borrowing meaning from only half of the public contract.
## Lifecycle And Disposition
The public player `ERROR` event is terminal for IPTVnator's diagnostic
boundary. The installed engine handles recoverable finite-source early EOF
inside `IOController`; only failed recovery becomes
`UnrecoverableEarlyEof` and reaches the player event. Network, demux, and MSE
error paths likewise reach the player event after the active engine operation
has failed.
IPTVnator does not subscribe to internal recovery events or duplicate engine
retry logic. All accepted evidence therefore records `disposition=terminal`.
## User-Facing Classification
`classifyMpegTsPlaybackIssue` consumes sanitized evidence with this mapping:
1. `failure=http`, `timeout`, or `network` → `network-error`.
2. `failure=truncated-stream` → `media-decode-error`.
3. Exact `MediaError + FormatUnsupported` → `unsupported-container`.
4. Exact `MediaError + CodecUnsupported` → `unsupported-codec`.
5. `failure=format` or `media-source` → `media-decode-error`.
6. Everything else → `unknown-playback-error`.
The existing code-derived fallback behavior remains intentional:
- HTTP, timeout, generic network, and unknown failures do not claim that a
different decoder will fix the provider response;
- truncated-stream, unsupported-container, unsupported-codec, format, and
MediaSource failures may offer configured MPV/VLC actions because another
demuxer or media pipeline can plausibly handle the same source.
No mpegts.js error becomes `browser-access-error`. The public constants do not
distinguish CORS, mixed content, CSP, or private-network access from a generic
fetch exception, and status zero is not sufficient evidence.
## Runtime Integration
The raw event must cross the boundary once in each owner:
- `emitMpegTsPlaybackError` creates evidence for
`HtmlVideoPlayerComponent`;
- `VjsMpegTsSession` creates evidence before emitting its issue;
- `ArtPlayerSourceSession` creates evidence before emitting its issue.
All three call the same classifier with source metadata that preserves their
existing `InlinePlaybackPlayer` identity. No owner keeps or forwards the raw
event after normalization. Listener binding, stale-engine guards, live/VOD
mode, duration correction, source routing, play calls, and teardown remain
unchanged.
## Relationship To Shared Controls And Recommendations
`PlayerController` remains the engine-neutral command/state/capability
contract. It does not own error taxonomy, fallback policy, or provider
evidence.
Playback diagnostics are a sibling player-neutral layer: engine adapters emit
sanitized evidence, the diagnostic classifier maps it to a user-facing issue,
and the existing viewport renders that issue. A later recommendation layer can
consume diagnostic code, source, engine evidence, source metadata, and runtime
capabilities to explain whether another player is likely to help. That later
work must not require parsing the technical-details string or changing the
controls contract.
## User Interface
The existing technical `Error details` row renders only structured mpegts.js
evidence, for example:
`stage=loader · failure=http · type=NetworkError · details=HttpStatusCodeInvalid · disposition=terminal · HTTP 404`
When `issue.mpegTs` exists, legacy raw `details` and native message fields are
ignored even if a caller accidentally supplies them. Existing titles,
descriptions, HTTP badge, Retry, Copy URL, external-player actions, layout, and
translations remain unchanged.
## Testing
Use test-driven development:
- Compare the accepted constants and version with the real installed
mpegts.js `1.8.0` runtime.
- Cover every consistent public type/detail mapping.
- Prove mismatched, unknown, differently cased, and malformed inputs remain
unknown.
- Prove only exact `HttpStatusCodeInvalid` can expose a validated HTTP 4xx/5xx
status.
- Prove `info.msg`, nested status, URLs, headers, bodies, exceptions, and
arbitrary provider objects never enter evidence or rendered details.
- Cover HTTP 404, timeout, exception, unrecoverable early EOF, format error,
unsupported format, unsupported codec, MSE error, other, and unknown
classification.
- Cover evidence creation and issue emission through HTML5, Video.js, and
ArtPlayer integrations.
- Prove the rendered detail row uses only structured mpegts.js evidence and
preserves the HTTP metadata badge.
Run the focused red/green tests, complete `ui-playback` unit and lint targets,
web typecheck, i18n validation, release-note validation, and the repository
test-impact pass. No new E2E case is required because source selection,
playback lifecycle, controls, actions, and diagnostic layout remain unchanged;
the engine event, three adapter boundaries, classifier, and rendered output are
covered directly and deterministically.
## Documentation And Release Note
Update `docs/architecture/embedded-inline-playback.md` as the canonical
browser diagnostic contract. Update the mpegts.js diagnostic summaries in
`AGENTS.md` and `CLAUDE.md` because they describe the affected player engines
and evidence boundaries. No `player-controls-contract.md` change is needed
because the controls contract does not change.
Add a `fix(playback)` note under `.changes/` because users receive accurate HTTP
status, safer technical details, and more precise mpegts.js diagnoses.
@@ -186,10 +186,11 @@ describe('ArtPlayerSourceSession', () => {
details: 'stale callback',
fatal: true,
});
const secret = 'art-mpegts-secret';
mpegTsInstances.at(-1)?.handlers.get('error')?.(
'mediaError',
'unsupported codec',
{}
'MediaError',
'CodecUnsupported',
{ message: secret, headers: { Authorization: secret } }
);
expect(emitted).toEqual([
@@ -199,11 +200,18 @@ describe('ArtPlayerSourceSession', () => {
player: 'artplayer',
}),
expect.objectContaining({
code: 'unsupported-codec',
source: 'mpegts',
sourceUrl: 'https://example.test/live.ts',
player: 'artplayer',
mpegTs: expect.objectContaining({
engineType: 'MediaError',
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
}),
]);
expect(JSON.stringify(emitted)).not.toContain(secret);
});
it('reports only structured evidence for a fatal HLS manifest HTTP failure', () => {
@@ -10,6 +10,7 @@ import {
classifyMpegTsPlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { WebVideoControlsAdapter } from '../player-controls';
@@ -66,7 +67,7 @@ export class ArtPlayerSourceSession {
| null = null;
private mpegTsPlayer: mpegts.Player | null = null;
private mpegTsErrorListener:
| ((type: string, details: string, info: unknown) => void)
| ((type: unknown, details: unknown, info: unknown) => void)
| null = null;
private shakaSession: ShakaVideoSession | null = null;
private destroyed = false;
@@ -192,7 +193,7 @@ export class ArtPlayerSourceSession {
}
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
{ type, details, info },
createMpegTsPlaybackEvidence(type, details, info),
this.createSourceMetadata(url, 'video/mp2t')
)
);
@@ -239,10 +239,11 @@ describe('ArtPlayerComponent', () => {
artPlayerInstances[0].video,
'https://example.com/live/channel.ts'
);
const secret = 'art-component-mpegts-secret';
mpegTsInstances[0].handlers.get('error')?.(
'mediaError',
'unsupported codec',
{}
'MediaError',
'CodecUnsupported',
{ message: secret }
);
expect(issues).toEqual([
@@ -250,9 +251,15 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-codec',
source: 'mpegts',
sourceUrl: 'https://example.com/live/channel.ts',
mpegTs: expect.objectContaining({
engineType: 'MediaError',
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
externalFallbackRecommended: true,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
});
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
@@ -7,6 +7,7 @@ import {
classifyMpegTsPlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
@@ -69,12 +70,16 @@ export function emitFatalHlsPlaybackError(
export function emitMpegTsPlaybackError(
url: string,
error: { type: string; details: string; info: unknown },
error: { type: unknown; details: unknown; info: unknown },
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
): void {
emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
error,
createMpegTsPlaybackEvidence(
error.type,
error.details,
error.info
),
createHtml5SourceMetadata(url, 'video/mp2t')
)
);
@@ -114,6 +114,52 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
}
);
it('emits structured HTTP evidence for mpegts.js failures', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: {
...TEST_CHANNEL,
url: 'https://example.test/missing.ts',
},
}
);
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
const secret = 'html-mpegts-secret';
mpegTsInstances[0].emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
}
);
expect(issues).toEqual([
expect.objectContaining({
code: 'network-error',
source: 'mpegts',
sourceUrl: 'https://example.test/missing.ts',
player: 'html5',
httpStatus: 404,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
});
it('owns one MPEG-TS source between media attachment and loading', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
@@ -93,10 +93,22 @@ export class MockHls {
}
export class MockMpegTsPlayer {
private readonly listeners = new Map<
string,
Set<(...args: unknown[]) => void>
>();
readonly attachMediaElement = jest.fn(() =>
lifecycle.push('mpegts:attachMedia')
);
readonly on = jest.fn();
readonly on = jest.fn(
(event: string, listener: (...args: unknown[]) => void) => {
const listeners =
this.listeners.get(event) ??
new Set<(...args: unknown[]) => void>();
listeners.add(listener);
this.listeners.set(event, listeners);
}
);
readonly load = jest.fn(() => lifecycle.push('mpegts:load'));
readonly pause = jest.fn();
readonly unload = jest.fn();
@@ -106,6 +118,12 @@ export class MockMpegTsPlayer {
constructor() {
mpegTsInstances.push(this);
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
}
export const mpegTsCreatePlayer = jest.fn(() => new MockMpegTsPlayer());
@@ -0,0 +1,64 @@
export const MpegTsPlaybackEngineType = {
Network: 'NetworkError',
Media: 'MediaError',
Other: 'OtherError',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineType =
(typeof MpegTsPlaybackEngineType)[keyof typeof MpegTsPlaybackEngineType];
export const MpegTsPlaybackEngineDetails = {
NetworkException: 'Exception',
HttpStatusCodeInvalid: 'HttpStatusCodeInvalid',
ConnectingTimeout: 'ConnectingTimeout',
UnrecoverableEarlyEof: 'UnrecoverableEarlyEof',
MediaMseError: 'MediaMSEError',
FormatError: 'FormatError',
FormatUnsupported: 'FormatUnsupported',
CodecUnsupported: 'CodecUnsupported',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineDetails =
(typeof MpegTsPlaybackEngineDetails)[keyof typeof MpegTsPlaybackEngineDetails];
export const MpegTsPlaybackDisposition = {
Terminal: 'terminal',
} as const;
export type MpegTsPlaybackDisposition =
(typeof MpegTsPlaybackDisposition)[keyof typeof MpegTsPlaybackDisposition];
export const MpegTsPlaybackStage = {
Loader: 'loader',
Demux: 'demux',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackStage =
(typeof MpegTsPlaybackStage)[keyof typeof MpegTsPlaybackStage];
export const MpegTsPlaybackFailure = {
Http: 'http',
Timeout: 'timeout',
Network: 'network',
TruncatedStream: 'truncated-stream',
Format: 'format',
Codec: 'codec',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackFailure =
(typeof MpegTsPlaybackFailure)[keyof typeof MpegTsPlaybackFailure];
export interface MpegTsPlaybackEvidence {
readonly engineType: MpegTsPlaybackEngineType;
readonly engineDetails: MpegTsPlaybackEngineDetails;
readonly disposition: MpegTsPlaybackDisposition;
readonly stage: MpegTsPlaybackStage;
readonly failure: MpegTsPlaybackFailure;
readonly httpStatus?: number;
}
@@ -0,0 +1,239 @@
import mpegts from 'mpegts.js';
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
classifyMpegTsPlaybackIssue,
createPlaybackSourceMetadata,
} from './playback-diagnostics.util';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
} from './mpegts-playback-evidence.model';
import {
MPEGTS_DIAGNOSTIC_VERSION,
createMpegTsPlaybackEvidence,
} from './mpegts-playback-evidence.util';
const METADATA = createPlaybackSourceMetadata({
url: 'https://example.test/live.ts',
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
});
describe('mpegts.js playback evidence', () => {
it('locks the accepted public contract to mpegts.js 1.8.0', () => {
expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION);
expect(mpegts.ErrorTypes).toEqual({
NETWORK_ERROR: MpegTsPlaybackEngineType.Network,
MEDIA_ERROR: MpegTsPlaybackEngineType.Media,
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
});
expect(mpegts.ErrorDetails).toEqual({
NETWORK_EXCEPTION:
MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_STATUS_CODE_INVALID:
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
NETWORK_UNRECOVERABLE_EARLY_EOF:
MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof,
MEDIA_MSE_ERROR: MpegTsPlaybackEngineDetails.MediaMseError,
MEDIA_FORMAT_ERROR: MpegTsPlaybackEngineDetails.FormatError,
MEDIA_FORMAT_UNSUPPORTED:
MpegTsPlaybackEngineDetails.FormatUnsupported,
MEDIA_CODEC_UNSUPPORTED:
MpegTsPlaybackEngineDetails.CodecUnsupported,
});
});
it.each([
[
'NetworkError',
'HttpStatusCodeInvalid',
'loader',
'http',
],
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
['NetworkError', 'Exception', 'loader', 'network'],
[
'NetworkError',
'UnrecoverableEarlyEof',
'loader',
'truncated-stream',
],
['MediaError', 'FormatError', 'demux', 'format'],
['MediaError', 'FormatUnsupported', 'demux', 'format'],
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
[
'MediaError',
'MediaMSEError',
'media-source',
'media-source',
],
])(
'maps %s + %s to %s/%s',
(engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(
engineType,
engineDetails,
undefined
)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
}
);
it.each([
['OtherError', 'CodecUnsupported'],
['NetworkError', 'CodecUnsupported'],
['MediaError', 'ConnectingTimeout'],
['networkerror', 'HttpStatusCodeInvalid'],
['NetworkError', 'httpstatuscodeinvalid'],
[{ type: 'NetworkError' }, 'Exception'],
['MediaError', { details: 'FormatError' }],
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
});
it.each([399, 600, 404.5, '404', null, undefined])(
'rejects invalid HTTP status %p',
(code) => {
const evidence = createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code }
);
expect(evidence.httpStatus).toBeUndefined();
}
);
it('reads HTTP status only from the exact top-level public slot', () => {
expect(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code: 404 }
).httpStatus
).toBe(404);
expect(
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
code: 503,
}).httpStatus
).toBeUndefined();
expect(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ response: { code: 503 } }
).httpStatus
).toBeUndefined();
});
it('does not inspect or retain arbitrary engine information', () => {
const secret = 'mpegts-info-secret';
const circularInfo: Record<string, unknown> = {
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
headers: { Authorization: secret },
};
circularInfo['self'] = circularInfo;
const evidence = createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
circularInfo
);
expect(evidence.httpStatus).toBe(404);
expect(JSON.stringify(evidence)).not.toContain(secret);
expect(JSON.stringify(evidence)).not.toContain('provider.example');
});
it.each([
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
['NetworkError', 'ConnectingTimeout', 'network-error', false],
['NetworkError', 'Exception', 'network-error', false],
[
'NetworkError',
'UnrecoverableEarlyEof',
'media-decode-error',
true,
],
['MediaError', 'FormatError', 'media-decode-error', true],
[
'MediaError',
'FormatUnsupported',
'unsupported-container',
true,
],
[
'MediaError',
'CodecUnsupported',
'unsupported-codec',
true,
],
['MediaError', 'MediaMSEError', 'media-decode-error', true],
['OtherError', 'Exception', 'unknown-playback-error', false],
])(
'classifies %s + %s as %s with fallback=%s',
(type, details, code, externalFallbackRecommended) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
expect(issue.code).toBe(code);
expect(issue.externalFallbackRecommended).toBe(
externalFallbackRecommended
);
expect(issue.details).toBeUndefined();
}
);
it('preserves an exact HTTP failure without retaining provider details', () => {
const secret = 'mpegts-http-secret';
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
}
),
METADATA
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 404,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
disposition: 'terminal',
stage: 'loader',
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
expect(issue.details).toBeUndefined();
expect(JSON.stringify(issue)).not.toContain(secret);
});
});
@@ -0,0 +1,167 @@
import {
type MpegTsPlaybackEngineDetails as MpegTsPlaybackEngineDetailsValue,
type MpegTsPlaybackEngineType as MpegTsPlaybackEngineTypeValue,
type MpegTsPlaybackEvidence,
type MpegTsPlaybackFailure as MpegTsPlaybackFailureValue,
type MpegTsPlaybackStage as MpegTsPlaybackStageValue,
MpegTsPlaybackDisposition,
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
MpegTsPlaybackFailure,
MpegTsPlaybackStage,
} from './mpegts-playback-evidence.model';
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.0';
interface MpegTsPlaybackCause {
readonly stage: MpegTsPlaybackStageValue;
readonly failure: MpegTsPlaybackFailureValue;
}
export function createMpegTsPlaybackEvidence(
type: unknown,
details: unknown,
info: unknown
): MpegTsPlaybackEvidence {
const engineType = normalizeEngineType(type);
const engineDetails = normalizeEngineDetails(details);
const cause = deriveMpegTsPlaybackCause(engineType, engineDetails);
const httpStatus =
engineType === MpegTsPlaybackEngineType.Network &&
engineDetails ===
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid
? readHttpStatus(info)
: undefined;
return {
engineType,
engineDetails,
disposition: MpegTsPlaybackDisposition.Terminal,
...cause,
...(httpStatus === undefined ? {} : { httpStatus }),
};
}
function normalizeEngineType(value: unknown): MpegTsPlaybackEngineTypeValue {
switch (value) {
case MpegTsPlaybackEngineType.Network:
case MpegTsPlaybackEngineType.Media:
case MpegTsPlaybackEngineType.Other:
return value;
default:
return MpegTsPlaybackEngineType.Unknown;
}
}
function normalizeEngineDetails(
value: unknown
): MpegTsPlaybackEngineDetailsValue {
switch (value) {
case MpegTsPlaybackEngineDetails.NetworkException:
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
case MpegTsPlaybackEngineDetails.MediaMseError:
case MpegTsPlaybackEngineDetails.FormatError:
case MpegTsPlaybackEngineDetails.FormatUnsupported:
case MpegTsPlaybackEngineDetails.CodecUnsupported:
return value;
default:
return MpegTsPlaybackEngineDetails.Unknown;
}
}
function deriveMpegTsPlaybackCause(
type: MpegTsPlaybackEngineTypeValue,
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
if (type === MpegTsPlaybackEngineType.Network) {
return deriveNetworkCause(details);
}
if (type === MpegTsPlaybackEngineType.Media) {
return deriveMediaCause(details);
}
return unknownCause();
}
function deriveNetworkCause(
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
switch (details) {
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Http
);
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Timeout
);
case MpegTsPlaybackEngineDetails.NetworkException:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Network
);
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.TruncatedStream
);
default:
return unknownCause();
}
}
function deriveMediaCause(
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
switch (details) {
case MpegTsPlaybackEngineDetails.FormatError:
case MpegTsPlaybackEngineDetails.FormatUnsupported:
return cause(
MpegTsPlaybackStage.Demux,
MpegTsPlaybackFailure.Format
);
case MpegTsPlaybackEngineDetails.CodecUnsupported:
return cause(
MpegTsPlaybackStage.Demux,
MpegTsPlaybackFailure.Codec
);
case MpegTsPlaybackEngineDetails.MediaMseError:
return cause(
MpegTsPlaybackStage.MediaSource,
MpegTsPlaybackFailure.MediaSource
);
default:
return unknownCause();
}
}
function cause(
stage: MpegTsPlaybackStageValue,
failure: MpegTsPlaybackFailureValue
): MpegTsPlaybackCause {
return { stage, failure };
}
function unknownCause(): MpegTsPlaybackCause {
return cause(
MpegTsPlaybackStage.Unknown,
MpegTsPlaybackFailure.Unknown
);
}
function readHttpStatus(info: unknown): number | undefined {
if (typeof info !== 'object' || info === null || Array.isArray(info)) {
return undefined;
}
const code = (info as Readonly<Record<string, unknown>>)['code'];
return typeof code === 'number' &&
Number.isInteger(code) &&
code >= 400 &&
code <= 599
? code
: undefined;
}
@@ -3,6 +3,9 @@ import type {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
export * from './mpegts-playback-evidence.model';
export const PlaybackDiagnosticCode = {
UnsupportedContainer: 'unsupported-container',
@@ -243,13 +246,6 @@ export interface ShakaPlaybackEvidence {
readonly httpStatus?: number;
}
export interface MpegTsPlaybackErrorInput {
readonly type?: string;
readonly details?: string;
readonly message?: string;
readonly info?: unknown;
}
export interface PlaybackDiagnostic {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
@@ -266,6 +262,7 @@ export interface PlaybackDiagnostic {
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
@@ -10,6 +10,7 @@ import {
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
@@ -584,13 +585,12 @@ describe('playback diagnostics', () => {
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies mpegts browser fetch restrictions separately from generic network errors', () => {
it('does not guess mpegts browser access from exception messages', () => {
const secret = 'blocked by CORS policy';
const issue = classifyMpegTsPlaybackIssue(
{
type: 'NetworkError',
details:
'Fetch blocked by access-control policy while loading segment',
},
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
msg: secret,
}),
createPlaybackSourceMetadata({
url: 'https://provider.example/live/channel.ts',
mimeType: 'video/mp2t',
@@ -598,17 +598,18 @@ describe('playback diagnostics', () => {
})
);
expect(issue.code).toBe('browser-access-error');
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(JSON.stringify(issue)).not.toContain(secret);
});
it('classifies mpegts early EOF failures as fallback-actionable media errors', () => {
const issue = classifyMpegTsPlaybackIssue(
{
type: 'NetworkError',
details: 'UnrecoverableEarlyEof',
info: { msg: 'Fetch stream meet Early-EOF' },
},
createMpegTsPlaybackEvidence(
'NetworkError',
'UnrecoverableEarlyEof',
{ msg: 'Fetch stream meet Early-EOF' }
),
createPlaybackSourceMetadata({
url: 'https://provider.example/movie/123.ts',
mimeType: 'video/mp2t',
@@ -618,15 +619,17 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.details).toContain('Early-EOF');
expect(issue.mpegTs?.failure).toBe('truncated-stream');
expect(issue.details).toBeUndefined();
});
it('classifies mpegts codec errors as unsupported codec fallbacks', () => {
const issue = classifyMpegTsPlaybackIssue(
{
type: 'MediaError',
details: 'MediaCodecUnsupported',
},
createMpegTsPlaybackEvidence(
'MediaError',
'CodecUnsupported',
undefined
),
createPlaybackSourceMetadata({
url: 'https://example.com/live/channel.ts',
mimeType: 'video/mp2t',
@@ -1,6 +1,6 @@
import type {
HlsPlaybackEvidence,
MpegTsPlaybackErrorInput,
MpegTsPlaybackEvidence,
NativePlaybackErrorInput,
PlaybackDiagnostic,
PlaybackDiagnosticCode,
@@ -12,23 +12,23 @@ import type {
} from './playback-diagnostics.model';
import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model';
import { HlsPlaybackDisposition } from './playback-diagnostics.model';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackFailure,
} from './mpegts-playback-evidence.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,
isEarlyEofFailure,
isNetworkFailure,
normalizeErrorDetails,
} from './playback-error-patterns.util';
import { isBrowserAccessFailure } 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 { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.util';
export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export {
createPlaybackSourceMetadata,
@@ -163,65 +163,15 @@ export function classifyHlsPlaybackIssue(
}
export function classifyMpegTsPlaybackIssue(
error: MpegTsPlaybackErrorInput,
evidence: MpegTsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const details = normalizeErrorDetails(error);
const lowerDetails = details.toLowerCase();
const lowerType = (error.type ?? '').toLowerCase();
if (isEarlyEofFailure(lowerDetails)) {
return createPlaybackDiagnostic({
code: DiagnosticCode.MediaDecodeError,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (isNetworkFailure(lowerType, lowerDetails)) {
return createPlaybackDiagnostic({
code: isBrowserAccessFailure(lowerDetails)
? DiagnosticCode.BrowserAccessError
: DiagnosticCode.NetworkError,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (lowerDetails.includes('codec')) {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedCodec,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (lowerDetails.includes('format') || lowerDetails.includes('mse')) {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedContainer,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (lowerType.includes('media')) {
return createPlaybackDiagnostic({
code: DiagnosticCode.MediaDecodeError,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
return createPlaybackDiagnostic({
code: DiagnosticCode.UnknownPlaybackError,
code: getMpegTsPlaybackDiagnosticCode(evidence),
source: DiagnosticSource.MpegTs,
metadata,
details,
httpStatus: evidence.httpStatus,
mpegTs: evidence,
});
}
@@ -258,6 +208,7 @@ export function createPlaybackDiagnostic(options: {
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
@@ -274,6 +225,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorType,
vhs,
hls,
mpegTs,
shaka,
} = options;
@@ -293,6 +245,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorType,
vhs,
hls,
mpegTs,
shaka,
externalFallbackRecommended:
options.externalFallbackRecommended ??
@@ -300,6 +253,29 @@ export function createPlaybackDiagnostic(options: {
};
}
function getMpegTsPlaybackDiagnosticCode(
evidence: MpegTsPlaybackEvidence
): PlaybackDiagnosticCode {
switch (evidence.failure) {
case MpegTsPlaybackFailure.Http:
case MpegTsPlaybackFailure.Timeout:
case MpegTsPlaybackFailure.Network:
return DiagnosticCode.NetworkError;
case MpegTsPlaybackFailure.TruncatedStream:
case MpegTsPlaybackFailure.MediaSource:
return DiagnosticCode.MediaDecodeError;
case MpegTsPlaybackFailure.Codec:
return DiagnosticCode.UnsupportedCodec;
case MpegTsPlaybackFailure.Format:
return evidence.engineDetails ===
MpegTsPlaybackEngineDetails.FormatUnsupported
? DiagnosticCode.UnsupportedContainer
: DiagnosticCode.MediaDecodeError;
default:
return DiagnosticCode.UnknownPlaybackError;
}
}
function getVhsPlaybackDiagnosticCode(
evidence: VhsPlaybackEvidence,
metadata: PlaybackSourceMetadata
@@ -1,25 +1,3 @@
import type { MpegTsPlaybackErrorInput } from './playback-diagnostics.model';
export function normalizeErrorDetails(
error: MpegTsPlaybackErrorInput
): string {
const info = normalizeErrorPayload(error.info);
return [error.details, error.message, info]
.filter((part): part is string => Boolean(part))
.join(' ');
}
export function isNetworkFailure(type: string, details: string): boolean {
return (
type.includes('network') ||
details.includes('network') ||
details.includes('loaderror') ||
details.includes('timeout') ||
details.includes('status')
);
}
export function isBrowserAccessFailure(details: string): boolean {
return (
details.includes('cors') ||
@@ -38,53 +16,3 @@ export function isBrowserAccessFailure(details: string): boolean {
details.includes('err_cleartext')
);
}
export function isEarlyEofFailure(details: string): boolean {
const compactDetails = details.replace(/[^a-z0-9]/g, '');
return compactDetails.includes('earlyeof');
}
export function isCodecFailure(details: string): boolean {
return (
details.includes('codec') ||
details.includes('incompatiblecodecs') ||
details.includes('addcodec')
);
}
export function isDrmOrEncryptionFailure(details: string): boolean {
return (
details.includes('decrypt') ||
details.includes('keysystem') ||
details.includes('keyload') ||
details.includes('license') ||
details.includes('drm')
);
}
function normalizeErrorPayload(payload: unknown): string {
if (!payload) {
return '';
}
if (typeof payload === 'string') {
return payload;
}
if (payload instanceof Error) {
const extraDetails = stringifyUnknown(payload);
return [payload.message, extraDetails === '{}' ? '' : extraDetails]
.filter(Boolean)
.join(' ');
}
return stringifyUnknown(payload);
}
function stringifyUnknown(value: unknown): string {
try {
return JSON.stringify(value) || '';
} catch {
return String(value);
}
}
@@ -111,28 +111,68 @@ describe('VjsMpegTsSession', () => {
expect(duration).toHaveBeenCalledWith(200);
});
it('classifies engine errors with Video.js source metadata', () => {
it('emits structured HTTP evidence with Video.js source metadata', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
const secret = 'vjs-mpegts-secret';
session.start('https://example.test/live/stream.ts', video);
mpegTsPlayer.emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 503,
msg: `Service Unavailable ${secret}`,
headers: { Authorization: secret },
}
);
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'network-error',
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
httpStatus: 503,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
failure: 'http',
httpStatus: 503,
}),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
secret
);
});
it('does not guess browser access from a generic mpegts exception', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
session.start('https://example.test/live/stream.ts', video);
mpegTsPlayer.emit(
'error',
'NetworkError',
'FetchError',
new Error('CORS blocked')
);
mpegTsPlayer.emit('error', 'NetworkError', 'Exception', {
code: -1,
msg: 'blocked by CORS policy',
});
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'browser-access-error',
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
code: 'network-error',
mpegTs: expect.objectContaining({ failure: 'network' }),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
'CORS'
);
});
it('replaces an active session and removes listeners from the old video', () => {
@@ -3,6 +3,7 @@ import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
@@ -59,12 +60,7 @@ export class VjsMpegTsSession {
this.syncDuration();
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
{
type: typeof type === 'string' ? type : undefined,
details:
typeof details === 'string' ? details : undefined,
info,
},
createMpegTsPlaybackEvidence(type, details, info),
createPlaybackSourceMetadata({
url,
mimeType: 'video/mp2t',
@@ -83,7 +83,7 @@ export function getDiagnosticDetails(
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value:
issue.vhs || issue.shaka
issue.vhs || issue.mpegTs || issue.shaka
? ''
: (issue.nativeErrorMessage ?? ''),
},
@@ -95,6 +95,21 @@ export function getDiagnosticDetails(
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.mpegTs) {
return [
`stage=${issue.mpegTs.stage}`,
`failure=${issue.mpegTs.failure}`,
`type=${issue.mpegTs.engineType}`,
`details=${issue.mpegTs.engineDetails}`,
`disposition=${issue.mpegTs.disposition}`,
issue.mpegTs.httpStatus === undefined
? ''
: `HTTP ${issue.mpegTs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.shaka) {
return [
`stage=${issue.shaka.stage}`,
@@ -401,6 +401,37 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('response body');
});
it('renders only sanitized structured mpegts evidence in technical details', () => {
const issue = createStructuredMpegTsDiagnostic();
component.handlePlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(details).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'mpegts.js',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=loader · failure=http · type=NetworkError · ' +
'details=HttpStatusCodeInvalid · ' +
'disposition=terminal · HTTP 404',
},
])
);
expect(renderedDetails).not.toContain('mpegts-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';
@@ -1038,3 +1069,30 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: false,
};
}
function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.MpegTs,
sourceUrl:
'https://provider.example/live.ts?token=mpegts-render-secret',
container: 'ts',
mimeType: 'video/mp2t',
player: 'html5',
audioCodecs: [],
videoCodecs: [],
details: 'Authorization response body mpegts-render-secret',
nativeErrorMessage:
'https://provider.example/error?token=mpegts-render-secret',
httpStatus: 404,
mpegTs: {
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
disposition: 'terminal',
stage: 'loader',
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}