From 0c59aace7159ea77b5d3a6e97a246e98f63ddd0e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:13:16 +0200 Subject: [PATCH] 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 --- .../playback-structured-mpegts-diagnostics.md | 9 + AGENTS.md | 6 + CLAUDE.md | 8 + docs/architecture/embedded-inline-playback.md | 26 +- ...026-08-01-structured-mpegts-diagnostics.md | 783 ++++++++++++++++++ ...01-structured-mpegts-diagnostics-design.md | 301 +++++++ .../art-player-source-session.spec.ts | 14 +- .../art-player/art-player-source-session.ts | 5 +- .../art-player/art-player.component.spec.ts | 13 +- .../html-video-player-diagnostics.ts | 9 +- ....component.shared-controls.sources.spec.ts | 46 + ...component.shared-controls.spec-fixtures.ts | 20 +- .../mpegts-playback-evidence.model.ts | 64 ++ .../mpegts-playback-evidence.util.spec.ts | 239 ++++++ .../mpegts-playback-evidence.util.ts | 167 ++++ .../playback-diagnostics.model.ts | 11 +- .../playback-diagnostics.util.spec.ts | 39 +- .../playback-diagnostics.util.ts | 98 +-- .../playback-error-patterns.util.ts | 72 -- .../lib/vjs-player/vjs-mpegts-session.spec.ts | 62 +- .../src/lib/vjs-player/vjs-mpegts-session.ts | 8 +- .../web-player-view-diagnostics.utils.ts | 17 +- .../web-player-view.component.spec.ts | 58 ++ 23 files changed, 1886 insertions(+), 189 deletions(-) create mode 100644 .changes/playback-structured-mpegts-diagnostics.md create mode 100644 docs/superpowers/plans/2026-08-01-structured-mpegts-diagnostics.md create mode 100644 docs/superpowers/specs/2026-08-01-structured-mpegts-diagnostics-design.md create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts diff --git a/.changes/playback-structured-mpegts-diagnostics.md b/.changes/playback-structured-mpegts-diagnostics.md new file mode 100644 index 000000000..2b445d295 --- /dev/null +++ b/.changes/playback-structured-mpegts-diagnostics.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 05878a128..0829e2403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a01743a2e..531033b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.*` diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 395a7349a..426664fba 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/docs/superpowers/plans/2026-08-01-structured-mpegts-diagnostics.md b/docs/superpowers/plans/2026-08-01-structured-mpegts-diagnostics.md new file mode 100644 index 000000000..b26fc7fbb --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-structured-mpegts-diagnostics.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-01-structured-mpegts-diagnostics-design.md b/docs/superpowers/specs/2026-08-01-structured-mpegts-diagnostics-design.md new file mode 100644 index 000000000..7c0baeab7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-structured-mpegts-diagnostics-design.md @@ -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. diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index 3533dfd45..fcf24cf3b 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -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', () => { diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index b5cda67c8..5c71da94b 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -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') ) ); diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 8ad9a83b7..60f0f0bcb 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -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', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts index 3d4ffa8bd..048176b95 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -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') ) ); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts index 929772534..ac5f748d5 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts @@ -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( diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts index 65bce6152..006f4d97b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts @@ -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()); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts new file mode 100644 index 000000000..072334560 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts new file mode 100644 index 000000000..583f4cd34 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts @@ -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 = { + 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); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts new file mode 100644 index 000000000..f816fcfab --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts @@ -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>)['code']; + return typeof code === 'number' && + Number.isInteger(code) && + code >= 400 && + code <= 599 + ? code + : undefined; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts index df9185068..31c3a94df 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -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; } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts index 535ad86ff..bda83e268 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts @@ -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', diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index 6e62f1bc5..c6c61aa53 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -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 diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts index 996268692..cd2428fd4 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts @@ -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); - } -} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts index d472e0e80..e8603a3ad 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts @@ -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', () => { diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts index 6ee7e4fc0..3e272eb0e 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts @@ -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', diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts index 08d536456..284ab8575 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -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}`, diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 8d550fb13..8c3eccb57 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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, + }; +}