fix(playback): structure Shaka diagnostics (#1318)

* docs(playback): design structured Shaka diagnostics

* fix(playback): structure Shaka diagnostics

* docs(playback): document Shaka evidence boundary

* docs(playback): fix Shaka validation commands

* fix(playback): preserve Shaka fallback evidence

* fix(playback): preserve Shaka text error evidence
This commit is contained in:
4gray authored and GitHub committed 2026-07-31 21:25:06 +02:00
1 parent 9a50e7385b
commit 9f4e11d6de
22 files changed
+2779 -277

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
Shaka playback errors now use exact engine evidence, including subtitle parsing, preserve external-player fallback when browser prerequisites are missing, keep recoverable retries non-terminal, and omit provider URLs, credentials, and response data from technical details.
+7 -1
View File
@@ -210,7 +210,13 @@ Key files:
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
ArtPlayer components; ClearKey keys come from KODIPROP-derived
`Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via
source kind `shaka`. See the CLAUDE.md "Video Players" feature entry and
source kind `shaka`. The Shaka `5.2.2` diagnostic boundary version-locks
public severity/category/code evidence, ignores recoverable error events,
treats rejected loads as terminal lifecycle outcomes, preserves exact public
DASH text-parser category/code evidence with unknown stage/failure, and never
retains or renders raw messages or `error.data`. A failed browser-support
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").
- The built-in HTML5/hls.js player is the second guarded consumer.
`HtmlVideoPlayerComponent` provides a component-scoped
+13 -7
View File
@@ -400,10 +400,10 @@ Key patterns:
Xtream data strategies by runtime capability:
| Capability | Strategy |
| ---------- | -------- |
| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB |
| **Bridge unavailable** | API-only: fetch from API and keep session data in memory |
| Capability | Strategy |
| --------------------------------- | -------------------------------------------------------- |
| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB |
| **Bridge unavailable** | API-only: fetch from API and keep session data in memory |
**M3U Playlist Module Architecture**:
@@ -734,7 +734,13 @@ app as a real argument, so it is not an option.
ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
need the castLabs Electron fork) surface a DRM playback diagnostic instead
of crashing. ClearKey EME works in stock Electron. Engine:
`libs/ui/playback/src/lib/shaka-engine/`; details in
`libs/ui/playback/src/lib/shaka-engine/`. Its Shaka `5.2.2` diagnostic
boundary version-locks public severity/category/code evidence, ignores
recoverable error events, treats rejected loads as terminal lifecycle
outcomes, preserves exact public DASH text-parser category/code evidence with
unknown stage/failure, and never retains or renders raw messages or
`error.data`. A failed browser-support preflight stays unknown but keeps
external fallback for clear DASH; KODIPROP DRM still suppresses it. Details in
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
@@ -910,8 +916,8 @@ engine` (restart required) or
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal.
- Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing.
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an _exact_ match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read _before_ the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A write stores the decision under **every** key in `write` (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`: one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enriched `tmdb:` key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin **id** rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`.
- Pins are included in playlist backup as the optional `sourcePins` collection, carried under the playlist they point at; `matchKey` survives untouched and only the playlist id is remapped on restore (older archives simply lack the field).
+44 -2
View File
@@ -181,7 +181,7 @@ seasons, with per-episode watch-progress bars from playback positions.
prefetch is claimed synchronously and answered seasons (including genuinely
empty ones) are never re-requested: a failed request leaves `episodes` empty
with `isLoading` back to false, which would otherwise re-run the effect that
issued it and loop. A *failed* request releases the claim but is pinned to
issued it and loop. A _failed_ request releases the claim but is pinned to
the episode that triggered it, so a transient portal error retries on the
next playback change instead of either looping or giving up permanently.
- Gating mirrors ambient mode: the `playerUpNextRail` setting (Settings →
@@ -388,6 +388,48 @@ external-player workflows; it is not copied from the HLS error payload into
the evidence or technical details. HLS startup development logs are event-only:
they do not include provider-supplied channel names or source URLs.
Shaka Player `5.2.2` errors cross a separate structured boundary before the
HTML5 or ArtPlayer DASH session emits a diagnostic. Version-locked tests assert
the installed Shaka version plus the public `Severity`, `Category`, and selected
online-playback `Code` values used by the boundary. Evidence retains only
validated severity/category/code, the lifecycle disposition, an exact
code-derived stage and failure kind, and a validated HTTP status. A direct
`Network.BAD_HTTP_STATUS` may expose `data[1]` as the status; the same status is
accepted from the documented nested networking error for
`Drm.LICENSE_REQUEST_FAILED` and
`Drm.SERVER_CERTIFICATE_REQUEST_FAILED`. No other `error.data` value is read.
A recoverable Shaka `error` event does not become a terminal playback
diagnostic because the engine continues its retry/recovery lifecycle. A
critical event is terminal. A rejected `Player.load()` is also terminal even
when its last networking error still carries recoverable severity, because the
load lifecycle has ended; the structured evidence preserves both facts as
`severity=recoverable` and `disposition=terminal`. Unknown event severity does
not prove terminal failure and is ignored. Exact public code/category pairs may
classify network, DRM/encryption, manifest/parsing, or media/decode failures.
Ambiguous evidence stays `unknown-playback-error`: in particular, the Manifest
category alone is not container incompatibility, and Shaka messages never infer
CORS, codec, DRM, container, or stage. The public critical
`STREAMING_ENGINE_STARTUP_INVALID_STATE` code remains exact evidence while its
stage and failure stay unknown because the code does not identify a user-facing
media cause. Public DASH text-parser codes are also retained exactly; their
`TEXT` category proves the parser subsystem, but not a safe manifest, segment,
or media cause, so stage and failure remain unknown.
A failed public `Player.isBrowserSupported()` preflight is not a Shaka error
and therefore retains fully unknown technical evidence instead of being
mislabelled as an unsupported container. For clear DASH, the diagnostic still
offers configured MPV/VLC actions because the failure is specific to the web
engine. KODIPROP DRM sources keep external fallback disabled because external
players do not receive their key configuration.
Shaka messages, URLs, headers, request/response bodies, credentials,
license/key payloads, and arbitrary `error.data` objects are neither retained
nor rendered. Technical details show only sanitized stage, failure, severity,
category, code, disposition, and optional HTTP status. Unsupported playlist
DRM uses a fixed safe description rather than echoing provider license
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.
@@ -397,7 +439,7 @@ with a compact warning badge, a native-player fallback headline, and
player-card actions for configured external players. It exposes technical
details on demand: diagnostic code, reporting player/source, detected
container/MIME, video/audio codecs, native browser error fields, sanitized
structured Video.js/VHS and HLS evidence, and existing mpegts details. HLS
structured Video.js/VHS, HLS, and Shaka evidence, and existing mpegts details. HLS
manifest codec metadata also drives a concise browser-support hint for codecs
that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3,
E-AC-3, DTS, and MPEG-2 video.
+33 -19
View File
@@ -96,22 +96,22 @@ profiles are never mixed into them.
The benchmark attributes these non-additive intervals:
| Field | Boundary |
| --- | --- |
| `dataAcquireMs` | loopback response acquisition |
| `m3uParsingMs` | parser call |
| `normalizationMs` | parsed-item normalization |
| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs |
| `storeImportDispatchMs` | renderer import dispatch |
| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs |
| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs |
| `playlistSerializationMs` | database-worker playlist JSON serialization |
| `sqliteWriteMs` | SQLite upsert, including its autocommit |
| `sqliteReadMs` | SQLite read of the newly persisted playlist |
| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing |
| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs |
| `storePublishChannelsMs` | renderer channel publication |
| `angularRenderingMs` | publication end to the terminal two-frame paint proof |
| Field | Boundary |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `dataAcquireMs` | loopback response acquisition |
| `m3uParsingMs` | parser call |
| `normalizationMs` | parsed-item normalization |
| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs |
| `storeImportDispatchMs` | renderer import dispatch |
| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs |
| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs |
| `playlistSerializationMs` | database-worker playlist JSON serialization |
| `sqliteWriteMs` | SQLite upsert, including its autocommit |
| `sqliteReadMs` | SQLite read of the newly persisted playlist |
| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing |
| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs |
| `storePublishChannelsMs` | renderer channel publication |
| `angularRenderingMs` | publication end to the terminal two-frame paint proof |
`ipcStructuredCloneProxyMs` is the explicit sum of the four directional proxy
fields. Each directional field can combine the applicable initial-result,
@@ -994,10 +994,24 @@ player in settings.
- `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the
engine: lazy `import('shaka-player')` on first use (the module is a separate
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
queue + generation guard against channel-switch races, and Shaka-error →
`PlaybackDiagnostic` classification (`PlaybackDiagnosticSource.Shaka`).
queue + generation guard against channel-switch races, and a Shaka `5.2.2`
public-error boundary. The boundary version-locks its allowlisted
severity/category/code values, emits only structured sanitized
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
and treats a rejected load as terminal even if its final retry error retains
recoverable severity. Raw messages and `error.data` never cross the boundary;
only the documented direct/nested `BAD_HTTP_STATUS` slot may contribute a
validated HTTP status. Exact category/code pairs classify failures, while an
ambiguous Manifest category or unknown pair remains an unknown diagnostic.
The public streaming-startup code `5006` is retained exactly but keeps
unknown stage/failure. Public DASH text-parser codes likewise retain their
exact `TEXT` category/code but keep stage/failure unknown. A failed
`Player.isBrowserSupported()` preflight also stays unknown rather than
claiming container incompatibility; clear DASH still offers configured
external-player actions, while KODIPROP DRM keeps them disabled because
those players never receive its keys.
Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic
without starting an engine.
with a fixed safe detail string and without starting an engine.
- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer:
`customType.mpd` in `ArtPlayerSourceSession`. Shared-controls bridge:
`WebVideoControlsSource` kind `'shaka'` + `WebVideoShakaControls`
@@ -0,0 +1,717 @@
# Structured Shaka 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 raw Shaka error retention with a version-locked, public,
allowlisted evidence boundary that distinguishes recoverable events from
terminal load failures.
**Architecture:** Normalize only Shaka 5.2.2 public severity/category/code
values and documented HTTP status layouts into `ShakaPlaybackEvidence`.
`ShakaVideoSession` supplies the lifecycle disposition, the classifier maps
only exact category/code pairs, and the existing UI renders only structured
evidence.
**Tech Stack:** Angular 21, TypeScript 5.9, Shaka Player 5.2.2, Jest through
Nx, Markdown architecture and release-note documentation.
---
### Task 0: Establish The Evidence And Baseline
**Files:**
- Verify: `package.json`
- Verify: `pnpm-lock.yaml`
- Verify: `node_modules/shaka-player/lib/util/error.js`
- Verify: `node_modules/shaka-player/lib/net/http_plugin_utils.js`
- Verify: `node_modules/shaka-player/lib/net/networking_engine.js`
- Verify: `node_modules/shaka-player/lib/media/preload_manager.js`
- Verify: `node_modules/shaka-player/lib/player.js`
- Verify: `node_modules/shaka-player/dist/shaka-player.compiled.d.ts`
- [x] **Step 1: Install locked dependencies**
Run:
```bash
pnpm install --frozen-lockfile
```
Expected: exit 0, Shaka Player `5.2.2`, and no lockfile change.
- [x] **Step 2: Verify Nx workspace discovery**
Run:
```bash
pnpm nx show projects
```
Expected: exit 0 and output containing `ui-playback`, `web`, and `web-e2e`.
- [x] **Step 3: Run the affected-project baseline**
Run:
```bash
pnpm nx test ui-playback
```
Expected: 86 suites and 801 tests pass before implementation.
- [x] **Step 4: Audit the installed public runtime**
Confirm:
```text
shaka.Player.version = v5.2.2
Severity = { RECOVERABLE: 1, CRITICAL: 2 }
Category = {
NETWORK: 1, TEXT: 2, MEDIA: 3, MANIFEST: 4, STREAMING: 5,
DRM: 6, PLAYER: 7, CAST: 8, STORAGE: 9, ADS: 10
}
BAD_HTTP_STATUS data[1] = HTTP status
LICENSE_REQUEST_FAILED data[0] = nested Shaka network error
SERVER_CERTIFICATE_REQUEST_FAILED data[0] = nested Shaka network error
```
Confirm the installed event order:
```text
recoverable Player#error -> engine continues
critical PreloadManager error -> public error event -> load rejection
direct manifest/parser throw -> load rejection without required error event
attempts exhausted -> final recoverable-severity error rejects load
```
### Task 1: Drive The Public Shaka Evidence Boundary From Failing Tests
**Files:**
- Create:
`libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts`
- Create:
`libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts`
- Create:
`libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts`
- Modify:
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts`
- Modify:
`libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts`
- [ ] **Step 1: Write the failing installed-runtime contract test**
In `shaka-playback-evidence.util.spec.ts`, load the real compiled package in a
child process with `global.self = globalThis` and return:
```typescript
interface InstalledShakaContract {
readonly version: string;
readonly severity: Readonly<Record<string, number>>;
readonly category: Readonly<Record<string, number>>;
readonly code: Readonly<Record<string, number>>;
}
```
Assert:
```typescript
expect(installed.version).toBe(SHAKA_DIAGNOSTIC_VERSION);
expect(installed.severity).toEqual(SHAKA_ERROR_SEVERITY);
expect(installed.category).toEqual(SHAKA_ERROR_CATEGORY);
for (const [name, value] of Object.entries(SHAKA_ERROR_CODE)) {
expect(installed.code[name]).toBe(value);
}
```
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/shaka-engine/shaka-playback-evidence.util.spec.ts \
--runInBand
```
Expected: FAIL because the contract and evidence module do not exist.
- [ ] **Step 2: Add the minimal public model**
Add const-derived types to `playback-diagnostics.model.ts`:
```typescript
export const ShakaPlaybackSeverity = {
Recoverable: 'recoverable',
Critical: 'critical',
Unknown: 'unknown',
} as const;
export const ShakaPlaybackCategory = {
Network: 'network',
Text: 'text',
Media: 'media',
Manifest: 'manifest',
Streaming: 'streaming',
Drm: 'drm',
Player: 'player',
Cast: 'cast',
Storage: 'storage',
Ads: 'ads',
Unknown: 'unknown',
} as const;
export const ShakaPlaybackDisposition = {
Terminal: 'terminal',
Recoverable: 'recoverable',
} as const;
export const ShakaPlaybackStage = {
Manifest: 'manifest',
Segment: 'segment',
Media: 'media',
License: 'license',
Unknown: 'unknown',
} as const;
export const ShakaPlaybackFailure = {
Network: 'network',
Drm: 'drm',
Manifest: 'manifest',
Media: 'media',
Unknown: 'unknown',
} as const;
export const ShakaPlaybackUnknownCode = 'unknown' as const;
export interface ShakaPlaybackEvidence {
readonly severity: ShakaPlaybackSeverity;
readonly category: ShakaPlaybackCategory;
readonly engineCode: number | typeof ShakaPlaybackUnknownCode;
readonly disposition: ShakaPlaybackDisposition;
readonly stage: ShakaPlaybackStage;
readonly failure: ShakaPlaybackFailure;
readonly httpStatus?: number;
}
```
Add `readonly shaka?: ShakaPlaybackEvidence` to `PlaybackDiagnostic`, and
remove `message` from `ShakaErrorLike` so downstream Shaka code has no typed
message access:
```typescript
export interface ShakaErrorLike {
severity: number;
category: number;
code: number;
data?: readonly unknown[];
}
```
- [ ] **Step 3: Add the exact 5.2.2 public allowlist**
In `shaka-error-contract.ts`, export:
```typescript
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.2';
export const SHAKA_ERROR_SEVERITY = {
RECOVERABLE: 1,
CRITICAL: 2,
} as const;
export const SHAKA_ERROR_CATEGORY = {
NETWORK: 1,
TEXT: 2,
MEDIA: 3,
MANIFEST: 4,
STREAMING: 5,
DRM: 6,
PLAYER: 7,
CAST: 8,
STORAGE: 9,
ADS: 10,
} as const;
```
Define `SHAKA_ERROR_CODE` with the exact active public Shaka 5.2.2 online
playback codes from categories NETWORK through PLAYER, including the direct
and nested HTTP codes, media and manifest codes, all DRM codes, and
`LOAD_INTERRUPTED`. Do not include retired numeric gaps. The contract test
must verify every name/value against the installed runtime.
- [ ] **Step 4: Write failing sanitizer and privacy tests**
Use the documented direct bad-status shape:
```typescript
const raw = {
severity: 1,
category: 1,
code: 1001,
message: 'https://user:secret@provider.example/manifest.mpd',
data: [
'https://provider.example/manifest.mpd?token=secret',
503,
'provider body secret',
{ Authorization: 'Bearer secret' },
0,
'https://provider.example/final?token=secret',
],
};
```
Expect:
```typescript
{
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
}
```
Add a nested `DRM + LICENSE_REQUEST_FAILED` shape whose `data[0]` is the same
network error and whose remaining data contains license/session secrets.
Expect only the nested status number to survive.
Cover:
- HTTP boundaries 99/100/599/600 and non-integers;
- status-like values on non-documented codes remain absent;
- unknown severity/category/code values become `unknown`;
- mismatched public category/code pairs have `failure=unknown`;
- no URL, message, header, body, key, license, credential, or arbitrary
property survives `JSON.stringify(evidence)`;
- exact manifest, segment, media, license, and unknown stages;
- exact network, DRM, manifest, media, restrictions, and unknown failures.
- [ ] **Step 5: Implement the minimal sanitizer**
In `shaka-playback-evidence.util.ts`, expose:
```typescript
export function createShakaPlaybackEvidence(
error: Partial<ShakaErrorLike> | null | undefined,
disposition: ShakaPlaybackDisposition
): ShakaPlaybackEvidence;
```
Use only:
```typescript
error?.severity;
error?.category;
error?.code;
error?.data?.[1]; // exact direct BAD_HTTP_STATUS only
error?.data?.[0]; // exact documented nested DRM network error only
```
Build severity/category/code through exact sets, derive stage/failure through
exact category/code pairs, and return a fresh flat evidence object. Do not
read or serialize any other field.
Run the focused spec again.
Expected: PASS.
### Task 2: Drive Exact Classification And Shared Diagnostic Retention
**Files:**
- Modify:
`libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts`
- Modify:
`libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts`
- Modify:
`libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts`
- [ ] **Step 1: Replace heuristic expectations with failing exact-code tests**
Change the classifier API to:
```typescript
classifyShakaPlaybackIssue(
error,
metadata,
disposition
): PlaybackDiagnostic | null;
```
Add expectations:
```text
NETWORK + BAD_HTTP_STATUS -> network-error
DRM + REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE -> drm-or-encryption
MANIFEST + DASH_NO_COMMON_KEY_SYSTEM -> drm-or-encryption
MANIFEST + DASH_INVALID_XML -> unknown-playback-error
MANIFEST + DASH_UNSUPPORTED_CONTAINER -> unsupported-container
MANIFEST + CONTENT_UNSUPPORTED_BY_BROWSER -> unknown-playback-error
MEDIA + MEDIA_SOURCE_OPERATION_FAILED -> media-decode-error
MANIFEST + RESTRICTIONS_CANNOT_BE_MET -> unknown-playback-error
mismatched category/code -> unknown-playback-error
unknown values -> unknown-playback-error
recoverable disposition -> null
```
Put misleading `message` and `data` strings such as `CORS codec license`
alongside unknown or mismatched structured values and prove they do not change
classification.
Run the focused classifier spec.
Expected: FAIL against the current category/message heuristic classifier.
- [ ] **Step 2: Implement exact classification**
Create evidence first:
```typescript
const evidence = createShakaPlaybackEvidence(error, disposition);
if (evidence.disposition === ShakaPlaybackDisposition.Recoverable) {
return null;
}
```
Select the top-level code only from exact evidence:
```typescript
network -> NetworkError
drm -> DrmOrEncryption
DASH_UNSUPPORTED_CONTAINER -> UnsupportedContainer
MEDIA_SOURCE_OPERATION_FAILED -> MediaDecodeError
MEDIA_SOURCE_OPERATION_THREW -> MediaDecodeError
VIDEO_ERROR -> MediaDecodeError
everything else -> UnknownPlaybackError
```
Call `createPlaybackDiagnostic` with `httpStatus` and `shaka`, but no raw
`details` or native message.
Extend `createPlaybackDiagnostic` to accept and retain:
```typescript
readonly shaka?: ShakaPlaybackEvidence;
```
Run the focused evidence and classifier specs.
Expected: PASS.
- [ ] **Step 3: Sanitize the pre-engine DRM diagnostic**
Change `createUnsupportedDrmDiagnostic` to ignore the provider-supplied
license string in technical details:
```typescript
details: 'Unsupported DRM license configuration';
```
Add a secret-bearing license string test and prove the diagnostic JSON and
details omit it while the top-level code remains `drm-or-encryption` and
external fallback remains disabled.
### Task 3: Drive Session Lifecycle And Adapter Routing
**Files:**
- Modify:
`libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts`
- Modify:
`libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts`
- Modify:
`libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts`
- Modify:
`libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts`
- [ ] **Step 1: Write failing recoverable/terminal routing tests**
Add session regressions for:
```text
recoverable Player#error -> no diagnostic, same current player
unknown-severity Player#error -> no diagnostic, same current player
critical Player#error -> one terminal diagnostic, player destroyed
recoverable-severity load rejection -> one terminal diagnostic
critical event during stalled load -> one diagnostic, later interruption ignored
module-loader Error with URL/token -> terminal unknown evidence without message
```
For the terminal rejected `BAD_HTTP_STATUS`, expect:
```typescript
expect(issue.shaka).toEqual({
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
});
```
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/shaka-engine/shaka-video-session.spec.ts \
--runInBand
```
Expected: FAIL because the current session suppresses only event severity,
passes arbitrary messages on rejection, and has no structured disposition.
- [ ] **Step 2: Implement route-owned disposition**
In `ShakaVideoSession`:
- call the classifier with `Terminal` for module rejection, unsupported
browser support, and `load()` rejection;
- call it with `Recoverable` for exact recoverable events and emit nothing
when it returns null;
- call it with `Terminal` only for exact critical events;
- ignore unknown-severity events;
- suppress interruption only for the exact
`CRITICAL + PLAYER + LOAD_INTERRUPTED` triple;
- remove `toErrorMessage` and every Shaka message fallback;
- tear down only after a non-null terminal diagnostic.
Run the focused session spec.
Expected: PASS.
- [ ] **Step 3: Write and pass the ArtPlayer adapter regression**
Configure the fake Shaka player's load promise to reject with a documented
bad-status shape, pass `emitPlaybackIssue` into
`ArtPlayerSourceSession`, invoke the `mpd` custom type, and expect one
diagnostic with:
```typescript
{
source: PlaybackDiagnosticSource.Shaka,
player: InlinePlaybackPlayer.ArtPlayer,
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 503,
shaka: expect.objectContaining({
engineCode: 1001,
disposition: 'terminal',
}),
}
```
Prove the response body/header/token sentinel is absent from serialized
diagnostic data other than the pre-existing active `sourceUrl`.
### Task 4: Drive Safe Rendered Technical Details
**Files:**
- Modify:
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts`
- Modify:
`libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts`
- [ ] **Step 1: Write the failing rendered-detail regression**
Create a Shaka diagnostic with safe evidence plus malicious legacy fields:
```typescript
details: 'Authorization: Bearer shaka-render-secret',
nativeErrorMessage:
'https://provider.example/license?token=shaka-render-secret',
shaka: {
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
```
Expect the error-details row to equal:
```text
stage=unknown · failure=network · severity=recoverable · category=network · code=1001 · disposition=terminal · HTTP 503
```
Prove the rendered values omit the secret, provider hostname,
`Authorization`, raw body text, and legacy details.
Run the focused component spec.
Expected: FAIL because the UI has no Shaka structured branch.
- [ ] **Step 2: Render only Shaka evidence**
Add the Shaka branch before the HLS/VHS/legacy branches:
```typescript
if (issue.shaka) {
return [
`stage=${issue.shaka.stage}`,
`failure=${issue.shaka.failure}`,
`severity=${issue.shaka.severity}`,
`category=${issue.shaka.category}`,
`code=${issue.shaka.engineCode}`,
`disposition=${issue.shaka.disposition}`,
issue.shaka.httpStatus === undefined
? ''
: `HTTP ${issue.shaka.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
```
Run the focused component spec.
Expected: PASS.
### Task 5: Documentation, Release Note, And Full Validation
**Files:**
- Modify: `docs/architecture/embedded-inline-playback.md`
- Modify: `docs/architecture/m3u-playlist-module.md`
- Modify: `CLAUDE.md`
- Modify: `AGENTS.md`
- Create: `.changes/playback-structured-shaka-diagnostics.md`
- [ ] **Step 1: Update canonical documentation**
Document:
- Shaka 5.2.2 public version lock;
- evidence fields and rejected raw fields;
- direct and nested documented HTTP status layouts;
- exact failure/stage classification and ambiguous unknown cases;
- recoverable event versus terminal load-rejection semantics;
- sanitized technical detail output;
- unchanged source routing, ClearKey fallback rule, and VOD failover.
Keep the high-level Shaka summaries in `CLAUDE.md` and `AGENTS.md`
consistent.
- [ ] **Step 2: Add the user-facing release note**
Create:
```markdown
---
type: fix
area: playback
---
Shaka playback errors now use exact engine evidence, keep recoverable retries from becoming terminal diagnostics, and omit provider URLs, credentials, and response data from technical details.
```
- [ ] **Step 3: Run focused and affected validation**
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/shaka-engine/shaka-playback-evidence.util.spec.ts \
libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts \
libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts \
libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts \
--runInBand
pnpm nx test ui-playback
pnpm nx lint ui-playback
pnpm nx typecheck web
pnpm run i18n:check
pnpm run release:notes:validate
git diff --check
```
Expected: every command exits 0.
- [ ] **Step 4: Complete the test-impact pass**
Confirm:
```text
Affected project: ui-playback
Unit/component coverage: required and run
Lint: required and run
Web typecheck: required and run
i18n validation: required and run
Release-note validation: required and run
E2E: not required; no workflow, routing, player selection, or integration
lifecycle changed, and event/rejection behavior is covered at the
session plus ArtPlayer adapter boundaries
```
### Task 6: Independent Review, Fixes, And Ready PR
**Files:**
- Review: complete diff from `origin/master` to branch HEAD
- [ ] **Step 1: Commit the implementation before review**
Create focused conventional commits for the evidence boundary/runtime change
and documentation/release note. Confirm:
```bash
git status --short
git log --oneline origin/master..HEAD
```
- [ ] **Step 2: Dispatch an independent local Codex review**
Give the reviewer:
```text
Base: origin/master
Head: branch HEAD
Requirements: the structured Shaka design and this implementation plan
Focus: actionable P0/P1/P2 correctness, privacy, public-contract drift,
lifecycle duplication/suppression, classification accuracy, and tests
```
Require a full-diff review. If the reviewer reports an issue, verify it
against the installed Shaka 5.2.2 source before changing code.
- [ ] **Step 3: Fix confirmed findings through TDD**
For every confirmed P0/P1/P2:
1. add or tighten a regression test;
2. run it and observe the expected failure;
3. implement the minimal fix;
4. rerun the focused test and affected suite;
5. commit the fix.
- [ ] **Step 4: Repeat independent review**
Review the updated full diff from `origin/master`. Expected: no actionable
P0/P1/P2 findings.
- [ ] **Step 5: Repeat the complete validation ladder**
Rerun every command from Task 5 Step 3 after the final review fix. Expected:
all exit 0 with fresh output.
- [ ] **Step 6: Push and create a ready PR**
Push `agent/structured-shaka-diagnostics` and create a non-draft PR with:
```text
Title: fix(playback): structure Shaka diagnostics
Base: master
Summary: exact Shaka 5.2.2 evidence, lifecycle-aware recoverable handling,
safe technical details
Testing: list every fresh validation command and result
```
Inspect the created PR and confirm it is ready for review.
@@ -0,0 +1,286 @@
# Structured Shaka Diagnostics
## Context
PR #1314 stopped treating an ambiguous native `MediaError` as codec evidence
and retained explicit HTTP evidence. PR #1316 added an allowlisted hls.js
boundary for HTML5 and ArtPlayer. PR #1317 added an allowlisted Video.js/VHS
boundary for the default player. Shaka remains the only web source engine that
copies an arbitrary message and serialized `error.data` into a diagnostic.
The locked workspace installs Shaka Player `5.2.2`. The audit used the
installed package's public declarations, `shaka.util.Error` JSDoc, compiled
runtime exports, and the load/error paths in the installed source. The public
runtime reports:
- `shaka.Player.version === "v5.2.2"`;
- severity values `RECOVERABLE=1` and `CRITICAL=2`;
- category values `NETWORK=1`, `TEXT=2`, `MEDIA=3`, `MANIFEST=4`,
`STREAMING=5`, `DRM=6`, `PLAYER=7`, `CAST=8`, `STORAGE=9`, and `ADS=10`;
- numeric `shaka.util.Error.Code` values documented by the installed public
API.
The existing `ShakaVideoSession` binds the public `Player#error` event before
calling `load()`, and also observes the public `load()` rejection. Those paths
have different terminal meaning and must not be collapsed into a
severity-only gate.
## Goals
- Convert Shaka errors into a minimal allowlisted `ShakaPlaybackEvidence`
value before classification or UI rendering.
- Retain only validated public severity, category, and code values, an
explicit lifecycle disposition, a code-proven stage/failure kind, and a
documented HTTP status.
- Classify only exact Shaka 5.2.2 category/code pairs.
- Keep insufficient or inconsistent evidence unknown.
- Keep recoverable player events from becoming terminal IPTVnator
diagnostics.
- Treat a rejected `Player.load()` as terminal even when the final public
Shaka error still carries `RECOVERABLE`.
- Prevent messages, URLs, headers, bodies, license/key payloads, credentials,
and arbitrary objects from reaching stored or rendered diagnostics.
- Version-lock the allowlist and tests to the installed Shaka public API.
## Non-goals
- Changing hls.js or Video.js/VHS evidence contracts.
- Redesigning mpegts.js diagnostics.
- Adding top-level diagnostic codes, history, persistence, correlation,
probes, automatic engine failover, or a cross-player recommendation matrix.
- Inspecting Shaka private loaders, networking state, parser internals, or
retry counters.
- Changing VOD multi-source auto-failover.
- Inferring CORS, codec, DRM, container, or stage from message text.
## Approaches Considered
### Public error boundary with lifecycle disposition (selected)
Normalize the public `severity`, `category`, `code`, and only the documented
HTTP-status slot into `ShakaPlaybackEvidence`. The session supplies
`recoverable` for a recoverable event and `terminal` for a critical event or a
rejected load. Classification and technical details accept only the sanitized
evidence.
This preserves the strongest stable facts while making unsafe fields
structurally unavailable to downstream code. It also represents the important
case where Shaka's final network error remains severity-recoverable but the
load promise has terminated.
### Severity-only event and rejection gate
Suppressing every `severity=RECOVERABLE` error would hide a terminal manifest
load failure after Shaka exhausts retries. Treating every error as terminal
would tear down a player that Shaka is still recovering. The route that
delivers the error is therefore required evidence.
### Inspect request/parser internals
Private networking and parser state could reveal request types and richer
stages, but it also exposes URLs, headers, response data, license payloads, and
unstable implementation details. This is rejected by the privacy and public
contract requirements.
### Stop after the audit
Stopping would be correct if the public API could not distinguish safe facts
or terminal lifecycle. The installed API exposes exact enums, documented
status layouts, and distinct event/rejection semantics, so a stable focused
improvement is available.
## Installed Runtime And Event Ordering
Shaka 5.2.2 documents recoverable severity as an error from which the player
is attempting to recover. It explicitly warns that some media-segment retry
paths may never escalate to a critical error.
The installed runtime orders errors as follows:
1. `PreloadManager.onError()` rejects its success promise for a critical
error, destroys preload work, and synchronously dispatches the public
`error` event.
2. The existing session's event listener therefore observes that critical
event before the `await player.load()` continuation handles the rejection.
3. Session teardown changes the current-player identity; the later rejection
is ignored, preventing a duplicate diagnostic.
4. Direct manifest/parser failures can reject the preload/load promise without
dispatching a player error. The load-rejection path must classify them.
5. The networking engine retries errors while their public severity is
recoverable. When attempts are exhausted it throws the last error without
rewriting its severity. A rejected `load()` is therefore terminal by
lifecycle even if evidence says `severity=recoverable`.
6. A recoverable public player event is not terminal and must leave the
current engine attached.
`LOAD_INTERRUPTED` is suppressed only when the exact public triple is
`CRITICAL + PLAYER + LOAD_INTERRUPTED`; an arbitrary object containing the
number `7000` is not enough.
## Version-locked Public Contract
The production allowlist contains the exact Shaka 5.2.2 severity/category
values and the public online-playback error codes used by the boundary. A
contract test loads the installed compiled package in a child Node process,
asserts `v5.2.2`, compares the full severity/category maps, and verifies every
allowlisted code name/value against `shaka.util.Error.Code`.
The explicit version assertion makes a dependency upgrade fail before new or
changed codes are silently accepted. An upgrade requires reviewing the new
public JSDoc layouts and lifecycle before updating the lock.
## Evidence Contract
`ShakaPlaybackEvidence` contains:
- `severity`: `recoverable`, `critical`, or `unknown`;
- `category`: one validated public category name in lowercase, otherwise
`unknown`;
- `engineCode`: one allowlisted public numeric code, otherwise `unknown`;
- `disposition`: `terminal` or `recoverable`, supplied by the session route;
- `stage`: `manifest`, `segment`, `media`, `license`, or `unknown`;
- `failure`: `network`, `drm`, `manifest`, `media`, or `unknown`;
- optional `httpStatus`: an integer from 100 through 599.
The boundary does not retain:
- `error.message`;
- arbitrary or serialized `error.data`;
- request, redirect, manifest, segment, license, or certificate URLs;
- request or response headers;
- response text, bodies, or binary data;
- browser exceptions and events;
- DRM session metadata, keys, licenses, provider payloads, or credentials;
- unknown object properties.
The existing `PlaybackDiagnostic.sourceUrl` remains the active source metadata
used by Retry, Copy URL, explicit external-player actions, and the existing VOD
failover flow. No URL is copied from a Shaka error.
## HTTP Status Extraction
Shaka 5.2.2 publicly documents:
- `BAD_HTTP_STATUS` (`NETWORK`, code `1001`): `error.data[1]` is the status;
- `LICENSE_REQUEST_FAILED` (`DRM`, code `6007`): `error.data[0]` is a nested
Shaka networking error;
- `SERVER_CERTIFICATE_REQUEST_FAILED` (`DRM`, code `6017`):
`error.data[0]` is a nested Shaka networking error.
The boundary reads a status only from an exact `NETWORK + BAD_HTTP_STATUS`
shape, directly or through those two exact documented nested-error layouts.
It validates the integer protocol range and copies only the number. Status-like
values in any other data position or arbitrary object are ignored.
## Failure And Stage Mapping
Failure uses exact category/code pairs:
- allowlisted online network codes in the `NETWORK` category → `network`;
- allowlisted DRM codes in the `DRM` category → `drm`;
- exact manifest encryption/key-system codes → `drm`;
- exact media codes in the `MEDIA` category → `media`;
- exact manifest/parsing codes in the `MANIFEST` category → `manifest`;
- `DASH_UNSUPPORTED_CONTAINER` and
`CONTENT_UNSUPPORTED_BY_BROWSER` → `media`, because the public descriptions
prove a media support failure but the latter does not distinguish container
from codec;
- inconsistent category/code pairs, internal/test-only ambiguity, and
`RESTRICTIONS_CANNOT_BE_MET` → `unknown`.
Stage is narrower than failure:
- exact manifest codes → `manifest`;
- exact segment/index/init parsing codes and `SEGMENT_MISSING` → `segment`;
- exact MediaSource/video pipeline codes → `media`;
- exact license request/response/server-selection/expiry codes → `license`;
- everything else → `unknown`.
Messages and arbitrary data never affect either value.
## User-facing Classification
No new top-level diagnostic code is needed:
1. `failure=network` → `network-error`.
2. `failure=drm` → `drm-or-encryption`.
3. Exact `DASH_UNSUPPORTED_CONTAINER` →
`unsupported-container`.
4. Exact MediaSource operation or video-element failure codes that identify
the media pipeline → `media-decode-error`.
5. Manifest parsing, generic media parsing/transformation, ambiguous
browser-content support, restrictions, unknown values, and inconsistent
pairs → `unknown-playback-error`.
Shaka 5.2.2 has no exact public code that distinguishes an unsupported codec
from an unsupported container for the generic
`CONTENT_UNSUPPORTED_BY_BROWSER` case. It therefore remains unknown instead of
becoming a false codec or container diagnosis. No Shaka path emits
`browser-access-error` without a structured public access code.
## Session And Adapter Flow
`ShakaVideoSession` owns disposition:
- module-load rejection, unsupported browser capability, and `load()`
rejection are terminal;
- a public error event is terminal only for the exact critical severity;
- a recoverable event is passed through the shared evidence/classifier gate,
returns no diagnostic, and leaves the player attached;
- an event with unknown severity is ignored because terminal state is not
proven;
- a terminal diagnostic tears down the engine once;
- ClearKey sources keep the existing rule that external fallback is
unavailable because the external player never receives key configuration.
HTML5 and ArtPlayer keep their current Shaka session adapters. The structured
diagnostic travels through their existing `emitPlaybackIssue` callbacks; no
player-selection, source-selection, or VOD failover behavior changes.
Unsupported playlist DRM remains a pre-engine app diagnostic, but its
technical detail becomes a fixed safe description instead of echoing the
provider-supplied license string.
## User Interface
The existing technical “Error details” row renders only
`ShakaPlaybackEvidence`, for example:
`stage=unknown · failure=network · severity=recoverable · category=network · code=1001 · disposition=terminal · HTTP 503`
When structured Shaka evidence exists, legacy `details` and native message
fields are ignored even if a caller accidentally supplies them. Existing
titles, descriptions, HTTP badge, Retry, Copy URL, explicit MPV/VLC actions,
and layout remain unchanged. No translation or visual styling change is
required.
## Testing
Use test-driven development:
- Compare the allowlist/version with the real installed Shaka 5.2.2 runtime.
- Exercise real documented direct and nested HTTP error shapes.
- Prove all unsafe data fields and misleading messages are excluded.
- Cover exact network, DRM, manifest, container, media, restrictions,
mismatched, and unknown classification.
- Prove recoverable classification returns no terminal diagnostic.
- Prove a recoverable-severity load rejection is terminal by lifecycle.
- Prove recoverable and unknown-severity events leave the session running.
- Prove a critical event during an in-flight load emits once and teardown does
not duplicate the later rejection.
- Cover ClearKey fallback suppression and ArtPlayer adapter routing.
- Prove the rendered detail row uses only sanitized Shaka evidence.
Run the complete `ui-playback` unit target, its lint target, web typecheck,
i18n validation, release-note validation, and the repository test-impact pass.
No E2E is required because the player workflow, controls, source routing, and
integration lifecycle are unchanged; the event/rejection semantics are
covered at the session and adapter boundaries.
## Documentation And Release Note
Update `docs/architecture/embedded-inline-playback.md` as the canonical
browser diagnostic contract and the Shaka engine section in
`docs/architecture/m3u-playlist-module.md`. Keep the Shaka summaries in
`CLAUDE.md` and `AGENTS.md` current. Add a `fix(playback)` release note because
users receive safer technical details and more accurate Shaka diagnoses.
@@ -1,4 +1,10 @@
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks,
@@ -82,4 +88,62 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
await flushShakaMicrotasks();
expect(fakeShaka.instances).toHaveLength(1);
});
it('routes only sanitized terminal Shaka evidence through the ArtPlayer adapter', async () => {
const secret = 'artplayer-shaka-secret';
const issues: PlaybackDiagnostic[] = [];
const fakeShaka = createFakeShakaEnvironment({
onCreate: (shakaPlayer) => {
shakaPlayer.loadResult = Promise.reject({
severity: 1,
category: 1,
code: 1001,
message: `https://provider.example/?token=${secret}`,
data: [
`https://provider.example/manifest.mpd?token=${secret}`,
503,
`provider body ${secret}`,
{ Authorization: `Bearer ${secret}` },
],
});
},
});
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => issues.push(issue),
loadShaka: fakeShaka.loader,
});
session.attach(player);
session.customType['mpd']?.(
video,
'https://example.test/failing.mpd',
player
);
await flushShakaMicrotasks();
expect(issues).toHaveLength(1);
expect(issues[0]).toEqual(
expect.objectContaining({
source: PlaybackDiagnosticSource.Shaka,
player: InlinePlaybackPlayer.ArtPlayer,
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 503,
shaka: {
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
})
);
expect(JSON.stringify(issues[0].shaka)).not.toContain(secret);
expect(JSON.stringify(issues[0].shaka)).not.toContain(
'provider.example'
);
expect(JSON.stringify(issues[0].shaka)).not.toContain('Authorization');
});
});
@@ -173,6 +173,76 @@ export interface HlsPlaybackEvidence {
readonly httpStatus?: number;
}
export const ShakaPlaybackSeverity = {
Recoverable: 'recoverable',
Critical: 'critical',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackSeverity =
(typeof ShakaPlaybackSeverity)[keyof typeof ShakaPlaybackSeverity];
export const ShakaPlaybackCategory = {
Network: 'network',
Text: 'text',
Media: 'media',
Manifest: 'manifest',
Streaming: 'streaming',
Drm: 'drm',
Player: 'player',
Cast: 'cast',
Storage: 'storage',
Ads: 'ads',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackCategory =
(typeof ShakaPlaybackCategory)[keyof typeof ShakaPlaybackCategory];
export const ShakaPlaybackDisposition = {
Terminal: 'terminal',
Recoverable: 'recoverable',
} as const;
export type ShakaPlaybackDisposition =
(typeof ShakaPlaybackDisposition)[keyof typeof ShakaPlaybackDisposition];
export const ShakaPlaybackStage = {
Manifest: 'manifest',
Segment: 'segment',
Media: 'media',
License: 'license',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackStage =
(typeof ShakaPlaybackStage)[keyof typeof ShakaPlaybackStage];
export const ShakaPlaybackFailure = {
Network: 'network',
Drm: 'drm',
Manifest: 'manifest',
Media: 'media',
Unknown: 'unknown',
} as const;
export type ShakaPlaybackFailure =
(typeof ShakaPlaybackFailure)[keyof typeof ShakaPlaybackFailure];
export const ShakaPlaybackUnknownCode = 'unknown' as const;
export type ShakaPlaybackEngineCode = number | typeof ShakaPlaybackUnknownCode;
export interface ShakaPlaybackEvidence {
readonly severity: ShakaPlaybackSeverity;
readonly category: ShakaPlaybackCategory;
readonly engineCode: ShakaPlaybackEngineCode;
readonly disposition: ShakaPlaybackDisposition;
readonly stage: ShakaPlaybackStage;
readonly failure: ShakaPlaybackFailure;
readonly httpStatus?: number;
}
export interface MpegTsPlaybackErrorInput {
readonly type?: string;
readonly details?: string;
@@ -196,6 +266,7 @@ export interface PlaybackDiagnostic {
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
@@ -6,6 +6,7 @@ import type {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackSourceMetadata,
ShakaPlaybackEvidence,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
VhsPlaybackEvidence,
} from './playback-diagnostics.model';
@@ -257,6 +258,7 @@ export function createPlaybackDiagnostic(options: {
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
readonly externalFallbackRecommended?: boolean;
@@ -272,6 +274,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorType,
vhs,
hls,
shaka,
} = options;
return {
@@ -290,6 +293,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorType,
vhs,
hls,
shaka,
externalFallbackRecommended:
options.externalFallbackRecommended ??
isExternalFallbackRecommended(code),
@@ -9,91 +9,213 @@ import {
createUnsupportedDrmDiagnostic,
} from './shaka-error-classifier';
interface StructuredShakaDiagnostic {
readonly code: string;
readonly details?: string;
readonly httpStatus?: number;
readonly shaka?: {
readonly severity: string;
readonly category: string;
readonly engineCode: number | string;
readonly disposition: string;
readonly stage: string;
readonly failure: string;
readonly httpStatus?: number;
};
}
type StructuredClassifier = (
error: Record<string, unknown> | null | undefined,
sourceMetadata: typeof metadata,
disposition: 'terminal' | 'recoverable'
) => StructuredShakaDiagnostic | null;
const metadata = createPlaybackSourceMetadata({
url: 'http://example.com/stream.mpd',
mimeType: 'application/dash+xml',
player: InlinePlaybackPlayer.Html5,
});
const classify = classifyShakaPlaybackIssue as StructuredClassifier;
describe('classifyShakaPlaybackIssue', () => {
it.each([
['DRM category', { category: 6, code: 6001 }],
['restrictions-cannot-be-met', { category: 4, code: 4012 }],
['DRM category/code pair', { severity: 2, category: 6, code: 6001 }],
['manifest key-system code', { severity: 2, category: 4, code: 4008 }],
])('maps exact %s to DrmOrEncryption', (_label, error) => {
const issue = classify(error, metadata, 'terminal');
expect(issue).not.toBeNull();
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue?.shaka?.failure).toBe('drm');
});
it('maps an exact terminal network error and retains only safe status evidence', () => {
const issue = classify(
{
severity: 1,
category: 1,
code: 1001,
message:
'Blocked by CORS at https://provider.example/?token=secret',
data: [
'https://provider.example/manifest.mpd?token=secret',
503,
'provider response secret',
{ Authorization: 'Bearer secret' },
],
},
metadata,
'terminal'
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Shaka,
httpStatus: 503,
details: undefined,
shaka: {
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
})
);
const serialized = JSON.stringify(issue);
expect(serialized).not.toContain('secret');
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('response');
});
it.each([3014, 3015, 3016])(
'maps exact media pipeline code %s to MediaDecodeError',
(code) => {
const issue = classify(
{ severity: 2, category: 3, code },
metadata,
'terminal'
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
}
);
it('maps only the exact DASH unsupported-container code to UnsupportedContainer', () => {
const issue = classify(
{ severity: 2, category: 4, code: 4006 },
metadata,
'terminal'
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
});
it.each([
[
'license keyword in message',
{ category: 5, code: 5001, message: 'license request failed' },
'manifest parsing',
{ severity: 2, category: 4, code: 4001 },
'manifest',
],
])('maps %s to DrmOrEncryption', (_label, error) => {
const issue = classifyShakaPlaybackIssue(error, metadata);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
});
[
'ambiguous browser content support',
{ severity: 2, category: 4, code: 4032 },
'media',
],
[
'ambiguous restrictions',
{ severity: 2, category: 4, code: 4012 },
'unknown',
],
[
'generic media parsing',
{ severity: 2, category: 3, code: 3005 },
'media',
],
[
'mismatched category/code pair',
{ severity: 2, category: 1, code: 6001 },
'unknown',
],
[
'mismatched media code pair',
{ severity: 2, category: 1, code: 3014 },
'unknown',
],
[
'mismatched container code pair',
{ severity: 2, category: 3, code: 4006 },
'unknown',
],
[
'provider extension values',
{ severity: 3, category: 11, code: 123456 },
'unknown',
],
])(
'keeps %s as UnknownPlaybackError with structured failure=%s',
(_label, error, failure) => {
const issue = classify(
{
...error,
message:
'CORS codec DRM unsupported container license request',
data: [{ provider: 'secret-payload' }],
},
metadata,
'terminal'
);
it('maps network category to NetworkError', () => {
const issue = classifyShakaPlaybackIssue(
{ category: 1, code: 1001 },
metadata
expect(issue?.code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue?.shaka?.failure).toBe(failure);
expect(issue?.details).toBeUndefined();
}
);
it('does not turn a recoverable Shaka event into a terminal diagnostic', () => {
const issue = classify(
{ severity: 1, category: 1, code: 1002 },
metadata,
'recoverable'
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue).toBeNull();
});
it('maps CORS-flavored network failures to BrowserAccessError', () => {
const issue = classifyShakaPlaybackIssue(
{ category: 1, code: 1002, message: 'Blocked by CORS policy' },
metadata
it('creates unknown structured evidence when no public Shaka error is available', () => {
const issue = classify(null, metadata, 'terminal');
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.UnknownPlaybackError,
details: undefined,
shaka: {
severity: 'unknown',
category: 'unknown',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
},
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError);
});
it('maps media category to MediaDecodeError', () => {
const issue = classifyShakaPlaybackIssue(
{ category: 3, code: 3016 },
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
});
it('maps codec-flavored media failures to UnsupportedCodec', () => {
const issue = classifyShakaPlaybackIssue(
{ category: 3, code: 3005, message: 'addCodec failed for hvc1' },
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
});
it('maps manifest category to UnsupportedContainer', () => {
const issue = classifyShakaPlaybackIssue(
{ category: 4, code: 4001 },
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
});
it('falls back to UnknownPlaybackError and keeps details', () => {
const issue = classifyShakaPlaybackIssue(
{ category: 7, code: 7002, message: 'boom' },
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.details).toContain('boom');
expect(issue.details).toContain('7002');
});
it('tolerates missing error objects', () => {
const issue = classifyShakaPlaybackIssue(null, metadata);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
});
});
describe('createUnsupportedDrmDiagnostic', () => {
it('creates a DRM diagnostic without recommending unusable external fallbacks', () => {
it('creates a safe DRM diagnostic without echoing provider license data', () => {
const secret = 'unsupported-drm-secret';
const issue = createUnsupportedDrmDiagnostic(
'com.widevine.alpha',
`https://provider.example/license?token=${secret}`,
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
expect(issue.details).toContain('com.widevine.alpha');
expect(issue.details).toBe('Unsupported DRM license configuration');
expect(JSON.stringify(issue)).not.toContain(secret);
expect(JSON.stringify(issue)).not.toContain('provider.example');
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
// must not offer them as a fallback.
expect(issue.externalFallbackRecommended).toBe(false);
@@ -1,116 +1,93 @@
import type {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackSourceMetadata,
ShakaPlaybackDisposition,
ShakaPlaybackEvidence,
} from '../playback-diagnostics/playback-diagnostics.model';
import {
PlaybackDiagnosticCode as DiagnosticCode,
PlaybackDiagnosticSource as DiagnosticSource,
ShakaPlaybackCategory,
ShakaPlaybackDisposition as ShakaDisposition,
ShakaPlaybackFailure,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import {
isBrowserAccessFailure,
isCodecFailure,
isDrmOrEncryptionFailure,
} from '../playback-diagnostics/playback-error-patterns.util';
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
import type { ShakaErrorLike } from './shaka-module.types';
/**
* Numeric `shaka.util.Error.Category` values (stable public Shaka API). Kept
* local so classification stays a pure function that does not need the lazily
* loaded module.
*/
const SHAKA_CATEGORY = {
Network: 1,
Text: 2,
Media: 3,
Manifest: 4,
Streaming: 5,
Drm: 6,
} as const;
export {
SHAKA_DIAGNOSTIC_VERSION,
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
export { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
/** `shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET` — in practice this fires
* when every variant is restricted by unusable decryption keys. */
const RESTRICTIONS_CANNOT_BE_MET = 4012;
const MEDIA_DECODE_CODES = new Set<number>([
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
SHAKA_ERROR_CODE.VIDEO_ERROR,
]);
export function classifyShakaPlaybackIssue(
error: Partial<ShakaErrorLike> | null | undefined,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const details = formatShakaErrorDetails(error);
const lowerDetails = details.toLowerCase();
const category = error?.category;
if (
category === SHAKA_CATEGORY.Drm ||
error?.code === RESTRICTIONS_CANNOT_BE_MET ||
isDrmOrEncryptionFailure(lowerDetails)
) {
return createPlaybackDiagnostic({
code: DiagnosticCode.DrmOrEncryption,
source: DiagnosticSource.Shaka,
metadata,
details,
});
}
if (category === SHAKA_CATEGORY.Network) {
return createPlaybackDiagnostic({
code: isBrowserAccessFailure(lowerDetails)
? DiagnosticCode.BrowserAccessError
: DiagnosticCode.NetworkError,
source: DiagnosticSource.Shaka,
metadata,
details,
});
}
if (
category === SHAKA_CATEGORY.Media ||
category === SHAKA_CATEGORY.Streaming
) {
return createPlaybackDiagnostic({
code: isCodecFailure(lowerDetails)
? DiagnosticCode.UnsupportedCodec
: DiagnosticCode.MediaDecodeError,
source: DiagnosticSource.Shaka,
metadata,
details,
});
}
if (category === SHAKA_CATEGORY.Manifest) {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedContainer,
source: DiagnosticSource.Shaka,
metadata,
details,
});
metadata: PlaybackSourceMetadata,
disposition: ShakaPlaybackDisposition
): PlaybackDiagnostic | null {
const evidence = createShakaPlaybackEvidence(error, disposition);
if (evidence.disposition === ShakaDisposition.Recoverable) {
return null;
}
return createPlaybackDiagnostic({
code: DiagnosticCode.UnknownPlaybackError,
code: getDiagnosticCode(evidence),
source: DiagnosticSource.Shaka,
metadata,
details,
httpStatus: evidence.httpStatus,
shaka: evidence,
});
}
function getDiagnosticCode(
evidence: ShakaPlaybackEvidence
): PlaybackDiagnosticCode {
if (evidence.failure === ShakaPlaybackFailure.Network) {
return DiagnosticCode.NetworkError;
}
if (evidence.failure === ShakaPlaybackFailure.Drm) {
return DiagnosticCode.DrmOrEncryption;
}
if (
evidence.category === ShakaPlaybackCategory.Manifest &&
evidence.engineCode === SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER
) {
return DiagnosticCode.UnsupportedContainer;
}
if (
evidence.category === ShakaPlaybackCategory.Media &&
typeof evidence.engineCode === 'number' &&
MEDIA_DECODE_CODES.has(evidence.engineCode)
) {
return DiagnosticCode.MediaDecodeError;
}
return DiagnosticCode.UnknownPlaybackError;
}
/**
* Diagnostic for `.mpd` channels that declare a DRM system the app cannot
* handle (Widevine, PlayReady, malformed ClearKey config, …). Emitted before
* any Shaka engine is started.
* handle. Provider-supplied license strings are intentionally not retained.
*/
export function createUnsupportedDrmDiagnostic(
licenseType: string,
_licenseType: string,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
return createPlaybackDiagnostic({
code: DiagnosticCode.DrmOrEncryption,
source: DiagnosticSource.Shaka,
metadata,
details: licenseType
? `Unsupported DRM license configuration: ${licenseType}`
: 'Unsupported DRM license configuration',
details: 'Unsupported DRM license configuration',
// External MPV/VLC cannot receive the KODIPROP license config either,
// so offering them as a fallback would just fail differently.
externalFallbackRecommended: false,
@@ -118,56 +95,15 @@ export function createUnsupportedDrmDiagnostic(
}
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
export function asShakaError(
error: unknown
): Partial<ShakaErrorLike> | null {
export function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
if (!error || typeof error !== 'object') {
return null;
}
const candidate = error as Partial<ShakaErrorLike>;
return typeof candidate.code === 'number' ||
typeof candidate.category === 'number'
typeof candidate.category === 'number' ||
typeof candidate.severity === 'number'
? candidate
: null;
}
export function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : String(error);
}
function formatShakaErrorDetails(
error: Partial<ShakaErrorLike> | null | undefined
): string {
if (!error) {
return '';
}
const parts = [
typeof error.category === 'number'
? `Shaka category ${error.category}`
: '',
typeof error.code === 'number' ? `code ${error.code}` : '',
error.message ?? '',
stringifyErrorData(error.data),
];
return parts.filter((part) => part.length > 0).join(' ');
}
function stringifyErrorData(data: unknown[] | undefined): string {
if (!data || data.length === 0) {
return '';
}
try {
const serialized = JSON.stringify(data);
return serialized === '[]' ? '' : serialized;
} catch {
return '';
}
}
@@ -0,0 +1,125 @@
/**
* Public Shaka error values audited against the locked 5.2.2 runtime.
*
* Keep the version assertion in the contract spec: a Shaka upgrade must stop
* here for a new audit instead of silently accepting new error layouts.
*/
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.2';
export const SHAKA_ERROR_SEVERITY = {
RECOVERABLE: 1,
CRITICAL: 2,
} as const;
export const SHAKA_ERROR_CATEGORY = {
NETWORK: 1,
TEXT: 2,
MEDIA: 3,
MANIFEST: 4,
STREAMING: 5,
DRM: 6,
PLAYER: 7,
CAST: 8,
STORAGE: 9,
ADS: 10,
} as const;
/**
* Public online-playback codes whose numeric value may cross the evidence
* boundary. Retired gaps and offline/cast/ad-only codes are intentionally
* absent.
*/
export const SHAKA_ERROR_CODE = {
UNSUPPORTED_SCHEME: 1000,
BAD_HTTP_STATUS: 1001,
HTTP_ERROR: 1002,
TIMEOUT: 1003,
MALFORMED_DATA_URI: 1004,
REQUEST_FILTER_ERROR: 1006,
RESPONSE_FILTER_ERROR: 1007,
SEGMENT_MISSING: 1011,
// Text parsing reachable from DASH playback. HLS-only and app-uninvoked
// external-track/src= codes stay outside this boundary.
INVALID_TEXT_HEADER: 2000,
INVALID_TEXT_CUE: 2001,
UNABLE_TO_DETECT_ENCODING: 2003,
BAD_ENCODING: 2004,
INVALID_XML: 2005,
INVALID_MP4_TTML: 2007,
INVALID_MP4_VTT: 2008,
INVALID_MP4_CEA: 2010,
BUFFER_READ_OUT_OF_BOUNDS: 3000,
JS_INTEGER_OVERFLOW: 3001,
EBML_OVERFLOW: 3002,
EBML_BAD_FLOATING_POINT_SIZE: 3003,
MP4_SIDX_WRONG_BOX_TYPE: 3004,
MP4_SIDX_INVALID_TIMESCALE: 3005,
MP4_SIDX_TYPE_NOT_SUPPORTED: 3006,
WEBM_CUES_ELEMENT_MISSING: 3007,
WEBM_EBML_HEADER_ELEMENT_MISSING: 3008,
WEBM_SEGMENT_ELEMENT_MISSING: 3009,
WEBM_INFO_ELEMENT_MISSING: 3010,
WEBM_DURATION_ELEMENT_MISSING: 3011,
WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING: 3012,
WEBM_CUE_TIME_ELEMENT_MISSING: 3013,
MEDIA_SOURCE_OPERATION_FAILED: 3014,
MEDIA_SOURCE_OPERATION_THREW: 3015,
VIDEO_ERROR: 3016,
QUOTA_EXCEEDED_ERROR: 3017,
TRANSMUXING_FAILED: 3018,
CONTENT_TRANSFORMATION_FAILED: 3019,
TRANSMUXING_NO_VIDEO_DATA: 3023,
STREAMING_NOT_ALLOWED: 3024,
BUFFER_WRITE_OUT_OF_BOUNDS: 3025,
UNABLE_TO_GUESS_MANIFEST_TYPE: 4000,
DASH_INVALID_XML: 4001,
DASH_NO_SEGMENT_INFO: 4002,
DASH_EMPTY_ADAPTATION_SET: 4003,
DASH_EMPTY_PERIOD: 4004,
DASH_WEBM_MISSING_INIT: 4005,
DASH_UNSUPPORTED_CONTAINER: 4006,
DASH_PSSH_BAD_ENCODING: 4007,
DASH_NO_COMMON_KEY_SYSTEM: 4008,
DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED: 4009,
DASH_CONFLICTING_KEY_IDS: 4010,
RESTRICTIONS_CANNOT_BE_MET: 4012,
DASH_DUPLICATE_REPRESENTATION_ID: 4018,
DASH_UNSUPPORTED_XLINK_ACTUATE: 4027,
DASH_XLINK_DEPTH_LIMIT: 4028,
CONTENT_UNSUPPORTED_BY_BROWSER: 4032,
NO_VARIANTS: 4036,
PERIOD_FLATTENING_FAILED: 4037,
INCONSISTENT_DRM_ACROSS_PERIODS: 4038,
NO_WEB_CRYPTO_API: 4042,
AES_128_INVALID_IV_LENGTH: 4048,
AES_128_INVALID_KEY_LENGTH: 4049,
DASH_CONFLICTING_AES_128: 4050,
DASH_UNSUPPORTED_AES_128: 4051,
DASH_INVALID_PATCH: 4052,
DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED: 4054,
WEBTRANSPORT_NOT_AVAILABLE: 4056,
WEBTRANSPORT_INITIALIZATION_FAILED: 4057,
DASH_INVALID_JSON: 4061,
DASH_UNSUPPORTED_ESSENTIAL_PROPERTY: 4063,
STREAMING_ENGINE_STARTUP_INVALID_STATE: 5006,
NO_RECOGNIZED_KEY_SYSTEMS: 6000,
REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE: 6001,
FAILED_TO_CREATE_CDM: 6002,
FAILED_TO_ATTACH_TO_VIDEO: 6003,
INVALID_SERVER_CERTIFICATE: 6004,
FAILED_TO_CREATE_SESSION: 6005,
FAILED_TO_GENERATE_LICENSE_REQUEST: 6006,
LICENSE_REQUEST_FAILED: 6007,
LICENSE_RESPONSE_REJECTED: 6008,
ENCRYPTED_CONTENT_WITHOUT_DRM_INFO: 6010,
NO_LICENSE_SERVER_GIVEN: 6012,
OFFLINE_SESSION_REMOVED: 6013,
EXPIRED: 6014,
SERVER_CERTIFICATE_REQUIRED: 6015,
INIT_DATA_TRANSFORM_ERROR: 6016,
SERVER_CERTIFICATE_REQUEST_FAILED: 6017,
MIN_HDCP_VERSION_NOT_MATCH: 6018,
ERROR_CHECKING_HDCP_VERSION: 6019,
MISSING_EME_SUPPORT: 6020,
LOAD_INTERRUPTED: 7000,
} as const;
@@ -0,0 +1,29 @@
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
import type { ShakaErrorLike } from './shaka-module.types';
export function getShakaErrorEventDisposition(
error: Partial<ShakaErrorLike> | null
): (typeof ShakaDisposition)[keyof typeof ShakaDisposition] | null {
if (error?.severity === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
return ShakaDisposition.Recoverable;
}
if (error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL) {
return ShakaDisposition.Terminal;
}
return null;
}
export function isShakaLoadInterrupted(
error: Partial<ShakaErrorLike> | null
): boolean {
return (
error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL &&
error.category === SHAKA_ERROR_CATEGORY.PLAYER &&
error.code === SHAKA_ERROR_CODE.LOAD_INTERRUPTED
);
}
@@ -0,0 +1,121 @@
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
export const SHAKA_PUBLIC_CODES = new Set<number>(
Object.values(SHAKA_ERROR_CODE)
);
export const SHAKA_NETWORK_CODES = new Set<number>([
SHAKA_ERROR_CODE.UNSUPPORTED_SCHEME,
SHAKA_ERROR_CODE.BAD_HTTP_STATUS,
SHAKA_ERROR_CODE.HTTP_ERROR,
SHAKA_ERROR_CODE.TIMEOUT,
SHAKA_ERROR_CODE.MALFORMED_DATA_URI,
SHAKA_ERROR_CODE.REQUEST_FILTER_ERROR,
SHAKA_ERROR_CODE.RESPONSE_FILTER_ERROR,
SHAKA_ERROR_CODE.SEGMENT_MISSING,
]);
export const SHAKA_MEDIA_CODES = new Set<number>([
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
SHAKA_ERROR_CODE.EBML_OVERFLOW,
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
SHAKA_ERROR_CODE.VIDEO_ERROR,
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
]);
export const SHAKA_MANIFEST_DRM_CODES = new Set<number>([
SHAKA_ERROR_CODE.DASH_PSSH_BAD_ENCODING,
SHAKA_ERROR_CODE.DASH_NO_COMMON_KEY_SYSTEM,
SHAKA_ERROR_CODE.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED,
SHAKA_ERROR_CODE.DASH_CONFLICTING_KEY_IDS,
SHAKA_ERROR_CODE.INCONSISTENT_DRM_ACROSS_PERIODS,
SHAKA_ERROR_CODE.NO_WEB_CRYPTO_API,
SHAKA_ERROR_CODE.AES_128_INVALID_IV_LENGTH,
SHAKA_ERROR_CODE.AES_128_INVALID_KEY_LENGTH,
SHAKA_ERROR_CODE.DASH_CONFLICTING_AES_128,
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_AES_128,
SHAKA_ERROR_CODE.DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED,
]);
export const SHAKA_MANIFEST_MEDIA_CODES = new Set<number>([
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER,
SHAKA_ERROR_CODE.CONTENT_UNSUPPORTED_BY_BROWSER,
]);
export const SHAKA_AMBIGUOUS_MANIFEST_CODES = new Set<number>([
SHAKA_ERROR_CODE.RESTRICTIONS_CANNOT_BE_MET,
]);
export const SHAKA_MANIFEST_CODES = new Set<number>(
Object.values(SHAKA_ERROR_CODE).filter(
(code) => code >= 4000 && code < 5000
)
);
export const SHAKA_DRM_CODES = new Set<number>(
Object.values(SHAKA_ERROR_CODE).filter(
(code) => code >= 6000 && code < 7000
)
);
export const SHAKA_SEGMENT_STAGE_CODES = new Set<number>([
SHAKA_ERROR_CODE.SEGMENT_MISSING,
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
SHAKA_ERROR_CODE.EBML_OVERFLOW,
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
]);
export const SHAKA_MEDIA_STAGE_CODES = new Set<number>([
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
SHAKA_ERROR_CODE.VIDEO_ERROR,
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
]);
export const SHAKA_LICENSE_STAGE_CODES = new Set<number>([
SHAKA_ERROR_CODE.FAILED_TO_GENERATE_LICENSE_REQUEST,
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED,
SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN,
SHAKA_ERROR_CODE.EXPIRED,
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
]);
export const SHAKA_NESTED_HTTP_CODES = new Set<number>([
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
]);
@@ -26,8 +26,7 @@ export interface ShakaErrorLike {
severity: number;
category: number;
code: number;
message?: string;
data?: unknown[];
data?: readonly unknown[];
}
export interface ShakaPlayerLike {
@@ -66,10 +65,7 @@ export type ShakaModuleLoader = () => Promise<ShakaModuleLike>;
*/
export const loadShakaModule: ShakaModuleLoader = async () => {
const module = (await import('shaka-player')) as unknown as
| { default?: ShakaModuleLike }
| ShakaModuleLike;
return (
((module as { default?: ShakaModuleLike }).default ??
module) as ShakaModuleLike
);
{ default?: ShakaModuleLike } | ShakaModuleLike;
return ((module as { default?: ShakaModuleLike }).default ??
module) as ShakaModuleLike;
};
@@ -0,0 +1,376 @@
import { execFileSync } from 'node:child_process';
import * as shakaDiagnostics from './shaka-error-classifier';
interface ShakaErrorInput {
readonly severity?: unknown;
readonly category?: unknown;
readonly code?: unknown;
readonly message?: unknown;
readonly data?: readonly unknown[];
readonly [key: string]: unknown;
}
interface ExpectedShakaPlaybackEvidence {
readonly severity: string;
readonly category: string;
readonly engineCode: number | string;
readonly disposition: string;
readonly stage: string;
readonly failure: string;
readonly httpStatus?: number;
}
interface InstalledShakaContract {
readonly version: string;
readonly severity: Readonly<Record<string, number>>;
readonly category: Readonly<Record<string, number>>;
readonly code: Readonly<Record<string, number>>;
}
interface ShakaEvidenceExports {
readonly SHAKA_DIAGNOSTIC_VERSION?: string;
readonly SHAKA_ERROR_SEVERITY?: Readonly<Record<string, number>>;
readonly SHAKA_ERROR_CATEGORY?: Readonly<Record<string, number>>;
readonly SHAKA_ERROR_CODE?: Readonly<Record<string, number>>;
readonly createShakaPlaybackEvidence?: (
error: ShakaErrorInput | null | undefined,
disposition: 'terminal' | 'recoverable'
) => ExpectedShakaPlaybackEvidence;
}
const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports;
describe('Shaka playback evidence', () => {
it('matches the installed public Shaka 5.2.2 error contract', () => {
const installed = getInstalledShakaContract();
expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe(
installed.version
);
expect(exportsUnderTest.SHAKA_ERROR_SEVERITY).toEqual(
installed.severity
);
expect(exportsUnderTest.SHAKA_ERROR_CATEGORY).toEqual(
installed.category
);
expect(exportsUnderTest.SHAKA_ERROR_CODE).toBeDefined();
for (const [name, value] of Object.entries(
exportsUnderTest.SHAKA_ERROR_CODE ?? {}
)) {
expect(installed.code[name]).toBe(value);
}
});
it('extracts only the documented status from a real bad-status shape', () => {
const secret = 'shaka-direct-secret';
const evidence = createEvidence(
{
severity: 1,
category: 1,
code: 1001,
message: `request failed at https://user:${secret}@provider.example`,
data: [
`https://provider.example/manifest.mpd?token=${secret}`,
503,
`provider response body ${secret}`,
{ Authorization: `Bearer ${secret}` },
0,
`https://provider.example/final?token=${secret}`,
],
providerPayload: { key: secret },
},
'terminal'
);
const serialized = JSON.stringify(evidence);
expect(evidence).toEqual({
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
});
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('response body');
expect(Object.keys(evidence).sort()).toEqual(
[
'category',
'disposition',
'engineCode',
'failure',
'httpStatus',
'severity',
'stage',
].sort()
);
});
it.each([
{ code: 6007, label: 'license request' },
{ code: 6017, label: 'server certificate request' },
])(
'extracts a nested bad status from the documented $label layout',
({ code }) => {
const secret = 'shaka-nested-secret';
const evidence = createEvidence(
{
severity: 2,
category: 6,
code,
data: [
{
severity: 1,
category: 1,
code: 1001,
data: [
`https://provider.example/license?token=${secret}`,
403,
`license response ${secret}`,
{ Authorization: `Bearer ${secret}` },
],
},
{
licenseRequest: secret,
sessionMetadata: secret,
},
],
},
'terminal'
);
const serialized = JSON.stringify(evidence);
expect(evidence).toEqual({
severity: 'critical',
category: 'drm',
engineCode: code,
disposition: 'terminal',
stage: 'license',
failure: 'drm',
httpStatus: 403,
});
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
}
);
it.each([
{ status: 99, expected: undefined },
{ status: 100, expected: 100 },
{ status: 599, expected: 599 },
{ status: 600, expected: undefined },
{ status: 404.5, expected: undefined },
{ status: '503', expected: undefined },
])(
'retains documented HTTP status $status only inside the protocol range',
({ status, expected }) => {
const evidence = createEvidence(
{
severity: 1,
category: 1,
code: 1001,
data: ['https://provider.example/manifest.mpd', status],
},
'terminal'
);
expect(evidence.httpStatus).toBe(expected);
}
);
it('ignores status-like data on every undocumented layout', () => {
const evidence = createEvidence(
{
severity: 2,
category: 4,
code: 4001,
data: [503, 504, { status: 505 }],
},
'terminal'
);
expect(evidence.httpStatus).toBeUndefined();
});
it('preserves the exact public streaming startup code as unknown failure evidence', () => {
expect(
createEvidence({ severity: 2, category: 5, code: 5006 }, 'terminal')
).toEqual({
severity: 'critical',
category: 'streaming',
engineCode: 5006,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it.each([
['INVALID_TEXT_HEADER', 2000],
['INVALID_TEXT_CUE', 2001],
['UNABLE_TO_DETECT_ENCODING', 2003],
['BAD_ENCODING', 2004],
['INVALID_XML', 2005],
['INVALID_MP4_TTML', 2007],
['INVALID_MP4_VTT', 2008],
['INVALID_MP4_CEA', 2010],
])('preserves the exact public DASH text code %s', (_name, code) => {
expect(
createEvidence({ severity: 2, category: 2, code }, 'terminal')
).toEqual({
severity: 'critical',
category: 'text',
engineCode: code,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it('rejects public text codes from app-uninvoked explicit-track APIs', () => {
expect(
createEvidence({ severity: 2, category: 2, code: 2014 }, 'terminal')
).toEqual({
severity: 'critical',
category: 'text',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it.each([
{
label: 'manifest parsing',
error: { severity: 2, category: 4, code: 4001 },
stage: 'manifest',
failure: 'manifest',
},
{
label: 'segment index parsing',
error: { severity: 2, category: 3, code: 3004 },
stage: 'segment',
failure: 'media',
},
{
label: 'media source operation',
error: { severity: 2, category: 3, code: 3014 },
stage: 'media',
failure: 'media',
},
{
label: 'license response',
error: { severity: 2, category: 6, code: 6008 },
stage: 'license',
failure: 'drm',
},
{
label: 'network without proven request stage',
error: { severity: 2, category: 1, code: 1002 },
stage: 'unknown',
failure: 'network',
},
])(
'maps exact $label evidence without reading messages',
({ error, stage, failure }) => {
const evidence = createEvidence(
{
...error,
message:
'CORS codec manifest license segment provider guess',
},
'terminal'
);
expect(evidence.stage).toBe(stage);
expect(evidence.failure).toBe(failure);
}
);
it.each([
{
label: 'ambiguous restrictions',
error: { severity: 2, category: 4, code: 4012 },
stage: 'manifest',
},
{
label: 'mismatched public pair',
error: { severity: 2, category: 1, code: 6001 },
stage: 'unknown',
},
{
label: 'mismatched media stage pair',
error: { severity: 2, category: 1, code: 3004 },
stage: 'unknown',
},
{
label: 'unknown provider code',
error: { severity: 3, category: 11, code: 123456 },
stage: 'unknown',
},
])('keeps $label failure evidence unknown', ({ error, stage }) => {
const evidence = createEvidence(error, 'terminal');
expect(evidence.failure).toBe('unknown');
expect(evidence.stage).toBe(stage);
});
it('validates severity, category, code, and explicit disposition independently', () => {
expect(
createEvidence(
{ severity: 99, category: 99, code: 999999 },
'recoverable'
)
).toEqual({
severity: 'unknown',
category: 'unknown',
engineCode: 'unknown',
disposition: 'recoverable',
stage: 'unknown',
failure: 'unknown',
});
});
});
function createEvidence(
error: ShakaErrorInput | null | undefined,
disposition: 'terminal' | 'recoverable'
): ExpectedShakaPlaybackEvidence {
const factory = exportsUnderTest.createShakaPlaybackEvidence;
expect(factory).toBeDefined();
if (!factory) {
throw new Error('createShakaPlaybackEvidence is not exported');
}
return factory(error, disposition);
}
function getInstalledShakaContract(): InstalledShakaContract {
const script = `
global.self = globalThis;
import('shaka-player').then((module) => {
const shaka = module.default ?? module;
process.stdout.write(JSON.stringify({
version: shaka.Player.version,
severity: shaka.util.Error.Severity,
category: shaka.util.Error.Category,
code: shaka.util.Error.Code,
}));
}).catch((error) => {
console.error(error);
process.exit(1);
});
`;
return JSON.parse(
execFileSync(process.execPath, ['-e', script], {
cwd: process.cwd(),
encoding: 'utf8',
})
) as InstalledShakaContract;
}
@@ -0,0 +1,235 @@
import {
type ShakaPlaybackCategory as ShakaPlaybackCategoryValue,
type ShakaPlaybackDisposition,
type ShakaPlaybackEvidence,
type ShakaPlaybackFailure as ShakaPlaybackFailureValue,
type ShakaPlaybackStage as ShakaPlaybackStageValue,
ShakaPlaybackCategory,
ShakaPlaybackFailure,
ShakaPlaybackSeverity,
ShakaPlaybackStage,
ShakaPlaybackUnknownCode,
} from '../playback-diagnostics/playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
import {
SHAKA_AMBIGUOUS_MANIFEST_CODES,
SHAKA_DRM_CODES,
SHAKA_LICENSE_STAGE_CODES,
SHAKA_MANIFEST_CODES,
SHAKA_MANIFEST_DRM_CODES,
SHAKA_MANIFEST_MEDIA_CODES,
SHAKA_MEDIA_CODES,
SHAKA_MEDIA_STAGE_CODES,
SHAKA_NESTED_HTTP_CODES,
SHAKA_NETWORK_CODES,
SHAKA_PUBLIC_CODES,
SHAKA_SEGMENT_STAGE_CODES,
} from './shaka-error-mapping';
import type { ShakaErrorLike } from './shaka-module.types';
export function createShakaPlaybackEvidence(
error: Partial<ShakaErrorLike> | null | undefined,
disposition: ShakaPlaybackDisposition
): ShakaPlaybackEvidence {
const category = getCategory(error?.category);
const engineCode = getEngineCode(error?.code);
const httpStatus = getHttpStatus(error);
const evidence: ShakaPlaybackEvidence = {
severity: getSeverity(error?.severity),
category,
engineCode,
disposition,
stage: getStage(category, engineCode),
failure: getFailure(category, engineCode),
};
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
}
function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] {
if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
return ShakaPlaybackSeverity.Recoverable;
}
if (value === SHAKA_ERROR_SEVERITY.CRITICAL) {
return ShakaPlaybackSeverity.Critical;
}
return ShakaPlaybackSeverity.Unknown;
}
function getCategory(value: unknown): ShakaPlaybackCategoryValue {
switch (value) {
case SHAKA_ERROR_CATEGORY.NETWORK:
return ShakaPlaybackCategory.Network;
case SHAKA_ERROR_CATEGORY.TEXT:
return ShakaPlaybackCategory.Text;
case SHAKA_ERROR_CATEGORY.MEDIA:
return ShakaPlaybackCategory.Media;
case SHAKA_ERROR_CATEGORY.MANIFEST:
return ShakaPlaybackCategory.Manifest;
case SHAKA_ERROR_CATEGORY.STREAMING:
return ShakaPlaybackCategory.Streaming;
case SHAKA_ERROR_CATEGORY.DRM:
return ShakaPlaybackCategory.Drm;
case SHAKA_ERROR_CATEGORY.PLAYER:
return ShakaPlaybackCategory.Player;
case SHAKA_ERROR_CATEGORY.CAST:
return ShakaPlaybackCategory.Cast;
case SHAKA_ERROR_CATEGORY.STORAGE:
return ShakaPlaybackCategory.Storage;
case SHAKA_ERROR_CATEGORY.ADS:
return ShakaPlaybackCategory.Ads;
default:
return ShakaPlaybackCategory.Unknown;
}
}
function getEngineCode(value: unknown): ShakaPlaybackEvidence['engineCode'] {
return typeof value === 'number' &&
Number.isInteger(value) &&
SHAKA_PUBLIC_CODES.has(value)
? value
: ShakaPlaybackUnknownCode;
}
function getFailure(
category: ShakaPlaybackCategoryValue,
engineCode: ShakaPlaybackEvidence['engineCode']
): ShakaPlaybackFailureValue {
if (
category === ShakaPlaybackCategory.Network &&
typeof engineCode === 'number' &&
SHAKA_NETWORK_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Network;
}
if (
category === ShakaPlaybackCategory.Drm &&
typeof engineCode === 'number' &&
SHAKA_DRM_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Drm;
}
if (
category === ShakaPlaybackCategory.Media &&
typeof engineCode === 'number' &&
SHAKA_MEDIA_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Media;
}
if (
category === ShakaPlaybackCategory.Manifest &&
typeof engineCode === 'number'
) {
if (SHAKA_MANIFEST_DRM_CODES.has(engineCode)) {
return ShakaPlaybackFailure.Drm;
}
if (SHAKA_MANIFEST_MEDIA_CODES.has(engineCode)) {
return ShakaPlaybackFailure.Media;
}
if (
SHAKA_MANIFEST_CODES.has(engineCode) &&
!SHAKA_AMBIGUOUS_MANIFEST_CODES.has(engineCode)
) {
return ShakaPlaybackFailure.Manifest;
}
}
return ShakaPlaybackFailure.Unknown;
}
function getStage(
category: ShakaPlaybackCategoryValue,
engineCode: ShakaPlaybackEvidence['engineCode']
): ShakaPlaybackStageValue {
if (typeof engineCode !== 'number') {
return ShakaPlaybackStage.Unknown;
}
if (
category === ShakaPlaybackCategory.Manifest &&
SHAKA_MANIFEST_CODES.has(engineCode)
) {
return ShakaPlaybackStage.Manifest;
}
if (
(category === ShakaPlaybackCategory.Network &&
engineCode === SHAKA_ERROR_CODE.SEGMENT_MISSING) ||
(category === ShakaPlaybackCategory.Media &&
SHAKA_SEGMENT_STAGE_CODES.has(engineCode))
) {
return ShakaPlaybackStage.Segment;
}
if (
category === ShakaPlaybackCategory.Media &&
SHAKA_MEDIA_STAGE_CODES.has(engineCode)
) {
return ShakaPlaybackStage.Media;
}
if (
category === ShakaPlaybackCategory.Drm &&
SHAKA_LICENSE_STAGE_CODES.has(engineCode)
) {
return ShakaPlaybackStage.License;
}
return ShakaPlaybackStage.Unknown;
}
function getHttpStatus(
error: Partial<ShakaErrorLike> | null | undefined
): number | undefined {
if (
error?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
) {
return asHttpStatus(error.data?.[1]);
}
if (
error?.category !== SHAKA_ERROR_CATEGORY.DRM ||
typeof error.code !== 'number' ||
!SHAKA_NESTED_HTTP_CODES.has(error.code)
) {
return undefined;
}
const nested = asShakaErrorData(error.data?.[0]);
return nested?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
nested.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
? asHttpStatus(nested.data?.[1])
: undefined;
}
function asShakaErrorData(value: unknown): Partial<ShakaErrorLike> | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const candidate = value as {
readonly severity?: unknown;
readonly category?: unknown;
readonly code?: unknown;
readonly data?: unknown;
};
return {
severity:
typeof candidate.severity === 'number'
? candidate.severity
: undefined,
category:
typeof candidate.category === 'number'
? candidate.category
: undefined,
code: typeof candidate.code === 'number' ? candidate.code : undefined,
data: Array.isArray(candidate.data) ? candidate.data : undefined,
};
}
function asHttpStatus(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= 100 &&
value <= 599
? value
: undefined;
}
@@ -90,7 +90,8 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka);
expect(issues[0].details).toContain('com.widevine.alpha');
expect(issues[0].details).toBe('Unsupported DRM license configuration');
expect(JSON.stringify(issues[0])).not.toContain('com.widevine.alpha');
});
it('classifies critical shaka error events and ignores recoverable ones', async () => {
@@ -107,6 +108,14 @@ describe('ShakaVideoSession', () => {
player.dispatch('error', { severity: 2, category: 6, code: 6001 });
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issues[0].shaka).toEqual({
severity: 'critical',
category: 'drm',
engineCode: 6001,
disposition: 'terminal',
stage: 'unknown',
failure: 'drm',
});
// Without KODIPROP config the DRM hint may still help externally.
expect(issues[0].externalFallbackRecommended).toBe(true);
// Critical errors end playback: the dead engine must be torn down.
@@ -114,6 +123,25 @@ describe('ShakaVideoSession', () => {
expect(session.getPlayer()).toBeNull();
});
it('ignores error events whose public severity does not prove terminal state', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/a.mpd');
await flush();
const player = environment.instances[0];
player.dispatch('error', {
severity: 99,
category: 1,
code: 1002,
message: 'CORS codec license provider guess',
});
expect(issues).toEqual([]);
expect(player.destroyCount).toBe(0);
expect(session.getPlayer()).toBe(player);
});
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
@@ -168,14 +196,219 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnsupportedContainer
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd');
expect(issues[0].shaka).toEqual({
severity: 'critical',
category: 'manifest',
engineCode: 4001,
disposition: 'terminal',
stage: 'manifest',
failure: 'manifest',
});
// The failed engine must not stay attached or exposed to controls.
expect(environment.instances[0].destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
});
it('treats a recoverable-severity load rejection as terminal lifecycle evidence', async () => {
const secret = 'load-http-secret';
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.loadResult = Promise.reject({
severity: 1,
category: 1,
code: 1001,
message: `https://provider.example/?token=${secret}`,
data: [
`https://provider.example/manifest.mpd?token=${secret}`,
503,
`provider body ${secret}`,
{ Authorization: `Bearer ${secret}` },
],
});
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/retry-exhausted.mpd');
await flush();
expect(issues).toHaveLength(1);
expect(issues[0]).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 503,
shaka: {
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
})
);
const serialized = JSON.stringify(issues[0].shaka);
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain('provider.example');
expect(serialized).not.toContain('Authorization');
expect(environment.instances[0].destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
});
it('preserves the exact streaming startup error through the session boundary', async () => {
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.loadResult = Promise.reject({
severity: 2,
category: 5,
code: 5006,
});
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/startup-failed.mpd');
await flush();
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].shaka).toEqual({
severity: 'critical',
category: 'streaming',
engineCode: 5006,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it('preserves a terminal text-parser event without retaining parser data', async () => {
const secret = 'subtitle-parser-secret';
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/text-track.mpd');
await flush();
const player = environment.instances[0];
player.dispatch('error', {
severity: 2,
category: 2,
code: 2000,
message: `malformed WebVTT from ${secret}`,
data: [{ cue: secret }],
});
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].shaka).toEqual({
severity: 'critical',
category: 'text',
engineCode: 2000,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
expect(JSON.stringify(issues[0])).not.toContain(secret);
expect(player.destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
});
it('emits a critical in-flight error once before teardown interrupts load', async () => {
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.stallNextLoad = true;
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/stalled.mpd');
await flush();
const player = environment.instances[0];
player.dispatch('error', {
severity: 2,
category: 6,
code: 6008,
});
await flush();
expect(issues).toHaveLength(1);
expect(issues[0].shaka).toEqual(
expect.objectContaining({
engineCode: 6008,
disposition: 'terminal',
})
);
expect(player.destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
});
it('does not suppress an arbitrary load rejection that only reuses code 7000', async () => {
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.loadResult = Promise.reject({
severity: 2,
category: 1,
code: 7000,
});
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/not-interrupted.mpd');
await flush();
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].shaka).toEqual({
severity: 'critical',
category: 'network',
engineCode: 7000,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it('drops arbitrary module-loader rejection messages', async () => {
const secret = 'module-loader-secret';
const session = new ShakaVideoSession({
player: InlinePlaybackPlayer.Html5,
emitPlaybackIssue: (issue) => issues.push(issue),
loadShaka: () =>
Promise.reject(
new Error(
`https://user:${secret}@provider.example/shaka.js`
)
),
});
session.start(video, 'http://example.com/a.mpd');
await flush();
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].details).toBeUndefined();
expect(issues[0].shaka).toEqual({
severity: 'unknown',
category: 'unknown',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
expect(JSON.stringify(issues[0])).not.toContain(secret);
expect(JSON.stringify(issues[0])).not.toContain('provider.example');
});
it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => {
const environment = createFakeShakaEnvironment({
onCreate: (player, index) => {
@@ -264,7 +497,7 @@ describe('ShakaVideoSession', () => {
expect(player.selectTextTrackCalls).toHaveLength(2);
});
it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => {
it('keeps external fallback available when the browser cannot run Shaka for clear DASH', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -274,8 +507,36 @@ describe('ShakaVideoSession', () => {
expect(environment.instances).toHaveLength(0);
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnsupportedContainer
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].shaka).toEqual({
severity: 'unknown',
category: 'unknown',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
expect(issues[0].externalFallbackRecommended).toBe(true);
});
it('keeps external fallback unavailable when browser support fails for KODIPROP DRM', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
session.start(video, 'http://example.com/encrypted.mpd', {
licenseType: 'clearkey',
supported: true,
clearKeys: { abc: 'def' },
});
await flush();
expect(environment.instances).toHaveLength(0);
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].externalFallbackRecommended).toBe(false);
});
it('destroy tears down the engine and blocks later starts', async () => {
@@ -4,18 +4,17 @@ import type {
PlaybackDiagnostic,
PlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.model';
import { PlaybackDiagnosticCode as DiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
import { PlaybackDiagnosticSource as DiagnosticSource } from '../playback-diagnostics/playback-diagnostics.model';
import {
createPlaybackDiagnostic,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
import {
asShakaError,
classifyShakaPlaybackIssue,
createUnsupportedDrmDiagnostic,
toErrorMessage,
} from './shaka-error-classifier';
import {
getShakaErrorEventDisposition,
isShakaLoadInterrupted,
} from './shaka-error-lifecycle';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
import {
loadShakaModule,
@@ -33,11 +32,6 @@ export interface ShakaVideoSessionConfig {
}
const DASH_MIME_TYPE = 'application/dash+xml';
/** `shaka.util.Error.Severity.RECOVERABLE` — Shaka retries these itself. */
const SHAKA_SEVERITY_RECOVERABLE = 1;
/** `shaka.util.Error.Code.LOAD_INTERRUPTED` — expected when a newer
* `load()`/`destroy()` supersedes an in-flight one. */
const SHAKA_LOAD_INTERRUPTED = 7000;
const PLAYER_REFRESH_EVENTS = [
'loaded',
@@ -139,14 +133,8 @@ export class ShakaVideoSession {
let module: ShakaModuleLike;
try {
module = await this.loadModule();
} catch (error: unknown) {
this.emitIfCurrent(
generation,
classifyShakaPlaybackIssue(
{ message: toErrorMessage(error) },
this.createMetadata(url)
)
);
} catch {
this.emitTerminalIfCurrent(generation, null, url);
return;
}
@@ -155,14 +143,11 @@ export class ShakaVideoSession {
}
if (!module.Player.isBrowserSupported()) {
this.emitIfCurrent(
this.emitTerminalIfCurrent(
generation,
createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedContainer,
source: DiagnosticSource.Shaka,
metadata: this.createMetadata(url),
details: 'Shaka Player is not supported in this browser',
})
null,
url,
drm === undefined
);
return;
}
@@ -228,20 +213,21 @@ export class ShakaVideoSession {
if (
this.isStale(generation) ||
this.player !== player ||
shakaError?.code === SHAKA_LOAD_INTERRUPTED
isShakaLoadInterrupted(shakaError)
) {
return;
}
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(
classifyShakaPlaybackIssue(
shakaError ?? { message: toErrorMessage(error) },
this.createMetadata(url)
),
drmProvided
)
const issue = classifyShakaPlaybackIssue(
shakaError,
this.createMetadata(url),
ShakaDisposition.Terminal
);
if (issue) {
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
}
// Never leave a non-functional engine attached to the media element
// or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
@@ -274,20 +260,24 @@ export class ShakaVideoSession {
return;
}
const detail = (event as { detail?: Partial<ShakaErrorLike> })
.detail;
if (detail?.severity === SHAKA_SEVERITY_RECOVERABLE) {
const detail = (event as { detail?: unknown }).detail;
const shakaError = asShakaError(detail);
const disposition = getShakaErrorEventDisposition(shakaError);
if (!disposition) {
return;
}
const issue = classifyShakaPlaybackIssue(
shakaError,
this.createMetadata(url),
disposition
);
if (!issue) {
return;
}
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(
classifyShakaPlaybackIssue(
detail,
this.createMetadata(url)
),
drmProvided
)
this.withoutUnusableDrmFallback(issue, drmProvided)
);
// Critical errors end playback; never leave the dead engine
// attached or exposed to the shared-controls bridge.
@@ -371,15 +361,33 @@ export class ShakaVideoSession {
return this.destroyed || generation !== this.generation;
}
private emitIfCurrent(
generation: number,
issue: PlaybackDiagnostic
): void {
private emitIfCurrent(generation: number, issue: PlaybackDiagnostic): void {
if (!this.isStale(generation)) {
this.config.emitPlaybackIssue(issue);
}
}
private emitTerminalIfCurrent(
generation: number,
error: Partial<ShakaErrorLike> | null,
url: string,
externalFallbackRecommended?: boolean
): void {
const issue = classifyShakaPlaybackIssue(
error,
this.createMetadata(url),
ShakaDisposition.Terminal
);
if (issue) {
this.emitIfCurrent(
generation,
externalFallbackRecommended === undefined
? issue
: { ...issue, externalFallbackRecommended }
);
}
}
private notifyRefresh(): void {
for (const listener of [...this.refreshListeners]) {
listener();
@@ -82,7 +82,10 @@ export function getDiagnosticDetails(
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.vhs ? '' : (issue.nativeErrorMessage ?? ''),
value:
issue.vhs || issue.shaka
? ''
: (issue.nativeErrorMessage ?? ''),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
@@ -92,6 +95,22 @@ export function getDiagnosticDetails(
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.shaka) {
return [
`stage=${issue.shaka.stage}`,
`failure=${issue.shaka.failure}`,
`severity=${issue.shaka.severity}`,
`category=${issue.shaka.category}`,
`code=${issue.shaka.engineCode}`,
`disposition=${issue.shaka.disposition}`,
issue.shaka.httpStatus === undefined
? ''
: `HTTP ${issue.shaka.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.vhs) {
return [
`stage=${issue.vhs.stage}`,
@@ -371,6 +371,36 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('response body');
});
it('renders only sanitized structured Shaka evidence in technical details', () => {
const issue = createStructuredShakaDiagnostic();
component.handlePlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'Shaka Player',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=unknown · failure=network · ' +
'severity=recoverable · category=network · ' +
'code=1001 · disposition=terminal · HTTP 503',
},
])
);
expect(renderedDetails).not.toContain('shaka-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';
@@ -980,3 +1010,31 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: false,
};
}
function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Shaka,
sourceUrl:
'https://provider.example/live.mpd?token=shaka-render-secret',
container: 'mpd',
mimeType: 'application/dash+xml',
player: 'artplayer',
audioCodecs: [],
videoCodecs: [],
details: 'Authorization response body shaka-render-secret',
nativeErrorMessage:
'https://provider.example/error?token=shaka-render-secret',
httpStatus: 503,
shaka: {
severity: 'recoverable',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}