From 9f4e11d6de389c9dd14f08fc84ad858c8e0cedb2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:25:06 +0200 Subject: [PATCH] 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 --- .../playback-structured-shaka-diagnostics.md | 6 + AGENTS.md | 8 +- CLAUDE.md | 20 +- docs/architecture/embedded-inline-playback.md | 46 +- docs/architecture/m3u-playlist-module.md | 52 +- ...2026-07-31-structured-shaka-diagnostics.md | 717 ++++++++++++++++++ ...-31-structured-shaka-diagnostics-design.md | 286 +++++++ .../art-player-source-session.dash.spec.ts | 64 ++ .../playback-diagnostics.model.ts | 71 ++ .../playback-diagnostics.util.ts | 4 + .../shaka-error-classifier.spec.ts | 244 ++++-- .../shaka-engine/shaka-error-classifier.ts | 184 ++--- .../lib/shaka-engine/shaka-error-contract.ts | 125 +++ .../lib/shaka-engine/shaka-error-lifecycle.ts | 29 + .../lib/shaka-engine/shaka-error-mapping.ts | 121 +++ .../lib/shaka-engine/shaka-module.types.ts | 12 +- .../shaka-playback-evidence.util.spec.ts | 376 +++++++++ .../shaka-playback-evidence.util.ts | 235 ++++++ .../shaka-engine/shaka-video-session.spec.ts | 269 ++++++- .../lib/shaka-engine/shaka-video-session.ts | 108 +-- .../web-player-view-diagnostics.utils.ts | 21 +- .../web-player-view.component.spec.ts | 58 ++ 22 files changed, 2779 insertions(+), 277 deletions(-) create mode 100644 .changes/playback-structured-shaka-diagnostics.md create mode 100644 docs/superpowers/plans/2026-07-31-structured-shaka-diagnostics.md create mode 100644 docs/superpowers/specs/2026-07-31-structured-shaka-diagnostics-design.md create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts diff --git a/.changes/playback-structured-shaka-diagnostics.md b/.changes/playback-structured-shaka-diagnostics.md new file mode 100644 index 000000000..b6336b0f4 --- /dev/null +++ b/.changes/playback-structured-shaka-diagnostics.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index d27ab57a6..05878a128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 534b5bac8..a01743a2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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). diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index cfd18c01a..395a7349a 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index fb21681d1..cec501b91 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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` diff --git a/docs/superpowers/plans/2026-07-31-structured-shaka-diagnostics.md b/docs/superpowers/plans/2026-07-31-structured-shaka-diagnostics.md new file mode 100644 index 000000000..4c17bf12c --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-structured-shaka-diagnostics.md @@ -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>; + readonly category: Readonly>; + readonly code: Readonly>; +} +``` + +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 | 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. diff --git a/docs/superpowers/specs/2026-07-31-structured-shaka-diagnostics-design.md b/docs/superpowers/specs/2026-07-31-structured-shaka-diagnostics-design.md new file mode 100644 index 000000000..e0fc453b9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-structured-shaka-diagnostics-design.md @@ -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. diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts index dd48c9565..e0c839e19 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -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'); + }); }); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts index 54c164b0b..df9185068 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -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; } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index 6512a7f0f..6e62f1bc5 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -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), diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts index 9dbe76aca..529dbc9d4 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts @@ -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 | 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); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts index 221384c31..9bc96d8ef 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts @@ -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([ + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED, + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW, + SHAKA_ERROR_CODE.VIDEO_ERROR, +]); export function classifyShakaPlaybackIssue( error: Partial | 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 | null { +export function asShakaError(error: unknown): Partial | null { if (!error || typeof error !== 'object') { return null; } const candidate = error as Partial; 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 | 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 ''; - } -} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts new file mode 100644 index 000000000..78d0f09ed --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts @@ -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; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts new file mode 100644 index 000000000..b58c0a87c --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts @@ -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 | 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 | null +): boolean { + return ( + error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL && + error.category === SHAKA_ERROR_CATEGORY.PLAYER && + error.code === SHAKA_ERROR_CODE.LOAD_INTERRUPTED + ); +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts new file mode 100644 index 000000000..67be7c43f --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts @@ -0,0 +1,121 @@ +import { SHAKA_ERROR_CODE } from './shaka-error-contract'; + +export const SHAKA_PUBLIC_CODES = new Set( + Object.values(SHAKA_ERROR_CODE) +); + +export const SHAKA_NETWORK_CODES = new Set([ + 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([ + 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([ + 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([ + SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER, + SHAKA_ERROR_CODE.CONTENT_UNSUPPORTED_BY_BROWSER, +]); + +export const SHAKA_AMBIGUOUS_MANIFEST_CODES = new Set([ + SHAKA_ERROR_CODE.RESTRICTIONS_CANNOT_BE_MET, +]); + +export const SHAKA_MANIFEST_CODES = new Set( + Object.values(SHAKA_ERROR_CODE).filter( + (code) => code >= 4000 && code < 5000 + ) +); + +export const SHAKA_DRM_CODES = new Set( + Object.values(SHAKA_ERROR_CODE).filter( + (code) => code >= 6000 && code < 7000 + ) +); + +export const SHAKA_SEGMENT_STAGE_CODES = new Set([ + 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([ + 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([ + 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([ + SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED, + SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED, +]); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts index 50421098a..353d5d8c6 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -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; */ 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; }; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts new file mode 100644 index 000000000..8696866b0 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts @@ -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>; + readonly category: Readonly>; + readonly code: Readonly>; +} + +interface ShakaEvidenceExports { + readonly SHAKA_DIAGNOSTIC_VERSION?: string; + readonly SHAKA_ERROR_SEVERITY?: Readonly>; + readonly SHAKA_ERROR_CATEGORY?: Readonly>; + readonly SHAKA_ERROR_CODE?: Readonly>; + 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; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts new file mode 100644 index 000000000..479788259 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts @@ -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 | 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 | 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 | 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; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index a568e6f66..bcdc7f5cc 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -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 () => { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 6a25c597d..6898e23d7 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -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 }) - .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 | 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(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts index aec1c0e20..08d536456 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -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}`, diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 8b275e075..8d550fb13 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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, + }; +}