From 50ca78d3a8c27355d53e6e3ff1431e132f015b67 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 20:32:49 +0200 Subject: [PATCH] fix(playback): preserve Shaka fallback evidence --- .../playback-structured-shaka-diagnostics.md | 2 +- AGENTS.md | 4 +- CLAUDE.md | 4 +- docs/architecture/embedded-inline-playback.md | 12 ++++- docs/architecture/m3u-playlist-module.md | 5 ++ .../lib/shaka-engine/shaka-error-contract.ts | 1 + .../shaka-playback-evidence.util.spec.ts | 13 +++++ .../shaka-engine/shaka-video-session.spec.ts | 50 ++++++++++++++++++- .../lib/shaka-engine/shaka-video-session.ts | 17 +++++-- 9 files changed, 100 insertions(+), 8 deletions(-) diff --git a/.changes/playback-structured-shaka-diagnostics.md b/.changes/playback-structured-shaka-diagnostics.md index d1184ba2c..cb77c4e93 100644 --- a/.changes/playback-structured-shaka-diagnostics.md +++ b/.changes/playback-structured-shaka-diagnostics.md @@ -3,4 +3,4 @@ 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. +Shaka playback errors now use exact engine evidence, 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 f68511d90..f9ae7a08b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,7 +213,9 @@ Key files: 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 + 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 11de1f48e..5abf43553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -737,7 +737,9 @@ app as a real argument, so it is not an option. `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 + outcomes, 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`. diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 24c3faa02..f13825e76 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -409,7 +409,17 @@ 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. +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. + +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 diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 5f0b1aa38..5f732a73b 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1003,6 +1003,11 @@ player in settings. 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. 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 with a fixed safe detail string and without starting an engine. - HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer: 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 index 4e644c1ef..4cd9ca143 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts @@ -91,6 +91,7 @@ export const SHAKA_ERROR_CODE = { 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, 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 index 1a4e6f390..59586066e 100644 --- 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 @@ -197,6 +197,19 @@ describe('Shaka playback evidence', () => { 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([ { label: 'manifest parsing', 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 0e79ea33d..7eb3dcaab 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 @@ -258,6 +258,34 @@ describe('ShakaVideoSession', () => { 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('emits a critical in-flight error once before teardown interrupts load', async () => { const environment = createFakeShakaEnvironment({ onCreate: (player) => { @@ -436,7 +464,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); @@ -456,6 +484,26 @@ describe('ShakaVideoSession', () => { 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 b64386f1e..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 @@ -143,7 +143,12 @@ export class ShakaVideoSession { } if (!module.Player.isBrowserSupported()) { - this.emitTerminalIfCurrent(generation, null, url); + this.emitTerminalIfCurrent( + generation, + null, + url, + drm === undefined + ); return; } @@ -365,7 +370,8 @@ export class ShakaVideoSession { private emitTerminalIfCurrent( generation: number, error: Partial | null, - url: string + url: string, + externalFallbackRecommended?: boolean ): void { const issue = classifyShakaPlaybackIssue( error, @@ -373,7 +379,12 @@ export class ShakaVideoSession { ShakaDisposition.Terminal ); if (issue) { - this.emitIfCurrent(generation, issue); + this.emitIfCurrent( + generation, + externalFallbackRecommended === undefined + ? issue + : { ...issue, externalFallbackRecommended } + ); } }