docs(playback): document Shaka evidence boundary

This commit is contained in:
4gray committed 2026-07-31 09:57:25 +02:00
1 parent 1a621ffb6d
commit 6c0efa1042
7 files changed
+81 -32

No files matched your search

@@ -0,0 +1,6 @@
---
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.
+4 -1
View File
@@ -210,7 +210,10 @@ 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, and never retains or
renders raw messages or `error.data`. 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
+10 -7
View File
@@ -400,10 +400,10 @@ Key patterns:
Xtream data strategies by runtime capability:
| Capability | Strategy |
| ---------- | -------- |
| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB |
| **Bridge unavailable** | API-only: fetch from API and keep session data in memory |
| Capability | Strategy |
| --------------------------------- | -------------------------------------------------------- |
| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB |
| **Bridge unavailable** | API-only: fetch from API and keep session data in memory |
**M3U Playlist Module Architecture**:
@@ -734,7 +734,10 @@ 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, and never retains or renders raw messages or `error.data`; details in
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
@@ -910,8 +913,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).
+32 -2
View File
@@ -181,7 +181,7 @@ seasons, with per-episode watch-progress bars from playback positions.
prefetch is claimed synchronously and answered seasons (including genuinely
empty ones) are never re-requested: a failed request leaves `episodes` empty
with `isLoading` back to false, which would otherwise re-run the effect that
issued it and loop. A *failed* request releases the claim but is pinned to
issued it and loop. A _failed_ request releases the claim but is pinned to
the episode that triggered it, so a transient portal error retries on the
next playback change instead of either looping or giving up permanently.
- Gating mirrors ambient mode: the `playerUpNextRail` setting (Settings →
@@ -388,6 +388,36 @@ 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.
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 +427,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.
+26 -19
View File
@@ -96,22 +96,22 @@ profiles are never mixed into them.
The benchmark attributes these non-additive intervals:
| Field | Boundary |
| --- | --- |
| `dataAcquireMs` | loopback response acquisition |
| `m3uParsingMs` | parser call |
| `normalizationMs` | parsed-item normalization |
| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs |
| `storeImportDispatchMs` | renderer import dispatch |
| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs |
| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs |
| `playlistSerializationMs` | database-worker playlist JSON serialization |
| `sqliteWriteMs` | SQLite upsert, including its autocommit |
| `sqliteReadMs` | SQLite read of the newly persisted playlist |
| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing |
| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs |
| `storePublishChannelsMs` | renderer channel publication |
| `angularRenderingMs` | publication end to the terminal two-frame paint proof |
| Field | Boundary |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `dataAcquireMs` | loopback response acquisition |
| `m3uParsingMs` | parser call |
| `normalizationMs` | parsed-item normalization |
| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs |
| `storeImportDispatchMs` | renderer import dispatch |
| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs |
| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs |
| `playlistSerializationMs` | database-worker playlist JSON serialization |
| `sqliteWriteMs` | SQLite upsert, including its autocommit |
| `sqliteReadMs` | SQLite read of the newly persisted playlist |
| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing |
| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs |
| `storePublishChannelsMs` | renderer channel publication |
| `angularRenderingMs` | publication end to the terminal two-frame paint proof |
`ipcStructuredCloneProxyMs` is the explicit sum of the four directional proxy
fields. Each directional field can combine the applicable initial-result,
@@ -994,10 +994,17 @@ 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.
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`
@@ -527,7 +527,7 @@ shaka: {
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'manifest',
stage: 'unknown',
failure: 'network',
httpStatus: 503,
},
@@ -536,7 +536,7 @@ shaka: {
Expect the error-details row to equal:
```text
stage=manifest · failure=network · severity=recoverable · category=network · code=1001 · disposition=terminal · HTTP 503
stage=unknown · failure=network · severity=recoverable · category=network · code=1001 · disposition=terminal · HTTP 503
```
Prove the rendered values omit the secret, provider hostname,
@@ -246,7 +246,7 @@ provider-supplied license string.
The existing technical “Error details” row renders only
`ShakaPlaybackEvidence`, for example:
`stage=manifest · failure=network · severity=recoverable · category=network · code=1001 · disposition=terminal · HTTP 503`
`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