mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
);
|
||||
|
||||
+46
@@ -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(
|
||||
|
||||
+19
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user