mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes
This commit is contained in:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: playback
|
||||||
|
issues: [1159]
|
||||||
|
---
|
||||||
|
|
||||||
|
When playback fails, IPTVnator now ranks useful next steps from the reported
|
||||||
|
error, including another compatible built-in player, MPV/VLC, Retry, or another
|
||||||
|
source. Trying a built-in recommendation affects only the current item and
|
||||||
|
does not change the saved player setting.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: playback
|
||||||
|
---
|
||||||
|
|
||||||
|
Playback recovery now removes old error actions as soon as a new source or player starts, even while Electron prepares stream headers. Stalker series playing inline now picks up refreshed episode names and navigation, and safely disables episode commands if the playing episode disappears.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: playback
|
||||||
|
issues: [1159]
|
||||||
|
---
|
||||||
|
|
||||||
|
Playback recovery no longer crosses into a different selected channel, movie,
|
||||||
|
or exact Stalker episode. Switching content while a stream resolves no longer
|
||||||
|
restores stale playback or shows an error from the previous selection.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
apps/web-e2e/src/fixtures/playback/unsupported.mkv binary
|
||||||
@@ -223,20 +223,67 @@ Key files:
|
|||||||
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
|
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
|
||||||
ArtPlayer components; ClearKey keys come from KODIPROP-derived
|
ArtPlayer components; ClearKey keys come from KODIPROP-derived
|
||||||
`Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via
|
`Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via
|
||||||
source kind `shaka`. The Shaka `5.2.2` diagnostic boundary version-locks
|
source kind `shaka`. The DOM-free Shaka `5.2.2` diagnostic boundary lives in
|
||||||
public severity/category/code evidence, ignores recoverable error events,
|
`libs/playback/util`; it version-locks public severity/category/code evidence,
|
||||||
|
ignores recoverable error events,
|
||||||
treats rejected loads as terminal lifecycle outcomes, preserves exact public
|
treats rejected loads as terminal lifecycle outcomes, preserves exact public
|
||||||
DASH text-parser category/code evidence with unknown stage/failure, and never
|
DASH text-parser category/code evidence with unknown stage/failure, and never
|
||||||
retains or renders raw messages or `error.data`. A failed browser-support
|
retains or renders raw messages or `error.data`. A failed browser-support
|
||||||
preflight stays unknown but keeps external fallback for clear DASH; KODIPROP
|
preflight stays generic-unknown but carries the exact app-owned
|
||||||
DRM still suppresses it. See the CLAUDE.md "Video Players" feature entry and
|
`PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed
|
||||||
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
|
external fallback only for clear transferable DASH; PWA capability and
|
||||||
|
KODIPROP DRM still suppress it. See the CLAUDE.md "Video Players" feature
|
||||||
|
entry and the "DASH + ClearKey Playback" section of
|
||||||
|
`docs/architecture/m3u-playlist-module.md`.
|
||||||
- mpegts.js `1.8.0` errors from HTML5, Video.js, and ArtPlayer cross one
|
- mpegts.js `1.8.0` errors from HTML5, Video.js, and ArtPlayer cross one
|
||||||
version-locked structured evidence boundary. Only exact public type/detail
|
version-locked structured evidence boundary in `libs/playback/util`. Only
|
||||||
pairs, pair-derived stage/failure, terminal disposition, and the validated
|
exact public type/detail pairs, pair-derived stage/failure, terminal
|
||||||
HTTP 4xx/5xx status slot are retained; raw messages and arbitrary `info`
|
disposition, and the validated HTTP 4xx/5xx status slot are retained; raw
|
||||||
|
messages and arbitrary `info`
|
||||||
never reach diagnostics. This is a sibling of `PlayerController`, not part
|
never reach diagnostics. This is a sibling of `PlayerController`, not part
|
||||||
of the controls contract.
|
of the controls contract.
|
||||||
|
- Browser playback diagnostics and recovery policy live in
|
||||||
|
`libs/playback/util` and are exported by `@iptvnator/playback/util`.
|
||||||
|
Public engine errors cross allowlisted sanitizers into a
|
||||||
|
`PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at
|
||||||
|
most three actions, and `WebPlayerViewComponent` executes only the action
|
||||||
|
the user selects. The policy is a sibling of `PlayerController`; shared
|
||||||
|
controls only gate interaction while the diagnostic panel is visible.
|
||||||
|
`WebPlayerViewComponent` owns a host-derived content-session key that is
|
||||||
|
stable for the mounted logical selection, attempted target IDs, the temporary
|
||||||
|
player override, and VOD handoff position. Its `PlaybackBinding` is exactly
|
||||||
|
`{ generation, target }`, while every source/target/reload application uses a
|
||||||
|
fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless
|
||||||
|
intent `Symbol`, and source applications advance a third fieldless revision
|
||||||
|
`Symbol` that clears only the VOD handoff position; target-only switches and
|
||||||
|
Retry leave that revision stable. None of these ownership primitives contains
|
||||||
|
URLs, headers, DRM material, or credentials. The application effect
|
||||||
|
synchronizes the content session before tracking intent, so clearing a
|
||||||
|
temporary player override cannot schedule a duplicate application or header
|
||||||
|
handoff. Every application start clears both the diagnostic owner and backing
|
||||||
|
signal before asynchronous header setup; a current false result or rejection
|
||||||
|
leaves them clear, and a stale completion cannot erase a newer owned
|
||||||
|
diagnostic. Each
|
||||||
|
rendered web or Embedded MPV application captures its nullable binding, the
|
||||||
|
application and source-revision tokens, and live/VOD flag; a time update
|
||||||
|
changes resume state only while that exact capture still owns the current
|
||||||
|
application. A recommended built-in
|
||||||
|
player temporarily
|
||||||
|
outranks the host override and saved player for that mounted content session,
|
||||||
|
never mutates `Settings.player`, and resumes finite VOD position on a
|
||||||
|
best-effort basis; live playback returns to the live edge. Retry and
|
||||||
|
alternative sources preserve attempts, while a different content-session key
|
||||||
|
or component teardown resets them. Recovery recommendations never
|
||||||
|
auto-switch, persist history, learn across sessions, or emit telemetry.
|
||||||
|
The policy projects attempted inline target IDs through the validated
|
||||||
|
canonical source/target capabilities and excludes every attempted engine
|
||||||
|
family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network
|
||||||
|
and generic unknown evidence fail closed to Retry/alternative source; the
|
||||||
|
exact Shaka browser-unsupported preflight marker is the sole unknown-code
|
||||||
|
exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
|
||||||
|
DRM suppresses external targets because its payload is not transferable. Raw
|
||||||
|
engine messages, arbitrary data, and credentials never enter recommendation
|
||||||
|
evidence or ownership state.
|
||||||
- The built-in HTML5/hls.js player is the second guarded consumer.
|
- The built-in HTML5/hls.js player is the second guarded consumer.
|
||||||
`HtmlVideoPlayerComponent` provides a component-scoped
|
`HtmlVideoPlayerComponent` provides a component-scoped
|
||||||
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
|
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
|
||||||
|
|||||||
@@ -758,13 +758,56 @@ app as a real argument, so it is not an option.
|
|||||||
|
|
||||||
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
|
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
|
||||||
- mpegts.js `1.8.0` errors from all three built-in players cross one
|
- mpegts.js `1.8.0` errors from all three built-in players cross one
|
||||||
version-locked structured evidence boundary. It retains only exact public
|
version-locked structured evidence boundary in `libs/playback/util`. It
|
||||||
type/detail pairs, pair-derived stage/failure, terminal disposition, and a
|
retains only exact public type/detail pairs, pair-derived stage/failure,
|
||||||
|
terminal disposition, and a
|
||||||
validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach
|
validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach
|
||||||
stored or rendered diagnostics. HTTP/network failures avoid false decoder
|
stored or rendered diagnostics. HTTP/network failures avoid false decoder
|
||||||
recommendations, while exact format, codec, truncated-stream, and
|
recommendations, while exact format, codec, truncated-stream, and
|
||||||
MediaSource failures retain actionable fallback guidance. This diagnostic
|
MediaSource failures retain actionable recovery guidance. This diagnostic
|
||||||
layer remains separate from the shared `PlayerController` controls contract.
|
layer remains separate from the shared `PlayerController` controls contract.
|
||||||
|
- Browser playback diagnostics and recovery policy live in
|
||||||
|
`libs/playback/util` and are exported by `@iptvnator/playback/util`.
|
||||||
|
Public engine errors cross allowlisted sanitizers into a
|
||||||
|
`PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at
|
||||||
|
most three actions, and `WebPlayerViewComponent` executes only the action
|
||||||
|
the user selects. The policy is a sibling of `PlayerController`; shared
|
||||||
|
controls only gate interaction while the diagnostic panel is visible.
|
||||||
|
`WebPlayerViewComponent` owns a host-derived content-session key that is
|
||||||
|
stable for the mounted logical selection, attempted target IDs, the temporary
|
||||||
|
player override, and VOD handoff position. Its `PlaybackBinding` is exactly
|
||||||
|
`{ generation, target }`, while every source/target/reload application uses a
|
||||||
|
fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless
|
||||||
|
intent `Symbol`, and source applications advance a third fieldless revision
|
||||||
|
`Symbol` that clears only the VOD handoff position; target-only switches and
|
||||||
|
Retry leave that revision stable. None of these ownership primitives contains
|
||||||
|
URLs, headers, DRM material, or credentials. The application effect
|
||||||
|
synchronizes the content session before tracking intent, so clearing a
|
||||||
|
temporary player override cannot schedule a duplicate application or header
|
||||||
|
handoff. Every application start clears both the diagnostic owner and backing
|
||||||
|
signal before asynchronous header setup; a current false result or rejection
|
||||||
|
leaves them clear, and a stale completion cannot erase a newer owned
|
||||||
|
diagnostic. Each
|
||||||
|
rendered web or Embedded MPV application captures its nullable binding, the
|
||||||
|
application and source-revision tokens, and live/VOD flag; a time update
|
||||||
|
changes resume state only while that exact capture still owns the current
|
||||||
|
application. A recommended built-in
|
||||||
|
player temporarily
|
||||||
|
outranks the host override and saved player for that mounted content session,
|
||||||
|
never mutates `Settings.player`, and resumes finite VOD position on a
|
||||||
|
best-effort basis; live playback returns to the live edge. Retry and
|
||||||
|
alternative sources preserve attempts, while a different content-session key
|
||||||
|
or component teardown resets them. Recovery recommendations never
|
||||||
|
auto-switch, persist history, learn across sessions, or emit telemetry.
|
||||||
|
The policy projects attempted inline target IDs through the validated
|
||||||
|
canonical source/target capabilities and excludes every attempted engine
|
||||||
|
family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network
|
||||||
|
and generic unknown evidence fail closed to Retry/alternative source; the
|
||||||
|
exact Shaka browser-unsupported preflight marker is the sole unknown-code
|
||||||
|
exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
|
||||||
|
DRM suppresses external targets because its payload is not transferable. Raw
|
||||||
|
engine messages, arbitrary data, and credentials never enter recommendation
|
||||||
|
evidence or ownership state.
|
||||||
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
|
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
|
||||||
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
|
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
|
||||||
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
|
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
|
||||||
@@ -776,13 +819,17 @@ app as a real argument, so it is not an option.
|
|||||||
ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
|
ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
|
||||||
need the castLabs Electron fork) surface a DRM playback diagnostic instead
|
need the castLabs Electron fork) surface a DRM playback diagnostic instead
|
||||||
of crashing. ClearKey EME works in stock Electron. Engine:
|
of crashing. ClearKey EME works in stock Electron. Engine:
|
||||||
`libs/ui/playback/src/lib/shaka-engine/`. Its Shaka `5.2.2` diagnostic
|
`libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.2`
|
||||||
boundary version-locks public severity/category/code evidence, ignores
|
diagnostic boundary lives in `libs/playback/util`; it version-locks public
|
||||||
|
severity/category/code evidence, ignores
|
||||||
recoverable error events, treats rejected loads as terminal lifecycle
|
recoverable error events, treats rejected loads as terminal lifecycle
|
||||||
outcomes, preserves exact public DASH text-parser category/code evidence with
|
outcomes, preserves exact public DASH text-parser category/code evidence with
|
||||||
unknown stage/failure, and never retains or renders raw messages or
|
unknown stage/failure, and never retains or renders raw messages or
|
||||||
`error.data`. A failed browser-support preflight stays unknown but keeps
|
`error.data`. A failed browser-support preflight stays generic-unknown but
|
||||||
external fallback for clear DASH; KODIPROP DRM still suppresses it. Details in
|
carries the exact app-owned
|
||||||
|
`PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed
|
||||||
|
external fallback only for clear transferable DASH; PWA capability and
|
||||||
|
KODIPROP DRM still suppress it. Details in
|
||||||
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
|
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
|
||||||
- External players: MPV, VLC (via IPC to Electron backend)
|
- 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`.
|
- 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`.
|
||||||
@@ -915,7 +962,7 @@ engine` (restart required) or
|
|||||||
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
||||||
contracts: `docs/architecture/embedded-mpv-native.md` and
|
contracts: `docs/architecture/embedded-mpv-native.md` and
|
||||||
`tools/embedded-mpv/README.md`.
|
`tools/embedded-mpv/README.md`.
|
||||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendorLine truncated
|
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendoLine truncated
|
||||||
- Shared web picture-in-picture stays inside that default-off rollout.
|
- Shared web picture-in-picture stays inside that default-off rollout.
|
||||||
`PlayerController` exposes capability `pictureInPicture`, state
|
`PlayerController` exposes capability `pictureInPicture`, state
|
||||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||||
@@ -1126,7 +1173,7 @@ engine` (restart required) or
|
|||||||
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
|
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
|
||||||
- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text.
|
- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text.
|
||||||
- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
|
- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
|
||||||
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
|
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The _effective_ cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
|
||||||
- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
|
- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
|
||||||
- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
|
- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createServer, Server } from 'http';
|
import { createServer, Server } from 'http';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { basename, join } from 'path';
|
import { basename, join } from 'path';
|
||||||
|
import type {
|
||||||
|
ExternalPlayerName,
|
||||||
|
PlayerContentInfo,
|
||||||
|
} from '@iptvnator/shared/interfaces';
|
||||||
import {
|
import {
|
||||||
channelItemByTitle,
|
channelItemByTitle,
|
||||||
closeElectronApp,
|
closeElectronApp,
|
||||||
@@ -19,7 +23,47 @@ import {
|
|||||||
* context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures.
|
* context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash');
|
const DASH_FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash');
|
||||||
|
const PLAYBACK_FIXTURE_DIR = join(
|
||||||
|
workspaceRoot,
|
||||||
|
'apps/web-e2e/src/fixtures/playback'
|
||||||
|
);
|
||||||
|
|
||||||
|
type PlaybackFixture = {
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIXTURE_FILES: ReadonlyMap<string, PlaybackFixture> = new Map([
|
||||||
|
[
|
||||||
|
'clearkey.mpd',
|
||||||
|
{
|
||||||
|
contentType: 'application/dash+xml',
|
||||||
|
path: join(DASH_FIXTURE_DIR, 'clearkey.mpd'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'clearkey-video.mp4',
|
||||||
|
{
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
path: join(DASH_FIXTURE_DIR, 'clearkey-video.mp4'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'clearkey-audio.mp4',
|
||||||
|
{
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
path: join(DASH_FIXTURE_DIR, 'clearkey-audio.mp4'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'unsupported.mkv',
|
||||||
|
{
|
||||||
|
contentType: 'video/matroska',
|
||||||
|
path: join(PLAYBACK_FIXTURE_DIR, 'unsupported.mkv'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
|
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
|
||||||
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
|
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
|
||||||
@@ -29,31 +73,33 @@ type DashFixtureServer = {
|
|||||||
origin: string;
|
origin: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Serves the DASH fixture directory with HTTP Range support (Shaka fetches
|
/** Serves the explicit offline fixture allowlist with HTTP Range support
|
||||||
* init segments and the sidx via byte ranges). */
|
* (Shaka fetches init segments and the sidx via byte ranges). */
|
||||||
async function startDashFixtureServer(): Promise<DashFixtureServer> {
|
async function startDashFixtureServer(): Promise<DashFixtureServer> {
|
||||||
const server: Server = createServer((request, response) => {
|
const server: Server = createServer((request, response) => {
|
||||||
const pathname = (request.url ?? '').split('?')[0];
|
const pathname = (request.url ?? '').split('?')[0];
|
||||||
const fileName = basename(pathname);
|
const fileName = basename(pathname);
|
||||||
|
const fixture = FIXTURE_FILES.get(fileName);
|
||||||
|
if (!fixture) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end('not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
let body: Buffer;
|
let body: Buffer;
|
||||||
try {
|
try {
|
||||||
body = readFileSync(join(FIXTURE_DIR, fileName));
|
body = readFileSync(fixture.path);
|
||||||
} catch {
|
} catch {
|
||||||
response.writeHead(404);
|
response.writeHead(404);
|
||||||
response.end('not found');
|
response.end('not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = fileName.endsWith('.mpd')
|
const range = /bytes=(\d+)-(\d+)?/.exec(request.headers.range ?? '');
|
||||||
? 'application/dash+xml'
|
|
||||||
: 'video/mp4';
|
|
||||||
const range = /bytes=(\d+)-(\d+)?/.exec(
|
|
||||||
request.headers.range ?? ''
|
|
||||||
);
|
|
||||||
if (!range) {
|
if (!range) {
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
'Content-Type': contentType,
|
'Content-Type': fixture.contentType,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Content-Length': body.length,
|
'Content-Length': body.length,
|
||||||
});
|
});
|
||||||
response.end(body);
|
response.end(body);
|
||||||
@@ -64,8 +110,9 @@ async function startDashFixtureServer(): Promise<DashFixtureServer> {
|
|||||||
const end = range[2] ? Number(range[2]) : body.length - 1;
|
const end = range[2] ? Number(range[2]) : body.length - 1;
|
||||||
const chunk = body.subarray(start, end + 1);
|
const chunk = body.subarray(start, end + 1);
|
||||||
response.writeHead(206, {
|
response.writeHead(206, {
|
||||||
'Content-Type': contentType,
|
'Content-Type': fixture.contentType,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
||||||
'Content-Length': chunk.length,
|
'Content-Length': chunk.length,
|
||||||
});
|
});
|
||||||
@@ -106,6 +153,8 @@ function buildDashPlaylist(origin: string): string {
|
|||||||
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
|
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
|
||||||
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv',
|
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv',
|
||||||
`${origin}/clearkey.mpd`,
|
`${origin}/clearkey.mpd`,
|
||||||
|
'#EXTINF:-1 tvg-id="unsupported-mkv" group-title="DASH",Unsupported MKV',
|
||||||
|
`${origin}/unsupported.mkv`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,13 +171,179 @@ async function importDashPlaylistFromText(
|
|||||||
await waitForM3uCatalog(app.mainWindow);
|
await waitForM3uCatalog(app.mainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a diagnostic', async ({
|
type ExternalPlayerIpcArguments = [
|
||||||
|
url: string,
|
||||||
|
title: string,
|
||||||
|
thumbnail: string | undefined,
|
||||||
|
userAgent: string | undefined,
|
||||||
|
referer: string | undefined,
|
||||||
|
origin: string | undefined,
|
||||||
|
contentInfo: PlayerContentInfo | undefined,
|
||||||
|
startTime: number | undefined,
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
];
|
||||||
|
|
||||||
|
type CapturedExternalPlayerLaunch = {
|
||||||
|
args: ExternalPlayerIpcArguments;
|
||||||
|
player: ExternalPlayerName;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaybackRecommendationCaptureSnapshot = {
|
||||||
|
completed: number;
|
||||||
|
launches: CapturedExternalPlayerLaunch[];
|
||||||
|
released: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaybackRecommendationCaptureState =
|
||||||
|
PlaybackRecommendationCaptureSnapshot & {
|
||||||
|
releaseFirstResponse: (() => void) | undefined;
|
||||||
|
waitForFirstResponse: Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function installPlaybackRecommendationLaunchCapture(
|
||||||
|
app: LaunchedElectronApp
|
||||||
|
): Promise<void> {
|
||||||
|
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||||
|
let releaseFirstResponse: (() => void) | undefined;
|
||||||
|
const waitForFirstResponse = new Promise<void>((resolve) => {
|
||||||
|
releaseFirstResponse = resolve;
|
||||||
|
});
|
||||||
|
const state: PlaybackRecommendationCaptureState = {
|
||||||
|
completed: 0,
|
||||||
|
launches: [],
|
||||||
|
released: false,
|
||||||
|
releaseFirstResponse,
|
||||||
|
waitForFirstResponse,
|
||||||
|
};
|
||||||
|
const globalRef = globalThis as typeof globalThis & {
|
||||||
|
__playbackRecommendationCapture?: PlaybackRecommendationCaptureState;
|
||||||
|
};
|
||||||
|
globalRef.__playbackRecommendationCapture = state;
|
||||||
|
|
||||||
|
const captureLaunch =
|
||||||
|
(player: ExternalPlayerName) =>
|
||||||
|
async (
|
||||||
|
_event: unknown,
|
||||||
|
url: string,
|
||||||
|
title: string,
|
||||||
|
thumbnail?: string,
|
||||||
|
userAgent?: string,
|
||||||
|
referer?: string,
|
||||||
|
origin?: string,
|
||||||
|
contentInfo?: PlayerContentInfo,
|
||||||
|
startTime?: number,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) => {
|
||||||
|
const invocation = state.launches.push({
|
||||||
|
args: [
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
thumbnail,
|
||||||
|
userAgent,
|
||||||
|
referer,
|
||||||
|
origin,
|
||||||
|
contentInfo,
|
||||||
|
startTime,
|
||||||
|
headers,
|
||||||
|
],
|
||||||
|
player,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invocation === 1 && !state.released) {
|
||||||
|
await state.waitForFirstResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.completed += 1;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
canClose: false,
|
||||||
|
id: `e2e-recommended-${player}-${invocation}`,
|
||||||
|
player,
|
||||||
|
startedAt: now,
|
||||||
|
status: 'opened',
|
||||||
|
streamUrl: url,
|
||||||
|
thumbnail: thumbnail ?? null,
|
||||||
|
title,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.removeHandler('OPEN_MPV_PLAYER');
|
||||||
|
ipcMain.removeHandler('OPEN_VLC_PLAYER');
|
||||||
|
ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv'));
|
||||||
|
ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlaybackRecommendationCapture(
|
||||||
|
app: LaunchedElectronApp
|
||||||
|
): Promise<PlaybackRecommendationCaptureSnapshot> {
|
||||||
|
return app.electronApp.evaluate(() => {
|
||||||
|
const globalRef = globalThis as typeof globalThis & {
|
||||||
|
__playbackRecommendationCapture?: PlaybackRecommendationCaptureState;
|
||||||
|
};
|
||||||
|
const capture = globalRef.__playbackRecommendationCapture;
|
||||||
|
if (!capture) {
|
||||||
|
throw new Error(
|
||||||
|
'Playback recommendation capture is not installed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
completed: capture.completed,
|
||||||
|
launches: capture.launches,
|
||||||
|
released: capture.released,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releasePlaybackRecommendationCapture(
|
||||||
|
app: LaunchedElectronApp
|
||||||
|
): Promise<void> {
|
||||||
|
await app.electronApp.evaluate(() => {
|
||||||
|
const globalRef = globalThis as typeof globalThis & {
|
||||||
|
__playbackRecommendationCapture?: PlaybackRecommendationCaptureState;
|
||||||
|
};
|
||||||
|
const capture = globalRef.__playbackRecommendationCapture;
|
||||||
|
if (!capture || capture.released) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
capture.released = true;
|
||||||
|
capture.releaseFirstResponse?.();
|
||||||
|
capture.releaseFirstResponse = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens in MPV', async ({
|
||||||
dataDir,
|
dataDir,
|
||||||
}) => {
|
}) => {
|
||||||
const fixtureServer = await startDashFixtureServer();
|
const fixtureServer = await startDashFixtureServer();
|
||||||
const app = await launchElectronApp(dataDir);
|
const app = await launchElectronApp(dataDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const unsupportedFixtureResponse = await app.mainWindow.evaluate(
|
||||||
|
async (url) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const body = await response.text();
|
||||||
|
return {
|
||||||
|
body,
|
||||||
|
bodyBytes: new TextEncoder().encode(body).byteLength,
|
||||||
|
contentLength: response.headers.get('content-length'),
|
||||||
|
contentType: response.headers.get('content-type'),
|
||||||
|
rendererProtocol: window.location.protocol,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
`${fixtureServer.origin}/unsupported.mkv`
|
||||||
|
);
|
||||||
|
expect(unsupportedFixtureResponse).toEqual({
|
||||||
|
body: 'This fixture intentionally declares Matroska without playable media bytes.\n',
|
||||||
|
bodyBytes: 75,
|
||||||
|
contentLength: '75',
|
||||||
|
contentType: 'video/matroska',
|
||||||
|
rendererProtocol: 'file:',
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
await importDashPlaylistFromText(
|
await importDashPlaylistFromText(
|
||||||
app,
|
app,
|
||||||
buildDashPlaylist(fixtureServer.origin)
|
buildDashPlaylist(fixtureServer.origin)
|
||||||
@@ -160,12 +375,124 @@ test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a
|
|||||||
await channelItemByTitle(app.mainWindow, 'Widevine DASH')
|
await channelItemByTitle(app.mainWindow, 'Widevine DASH')
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
const banner = app.mainWindow.getByTestId(
|
const banner = app.mainWindow.getByTestId('playback-diagnostic-banner');
|
||||||
'playback-diagnostic-banner'
|
|
||||||
);
|
|
||||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id="playback-fallback-mpv"]')
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id="playback-fallback-vlc"]')
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id^="playback-recommendation-"]')
|
||||||
|
).toHaveCount(0);
|
||||||
|
const widevineDetails = banner.locator(
|
||||||
|
'[data-test-id="playback-diagnostic-details"]'
|
||||||
|
);
|
||||||
|
await widevineDetails.locator('summary').click();
|
||||||
|
await expect(widevineDetails).toContainText('drm-or-encryption');
|
||||||
|
|
||||||
|
await installPlaybackRecommendationLaunchCapture(app);
|
||||||
|
await channelItemByTitle(app.mainWindow, 'Unsupported MKV')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/container is likely unsupported by the browser player/i,
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
const mkvDetails = banner.locator(
|
||||||
|
'[data-test-id="playback-diagnostic-details"]'
|
||||||
|
);
|
||||||
|
await mkvDetails.locator('summary').click();
|
||||||
|
await expect(mkvDetails).toContainText('unsupported-container');
|
||||||
|
|
||||||
|
const mpvFallback = banner.locator(
|
||||||
|
'[data-test-id="playback-fallback-mpv"]'
|
||||||
|
);
|
||||||
|
const vlcFallback = banner.locator(
|
||||||
|
'[data-test-id="playback-fallback-vlc"]'
|
||||||
|
);
|
||||||
|
await expect(mpvFallback).toBeVisible();
|
||||||
|
await expect(vlcFallback).toBeVisible();
|
||||||
|
await expect(mpvFallback).toHaveClass(
|
||||||
|
/web-player-diagnostic__player-card--primary/
|
||||||
|
);
|
||||||
|
await expect(vlcFallback).not.toHaveClass(
|
||||||
|
/web-player-diagnostic__player-card--primary/
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedLaunches = [
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
`${fixtureServer.origin}/unsupported.mkv`,
|
||||||
|
'Unsupported MKV',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
player: 'mpv',
|
||||||
|
},
|
||||||
|
] satisfies CapturedExternalPlayerLaunch[];
|
||||||
|
expect(await getPlaybackRecommendationCapture(app)).toEqual({
|
||||||
|
completed: 0,
|
||||||
|
launches: [],
|
||||||
|
released: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mpvFallback.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => getPlaybackRecommendationCapture(app), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toEqual({
|
||||||
|
completed: 0,
|
||||||
|
launches: expectedLaunches,
|
||||||
|
released: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await getPlaybackRecommendationCapture(app)).launches.filter(
|
||||||
|
({ player }) => player === 'vlc'
|
||||||
|
)
|
||||||
|
).toHaveLength(0);
|
||||||
|
await expect(mpvFallback).toHaveCount(0);
|
||||||
|
await expect(vlcFallback).toHaveClass(
|
||||||
|
/web-player-diagnostic__player-card--primary/
|
||||||
|
);
|
||||||
|
|
||||||
|
await releasePlaybackRecommendationCapture(app);
|
||||||
|
await expect
|
||||||
|
.poll(() => getPlaybackRecommendationCapture(app), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toEqual({
|
||||||
|
completed: 1,
|
||||||
|
launches: expectedLaunches,
|
||||||
|
released: true,
|
||||||
|
});
|
||||||
|
await app.mainWindow.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await getPlaybackRecommendationCapture(app)).toEqual({
|
||||||
|
completed: 1,
|
||||||
|
launches: expectedLaunches,
|
||||||
|
released: true,
|
||||||
|
});
|
||||||
|
await expect(mpvFallback).toHaveCount(0);
|
||||||
|
await expect(vlcFallback).toHaveClass(
|
||||||
|
/web-player-diagnostic__player-card--primary/
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
await releasePlaybackRecommendationCapture(app).catch(() => undefined);
|
||||||
await closeElectronApp(app);
|
await closeElectronApp(app);
|
||||||
await fixtureServer.close();
|
await fixtureServer.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import playwright from 'eslint-plugin-playwright';
|
|||||||
import baseConfig from '../../eslint.config.mjs';
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['**/fixtures/playback/corrupt.ts'],
|
||||||
|
},
|
||||||
playwright.configs['flat/recommended'],
|
playwright.configs['flat/recommended'],
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,4 +150,13 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
|
|||||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id="playback-fallback-mpv"]')
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id="playback-fallback-vlc"]')
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id^="playback-recommendation-"]')
|
||||||
|
).toHaveCount(0);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
This is intentionally not an MPEG transport stream segment.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:1
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:1,
|
||||||
|
corrupt.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
This fixture intentionally declares Matroska without playable media bytes.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
const FIXTURE_DIR = join(__dirname, 'fixtures/playback');
|
||||||
|
const FIXTURE_HOST = 'https://playback-fixture.local';
|
||||||
|
const FATAL_HLS_PLAYLIST = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTINF:-1 tvg-id="fatal-hls" group-title="Playback",Fatal HLS',
|
||||||
|
`${FIXTURE_HOST}/fatal-media.m3u8`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const PLAYBACK_FIXTURES = new Map([
|
||||||
|
[
|
||||||
|
'/fatal-media.m3u8',
|
||||||
|
{
|
||||||
|
body: readFileSync(join(FIXTURE_DIR, 'fatal-media.m3u8')),
|
||||||
|
contentType: 'application/vnd.apple.mpegurl',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/corrupt.ts',
|
||||||
|
{
|
||||||
|
body: readFileSync(join(FIXTURE_DIR, 'corrupt.ts')),
|
||||||
|
contentType: 'video/mp2t',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||||
|
},
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
({ browserName }) => browserName !== 'chromium',
|
||||||
|
'HLS recovery coverage targets Chromium'
|
||||||
|
);
|
||||||
|
|
||||||
|
async function servePlaybackFixtures(page: Page): Promise<void> {
|
||||||
|
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
|
||||||
|
const fixture = PLAYBACK_FIXTURES.get(
|
||||||
|
new URL(route.request().url()).pathname
|
||||||
|
);
|
||||||
|
if (!fixture) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
body: 'not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: fixture.contentType,
|
||||||
|
body: fixture.body,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('link', { name: 'Open settings' }).click();
|
||||||
|
await page.waitForURL(/\/workspace\/settings$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Back', exact: true })
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAndSaveHtml5(page: Page): Promise<void> {
|
||||||
|
const playerSelect = page.locator('[data-test-id="select-video-player"]');
|
||||||
|
await playerSelect.click();
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||||
|
await saveButton.click();
|
||||||
|
await expect(saveButton).toBeDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFatalHlsPlaylist(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||||
|
await dialog
|
||||||
|
.getByLabel('Insert m3u(8) playlist as text')
|
||||||
|
.fill(FATAL_HLS_PLAYLIST);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||||
|
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText('1 channel')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('@web @m3u @playback temporarily switches to the recommended player', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await servePlaybackFixtures(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await openSettings(page);
|
||||||
|
await selectAndSaveHtml5(page);
|
||||||
|
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||||
|
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||||
|
|
||||||
|
await importFatalHlsPlaylist(page);
|
||||||
|
await page.getByText('1. Fatal HLS', { exact: true }).click();
|
||||||
|
|
||||||
|
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||||
|
const videoJsRecommendation = page.locator(
|
||||||
|
'[data-test-id="playback-recommendation-videojs"]'
|
||||||
|
);
|
||||||
|
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(
|
||||||
|
banner.locator('[data-test-id="playback-diagnostic-details"]')
|
||||||
|
).toContainText(
|
||||||
|
/HLS\.js[\s\S]*type=mediaError[\s\S]*details=fragParsingError[\s\S]*disposition=fatal/
|
||||||
|
);
|
||||||
|
await expect(videoJsRecommendation).toBeVisible();
|
||||||
|
await expect(videoJsRecommendation).toHaveClass(
|
||||||
|
/web-player-diagnostic__player-card--primary/
|
||||||
|
);
|
||||||
|
|
||||||
|
await videoJsRecommendation.click();
|
||||||
|
await expect(page.locator('app-vjs-player')).toBeVisible();
|
||||||
|
|
||||||
|
await openSettings(page);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\/settings$/);
|
||||||
|
const persistedPlayerSelect = page.locator(
|
||||||
|
'[data-test-id="select-video-player"]'
|
||||||
|
);
|
||||||
|
await expect(persistedPlayerSelect).toBeVisible();
|
||||||
|
await expect(persistedPlayerSelect).toBeEnabled();
|
||||||
|
await expect(persistedPlayerSelect).toHaveText('HTML5 video player');
|
||||||
|
});
|
||||||
@@ -17,5 +17,6 @@
|
|||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.e2e.ts"
|
"src/**/*.e2e.ts"
|
||||||
]
|
],
|
||||||
|
"exclude": ["src/fixtures/playback/corrupt.ts"]
|
||||||
}
|
}
|
||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "إجراءات الاسترداد الموصى بها",
|
||||||
|
"ACTION_TRY_PLAYER": "جرّب {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "مؤقت لهذا العنصر؛ سيبقى المشغل المحفوظ دون تغيير",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "أعد المحاولة بنفس المشغل بعد فشل مؤقت في التحميل",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "أعد المحاولة لأن الخطأ لم يحدد مشغلًا آخر متوافقًا",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "جرّب مصدرًا آخر متاحًا للمحتوى نفسه",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "يستخدم هذا المشغل محرك تشغيل مختلفًا في المتصفح",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "تدعم المشغلات الأصلية مزيدًا من برامج الترميز والحاويات",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "قد يتجاوز المشغل الأصلي قيود الوصول في المتصفح",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "هذا الهدف متوافق مع بيانات التشفير المتاحة",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "إجراءات الاسترجاع المقترحة",
|
||||||
|
"ACTION_TRY_PLAYER": "جرّب {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "مؤقت غير لهاد المحتوى؛ المشغل المحفوظ غيبقى بلا تغيير",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "عاود جرّب نفس المشغل من بعد مشكل مؤقت فالتحميل",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "عاود جرّب حيث الخطأ ما حدّد حتى مشغل آخر متوافق",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "جرّب مصدر آخر متوفر لنفس المحتوى",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "هاد المشغل كيستعمل محرك تشغيل مختلف فالمتصفح",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "المشغلات الأصلية كتدعم كوديكات وحاويات أكثر",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "يقدر مشغل أصلي يتجاوز قيود الولوج ديال المتصفح",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "هاد الهدف متوافق مع بيانات التشفير المتوفرة",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Рэкамендаваныя дзеянні для аднаўлення",
|
||||||
|
"ACTION_TRY_PLAYER": "Паспрабаваць {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Часова толькі для гэтага элемента; захаваны плэер не зменіцца",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Паўтарыць у тым жа плэеры пасля часовага збою загрузкі",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Паўтарыць, бо памылка не вызначыла іншы сумяшчальны плэер",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Паспрабаваць іншую даступную крыніцу таго ж змесціва",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Гэты плэер выкарыстоўвае іншы рухавік прайгравання ў браўзеры",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Сістэмныя плэеры падтрымліваюць больш кодэкаў і кантэйнераў",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Сістэмны плэер можа абысці абмежаванні доступу браўзера",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Гэтая мэта сумяшчальная з даступнымі данымі шыфравання",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Empfohlene Wiederherstellungsaktionen",
|
||||||
|
"ACTION_TRY_PLAYER": "{{player}} ausprobieren",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Nur vorübergehend für diesen Inhalt; der gespeicherte Player bleibt unverändert",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Nach einem vorübergehenden Ladefehler denselben Player erneut versuchen",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Erneut versuchen, da der Fehler keinen anderen kompatiblen Player erkennen ließ",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Eine andere verfügbare Quelle für denselben Inhalt ausprobieren",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Dieser Player verwendet eine andere Browser-Wiedergabe-Engine",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native Player unterstützen mehr Codecs und Container",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Ein nativer Player kann Zugriffsbeschränkungen des Browsers umgehen",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Dieses Ziel ist mit den verfügbaren Verschlüsselungsdaten kompatibel",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Προτεινόμενες ενέργειες ανάκτησης",
|
||||||
|
"ACTION_TRY_PLAYER": "Δοκιμή του {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Προσωρινά για αυτό το στοιχείο· το αποθηκευμένο πρόγραμμα αναπαραγωγής δεν αλλάζει",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Δοκιμάστε ξανά το ίδιο πρόγραμμα αναπαραγωγής μετά από μια προσωρινή αποτυχία φόρτωσης",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Δοκιμάστε ξανά, επειδή το σφάλμα δεν εντόπισε άλλο συμβατό πρόγραμμα αναπαραγωγής",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Δοκιμάστε μια άλλη διαθέσιμη πηγή για το ίδιο περιεχόμενο",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Αυτό το πρόγραμμα αναπαραγωγής χρησιμοποιεί διαφορετική μηχανή αναπαραγωγής του προγράμματος περιήγησης",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Τα εγγενή προγράμματα αναπαραγωγής υποστηρίζουν περισσότερους κωδικοποιητές και περιέκτες",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Ένα εγγενές πρόγραμμα αναπαραγωγής μπορεί να παρακάμψει περιορισμούς πρόσβασης του προγράμματος περιήγησης",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Αυτός ο στόχος είναι συμβατός με τα διαθέσιμα δεδομένα κρυπτογράφησης",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Recommended recovery actions",
|
||||||
|
"ACTION_TRY_PLAYER": "Try {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Temporary for this item; your saved player stays unchanged",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Retry the same player after a temporary loading failure",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Retry because the failure did not identify another compatible player",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Try another available source for the same content",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "This player uses a different browser playback engine",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native players support more codecs and containers",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "A native player may avoid browser access restrictions",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Acciones de recuperación recomendadas",
|
||||||
|
"ACTION_TRY_PLAYER": "Probar {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Temporal para este elemento; el reproductor guardado no cambiará",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Reintentar con el mismo reproductor tras un fallo temporal de carga",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Reintentar porque el error no identificó otro reproductor compatible",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Probar otra fuente disponible para el mismo contenido",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Este reproductor usa un motor de reproducción distinto del navegador",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Los reproductores nativos admiten más códecs y contenedores",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Un reproductor nativo puede evitar las restricciones de acceso del navegador",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Este destino es compatible con los datos de cifrado disponibles",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Actions de récupération recommandées",
|
||||||
|
"ACTION_TRY_PLAYER": "Essayer {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Temporaire pour cet élément ; votre lecteur enregistré reste inchangé",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Réessayer avec le même lecteur après un échec temporaire de chargement",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Réessayer, car l’erreur n’a identifié aucun autre lecteur compatible",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Essayer une autre source disponible pour le même contenu",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Ce lecteur utilise un autre moteur de lecture du navigateur",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Les lecteurs natifs prennent en charge davantage de codecs et de conteneurs",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Un lecteur natif peut contourner les restrictions d’accès du navigateur",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Cette cible est compatible avec les données de chiffrement disponibles",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "A kérést CORS-korlátozás, vegyes tartalom, illetve a szolgáltató fejléc- vagy proxykorlátozásai akadályozhatják. Az MPV vagy a VLC gyakran képes megkerülni a böngészőben működő lejátszó hozzáférési korlátait.",
|
"DESCRIPTION": "A kérést CORS-korlátozás, vegyes tartalom, illetve a szolgáltató fejléc- vagy proxykorlátozásai akadályozhatják. Az MPV vagy a VLC gyakran képes megkerülni a böngészőben működő lejátszó hozzáférési korlátait.",
|
||||||
"PWA_DESCRIPTION": "A kérést CORS-korlátozás, vegyes tartalom, illetve a szolgáltató fejléc- vagy proxykorlátozásai akadályozhatják. A PWA nem tud közvetlenül külső lejátszót indítani; válassza az Adatfolyam URL-címének másolása műveletet, majd nyissa meg kézzel MPV-ben, VLC-ben, IINA-ban vagy más lejátszóban."
|
"PWA_DESCRIPTION": "A kérést CORS-korlátozás, vegyes tartalom, illetve a szolgáltató fejléc- vagy proxykorlátozásai akadályozhatják. A PWA nem tud közvetlenül külső lejátszót indítani; válassza az Adatfolyam URL-címének másolása műveletet, majd nyissa meg kézzel MPV-ben, VLC-ben, IINA-ban vagy más lejátszóban."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "Az adatfolyam titkosított vagy DRM-védelemmel ellátott lehet",
|
"TITLE": "Az adatfolyam titkosított vagy DRM-védelemmel ellátott lehet",
|
||||||
"DESCRIPTION": "A böngészőben működő lejátszó a jelzett titkosítási vagy kulcshiba miatt nem tudja folytatni a lejátszást. Ha a szolgáltató DRM-védelmet alkalmaz, a külső lejátszás is sikertelen lehet."
|
"DESCRIPTION": "A böngészőben működő lejátszó a jelzett titkosítási vagy kulcshiba miatt nem tudja folytatni a lejátszást. Ha a szolgáltató DRM-védelmet alkalmaz, a külső lejátszás is sikertelen lehet."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Nyissa meg inkább külső lejátszóban",
|
"NATIVE_FALLBACK_TITLE": "Nyissa meg inkább külső lejátszóban",
|
||||||
"INLINE_FAILURE_TITLE": "Másolja ki az URL-címet, vagy próbálja újra",
|
"INLINE_FAILURE_TITLE": "Másolja ki az URL-címet, vagy próbálja újra",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Ajánlott helyreállítási műveletek",
|
||||||
|
"ACTION_TRY_PLAYER": "A(z) {{player}} kipróbálása",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Csak ennél az elemnél ideiglenes; a mentett lejátszó változatlan marad",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Ugyanannak a lejátszónak az újrapróbálása ideiglenes betöltési hiba után",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Újrapróbálás, mert a hiba nem azonosított más kompatibilis lejátszót",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Másik elérhető forrás kipróbálása ugyanahhoz a tartalomhoz",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Ez a lejátszó más böngészős lejátszómotort használ",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "A natív lejátszók több kodeket és konténert támogatnak",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Egy natív lejátszó megkerülheti a böngésző hozzáférési korlátozásait",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Ez a cél kompatibilis az elérhető titkosítási adatokkal",
|
||||||
"ACTION_OPEN_MPV": "Megnyitás MPV-ben",
|
"ACTION_OPEN_MPV": "Megnyitás MPV-ben",
|
||||||
"ACTION_OPEN_VLC": "Megnyitás VLC-ben",
|
"ACTION_OPEN_VLC": "Megnyitás VLC-ben",
|
||||||
"ACTION_OPEN_MPV_HINT": "beállított lejátszó",
|
"ACTION_OPEN_MPV_HINT": "beállított lejátszó",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Azioni di ripristino consigliate",
|
||||||
|
"ACTION_TRY_PLAYER": "Prova {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Temporaneo per questo elemento; il lettore salvato resta invariato",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Riprova con lo stesso lettore dopo un errore temporaneo di caricamento",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Riprova perché l’errore non ha identificato un altro lettore compatibile",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Prova un’altra fonte disponibile per lo stesso contenuto",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Questo lettore usa un motore di riproduzione del browser diverso",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "I lettori nativi supportano più codec e contenitori",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Un lettore nativo può evitare le restrizioni di accesso del browser",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Questa destinazione è compatibile con i dati di crittografia disponibili",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "推奨される復旧操作",
|
||||||
|
"ACTION_TRY_PLAYER": "{{player}} を試す",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "この項目にのみ一時的に適用され、保存済みのプレーヤーは変更されません",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "一時的な読み込みエラーの後、同じプレーヤーで再試行します",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "エラーから別の互換プレーヤーを特定できなかったため、再試行します",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "同じコンテンツで利用可能な別のソースを試します",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "このプレーヤーは別のブラウザー再生エンジンを使用します",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "ネイティブプレーヤーはより多くのコーデックとコンテナに対応しています",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "ネイティブプレーヤーならブラウザーのアクセス制限を回避できる場合があります",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "この対象は利用可能な暗号化データに対応しています",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "권장 복구 작업",
|
||||||
|
"ACTION_TRY_PLAYER": "{{player}} 사용해 보기",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "이 항목에만 임시로 적용되며 저장된 플레이어는 변경되지 않습니다",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "일시적인 로드 실패 후 같은 플레이어로 다시 시도합니다",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "오류에서 다른 호환 플레이어를 확인할 수 없어 다시 시도합니다",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "같은 콘텐츠에 사용 가능한 다른 소스를 시도합니다",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "이 플레이어는 다른 브라우저 재생 엔진을 사용합니다",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "네이티브 플레이어는 더 많은 코덱과 컨테이너를 지원합니다",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "네이티브 플레이어는 브라우저 접근 제한을 피할 수 있습니다",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "이 대상은 사용 가능한 암호화 데이터와 호환됩니다",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Aanbevolen herstelacties",
|
||||||
|
"ACTION_TRY_PLAYER": "{{player}} proberen",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Tijdelijk voor dit item; je opgeslagen speler blijft ongewijzigd",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Dezelfde speler opnieuw proberen na een tijdelijke laadfout",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Opnieuw proberen omdat de fout geen andere compatibele speler heeft vastgesteld",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Een andere beschikbare bron voor dezelfde inhoud proberen",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Deze speler gebruikt een andere afspeelengine in de browser",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Systeemeigen spelers ondersteunen meer codecs en containers",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Een systeemeigen speler kan toegangsbeperkingen van de browser omzeilen",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Dit doel is compatibel met de beschikbare versleutelingsgegevens",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Zalecane działania odzyskiwania",
|
||||||
|
"ACTION_TRY_PLAYER": "Wypróbuj {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Tymczasowo dla tego elementu; zapisany odtwarzacz pozostanie bez zmian",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Ponów próbę w tym samym odtwarzaczu po tymczasowym błędzie wczytywania",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Ponów próbę, ponieważ błąd nie wskazał innego zgodnego odtwarzacza",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Wypróbuj inne dostępne źródło tej samej treści",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Ten odtwarzacz używa innego silnika odtwarzania w przeglądarce",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Natywne odtwarzacze obsługują więcej kodeków i kontenerów",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Natywny odtwarzacz może ominąć ograniczenia dostępu przeglądarki",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Ten cel jest zgodny z dostępnymi danymi szyfrowania",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Ações de recuperação recomendadas",
|
||||||
|
"ACTION_TRY_PLAYER": "Experimentar {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Temporário para este item; o reprodutor salvo permanece inalterado",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Tentar novamente com o mesmo reprodutor após uma falha temporária de carregamento",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Tentar novamente porque o erro não identificou outro reprodutor compatível",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Experimentar outra fonte disponível para o mesmo conteúdo",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Este reprodutor usa um mecanismo de reprodução diferente no navegador",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Os reprodutores nativos oferecem suporte a mais codecs e contêineres",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Um reprodutor nativo pode contornar as restrições de acesso do navegador",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Este destino é compatível com os dados de criptografia disponíveis",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. MPV или VLC часто обходят такие ограничения браузерного плеера.",
|
"DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. MPV или VLC часто обходят такие ограничения браузерного плеера.",
|
||||||
"PWA_DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. PWA не может запускать внешние плееры напрямую: нажмите «Скопировать URL потока» и откройте его вручную в MPV, VLC, IINA или другом плеере."
|
"PWA_DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. PWA не может запускать внешние плееры напрямую: нажмите «Скопировать URL потока» и откройте его вручную в MPV, VLC, IINA или другом плеере."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "Для этого защищённого потока требуются настройки воспроизведения, которые нельзя передать вместе с URL-адресом потока.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "Поток может быть зашифрован или защищен DRM",
|
"TITLE": "Поток может быть зашифрован или защищен DRM",
|
||||||
"DESCRIPTION": "Браузерный плеер не может продолжить из-за ошибки ключа или шифрования. Внешний плеер тоже может не помочь, если требуется DRM провайдера."
|
"DESCRIPTION": "Браузерный плеер не может продолжить из-за ошибки ключа или шифрования. Внешний плеер тоже может не помочь, если требуется DRM провайдера."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Откройте поток во внешнем плеере",
|
"NATIVE_FALLBACK_TITLE": "Откройте поток во внешнем плеере",
|
||||||
"INLINE_FAILURE_TITLE": "Скопируйте URL или повторите попытку",
|
"INLINE_FAILURE_TITLE": "Скопируйте URL или повторите попытку",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Не удалось продолжить защищённое воспроизведение",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Рекомендуемые действия для восстановления",
|
||||||
|
"ACTION_TRY_PLAYER": "Попробовать {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Временно только для этого элемента; сохранённый плеер не изменится",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Повторить в том же плеере после временной ошибки загрузки",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Повторить, поскольку ошибка не указала другой совместимый плеер",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Попробовать другой доступный источник того же контента",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Этот плеер использует другой движок воспроизведения в браузере",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Нативные плееры поддерживают больше кодеков и контейнеров",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Нативный плеер может обойти ограничения доступа браузера",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Эта цель совместима с доступными данными шифрования",
|
||||||
"ACTION_OPEN_MPV": "Открыть в MPV",
|
"ACTION_OPEN_MPV": "Открыть в MPV",
|
||||||
"ACTION_OPEN_VLC": "Открыть в VLC",
|
"ACTION_OPEN_VLC": "Открыть в VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "настроенный плеер",
|
"ACTION_OPEN_MPV_HINT": "настроенный плеер",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "Önerilen kurtarma işlemleri",
|
||||||
|
"ACTION_TRY_PLAYER": "{{player}} ile dene",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "Bu öğe için geçicidir; kayıtlı oynatıcınız değişmeden kalır",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "Geçici bir yükleme hatasından sonra aynı oynatıcıyla yeniden deneyin",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "Hata başka bir uyumlu oynatıcı belirlemediği için yeniden deneyin",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Aynı içerik için kullanılabilir başka bir kaynak deneyin",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "Bu oynatıcı farklı bir tarayıcı oynatma motoru kullanır",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Yerel oynatıcılar daha fazla codec bileşenini ve kapsayıcıyı destekler",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "Yerel bir oynatıcı tarayıcı erişim kısıtlamalarını aşabilir",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "Bu hedef kullanılabilir şifreleme verileriyle uyumludur",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "推荐的恢复操作",
|
||||||
|
"ACTION_TRY_PLAYER": "尝试 {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "仅临时用于此项目;已保存的播放器不会更改",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "临时加载失败后使用同一播放器重试",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "重试,因为该错误未能确定其他兼容的播放器",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "为同一内容尝试其他可用源",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "此播放器使用不同的浏览器播放引擎",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "原生播放器支持更多编解码器和容器",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "原生播放器可以避开浏览器访问限制",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "此目标与可用的加密数据兼容",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -551,6 +551,7 @@
|
|||||||
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
"DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.",
|
||||||
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player."
|
||||||
},
|
},
|
||||||
|
"UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.",
|
||||||
"DRM_OR_ENCRYPTION": {
|
"DRM_OR_ENCRYPTION": {
|
||||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||||
@@ -561,6 +562,17 @@
|
|||||||
},
|
},
|
||||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||||
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
|
||||||
|
"UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue",
|
||||||
|
"RECOMMENDATIONS_LABEL": "建議的復原操作",
|
||||||
|
"ACTION_TRY_PLAYER": "嘗試 {{player}}",
|
||||||
|
"ACTION_TRY_PLAYER_HINT": "僅暫時套用於此項目;已儲存的播放器不會變更",
|
||||||
|
"REASON_RETRY_TRANSIENT_FAILURE": "暫時載入失敗後使用相同的播放器重試",
|
||||||
|
"REASON_RETRY_UNKNOWN_FAILURE": "重試,因為此錯誤未能識別其他相容的播放器",
|
||||||
|
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "為相同內容嘗試其他可用來源",
|
||||||
|
"REASON_DIFFERENT_ENGINE_FAMILY": "此播放器使用不同的瀏覽器播放引擎",
|
||||||
|
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "原生播放器支援更多編解碼器和容器",
|
||||||
|
"REASON_EXTERNAL_BROWSER_ACCESS": "原生播放器可以避開瀏覽器存取限制",
|
||||||
|
"REASON_COMPATIBLE_DRM_PATH": "此目標與可用的加密資料相容",
|
||||||
"ACTION_OPEN_MPV": "Open in MPV",
|
"ACTION_OPEN_MPV": "Open in MPV",
|
||||||
"ACTION_OPEN_VLC": "Open in VLC",
|
"ACTION_OPEN_VLC": "Open in VLC",
|
||||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ This document records the current contract for embedded playback in portal detai
|
|||||||
saved episode playback positions.
|
saved episode playback positions.
|
||||||
- Embedded playback UI is always hosted by the current view. `PlayerService`
|
- Embedded playback UI is always hosted by the current view. `PlayerService`
|
||||||
launches MPV/VLC only and does not open an embedded-player dialog.
|
launches MPV/VLC only and does not open an embedded-player dialog.
|
||||||
- Browser-player failures are diagnosed client-side and can offer explicit MPV/VLC fallback actions without changing the saved player setting.
|
- Browser-player failures are diagnosed client-side and produce ranked,
|
||||||
|
user-triggered recovery actions without changing the saved player setting.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -38,6 +39,58 @@ Collection/search VOD surfaces that expose embedded playback must host
|
|||||||
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
|
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
|
||||||
create embedded UI.
|
create embedded UI.
|
||||||
|
|
||||||
|
## Logical Playback Identity
|
||||||
|
|
||||||
|
Every inline playback host owns a required, URL-independent
|
||||||
|
`playbackSessionKey`. Live hosts derive it from the playlist/source and the
|
||||||
|
current channel identity; an M3U session uses `Channel.id` rather than a mutable
|
||||||
|
stream or catch-up URL. VOD and series hosts use the route or catalog
|
||||||
|
content identity, with series episode coordinates. Stalker episode identity
|
||||||
|
also includes the explicit series mode, normalized parent, exact season key,
|
||||||
|
season number, and episode number; synthesized episode hashes are not identity.
|
||||||
|
Mapped episodes retain the original provider command or episode ID for playback
|
||||||
|
resolution. Recovery ownership never serializes that value into the session
|
||||||
|
key or retained recovery state; its recovery-ownership use is limited to a
|
||||||
|
short-lived exact pending-request snapshot/guard that locates the selected
|
||||||
|
episode and rejects stale or out-of-order completion. This keeps token refreshes
|
||||||
|
in one logical session while ensuring colliding tracking hashes cannot select a
|
||||||
|
sibling episode.
|
||||||
|
After a Stalker episode mounts, the host retains only a frozen structural
|
||||||
|
identity (source, normalized parent, series mode, exact season key, season and
|
||||||
|
episode coordinates, and the credential-free session key). Each metadata,
|
||||||
|
navigation, or autoplay read resolves those coordinates against the current
|
||||||
|
`mappedSeasons()` value. Same-owner provider or TMDB refreshes therefore update
|
||||||
|
the mounted episode without changing its session key; if the episode disappears,
|
||||||
|
or if multiple episodes claim the same structural coordinates, the mounted
|
||||||
|
commands fail closed. The command/ID-bearing identity never outlives the pending
|
||||||
|
playback request.
|
||||||
|
Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass
|
||||||
|
the key unchanged to `WebPlayerViewComponent`.
|
||||||
|
|
||||||
|
Hosts invalidate both committed playback and pending resolution when the
|
||||||
|
canonical owner changes (playlist/source, content, and mode where applicable).
|
||||||
|
Refreshing data for the same canonical owner preserves the mounted player.
|
||||||
|
Collection UIDs remain a separate persistence concern; legacy M3U collection
|
||||||
|
UIDs continue to use stream URLs so saved favorites ordering remains compatible.
|
||||||
|
|
||||||
|
Temporary portal URLs, catch-up URLs, headers, DRM data, and alternative source
|
||||||
|
payloads are transport details and must not change this logical identity. A
|
||||||
|
content or episode change must produce a different key. The serialized key is
|
||||||
|
created with `createPlaybackSessionKey()` from `@iptvnator/playback/util` so
|
||||||
|
delimiter-bearing provider IDs remain unambiguous.
|
||||||
|
|
||||||
|
The key is host-owned and stable only for the logical selection represented by
|
||||||
|
that mounted host. In particular, M3U identity uses the current playlist/source
|
||||||
|
identity and `Channel.id`; it does not claim durability across a refresh that
|
||||||
|
replaces either identity. Recovery state contains this credential-free key,
|
||||||
|
target IDs, generation counters, and a finite VOD resume position. It never
|
||||||
|
uses a playback URL, headers, DRM configuration, or credentials as identity.
|
||||||
|
|
||||||
|
Inline hosts capture this identity before asynchronous playback resolution. A
|
||||||
|
completion may mount only while the same owner is current; stale completions
|
||||||
|
and embedded starts without a complete canonical identity are ignored without
|
||||||
|
replacing an already committed session.
|
||||||
|
|
||||||
## Embedded MPV Harness
|
## Embedded MPV Harness
|
||||||
|
|
||||||
The repository now contains a first-pass native embedded MPV harness for Electron:
|
The repository now contains a first-pass native embedded MPV harness for Electron:
|
||||||
@@ -231,9 +284,9 @@ Embedded playback does not have a fallback dialog path.
|
|||||||
`PlayerService.openResolvedPlayback(...)` remains the MPV/VLC external launch
|
`PlayerService.openResolvedPlayback(...)` remains the MPV/VLC external launch
|
||||||
entry point; for embedded players it returns without creating UI.
|
entry point; for embedded players it returns without creating UI.
|
||||||
|
|
||||||
Diagnostics and fallback UI:
|
Diagnostics, recovery policy, and recovery UI:
|
||||||
|
|
||||||
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts`
|
- `/Users/4gray/Code/iptvnator/libs/playback/util/src/index.ts`
|
||||||
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
|
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
|
||||||
|
|
||||||
## Playback Decision Rule
|
## Playback Decision Rule
|
||||||
@@ -309,7 +362,30 @@ Current contract:
|
|||||||
|
|
||||||
## Codec And Container Diagnostics
|
## Codec And Container Diagnostics
|
||||||
|
|
||||||
The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback, including live streams opened from favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier.
|
The shared `WebPlayerViewComponent` is the central browser-player viewport for
|
||||||
|
M3U, Xtream, and Stalker inline playback, including live streams opened from
|
||||||
|
favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer
|
||||||
|
report native media errors, hls.js errors, Video.js/VHS errors, Shaka errors,
|
||||||
|
mpegts.js errors, and HLS manifest codec metadata into the DOM-free classifiers
|
||||||
|
exported by `@iptvnator/playback/util`.
|
||||||
|
|
||||||
|
The canonical recovery flow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
engine public error
|
||||||
|
-> sanitized PlaybackDiagnostic (@iptvnator/playback/util)
|
||||||
|
-> recommendPlaybackRecovery(context)
|
||||||
|
-> ranked maximum-three action model
|
||||||
|
-> WebPlayerView session-local user action
|
||||||
|
```
|
||||||
|
|
||||||
|
Engine adapters own public-event collection and sanitation. The playback
|
||||||
|
utility owns diagnostic contracts, evidence classification, source/engine
|
||||||
|
mapping, capability contracts, and the pure recommendation policy.
|
||||||
|
`WebPlayerViewComponent` owns only the current session state and execution of a
|
||||||
|
user-selected action. Diagnostic producers do not decide which player to show,
|
||||||
|
and the recommendation policy does not inspect Angular, the DOM, settings,
|
||||||
|
storage, or Electron globals.
|
||||||
|
|
||||||
The diagnostics remain client-only:
|
The diagnostics remain client-only:
|
||||||
|
|
||||||
@@ -418,10 +494,14 @@ or media cause, so stage and failure remain unknown.
|
|||||||
|
|
||||||
A failed public `Player.isBrowserSupported()` preflight is not a Shaka error
|
A failed public `Player.isBrowserSupported()` preflight is not a Shaka error
|
||||||
and therefore retains fully unknown technical evidence instead of being
|
and therefore retains fully unknown technical evidence instead of being
|
||||||
mislabelled as an unsupported container. For clear DASH, the diagnostic still
|
mislabelled as an unsupported container. The app adds only the exact,
|
||||||
offers configured MPV/VLC actions because the failure is specific to the web
|
enumerated `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker to that
|
||||||
engine. KODIPROP DRM sources keep external fallback disabled because external
|
otherwise unknown diagnostic. This Shaka/DASH runtime-preflight marker is the
|
||||||
players do not receive their key configuration.
|
sole unknown-code exception that can rank configured MPV/VLC actions, and only
|
||||||
|
for clear, externally transferable DASH in a managed-external runtime. Generic
|
||||||
|
unknown diagnostics remain Retry/alternative-source only. PWA capability and
|
||||||
|
KODIPROP DRM suppress the external actions because those players are absent or
|
||||||
|
the launch contract cannot transfer the key configuration.
|
||||||
|
|
||||||
Shaka messages, URLs, headers, request/response bodies, credentials,
|
Shaka messages, URLs, headers, request/response bodies, credentials,
|
||||||
license/key payloads, and arbitrary `error.data` objects are neither retained
|
license/key payloads, and arbitrary `error.data` objects are neither retained
|
||||||
@@ -443,7 +523,8 @@ status from the top-level `info.code` slot of
|
|||||||
Exact public pairs classify HTTP/timeout/exception as network failures,
|
Exact public pairs classify HTTP/timeout/exception as network failures,
|
||||||
`FormatUnsupported` as an unsupported container, `CodecUnsupported` as an
|
`FormatUnsupported` as an unsupported container, `CodecUnsupported` as an
|
||||||
unsupported codec, and `FormatError`/`MediaMSEError` as media failures.
|
unsupported codec, and `FormatError`/`MediaMSEError` as media failures.
|
||||||
`UnrecoverableEarlyEof` remains a fallback-actionable `media-decode-error`:
|
`UnrecoverableEarlyEof` remains a `media-decode-error` that may rank a distinct
|
||||||
|
engine or external player:
|
||||||
mpegts.js has already exhausted its internal finite-source early-EOF recovery,
|
mpegts.js has already exhausted its internal finite-source early-EOF recovery,
|
||||||
and another demuxer may tolerate the truncated transport stream. Mismatched or
|
and another demuxer may tolerate the truncated transport stream. Mismatched or
|
||||||
unknown pairs fail closed to `unknown-playback-error`.
|
unknown pairs fail closed to `unknown-playback-error`.
|
||||||
@@ -454,17 +535,20 @@ prove CORS, mixed content, CSP, or private-network access, so mpegts.js no
|
|||||||
longer creates `browser-access-error` from message text. HTTP and other network
|
longer creates `browser-access-error` from message text. HTTP and other network
|
||||||
failures do not claim an external decoder will fix the provider response;
|
failures do not claim an external decoder will fix the provider response;
|
||||||
container, codec, truncated-stream, format, and MediaSource failures retain
|
container, codec, truncated-stream, format, and MediaSource failures retain
|
||||||
the existing explicit MPV/VLC fallback behavior.
|
evidence that can rank an explicit MPV/VLC action when the payload and runtime
|
||||||
|
permit it.
|
||||||
|
|
||||||
The diagnostic surface covers the inline player viewport when playback fails,
|
The diagnostic surface covers the inline player viewport when playback fails,
|
||||||
with a compact warning badge, a native-player fallback headline, and
|
with a compact warning badge, one primary recommendation, at most two secondary
|
||||||
player-card actions for configured external players. It exposes technical
|
recommendations, and always-available Copy URL and Technical details utilities.
|
||||||
details on demand: diagnostic code, reporting player/source, detected
|
An alternative-source recommendation consumes one of those three slots even
|
||||||
container/MIME, video/audio codecs, native browser error fields, sanitized
|
when its bounded source list renders several rows. Technical details contain
|
||||||
structured Video.js/VHS, HLS, Shaka, and mpegts.js evidence. HLS
|
the diagnostic code, reporting player/source, detected container/MIME,
|
||||||
manifest codec metadata also drives a concise browser-support hint for codecs
|
video/audio codecs, native browser error fields, and sanitized structured
|
||||||
that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3,
|
Video.js/VHS, HLS, Shaka, and mpegts.js evidence. HLS manifest codec metadata
|
||||||
E-AC-3, DTS, and MPEG-2 video.
|
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.
|
||||||
|
|
||||||
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
|
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
|
||||||
|
|
||||||
@@ -472,14 +556,125 @@ MKV sources are attempted through Chromium's native Matroska path. Video.js
|
|||||||
receives `video/matroska` for `.mkv` URLs and explicit query metadata such as
|
receives `video/matroska` for `.mkv` URLs and explicit query metadata such as
|
||||||
`extension=mkv` or `container=mkv`; ArtPlayer and HTML5 continue to use their
|
`extension=mkv` or `container=mkv`; ArtPlayer and HTML5 continue to use their
|
||||||
native video paths. This is container support rather than a universal codec
|
native video paths. This is container support rather than a universal codec
|
||||||
guarantee: native source or decode failures still produce the existing
|
guarantee: native source or decode failures still produce a diagnostic whose
|
||||||
diagnostic and explicit MPV/VLC fallback.
|
ranked actions may include MPV/VLC.
|
||||||
|
|
||||||
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
|
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
|
||||||
|
|
||||||
When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting.
|
## Recovery Recommendation Policy
|
||||||
|
|
||||||
`PortalPlayer.openExternalPlayback(playback, player)` is the forced external launch API. It sends the playback payload to MPV or VLC regardless of the current saved player setting, so fallback buttons do not mutate preferences.
|
Recommendations change playback paths only when structured evidence supports
|
||||||
|
that conclusion. A different skin over the same engine family is not a distinct
|
||||||
|
recovery target.
|
||||||
|
|
||||||
|
| Source path | Active engine family | Distinct built-in recommendation |
|
||||||
|
| ---------------------------------------- | ------------------------------- | -------------------------------- |
|
||||||
|
| HLS in Video.js | Video.js/VHS | HTML5 through hls.js |
|
||||||
|
| HLS in HTML5 or ArtPlayer | hls.js | Video.js/VHS |
|
||||||
|
| MPEG-TS in any web player | shared mpegts.js | None |
|
||||||
|
| DASH in HTML5 or ArtPlayer | Shaka | None |
|
||||||
|
| DASH in Video.js | unsupported recommendation path | None |
|
||||||
|
| Native media/container in any web player | browser native-media | None |
|
||||||
|
|
||||||
|
HTML5 is the canonical hls.js alternative to Video.js/VHS; ArtPlayer is not a
|
||||||
|
second independent hls.js choice. MPEG-TS, DASH/Shaka, and native media never
|
||||||
|
offer a same-family built-in alternative. Unknown source or engine-family facts
|
||||||
|
also suppress built-in recommendations.
|
||||||
|
|
||||||
|
The pure policy builds the following order, filters the current, unavailable,
|
||||||
|
incompatible, and already attempted targets, and then returns at most three
|
||||||
|
actions. It also projects attempted inline target IDs through the validated
|
||||||
|
canonical source/target capability matrix and filters every engine family that
|
||||||
|
has already been attempted. HTML5 and ArtPlayer therefore cannot be offered as
|
||||||
|
separate hls.js recovery attempts. The first surviving action is primary and
|
||||||
|
later actions are secondary.
|
||||||
|
|
||||||
|
| Sanitized evidence | Candidate order |
|
||||||
|
| ----------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| HTTP, timeout, or generic network failure | Retry -> Alternative source |
|
||||||
|
| Generic unknown playback error | Retry -> Alternative source |
|
||||||
|
| Exact Shaka browser-unsupported preflight | MPV -> VLC -> Alternative source |
|
||||||
|
| Browser access/CORS/CSP-class failure | MPV -> VLC -> Alternative source |
|
||||||
|
| Unsupported codec or container | MPV -> VLC -> Alternative source |
|
||||||
|
| Media/decode/engine processing failure | Distinct built-in family -> MPV -> VLC -> Alternative source |
|
||||||
|
| DRM/encryption failure | Compatible built-in path -> Alternative source -> MPV -> VLC |
|
||||||
|
|
||||||
|
Network and generic unknown evidence fail closed: they never claim that
|
||||||
|
changing a decoder will repair the provider response. The exact app-owned
|
||||||
|
Shaka browser-unsupported runtime-preflight marker above is the only
|
||||||
|
unknown-code exception. Contradictory or incomplete capability facts fail
|
||||||
|
closed to Retry and an available alternative source.
|
||||||
|
External targets require both managed external-player support and a transferable
|
||||||
|
payload. PWA builds therefore never rank MPV/VLC. Any ClearKey/KODIPROP DRM
|
||||||
|
payload is non-transferable and suppresses both external targets; the policy
|
||||||
|
does not infer transferability from a message or URL. Eligible Electron portal
|
||||||
|
fallback requests keep using the original `ResolvedPortalPlayback`, so the
|
||||||
|
existing host path can forward its required headers and playback metadata.
|
||||||
|
|
||||||
|
## Recovery Session Lifecycle And Privacy
|
||||||
|
|
||||||
|
Each mounted `WebPlayerViewComponent` owns one in-memory recovery session for
|
||||||
|
its required `playbackSessionKey`. Retry and an alternative source for the same
|
||||||
|
logical content keep the key and attempted-target set. A different channel,
|
||||||
|
movie, or exact episode changes the key and synchronously clears the diagnostic,
|
||||||
|
attempts, temporary player override, and handoff position. Destroying the
|
||||||
|
component also ends the session; the same key in a later component is a new
|
||||||
|
session. Applying a different source under the same key, including another
|
||||||
|
catch-up programme, clears only the VOD handoff position; attempts and the
|
||||||
|
temporary player override remain available for the recovery session.
|
||||||
|
|
||||||
|
On a terminal failure, the current binding is accepted only when both its
|
||||||
|
generation and inline target still match. The current target becomes attempted,
|
||||||
|
the sanitized diagnostic is stored, and recommendations are reranked. The
|
||||||
|
`PlaybackBinding` contains exactly `{ generation, target }`. Changes to the
|
||||||
|
playback URL, headers, DRM, live/VOD mode, target, or reload generation are
|
||||||
|
instead correlated with a fieldless opaque `Symbol` application token. Source
|
||||||
|
applications also advance a second fieldless source-revision `Symbol` that
|
||||||
|
resets the VOD handoff position; target-only switches and Retry leave that
|
||||||
|
revision stable. None of these ownership objects embed source material, and
|
||||||
|
recovery ownership state never stores URLs, headers, DRM keys, error payloads,
|
||||||
|
or credentials. Each rendered web or Embedded MPV application captures the
|
||||||
|
nullable binding, both opaque tokens, and its live/VOD flag. A time update can
|
||||||
|
change the resume position only while that exact capture still owns the current
|
||||||
|
application, so a replaced source cannot repopulate cleared handoff state.
|
||||||
|
|
||||||
|
A separate fieldless intent token invalidates on each new source, target, or
|
||||||
|
reload intent. The application effect synchronizes the content session before
|
||||||
|
tracking that intent, so clearing a temporary player override is incorporated
|
||||||
|
into one application instead of scheduling a duplicate Electron header handoff.
|
||||||
|
Each application start clears both the diagnostic owner and backing signal,
|
||||||
|
making the prior diagnostic neither retained nor actionable before asynchronous
|
||||||
|
header setup completes. False or rejected current handoffs leave it clear, and
|
||||||
|
a stale success, false result, or rejection cannot erase a newer owned
|
||||||
|
diagnostic or mutate the newer application state.
|
||||||
|
|
||||||
|
Selecting a built-in recommendation records the target, immediately detaches
|
||||||
|
the diagnostic, and installs a temporary local override ahead of the host
|
||||||
|
override and saved player setting. The new engine receives the latest finite
|
||||||
|
VOD position as a best-effort resume point; live playback starts at the live
|
||||||
|
edge. Retry reloads the active target without clearing attempts. Selecting MPV
|
||||||
|
or VLC records the external target before emitting the existing fallback
|
||||||
|
request. The system does not infer whether the external process ultimately
|
||||||
|
played the stream.
|
||||||
|
|
||||||
|
No recommendation mutates `Settings.player` or another persisted setting.
|
||||||
|
Recovery recommendations never auto-switch a player or source and do not
|
||||||
|
replace the separate source-owner auto-failover feature. Attempts, overrides,
|
||||||
|
resume handoff, and diagnostics are session-local: there is no persistent
|
||||||
|
history, cross-session learning, correlation, or telemetry.
|
||||||
|
|
||||||
|
Only allowlisted public engine evidence crosses the structured sanitizers.
|
||||||
|
Provider/engine messages, request and response objects, arbitrary `error.data`
|
||||||
|
or `info`, bodies, URLs copied from error payloads, headers, DRM material, and
|
||||||
|
credentials do not enter recommendation evidence or recovery ownership state.
|
||||||
|
The active playback URL remains available only through the pre-existing
|
||||||
|
playback metadata needed by Retry, Copy URL, and eligible explicit
|
||||||
|
external-player actions.
|
||||||
|
|
||||||
|
`PortalPlayer.openExternalPlayback(playback, player)` remains the forced
|
||||||
|
external launch API. It sends the resolved playback payload to MPV or VLC
|
||||||
|
regardless of the current saved player setting, so a recommendation never
|
||||||
|
mutates preferences.
|
||||||
|
|
||||||
## External Player Arguments
|
## External Player Arguments
|
||||||
|
|
||||||
|
|||||||
@@ -994,8 +994,9 @@ player in settings.
|
|||||||
- `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the
|
- `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the
|
||||||
engine: lazy `import('shaka-player')` on first use (the module is a separate
|
engine: lazy `import('shaka-player')` on first use (the module is a separate
|
||||||
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
|
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
|
||||||
queue + generation guard against channel-switch races, and a Shaka `5.2.2`
|
queue + generation guard against channel-switch races. The DOM-free Shaka
|
||||||
public-error boundary. The boundary version-locks its allowlisted
|
`5.2.2` public-error boundary lives in `libs/playback/util`; it version-locks
|
||||||
|
its allowlisted
|
||||||
severity/category/code values, emits only structured sanitized
|
severity/category/code values, emits only structured sanitized
|
||||||
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
|
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
|
||||||
and treats a rejected load as terminal even if its final retry error retains
|
and treats a rejected load as terminal even if its final retry error retains
|
||||||
@@ -1007,9 +1008,11 @@ player in settings.
|
|||||||
unknown stage/failure. Public DASH text-parser codes likewise retain their
|
unknown stage/failure. Public DASH text-parser codes likewise retain their
|
||||||
exact `TEXT` category/code but keep stage/failure unknown. A failed
|
exact `TEXT` category/code but keep stage/failure unknown. A failed
|
||||||
`Player.isBrowserSupported()` preflight also stays unknown rather than
|
`Player.isBrowserSupported()` preflight also stays unknown rather than
|
||||||
claiming container incompatibility; clear DASH still offers configured
|
claiming container incompatibility. It carries only the enumerated,
|
||||||
external-player actions, while KODIPROP DRM keeps them disabled because
|
app-owned `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker — never a
|
||||||
those players never receive its keys.
|
producer recommendation hint or engine payload. 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
|
Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic
|
||||||
with a fixed safe detail string and without starting an engine.
|
with a fixed safe detail string and without starting an engine.
|
||||||
- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer:
|
- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer:
|
||||||
|
|||||||
@@ -73,6 +73,26 @@ in `libs/portal/shared/util`, while reusable collection views stay in
|
|||||||
`libs/portal/shared/ui`. Existing injectable or stateful services in a `util`
|
`libs/portal/shared/ui`. Existing injectable or stateful services in a `util`
|
||||||
path are legacy debt, not precedent for new placement.
|
path are legacy debt, not precedent for new placement.
|
||||||
|
|
||||||
|
Playback follows the same split: browser and Angular player integration stays
|
||||||
|
in `libs/ui/playback`, while DOM-free diagnostic contracts and classifiers live
|
||||||
|
in `libs/playback/util` and receive browser capability checks as explicit
|
||||||
|
probes.
|
||||||
|
|
||||||
|
`libs/playback/util` is the `playback-util` Nx project and is imported through
|
||||||
|
`@iptvnator/playback/util`. Its exact tags are `scope:shared`,
|
||||||
|
`domain:playback`, and `type:util`. It owns the public playback diagnostic,
|
||||||
|
structured engine-evidence, source/engine-family, target-capability,
|
||||||
|
content-session-key, and recovery-recommendation contracts and pure helpers.
|
||||||
|
Its public API is `libs/playback/util/src/index.ts`.
|
||||||
|
|
||||||
|
`playback-util` has no Angular, DOM, settings, storage, UI, or Electron IPC
|
||||||
|
ownership. Browser/player adapters collect public engine events and supply
|
||||||
|
explicit capability facts; `playback-util` classifies and ranks them without
|
||||||
|
inspecting runtime globals. As a `type:util` project it may depend only on
|
||||||
|
other utility projects, including shared interface contracts, while
|
||||||
|
`ui-playback` and feature hosts may depend on it to render and execute
|
||||||
|
session-local recovery actions.
|
||||||
|
|
||||||
## Project Tags
|
## Project Tags
|
||||||
|
|
||||||
Every Nx project keeps one tag from each family in `project.json`:
|
Every Nx project keeps one tag from each family in `project.json`:
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ Embedded MPV rendering and native-view bounds behavior remain documented in
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
The shared-controls foundation from PR #1148 now supports four runtime
|
The shared-controls foundation supports four runtime consumers and includes:
|
||||||
consumers and includes:
|
|
||||||
|
|
||||||
- the `PlayerController` contract, default state, and capability presets;
|
- the `PlayerController` contract, default state, and capability presets;
|
||||||
- the standalone `app-player-controls` presentation component and its
|
- the standalone `app-player-controls` presentation component and its
|
||||||
@@ -82,6 +81,25 @@ also includes a recording coordinator that correlates asynchronous snapshots
|
|||||||
with the active playback/session owner, serializes toggles, and cancels pending
|
with the active playback/session owner, serializes toggles, and cancels pending
|
||||||
ownership when the session, playback, engine, or component changes.
|
ownership when the session, playback, engine, or component changes.
|
||||||
|
|
||||||
|
## Diagnostics And Recovery Ownership Boundary
|
||||||
|
|
||||||
|
`PlayerController` remains a sibling of playback diagnostics and recovery
|
||||||
|
recommendations. It owns engine-neutral playback state, capabilities, and
|
||||||
|
commands for the shared controls; it does not classify errors, call
|
||||||
|
`recommendPlaybackRecovery()`, rank actions, track recovery attempts, choose a
|
||||||
|
temporary player, or own content-session policy.
|
||||||
|
|
||||||
|
Those pure contracts and policies live in `@iptvnator/playback/util` (Nx
|
||||||
|
project `playback-util`). `WebPlayerViewComponent` owns their in-memory,
|
||||||
|
session-local application. No diagnostic or recommendation state or command is
|
||||||
|
added to `PlayerController`.
|
||||||
|
|
||||||
|
The only controls-layer participation is interaction gating. While the sibling
|
||||||
|
diagnostic panel is visible, a web-player host disables shared surface and
|
||||||
|
keyboard ownership and exits only its own DOM fullscreen so the recovery
|
||||||
|
actions remain reachable. Clearing the diagnostic restores those paths; it
|
||||||
|
does not make the controls contract an owner of the recovery lifecycle.
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
Historically each playback engine owned both media integration and controls UI.
|
Historically each playback engine owned both media integration and controls UI.
|
||||||
@@ -306,7 +324,7 @@ playback diagnostic is visible: `WebPlayerViewComponent` passes
|
|||||||
components bind that value to `showControls` and
|
components bind that value to `showControls` and
|
||||||
`shortcutsEnabled`. If the active player shell owns DOM fullscreen, its host
|
`shortcutsEnabled`. If the active player shell owns DOM fullscreen, its host
|
||||||
exits fullscreen before hiding the controls so the sibling diagnostic banner
|
exits fullscreen before hiding the controls so the sibling diagnostic banner
|
||||||
and its retry/fallback actions remain visible; fullscreen owned by another
|
and its recovery actions remain visible; fullscreen owned by another
|
||||||
element is left untouched. Retrying playback or clearing the diagnostic
|
element is left untouched. Retrying playback or clearing the diagnostic
|
||||||
restores both interaction paths.
|
restores both interaction paths.
|
||||||
|
|
||||||
|
|||||||
@@ -1263,7 +1263,19 @@ Series inline playback behavior is shared across all three modes:
|
|||||||
blocks the action rather than binding regular, embedded, or lazy VOD content
|
blocks the action rather than binding regular, embedded, or lazy VOD content
|
||||||
to another mode; an exact canonical episode id still wins.
|
to another mode; an exact canonical episode id still wins.
|
||||||
|
|
||||||
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`.
|
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and uses
|
||||||
|
two episode identities. A pending playback request keeps an exact,
|
||||||
|
request-local provider command or episode ID so command rotation and hash
|
||||||
|
collisions reject stale completion. Once mounted, the component freezes only
|
||||||
|
the credential-free structural identity and session key (source, normalized
|
||||||
|
parent, mode, season key, season number, and episode number).
|
||||||
|
- The mounted structural identity is re-resolved against the current
|
||||||
|
`mappedSeasons()` for metadata, Previous/Next, and autoplay. Same-owner
|
||||||
|
provider and TMDB refreshes therefore expose current episode objects and
|
||||||
|
commands without remounting the player; if the episode is missing or its
|
||||||
|
structural coordinates are ambiguous, those surfaces and commands fail
|
||||||
|
closed. Provider commands and IDs are never retained in mounted session
|
||||||
|
state.
|
||||||
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
|
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
|
||||||
- Embedded players receive previous/next episode state for the current season only.
|
- Embedded players receive previous/next episode state for the current season only.
|
||||||
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
|
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,490 @@
|
|||||||
|
# Playback Recovery Recommendations
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Issue #1159 started as a request for more accurate playback errors and useful
|
||||||
|
guidance when a stream fails in one player but may work in another. The browser
|
||||||
|
players now emit structured, fail-closed diagnostics for native media,
|
||||||
|
hls.js, Video.js/VHS, Shaka Player, and mpegts.js. Those boundaries preserve
|
||||||
|
public engine evidence without retaining provider messages, credentials, or
|
||||||
|
arbitrary error payloads.
|
||||||
|
|
||||||
|
The remaining recovery policy is still embedded in the UI. Every
|
||||||
|
`PlaybackDiagnostic` carries an `externalFallbackRecommended` boolean, and
|
||||||
|
`WebPlayerViewComponent` converts that flag into a fixed MPV/VLC section while
|
||||||
|
always showing Retry and every available alternative-source row. This cannot
|
||||||
|
explain when a distinct built-in engine is the better next step, rank recovery
|
||||||
|
actions by evidence, or avoid recommending a target already tried during the
|
||||||
|
same playback session.
|
||||||
|
|
||||||
|
This design introduces one player-neutral recommendation layer. It ranks a
|
||||||
|
small set of explicit user actions from structured diagnostic evidence and
|
||||||
|
runtime capabilities. It does not perform automatic failover.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Convert structured playback diagnostics into deterministic, evidence-based
|
||||||
|
recovery recommendations.
|
||||||
|
- Rank one primary recommendation and at most two secondary recommendations.
|
||||||
|
- Recommend a distinct built-in web engine only when it can plausibly change
|
||||||
|
the failing playback path.
|
||||||
|
- Keep MPV/VLC recommendations for source formats and browser restrictions
|
||||||
|
where an external player can receive the required playback data.
|
||||||
|
- Keep network, HTTP, and unknown failures fail-closed: prefer Retry or an
|
||||||
|
alternative source instead of guessing that another decoder will help.
|
||||||
|
- Let a user temporarily try a recommended built-in player for the current
|
||||||
|
content without changing the saved player setting.
|
||||||
|
- Remember attempted targets only for the current in-memory content session so
|
||||||
|
repeated failures produce a more useful next recommendation.
|
||||||
|
- Establish a pure playback-domain boundary that future structured native
|
||||||
|
diagnostics and target capabilities can extend.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Automatically switching players or sources.
|
||||||
|
- Changing the persisted player setting.
|
||||||
|
- Persistent diagnostic history, cross-session learning, telemetry, or
|
||||||
|
correlation.
|
||||||
|
- A retry scheduler, health monitor, or full failover orchestrator.
|
||||||
|
- Moving diagnostics or recommendation policy into `PlayerController`.
|
||||||
|
- Replacing the existing multi-source auto-failover behavior.
|
||||||
|
- Recommending Embedded MPV as an inline target before it emits an equivalent
|
||||||
|
structured diagnostic lifecycle.
|
||||||
|
- Detecting whether an external MPV/VLC process ultimately played the stream.
|
||||||
|
- AirPlay, Cast, Document Picture-in-Picture, or remote-device capabilities.
|
||||||
|
- Redesigning the diagnostic overlay or the player controls.
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
### Pure ranked policy layer (selected)
|
||||||
|
|
||||||
|
Create a pure playback-domain function that receives structured evidence,
|
||||||
|
source context, target capabilities, and session-local attempts, then returns
|
||||||
|
an ordered list of typed recommendations. `WebPlayerViewComponent` remains the
|
||||||
|
owner of UI state and user-triggered switching.
|
||||||
|
|
||||||
|
This keeps classification, policy, and rendering independently testable. It
|
||||||
|
also makes future evidence sources additive without coupling playback engines
|
||||||
|
to Angular or turning the controls contract into an error orchestrator.
|
||||||
|
|
||||||
|
### Extend diagnostic booleans
|
||||||
|
|
||||||
|
Add fields such as `retryRecommended`, `builtInFallbackRecommended`, and
|
||||||
|
`alternativeSourceRecommended` to `PlaybackDiagnostic` and let the component
|
||||||
|
choose the buttons. This looks small but duplicates ranking logic, makes
|
||||||
|
diagnostic producers own runtime/UI policy, and cannot cleanly account for
|
||||||
|
attempted targets. It is rejected.
|
||||||
|
|
||||||
|
### Full failover orchestrator
|
||||||
|
|
||||||
|
Introduce a state machine that launches players, observes outcomes, mutates
|
||||||
|
preferences, and manages retries. This could eventually support automatic
|
||||||
|
recovery, but it is materially larger and would blur the existing ownership of
|
||||||
|
source selection, settings, and engine lifecycle. It is rejected for v1.
|
||||||
|
|
||||||
|
## Architecture And Ownership
|
||||||
|
|
||||||
|
### New playback utility project
|
||||||
|
|
||||||
|
Create `libs/playback/util` as the pure playback-domain boundary:
|
||||||
|
|
||||||
|
- Nx project name: `playback-util`;
|
||||||
|
- path alias: `@iptvnator/playback/util`;
|
||||||
|
- tags: `scope:shared`, `domain:playback`, `type:util`;
|
||||||
|
- public exports through `libs/playback/util/src/index.ts` only.
|
||||||
|
|
||||||
|
The project contains contracts, evidence normalization, diagnostic
|
||||||
|
classification, source/engine-family mapping, target-capability contracts, and
|
||||||
|
the recommendation policy. It has no Angular, DOM, storage, settings-store, or
|
||||||
|
Electron IPC dependency. As a `type:util` project it depends only on other
|
||||||
|
utility projects, including the existing shared interfaces required for
|
||||||
|
external-player names and resolved playback metadata.
|
||||||
|
|
||||||
|
Move the pure diagnostic model and classifiers from
|
||||||
|
`libs/ui/playback/src/lib/playback-diagnostics/` into this project. Move only
|
||||||
|
the pure Shaka evidence/classifier helpers from the Shaka area; the Shaka
|
||||||
|
engine and video session remain in `ui-playback`. Engine components import the
|
||||||
|
new alias instead of reaching back into UI-owned diagnostic paths.
|
||||||
|
|
||||||
|
For one compatibility window, `@iptvnator/ui/playback` re-exports the public
|
||||||
|
diagnostic contracts from `@iptvnator/playback/util`. Existing consumers can
|
||||||
|
therefore migrate without deep imports or a flag day. New code imports the
|
||||||
|
new alias directly, and the compatibility export can be removed in a later
|
||||||
|
cleanup PR after all consumers have migrated.
|
||||||
|
|
||||||
|
### UI owner
|
||||||
|
|
||||||
|
`WebPlayerViewComponent` owns:
|
||||||
|
|
||||||
|
- the current content-session key;
|
||||||
|
- the temporary built-in player override;
|
||||||
|
- the set of targets attempted in the current session;
|
||||||
|
- the most recent VOD position used for a best-effort engine handoff;
|
||||||
|
- the binding generation that rejects stale engine events;
|
||||||
|
- mapping ranked recommendations to translated, accessible UI;
|
||||||
|
- existing external-player and alternative-source outputs.
|
||||||
|
|
||||||
|
UI-specific translation keys, formatting helpers, Material components, styles,
|
||||||
|
and diagnostic action cards remain in `libs/ui/playback`.
|
||||||
|
|
||||||
|
### Controls contract
|
||||||
|
|
||||||
|
`PlayerController` remains the engine-neutral state, command, and capability
|
||||||
|
contract used by shared controls. Diagnostics and recovery policy stay as a
|
||||||
|
sibling layer. No recommendation state or commands are added to
|
||||||
|
`PlayerController`.
|
||||||
|
|
||||||
|
## Recommendation Contracts
|
||||||
|
|
||||||
|
The utility layer exposes a pure
|
||||||
|
`recommendPlaybackRecovery(context)` function and a discriminated
|
||||||
|
recommendation union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PlaybackRecommendation =
|
||||||
|
| {
|
||||||
|
readonly action: 'retry';
|
||||||
|
readonly reason: PlaybackRecommendationReason;
|
||||||
|
readonly priority: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly action: 'alternative-source';
|
||||||
|
readonly reason: PlaybackRecommendationReason;
|
||||||
|
readonly priority: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly action: 'player';
|
||||||
|
readonly target: PlaybackRecommendationTarget;
|
||||||
|
readonly reason: PlaybackRecommendationReason;
|
||||||
|
readonly priority: 'primary' | 'secondary';
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`PlaybackRecommendationTarget` is the union of the three built-in diagnostic
|
||||||
|
players (`videojs`, `html5`, `artplayer`) and the managed external targets
|
||||||
|
(`mpv`, `vlc`). Embedded MPV is deliberately absent in v1.
|
||||||
|
|
||||||
|
Reasons are stable app-owned values, not user-visible prose:
|
||||||
|
|
||||||
|
- `retry-transient-failure`;
|
||||||
|
- `retry-unknown-failure`;
|
||||||
|
- `alternative-source-available`;
|
||||||
|
- `different-engine-family`;
|
||||||
|
- `external-codec-or-container-support`;
|
||||||
|
- `external-browser-access`;
|
||||||
|
- `compatible-drm-path`.
|
||||||
|
|
||||||
|
Angular maps those values to translations. The model has no numeric confidence
|
||||||
|
score: the evidence matrix and list order are the contract.
|
||||||
|
|
||||||
|
The policy input contains:
|
||||||
|
|
||||||
|
- the sanitized `PlaybackDiagnostic`;
|
||||||
|
- the active target;
|
||||||
|
- the session-local attempted-target set;
|
||||||
|
- available target capabilities and their engine family for this source;
|
||||||
|
- source kind, live/VOD state, and DRM/external-transfer context;
|
||||||
|
- the count of alternative sources.
|
||||||
|
|
||||||
|
The policy output is deterministic, contains at most three entries, and has at
|
||||||
|
most one primary entry. If the list is non-empty, its first entry is primary
|
||||||
|
and every later entry is secondary. Current, unavailable, incompatible, and
|
||||||
|
already attempted targets are excluded before ranking.
|
||||||
|
|
||||||
|
`PlaybackDiagnostic.externalFallbackRecommended` is removed. Diagnostic
|
||||||
|
producers report evidence and classification only; the recommendation policy
|
||||||
|
decides which actions are safe and useful in the current runtime.
|
||||||
|
|
||||||
|
Copy URL and Technical details are utilities rather than recommendations and
|
||||||
|
are therefore not part of this union.
|
||||||
|
|
||||||
|
## Capability And Source Context
|
||||||
|
|
||||||
|
The caller supplies explicit capability facts instead of asking the policy to
|
||||||
|
inspect settings, the DOM, or Electron globals. Each player target records
|
||||||
|
whether it is available for the current runtime/source and its effective
|
||||||
|
engine family.
|
||||||
|
|
||||||
|
The source context records whether the playback payload can be transferred to
|
||||||
|
an external player. ClearKey/KODIPROP playback is always non-transferable in
|
||||||
|
v1: MPV and VLC are excluded because the current external-player request does
|
||||||
|
not carry an equivalent DRM contract. Header-bearing portal playback is
|
||||||
|
transferable only when its existing host-specific external launch path forwards
|
||||||
|
the required resolved playback fields; otherwise the host capability marks it
|
||||||
|
non-transferable. The policy never infers transferability from an error message
|
||||||
|
or URL substring.
|
||||||
|
|
||||||
|
## Engine-Family Matrix
|
||||||
|
|
||||||
|
Recommendations change engines, not merely skins:
|
||||||
|
|
||||||
|
| Source path | Built-in engine families | v1 built-in alternative |
|
||||||
|
| -------------------------------- | ----------------------------------- | ----------------------- |
|
||||||
|
| HLS in Video.js | Video.js/VHS | HTML5 using hls.js |
|
||||||
|
| HLS in HTML5 or ArtPlayer | hls.js | Video.js/VHS |
|
||||||
|
| MPEG-TS in any web player | mpegts.js | None |
|
||||||
|
| DASH in HTML5 or ArtPlayer | Shaka Player | None |
|
||||||
|
| DASH in Video.js | not a supported recommendation path | None |
|
||||||
|
| Native MP4/MKV in any web player | browser media element | None |
|
||||||
|
|
||||||
|
Only one target represents a distinct engine family in the ranked list. For a
|
||||||
|
Video.js HLS failure, HTML5 is the canonical hls.js target; ArtPlayer is not a
|
||||||
|
second independent engine recommendation. If that target is unavailable or
|
||||||
|
already attempted, the policy proceeds to an external target rather than
|
||||||
|
presenting a duplicate engine-family guess.
|
||||||
|
|
||||||
|
MPEG-TS does not recommend another built-in player because all three use the
|
||||||
|
same mpegts.js engine. DASH does not recommend Video.js in v1 because it is not
|
||||||
|
an equivalent supported Shaka path. Native media does not recommend another
|
||||||
|
web player because all three ultimately depend on the same browser decoder.
|
||||||
|
|
||||||
|
## Policy Matrix
|
||||||
|
|
||||||
|
The policy builds the following exact candidate order, then filters unavailable,
|
||||||
|
current, incompatible, and attempted targets and truncates the result to three
|
||||||
|
entries. “Alternative source” is omitted when its count is zero.
|
||||||
|
|
||||||
|
| Evidence | Ordered candidates | Forbidden guess |
|
||||||
|
| ----------------------------------------- | --------------------------------------------------------- | -------------------------------- |
|
||||||
|
| HTTP, timeout, or generic network failure | Retry → Alternative source | Any player change |
|
||||||
|
| Unknown playback error | Retry → Alternative source | Any player change |
|
||||||
|
| Browser access/CORS/CSP-class evidence | MPV → VLC → Alternative source | Another browser player |
|
||||||
|
| Unsupported codec or container | MPV → VLC → Alternative source | Another browser player |
|
||||||
|
| Media/decode/engine processing failure | Distinct built-in family → MPV → VLC → Alternative source | Same engine family |
|
||||||
|
| DRM/encryption failure | Compatible built-in path → Alternative source → MPV → VLC | Non-transferable external target |
|
||||||
|
|
||||||
|
Network and unknown cases never claim that another decoder is likely to fix
|
||||||
|
the failure. For DRM, every candidate still passes explicit target and payload
|
||||||
|
capability checks; ClearKey/KODIPROP therefore never reaches MPV or VLC.
|
||||||
|
|
||||||
|
For external actions, MPV precedes VLC to preserve the existing primary
|
||||||
|
fallback. If MPV is unavailable or attempted, VLC may become primary. When no
|
||||||
|
ranked recommendation survives, the overlay still exposes Copy URL and
|
||||||
|
Technical details instead of fabricating a guess.
|
||||||
|
|
||||||
|
## Playback Session Lifecycle
|
||||||
|
|
||||||
|
### Stable content identity
|
||||||
|
|
||||||
|
Every `WebPlayerViewComponent` host supplies a required
|
||||||
|
`playbackSessionKey`. The key identifies canonical logical content, not the
|
||||||
|
current URL or selected provider copy:
|
||||||
|
|
||||||
|
- an M3U live channel key uses playlist/source identity plus channel identity;
|
||||||
|
- Xtream and Stalker live keys use provider/account plus content identity;
|
||||||
|
- movie keys use the owning route/catalog's original source plus movie
|
||||||
|
identity;
|
||||||
|
- episode keys use the owning series route's original source and series plus
|
||||||
|
season and episode identity.
|
||||||
|
|
||||||
|
An alternative source's `playback.contentInfo` is provider-scoped playback and
|
||||||
|
resume metadata, not recovery-session identity. Source-owning route and series
|
||||||
|
hosts derive the key before passing it through the inline player, so replacing
|
||||||
|
that playback payload cannot replace the recovery session.
|
||||||
|
|
||||||
|
Retry and alternative sources for the same channel, movie, or episode keep the
|
||||||
|
same key. Selecting a different channel, movie, or episode changes it. A key
|
||||||
|
change synchronously clears the attempted-target set, temporary override,
|
||||||
|
handoff position, and visible diagnostic, then advances the binding generation
|
||||||
|
so every callback from the previous content becomes stale.
|
||||||
|
|
||||||
|
The state is component-local, so a same-content source change must retain the
|
||||||
|
`WebPlayerViewComponent` instance and update its inputs. Destroying that
|
||||||
|
component ends the recovery session even if a later instance receives the same
|
||||||
|
key. Host tests enforce retained identity for supported multi-source flows.
|
||||||
|
|
||||||
|
### Failure and reranking
|
||||||
|
|
||||||
|
When the active web engine emits a terminal diagnostic, the component:
|
||||||
|
|
||||||
|
1. verifies that the event belongs to the current binding generation and
|
||||||
|
active target;
|
||||||
|
2. records the current target as attempted;
|
||||||
|
3. stores the diagnostic;
|
||||||
|
4. emits the existing `playbackFailed` output for source-owner behavior;
|
||||||
|
5. runs the pure recommendation policy with current capabilities and attempts.
|
||||||
|
|
||||||
|
An event from a destroyed or replaced engine is ignored and cannot overwrite
|
||||||
|
the new session's UI.
|
||||||
|
|
||||||
|
### Trying a built-in target
|
||||||
|
|
||||||
|
When the user selects a built-in recommendation, the component records that
|
||||||
|
target as attempted, captures the latest VOD position, clears the diagnostic,
|
||||||
|
and applies a local override that outranks the host override and saved setting
|
||||||
|
for this content session. The player host is recreated for the chosen target.
|
||||||
|
No storage or settings-store mutation occurs.
|
||||||
|
|
||||||
|
For VOD and episodes, the new engine receives the latest finite playback
|
||||||
|
position as a best-effort start time. Live playback restarts at the live edge.
|
||||||
|
If the target fails, the new diagnostic is reranked with both attempted
|
||||||
|
targets excluded. If the target proves unavailable before attachment, the
|
||||||
|
component keeps it attempted and immediately reranks without throwing.
|
||||||
|
|
||||||
|
Retry keeps the same content session and attempts, clears the current
|
||||||
|
diagnostic, and reloads the active target. An alternative-source request also
|
||||||
|
keeps the same content session and attempts; the source-owning host changes the
|
||||||
|
resolved playback URL without resetting the recommendation history.
|
||||||
|
|
||||||
|
### External targets
|
||||||
|
|
||||||
|
MPV/VLC actions continue to emit the existing `PlaybackFallbackRequest` with
|
||||||
|
the full `ResolvedPortalPlayback` and diagnostic. The selected external target
|
||||||
|
is recorded as attempted before emission so returning to the overlay can
|
||||||
|
promote the next useful action. v1 does not infer launch or playback success
|
||||||
|
and does not persist the result.
|
||||||
|
|
||||||
|
## User Interface
|
||||||
|
|
||||||
|
Keep the existing diagnostic overlay and its badge, headline, description,
|
||||||
|
HTTP/container/codec metadata, codec hint, and `role="status"`. This feature
|
||||||
|
changes the action hierarchy, not the overall visual language.
|
||||||
|
|
||||||
|
The overlay renders:
|
||||||
|
|
||||||
|
1. one prominent primary recommendation card;
|
||||||
|
2. at most two compact secondary recommendation cards;
|
||||||
|
3. the always-available Copy URL and Technical details utilities.
|
||||||
|
|
||||||
|
An alternative-source recommendation renders the existing
|
||||||
|
`VodSourceRow`-based block and occupies one recommendation slot regardless of
|
||||||
|
the number of visible source rows. Its existing bounded row count and “more
|
||||||
|
sources” affordance remain.
|
||||||
|
|
||||||
|
Retry moves out of the unconditional utility row and appears only when the
|
||||||
|
policy ranks it. Preserve `playback-retry`, `playback-fallback-mpv`, and
|
||||||
|
`playback-fallback-vlc`. Built-in actions use
|
||||||
|
`playback-recommendation-videojs`, `playback-recommendation-html5`, and
|
||||||
|
`playback-recommendation-artplayer`. Their copy explicitly says that the
|
||||||
|
change is temporary and does not alter the saved setting.
|
||||||
|
|
||||||
|
All actions are native buttons with visible keyboard focus. No autofocus or
|
||||||
|
focus trap is added to the status overlay. A pending switch disables repeated
|
||||||
|
activation until the current operation settles. At narrow widths cards stack
|
||||||
|
vertically, content uses `min-width: 0`, and long translated copy wraps without
|
||||||
|
forcing horizontal overflow. Existing light/dark application tokens remain
|
||||||
|
the styling source.
|
||||||
|
|
||||||
|
## Error Handling And Safety
|
||||||
|
|
||||||
|
- The recommendation function is total and does not throw for missing,
|
||||||
|
unknown, or future diagnostic evidence.
|
||||||
|
- Incomplete or contradictory context fails closed to Retry and an available
|
||||||
|
alternative source; it never upgrades uncertainty into a player claim.
|
||||||
|
- Unknown engine families cannot produce built-in recommendations.
|
||||||
|
- Unavailable or non-transferable targets are filtered before rendering.
|
||||||
|
- A binding generation plus exact target identity rejects stale diagnostic,
|
||||||
|
async header, and delayed player events after a switch.
|
||||||
|
- Switching is single-flight from the UI perspective; double clicks cannot
|
||||||
|
mount two targets or corrupt the attempt set.
|
||||||
|
- Session state remains memory-only and contains stable target IDs and a
|
||||||
|
numeric playback position, never raw error payloads or credentials.
|
||||||
|
- Existing structured-evidence redaction guarantees remain unchanged after
|
||||||
|
moving the pure files into `playback-util`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use test-driven development.
|
||||||
|
|
||||||
|
### Pure policy and boundary tests
|
||||||
|
|
||||||
|
- Add exhaustive table-driven tests in `playback-util` for every policy row,
|
||||||
|
source engine family, priority transition, availability combination, attempt
|
||||||
|
exclusion, DRM transfer constraint, and three-result limit.
|
||||||
|
- Prove network and unknown diagnostics never recommend a player.
|
||||||
|
- Prove MPEG-TS, native media, and Shaka/DASH never offer a same-engine browser
|
||||||
|
alternative.
|
||||||
|
- Prove HLS offers only one distinct built-in engine family.
|
||||||
|
- Prove ClearKey/KODIPROP excludes MPV/VLC.
|
||||||
|
- Preserve and migrate all diagnostic/evidence contract tests, including
|
||||||
|
package-version locks and redaction assertions.
|
||||||
|
- Add a module-boundary assertion that the new utility project has no Angular,
|
||||||
|
DOM, Electron, storage, or UI dependency.
|
||||||
|
|
||||||
|
### Component and host tests
|
||||||
|
|
||||||
|
Extend `WebPlayerViewComponent` tests to cover:
|
||||||
|
|
||||||
|
- deterministic primary/secondary rendering and stable test IDs;
|
||||||
|
- a temporary built-in switch without settings mutation;
|
||||||
|
- attempted-target exclusion and reranking after another failure;
|
||||||
|
- Retry and alternative-source preservation of session state;
|
||||||
|
- complete reset on `playbackSessionKey` change;
|
||||||
|
- VOD position handoff and live-edge behavior;
|
||||||
|
- stale generation/target event rejection;
|
||||||
|
- single-flight action handling and unavailable-target reranking;
|
||||||
|
- DRM and runtime-capability filtering;
|
||||||
|
- keyboard focus, disabled state, and narrow-layout structure.
|
||||||
|
|
||||||
|
Update the closest M3U, Xtream, Stalker, unified live, and portal inline-player
|
||||||
|
specs to prove that each host supplies a stable content key and preserves it
|
||||||
|
while switching sources for the same content.
|
||||||
|
|
||||||
|
### E2E coverage
|
||||||
|
|
||||||
|
- Add a deterministic web Playwright case backed by repository-owned media
|
||||||
|
fixtures: trigger an engine-specific HLS failure, assert the distinct
|
||||||
|
built-in recommendation, activate it, verify the new player host mounts, and
|
||||||
|
verify the persisted player setting remains unchanged.
|
||||||
|
- Extend the existing web and Electron DASH/ClearKey E2E cases to prove that
|
||||||
|
non-transferable DRM never exposes MPV/VLC recommendations.
|
||||||
|
- Preserve or add an Electron case where an eligible browser/container failure
|
||||||
|
still exposes the existing managed MPV/VLC actions and emits the expected
|
||||||
|
external fallback request.
|
||||||
|
|
||||||
|
Do not add a production-only diagnostic injection hook for E2E. If a browser
|
||||||
|
cannot deterministically expose the chosen HLS engine event from a bounded
|
||||||
|
fixture, the implementation plan must select another public, deterministic
|
||||||
|
engine event and retain component-level coverage of the exact policy branch.
|
||||||
|
|
||||||
|
### Validation ladder
|
||||||
|
|
||||||
|
Run at minimum:
|
||||||
|
|
||||||
|
- `pnpm nx test playback-util`;
|
||||||
|
- `pnpm nx lint playback-util`;
|
||||||
|
- `pnpm nx test ui-playback`;
|
||||||
|
- `pnpm nx lint ui-playback`;
|
||||||
|
- focused tests for every host whose session-key binding changes;
|
||||||
|
- the targeted web and Electron E2E atomized targets discovered from Nx;
|
||||||
|
- affected application typecheck/build targets;
|
||||||
|
- `pnpm run i18n:validate`;
|
||||||
|
- `pnpm run release:notes:validate`;
|
||||||
|
- Nx module-boundary and project-discovery checks.
|
||||||
|
|
||||||
|
The implementation plan must use the exact target names reported by the fresh
|
||||||
|
workspace rather than inventing commands.
|
||||||
|
|
||||||
|
## Documentation And Release Note
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- `docs/architecture/embedded-inline-playback.md` with the canonical
|
||||||
|
diagnostic-to-recommendation flow and engine-family matrix;
|
||||||
|
- `docs/architecture/nx-workspace-boundaries.md` with the new
|
||||||
|
`playback-util` project and alias;
|
||||||
|
- `AGENTS.md` and `CLAUDE.md` with the new shared playback recommendation
|
||||||
|
ownership and session behavior.
|
||||||
|
|
||||||
|
Update `docs/architecture/player-controls-contract.md` with a short boundary
|
||||||
|
clarification that recovery recommendations remain outside the controls
|
||||||
|
contract; the controls API itself does not change.
|
||||||
|
|
||||||
|
Add a `fix(playback)` note under `.changes/` because users gain new recovery
|
||||||
|
actions and temporary built-in player switching. The note describes the user
|
||||||
|
outcome, not the internal policy extraction.
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
The boundary is intentionally extensible where additional evidence changes a
|
||||||
|
decision:
|
||||||
|
|
||||||
|
- structured Embedded MPV/native-view diagnostics and capabilities;
|
||||||
|
- explicit external-launch failure results;
|
||||||
|
- richer per-target codec, container, DRM, and header-transfer capabilities;
|
||||||
|
- session-local outcome adaptation when a target starts successfully;
|
||||||
|
- source health facts supplied by an existing source owner.
|
||||||
|
|
||||||
|
Persistent history, telemetry-driven ranking, and automatic mutation of player
|
||||||
|
settings remain low-value or high-risk until a concrete user problem justifies
|
||||||
|
them.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "playback-util",
|
||||||
|
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/playback/util/src",
|
||||||
|
"prefix": "lib",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": ["scope:shared", "domain:playback", "type:util"],
|
||||||
|
"targets": {
|
||||||
|
"test": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||||
|
"options": {
|
||||||
|
"command": [
|
||||||
|
"node",
|
||||||
|
"./tools/testing/run-web-esm-lib-tests.mjs",
|
||||||
|
"libs/playback/util/src"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"NODE_OPTIONS": "--experimental-vm-modules"
|
||||||
|
},
|
||||||
|
"forwardAllArgs": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './lib/diagnostics/playback-diagnostics.util';
|
||||||
|
export * from './lib/diagnostics/shaka-error-classifier';
|
||||||
|
export * from './lib/diagnostics/shaka-error-contract';
|
||||||
|
export * from './lib/diagnostics/shaka-error-lifecycle';
|
||||||
|
export type * from './lib/diagnostics/shaka-error.types';
|
||||||
|
export * from './lib/playback-recommendation.model';
|
||||||
|
export * from './lib/playback-recommendation-policy';
|
||||||
|
export * from './lib/playback-session-key';
|
||||||
|
export * from './lib/playback-target-capabilities';
|
||||||
+1
-1
@@ -154,7 +154,7 @@ describe('HLS playback evidence', () => {
|
|||||||
url: `https://provider.example/context?token=${secret}`,
|
url: `https://provider.example/context?token=${secret}`,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
headers: { Authorization: `Bearer ${secret}` },
|
headers: { Authorization: `Bearer ${secret}` },
|
||||||
} as ErrorData['context'],
|
} as unknown as ErrorData['context'],
|
||||||
networkDetails: {
|
networkDetails: {
|
||||||
responseURL: `https://provider.example/xhr?token=${secret}`,
|
responseURL: `https://provider.example/xhr?token=${secret}`,
|
||||||
responseText: secret,
|
responseText: secret,
|
||||||
File renamed without changes.
File renamed without changes.
+43
-85
@@ -29,8 +29,7 @@ describe('mpegts.js playback evidence', () => {
|
|||||||
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
|
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
|
||||||
});
|
});
|
||||||
expect(mpegts.ErrorDetails).toEqual({
|
expect(mpegts.ErrorDetails).toEqual({
|
||||||
NETWORK_EXCEPTION:
|
NETWORK_EXCEPTION: MpegTsPlaybackEngineDetails.NetworkException,
|
||||||
MpegTsPlaybackEngineDetails.NetworkException,
|
|
||||||
NETWORK_STATUS_CODE_INVALID:
|
NETWORK_STATUS_CODE_INVALID:
|
||||||
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
|
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
|
||||||
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
|
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
|
||||||
@@ -46,47 +45,25 @@ describe('mpegts.js playback evidence', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
['NetworkError', 'HttpStatusCodeInvalid', 'loader', 'http'],
|
||||||
'NetworkError',
|
|
||||||
'HttpStatusCodeInvalid',
|
|
||||||
'loader',
|
|
||||||
'http',
|
|
||||||
],
|
|
||||||
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
|
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
|
||||||
['NetworkError', 'Exception', 'loader', 'network'],
|
['NetworkError', 'Exception', 'loader', 'network'],
|
||||||
[
|
['NetworkError', 'UnrecoverableEarlyEof', 'loader', 'truncated-stream'],
|
||||||
'NetworkError',
|
|
||||||
'UnrecoverableEarlyEof',
|
|
||||||
'loader',
|
|
||||||
'truncated-stream',
|
|
||||||
],
|
|
||||||
['MediaError', 'FormatError', 'demux', 'format'],
|
['MediaError', 'FormatError', 'demux', 'format'],
|
||||||
['MediaError', 'FormatUnsupported', 'demux', 'format'],
|
['MediaError', 'FormatUnsupported', 'demux', 'format'],
|
||||||
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
|
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
|
||||||
[
|
['MediaError', 'MediaMSEError', 'media-source', 'media-source'],
|
||||||
'MediaError',
|
])('maps %s + %s to %s/%s', (engineType, engineDetails, stage, failure) => {
|
||||||
'MediaMSEError',
|
expect(
|
||||||
'media-source',
|
createMpegTsPlaybackEvidence(engineType, engineDetails, undefined)
|
||||||
'media-source',
|
).toEqual({
|
||||||
],
|
engineType,
|
||||||
])(
|
engineDetails,
|
||||||
'maps %s + %s to %s/%s',
|
disposition: 'terminal',
|
||||||
(engineType, engineDetails, stage, failure) => {
|
stage,
|
||||||
expect(
|
failure,
|
||||||
createMpegTsPlaybackEvidence(
|
});
|
||||||
engineType,
|
});
|
||||||
engineDetails,
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
).toEqual({
|
|
||||||
engineType,
|
|
||||||
engineDetails,
|
|
||||||
disposition: 'terminal',
|
|
||||||
stage,
|
|
||||||
failure,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['OtherError', 'CodecUnsupported'],
|
['OtherError', 'CodecUnsupported'],
|
||||||
@@ -96,15 +73,18 @@ describe('mpegts.js playback evidence', () => {
|
|||||||
['NetworkError', 'httpstatuscodeinvalid'],
|
['NetworkError', 'httpstatuscodeinvalid'],
|
||||||
[{ type: 'NetworkError' }, 'Exception'],
|
[{ type: 'NetworkError' }, 'Exception'],
|
||||||
['MediaError', { details: 'FormatError' }],
|
['MediaError', { details: 'FormatError' }],
|
||||||
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
|
])(
|
||||||
const evidence = createMpegTsPlaybackEvidence(type, details, {
|
'keeps inconsistent or malformed %p + %p evidence unknown',
|
||||||
code: 503,
|
(type, details) => {
|
||||||
});
|
const evidence = createMpegTsPlaybackEvidence(type, details, {
|
||||||
|
code: 503,
|
||||||
|
});
|
||||||
|
|
||||||
expect(evidence.stage).toBe('unknown');
|
expect(evidence.stage).toBe('unknown');
|
||||||
expect(evidence.failure).toBe('unknown');
|
expect(evidence.failure).toBe('unknown');
|
||||||
expect(evidence.httpStatus).toBeUndefined();
|
expect(evidence.httpStatus).toBeUndefined();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it.each([399, 600, 404.5, '404', null, undefined])(
|
it.each([399, 600, 404.5, '404', null, undefined])(
|
||||||
'rejects invalid HTTP status %p',
|
'rejects invalid HTTP status %p',
|
||||||
@@ -163,45 +143,24 @@ describe('mpegts.js playback evidence', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
|
['NetworkError', 'HttpStatusCodeInvalid', 'network-error'],
|
||||||
['NetworkError', 'ConnectingTimeout', 'network-error', false],
|
['NetworkError', 'ConnectingTimeout', 'network-error'],
|
||||||
['NetworkError', 'Exception', 'network-error', false],
|
['NetworkError', 'Exception', 'network-error'],
|
||||||
[
|
['NetworkError', 'UnrecoverableEarlyEof', 'media-decode-error'],
|
||||||
'NetworkError',
|
['MediaError', 'FormatError', 'media-decode-error'],
|
||||||
'UnrecoverableEarlyEof',
|
['MediaError', 'FormatUnsupported', 'unsupported-container'],
|
||||||
'media-decode-error',
|
['MediaError', 'CodecUnsupported', 'unsupported-codec'],
|
||||||
true,
|
['MediaError', 'MediaMSEError', 'media-decode-error'],
|
||||||
],
|
['OtherError', 'Exception', 'unknown-playback-error'],
|
||||||
['MediaError', 'FormatError', 'media-decode-error', true],
|
])('classifies %s + %s as %s', (type, details, code) => {
|
||||||
[
|
const issue = classifyMpegTsPlaybackIssue(
|
||||||
'MediaError',
|
createMpegTsPlaybackEvidence(type, details, undefined),
|
||||||
'FormatUnsupported',
|
METADATA
|
||||||
'unsupported-container',
|
);
|
||||||
true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'MediaError',
|
|
||||||
'CodecUnsupported',
|
|
||||||
'unsupported-codec',
|
|
||||||
true,
|
|
||||||
],
|
|
||||||
['MediaError', 'MediaMSEError', 'media-decode-error', true],
|
|
||||||
['OtherError', 'Exception', 'unknown-playback-error', false],
|
|
||||||
])(
|
|
||||||
'classifies %s + %s as %s with fallback=%s',
|
|
||||||
(type, details, code, externalFallbackRecommended) => {
|
|
||||||
const issue = classifyMpegTsPlaybackIssue(
|
|
||||||
createMpegTsPlaybackEvidence(type, details, undefined),
|
|
||||||
METADATA
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(issue.code).toBe(code);
|
expect(issue.code).toBe(code);
|
||||||
expect(issue.externalFallbackRecommended).toBe(
|
expect(issue.details).toBeUndefined();
|
||||||
externalFallbackRecommended
|
});
|
||||||
);
|
|
||||||
expect(issue.details).toBeUndefined();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it('preserves an exact HTTP failure without retaining provider details', () => {
|
it('preserves an exact HTTP failure without retaining provider details', () => {
|
||||||
const secret = 'mpegts-http-secret';
|
const secret = 'mpegts-http-secret';
|
||||||
@@ -230,7 +189,6 @@ describe('mpegts.js playback evidence', () => {
|
|||||||
failure: 'http',
|
failure: 'http',
|
||||||
httpStatus: 404,
|
httpStatus: 404,
|
||||||
}),
|
}),
|
||||||
externalFallbackRecommended: false,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(issue.details).toBeUndefined();
|
expect(issue.details).toBeUndefined();
|
||||||
File renamed without changes.
+8
-1
@@ -32,6 +32,13 @@ export const PlaybackDiagnosticSource = {
|
|||||||
export type PlaybackDiagnosticSource =
|
export type PlaybackDiagnosticSource =
|
||||||
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
|
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
|
||||||
|
|
||||||
|
export const PlaybackRuntimeSupport = {
|
||||||
|
ShakaBrowserUnsupported: 'shaka-browser-unsupported',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PlaybackRuntimeSupport =
|
||||||
|
(typeof PlaybackRuntimeSupport)[keyof typeof PlaybackRuntimeSupport];
|
||||||
|
|
||||||
export const InlinePlaybackPlayer = {
|
export const InlinePlaybackPlayer = {
|
||||||
VideoJs: 'videojs',
|
VideoJs: 'videojs',
|
||||||
Html5: 'html5',
|
Html5: 'html5',
|
||||||
@@ -255,6 +262,7 @@ export interface PlaybackDiagnostic {
|
|||||||
readonly player?: InlinePlaybackPlayer;
|
readonly player?: InlinePlaybackPlayer;
|
||||||
readonly audioCodecs: readonly string[];
|
readonly audioCodecs: readonly string[];
|
||||||
readonly videoCodecs: readonly string[];
|
readonly videoCodecs: readonly string[];
|
||||||
|
readonly runtimeSupport?: PlaybackRuntimeSupport;
|
||||||
readonly details?: string;
|
readonly details?: string;
|
||||||
readonly nativeErrorCode?: number;
|
readonly nativeErrorCode?: number;
|
||||||
readonly nativeErrorMessage?: string;
|
readonly nativeErrorMessage?: string;
|
||||||
@@ -264,7 +272,6 @@ export interface PlaybackDiagnostic {
|
|||||||
readonly hls?: HlsPlaybackEvidence;
|
readonly hls?: HlsPlaybackEvidence;
|
||||||
readonly mpegTs?: MpegTsPlaybackEvidence;
|
readonly mpegTs?: MpegTsPlaybackEvidence;
|
||||||
readonly shaka?: ShakaPlaybackEvidence;
|
readonly shaka?: ShakaPlaybackEvidence;
|
||||||
readonly externalFallbackRecommended: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackFallbackRequest {
|
export interface PlaybackFallbackRequest {
|
||||||
+33
-22
@@ -10,6 +10,7 @@ import {
|
|||||||
classifyHlsPlaybackIssue,
|
classifyHlsPlaybackIssue,
|
||||||
classifyMpegTsPlaybackIssue,
|
classifyMpegTsPlaybackIssue,
|
||||||
classifyNativePlaybackIssue,
|
classifyNativePlaybackIssue,
|
||||||
|
classifyUnsupportedHlsManifestCodecs,
|
||||||
createMpegTsPlaybackEvidence,
|
createMpegTsPlaybackEvidence,
|
||||||
createPlaybackSourceMetadata,
|
createPlaybackSourceMetadata,
|
||||||
getLikelyBrowserUnsupportedCodecLabels,
|
getLikelyBrowserUnsupportedCodecLabels,
|
||||||
@@ -38,6 +39,38 @@ type VhsClassifier = (
|
|||||||
) => PlaybackDiagnostic;
|
) => PlaybackDiagnostic;
|
||||||
|
|
||||||
describe('playback diagnostics', () => {
|
describe('playback diagnostics', () => {
|
||||||
|
it('classifies manifest codecs only when the explicit support probe rejects them', () => {
|
||||||
|
const metadata = createPlaybackSourceMetadata({
|
||||||
|
url: 'https://example.com/live/index.m3u8',
|
||||||
|
player: 'html5',
|
||||||
|
audioCodecs: ['ac-3'],
|
||||||
|
videoCodecs: ['avc1.64001f'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = classifyUnsupportedHlsManifestCodecs(
|
||||||
|
metadata,
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, undefined])(
|
||||||
|
'does not classify manifest codecs when the support probe returns %s',
|
||||||
|
(supportResult) => {
|
||||||
|
const issue = classifyUnsupportedHlsManifestCodecs(
|
||||||
|
createPlaybackSourceMetadata({
|
||||||
|
url: 'https://example.com/live/index.m3u8',
|
||||||
|
player: 'html5',
|
||||||
|
videoCodecs: ['avc1.64001f'],
|
||||||
|
}),
|
||||||
|
() => supportResult
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issue).toBeNull();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
|
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
|
||||||
const issue = classifyStructuredHlsPlaybackIssue(
|
const issue = classifyStructuredHlsPlaybackIssue(
|
||||||
{
|
{
|
||||||
@@ -57,7 +90,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
|
||||||
expect(issue?.audioCodecs).toEqual(['ac-3']);
|
expect(issue?.audioCodecs).toEqual(['ac-3']);
|
||||||
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
|
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
|
||||||
});
|
});
|
||||||
@@ -78,7 +110,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies native decode and unsupported source errors without using network wording', () => {
|
it('classifies native decode and unsupported source errors without using network wording', () => {
|
||||||
@@ -100,11 +131,9 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||||
expect(decodeIssue.externalFallbackRecommended).toBe(true);
|
|
||||||
expect(unsupportedSourceIssue.code).toBe(
|
expect(unsupportedSourceIssue.code).toBe(
|
||||||
PlaybackDiagnosticCode.UnsupportedContainer
|
PlaybackDiagnosticCode.UnsupportedContainer
|
||||||
);
|
);
|
||||||
expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies MIME-only unsupported containers as container diagnostics', () => {
|
it('classifies MIME-only unsupported containers as container diagnostics', () => {
|
||||||
@@ -133,7 +162,6 @@ describe('playback diagnostics', () => {
|
|||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||||
expect(issue.container).toBe('mp2t');
|
expect(issue.container).toBe('mp2t');
|
||||||
expect(issue.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies native HTTP source failures from a safe status and error type', () => {
|
it('classifies native HTTP source failures from a safe status and error type', () => {
|
||||||
@@ -153,7 +181,6 @@ describe('playback diagnostics', () => {
|
|||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||||
expect(issue.httpStatus).toBe(404);
|
expect(issue.httpStatus).toBe(404);
|
||||||
expect(issue.nativeErrorType).toBe('networkrequestfailed');
|
expect(issue.nativeErrorType).toBe('networkrequestfailed');
|
||||||
expect(issue.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies exact VHS network evidence without using provider text', () => {
|
it('classifies exact VHS network evidence without using provider text', () => {
|
||||||
@@ -177,7 +204,6 @@ describe('playback diagnostics', () => {
|
|||||||
source: 'vhs',
|
source: 'vhs',
|
||||||
httpStatus: undefined,
|
httpStatus: undefined,
|
||||||
nativeErrorMessage: undefined,
|
nativeErrorMessage: undefined,
|
||||||
externalFallbackRecommended: false,
|
|
||||||
vhs: {
|
vhs: {
|
||||||
engineType: 'networkrequestfailed',
|
engineType: 'networkrequestfailed',
|
||||||
mediaErrorCode: 4,
|
mediaErrorCode: 4,
|
||||||
@@ -205,7 +231,6 @@ describe('playback diagnostics', () => {
|
|||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||||
expect(issue.source).toBe('vhs');
|
expect(issue.source).toBe('vhs');
|
||||||
expect(issue.externalFallbackRecommended).toBe(true);
|
|
||||||
expect(issue.nativeErrorMessage).toBeUndefined();
|
expect(issue.nativeErrorMessage).toBeUndefined();
|
||||||
expect(issue.vhs).toEqual({
|
expect(issue.vhs).toEqual({
|
||||||
engineType: 'streamingfailedtodecryptsegment',
|
engineType: 'streamingfailedtodecryptsegment',
|
||||||
@@ -247,7 +272,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||||
expect(vhsIssue.externalFallbackRecommended).toBe(false);
|
|
||||||
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
|
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
|
||||||
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||||
});
|
});
|
||||||
@@ -310,7 +334,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||||
expect(issue.externalFallbackRecommended).toBe(false);
|
|
||||||
expect(issue.httpStatus).toBeUndefined();
|
expect(issue.httpStatus).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,7 +354,6 @@ describe('playback diagnostics', () => {
|
|||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||||
expect(issue.httpStatus).toBeUndefined();
|
expect(issue.httpStatus).toBeUndefined();
|
||||||
expect(issue.nativeErrorType).toBeUndefined();
|
expect(issue.nativeErrorType).toBeUndefined();
|
||||||
expect(issue.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -400,7 +422,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retains structured HLS HTTP and stage evidence', () => {
|
it('retains structured HLS HTTP and stage evidence', () => {
|
||||||
@@ -431,7 +452,6 @@ describe('playback diagnostics', () => {
|
|||||||
failure: 'http',
|
failure: 'http',
|
||||||
httpStatus: 404,
|
httpStatus: 404,
|
||||||
}),
|
}),
|
||||||
externalFallbackRecommended: false,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -471,7 +491,6 @@ describe('playback diagnostics', () => {
|
|||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||||
expect(issue?.httpStatus).toBeUndefined();
|
expect(issue?.httpStatus).toBeUndefined();
|
||||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
|
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
|
||||||
@@ -490,7 +509,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies exact HLS key load failures as network errors', () => {
|
it('classifies exact HLS key load failures as network errors', () => {
|
||||||
@@ -509,7 +527,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
|
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
|
||||||
@@ -549,7 +566,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies native CORS failures as browser access errors', () => {
|
it('classifies native CORS failures as browser access errors', () => {
|
||||||
@@ -566,7 +582,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue.code).toBe('browser-access-error');
|
expect(issue.code).toBe('browser-access-error');
|
||||||
expect(issue.externalFallbackRecommended).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps opaque native network failures generic when the browser hides access details', () => {
|
it('keeps opaque native network failures generic when the browser hides access details', () => {
|
||||||
@@ -582,7 +597,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||||
expect(issue.externalFallbackRecommended).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not guess mpegts browser access from exception messages', () => {
|
it('does not guess mpegts browser access from exception messages', () => {
|
||||||
@@ -599,7 +613,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||||
expect(issue.externalFallbackRecommended).toBe(false);
|
|
||||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -618,7 +631,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||||
expect(issue.externalFallbackRecommended).toBe(true);
|
|
||||||
expect(issue.mpegTs?.failure).toBe('truncated-stream');
|
expect(issue.mpegTs?.failure).toBe('truncated-stream');
|
||||||
expect(issue.details).toBeUndefined();
|
expect(issue.details).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -638,7 +650,6 @@ describe('playback diagnostics', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||||
expect(issue.externalFallbackRecommended).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers stream extension query metadata over web script path extensions', () => {
|
it('prefers stream extension query metadata over web script path extensions', () => {
|
||||||
+13
-26
@@ -5,6 +5,7 @@ import type {
|
|||||||
PlaybackDiagnostic,
|
PlaybackDiagnostic,
|
||||||
PlaybackDiagnosticCode,
|
PlaybackDiagnosticCode,
|
||||||
PlaybackDiagnosticSource,
|
PlaybackDiagnosticSource,
|
||||||
|
PlaybackRuntimeSupport,
|
||||||
PlaybackSourceMetadata,
|
PlaybackSourceMetadata,
|
||||||
ShakaPlaybackEvidence,
|
ShakaPlaybackEvidence,
|
||||||
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
|
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
|
||||||
@@ -50,17 +51,13 @@ const VHS_NETWORK_ERROR_TYPES: ReadonlySet<VhsPlaybackEngineTypeValue> =
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export function classifyNativePlaybackIssue(
|
export function classifyNativePlaybackIssue(
|
||||||
error: NativePlaybackErrorInput | MediaError | null | undefined,
|
error: NativePlaybackErrorInput | null | undefined,
|
||||||
metadata: PlaybackSourceMetadata
|
metadata: PlaybackSourceMetadata
|
||||||
): PlaybackDiagnostic {
|
): PlaybackDiagnostic {
|
||||||
const nativeErrorCode = error?.code;
|
const nativeErrorCode = error?.code;
|
||||||
const nativeErrorMessage = error?.message || undefined;
|
const nativeErrorMessage = error?.message || undefined;
|
||||||
const nativeErrorInput = error as
|
const httpStatus = getNativeHttpStatus(error?.status);
|
||||||
NativePlaybackErrorInput | null | undefined;
|
const nativeErrorType = getNativeErrorType(error?.metadata?.errorType);
|
||||||
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
|
|
||||||
const nativeErrorType = getNativeErrorType(
|
|
||||||
nativeErrorInput?.metadata?.errorType
|
|
||||||
);
|
|
||||||
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
|
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
|
||||||
|
|
||||||
if (httpStatus !== undefined) {
|
if (httpStatus !== undefined) {
|
||||||
@@ -175,17 +172,20 @@ export function classifyMpegTsPlaybackIssue(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined;
|
||||||
|
|
||||||
export function classifyUnsupportedHlsManifestCodecs(
|
export function classifyUnsupportedHlsManifestCodecs(
|
||||||
metadata: PlaybackSourceMetadata
|
metadata: PlaybackSourceMetadata,
|
||||||
|
isTypeSupported: MediaTypeSupportProbe
|
||||||
): PlaybackDiagnostic | null {
|
): PlaybackDiagnostic | null {
|
||||||
if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') {
|
if (!hasCodecs(metadata)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
|
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
|
||||||
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
|
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
|
||||||
|
|
||||||
if (MediaSource.isTypeSupported(mimeType)) {
|
if (isTypeSupported(mimeType) !== false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +201,7 @@ export function createPlaybackDiagnostic(options: {
|
|||||||
readonly code: PlaybackDiagnosticCode;
|
readonly code: PlaybackDiagnosticCode;
|
||||||
readonly source: PlaybackDiagnosticSource;
|
readonly source: PlaybackDiagnosticSource;
|
||||||
readonly metadata: PlaybackSourceMetadata;
|
readonly metadata: PlaybackSourceMetadata;
|
||||||
|
readonly runtimeSupport?: PlaybackRuntimeSupport;
|
||||||
readonly details?: string;
|
readonly details?: string;
|
||||||
readonly nativeErrorCode?: number;
|
readonly nativeErrorCode?: number;
|
||||||
readonly nativeErrorMessage?: string;
|
readonly nativeErrorMessage?: string;
|
||||||
@@ -210,14 +211,12 @@ export function createPlaybackDiagnostic(options: {
|
|||||||
readonly hls?: HlsPlaybackEvidence;
|
readonly hls?: HlsPlaybackEvidence;
|
||||||
readonly mpegTs?: MpegTsPlaybackEvidence;
|
readonly mpegTs?: MpegTsPlaybackEvidence;
|
||||||
readonly shaka?: ShakaPlaybackEvidence;
|
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;
|
|
||||||
}): PlaybackDiagnostic {
|
}): PlaybackDiagnostic {
|
||||||
const {
|
const {
|
||||||
code,
|
code,
|
||||||
source,
|
source,
|
||||||
metadata,
|
metadata,
|
||||||
|
runtimeSupport,
|
||||||
details,
|
details,
|
||||||
nativeErrorCode,
|
nativeErrorCode,
|
||||||
nativeErrorMessage,
|
nativeErrorMessage,
|
||||||
@@ -238,6 +237,7 @@ export function createPlaybackDiagnostic(options: {
|
|||||||
player: metadata.player,
|
player: metadata.player,
|
||||||
audioCodecs: metadata.audioCodecs,
|
audioCodecs: metadata.audioCodecs,
|
||||||
videoCodecs: metadata.videoCodecs,
|
videoCodecs: metadata.videoCodecs,
|
||||||
|
runtimeSupport,
|
||||||
details: details || undefined,
|
details: details || undefined,
|
||||||
nativeErrorCode,
|
nativeErrorCode,
|
||||||
nativeErrorMessage,
|
nativeErrorMessage,
|
||||||
@@ -247,9 +247,6 @@ export function createPlaybackDiagnostic(options: {
|
|||||||
hls,
|
hls,
|
||||||
mpegTs,
|
mpegTs,
|
||||||
shaka,
|
shaka,
|
||||||
externalFallbackRecommended:
|
|
||||||
options.externalFallbackRecommended ??
|
|
||||||
isExternalFallbackRecommended(code),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,16 +320,6 @@ function getNativeErrorType(errorType: unknown): string | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
|
|
||||||
return (
|
|
||||||
code === DiagnosticCode.UnsupportedContainer ||
|
|
||||||
code === DiagnosticCode.UnsupportedCodec ||
|
|
||||||
code === DiagnosticCode.MediaDecodeError ||
|
|
||||||
code === DiagnosticCode.BrowserAccessError ||
|
|
||||||
code === DiagnosticCode.DrmOrEncryption
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
|
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
|
||||||
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
|
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
+2
-5
@@ -2,8 +2,8 @@ import {
|
|||||||
InlinePlaybackPlayer,
|
InlinePlaybackPlayer,
|
||||||
PlaybackDiagnosticCode,
|
PlaybackDiagnosticCode,
|
||||||
PlaybackDiagnosticSource,
|
PlaybackDiagnosticSource,
|
||||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
} from './playback-diagnostics.model';
|
||||||
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
|
import { createPlaybackSourceMetadata } from './playback-diagnostics.util';
|
||||||
import {
|
import {
|
||||||
classifyShakaPlaybackIssue,
|
classifyShakaPlaybackIssue,
|
||||||
createUnsupportedDrmDiagnostic,
|
createUnsupportedDrmDiagnostic,
|
||||||
@@ -216,8 +216,5 @@ describe('createUnsupportedDrmDiagnostic', () => {
|
|||||||
expect(issue.details).toBe('Unsupported DRM license configuration');
|
expect(issue.details).toBe('Unsupported DRM license configuration');
|
||||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||||
expect(JSON.stringify(issue)).not.toContain('provider.example');
|
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+18
-7
@@ -4,18 +4,19 @@ import type {
|
|||||||
PlaybackSourceMetadata,
|
PlaybackSourceMetadata,
|
||||||
ShakaPlaybackDisposition,
|
ShakaPlaybackDisposition,
|
||||||
ShakaPlaybackEvidence,
|
ShakaPlaybackEvidence,
|
||||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
} from './playback-diagnostics.model';
|
||||||
import {
|
import {
|
||||||
PlaybackDiagnosticCode as DiagnosticCode,
|
PlaybackDiagnosticCode as DiagnosticCode,
|
||||||
PlaybackDiagnosticSource as DiagnosticSource,
|
PlaybackDiagnosticSource as DiagnosticSource,
|
||||||
ShakaPlaybackCategory,
|
ShakaPlaybackCategory,
|
||||||
ShakaPlaybackDisposition as ShakaDisposition,
|
ShakaPlaybackDisposition as ShakaDisposition,
|
||||||
ShakaPlaybackFailure,
|
ShakaPlaybackFailure,
|
||||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
PlaybackRuntimeSupport,
|
||||||
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
|
} from './playback-diagnostics.model';
|
||||||
|
import { createPlaybackDiagnostic } from './playback-diagnostics.util';
|
||||||
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
||||||
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
||||||
import type { ShakaErrorLike } from './shaka-module.types';
|
import type { ShakaErrorLike } from './shaka-error.types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SHAKA_DIAGNOSTIC_VERSION,
|
SHAKA_DIAGNOSTIC_VERSION,
|
||||||
@@ -88,9 +89,19 @@ export function createUnsupportedDrmDiagnostic(
|
|||||||
source: DiagnosticSource.Shaka,
|
source: DiagnosticSource.Shaka,
|
||||||
metadata,
|
metadata,
|
||||||
details: '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,
|
|
||||||
|
/** Exact app-owned evidence for Shaka's browser-support preflight. */
|
||||||
|
export function createShakaBrowserUnsupportedDiagnostic(
|
||||||
|
metadata: PlaybackSourceMetadata
|
||||||
|
): PlaybackDiagnostic {
|
||||||
|
return createPlaybackDiagnostic({
|
||||||
|
code: DiagnosticCode.UnknownPlaybackError,
|
||||||
|
source: DiagnosticSource.Shaka,
|
||||||
|
metadata,
|
||||||
|
runtimeSupport: PlaybackRuntimeSupport.ShakaBrowserUnsupported,
|
||||||
|
shaka: createShakaPlaybackEvidence(null, ShakaDisposition.Terminal),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
File renamed without changes.
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
|
import { ShakaPlaybackDisposition as ShakaDisposition } from './playback-diagnostics.model';
|
||||||
import {
|
import {
|
||||||
SHAKA_ERROR_CATEGORY,
|
SHAKA_ERROR_CATEGORY,
|
||||||
SHAKA_ERROR_CODE,
|
SHAKA_ERROR_CODE,
|
||||||
SHAKA_ERROR_SEVERITY,
|
SHAKA_ERROR_SEVERITY,
|
||||||
} from './shaka-error-contract';
|
} from './shaka-error-contract';
|
||||||
import type { ShakaErrorLike } from './shaka-module.types';
|
import type { ShakaErrorLike } from './shaka-error.types';
|
||||||
|
|
||||||
export function getShakaErrorEventDisposition(
|
export function getShakaErrorEventDisposition(
|
||||||
error: Partial<ShakaErrorLike> | null
|
error: Partial<ShakaErrorLike> | null
|
||||||
File renamed without changes.
@@ -0,0 +1,6 @@
|
|||||||
|
export interface ShakaErrorLike {
|
||||||
|
readonly severity: number;
|
||||||
|
readonly category: number;
|
||||||
|
readonly code: number;
|
||||||
|
readonly data?: readonly unknown[];
|
||||||
|
}
|
||||||
File renamed without changes.
+2
-2
@@ -9,7 +9,7 @@ import {
|
|||||||
ShakaPlaybackSeverity,
|
ShakaPlaybackSeverity,
|
||||||
ShakaPlaybackStage,
|
ShakaPlaybackStage,
|
||||||
ShakaPlaybackUnknownCode,
|
ShakaPlaybackUnknownCode,
|
||||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
} from './playback-diagnostics.model';
|
||||||
import {
|
import {
|
||||||
SHAKA_ERROR_CATEGORY,
|
SHAKA_ERROR_CATEGORY,
|
||||||
SHAKA_ERROR_CODE,
|
SHAKA_ERROR_CODE,
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
SHAKA_PUBLIC_CODES,
|
SHAKA_PUBLIC_CODES,
|
||||||
SHAKA_SEGMENT_STAGE_CODES,
|
SHAKA_SEGMENT_STAGE_CODES,
|
||||||
} from './shaka-error-mapping';
|
} from './shaka-error-mapping';
|
||||||
import type { ShakaErrorLike } from './shaka-module.types';
|
import type { ShakaErrorLike } from './shaka-error.types';
|
||||||
|
|
||||||
export function createShakaPlaybackEvidence(
|
export function createShakaPlaybackEvidence(
|
||||||
error: Partial<ShakaErrorLike> | null | undefined,
|
error: Partial<ShakaErrorLike> | null | undefined,
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,144 @@
|
|||||||
|
import { InlinePlaybackPlayer } from './diagnostics/playback-diagnostics.model';
|
||||||
|
import {
|
||||||
|
PlaybackEngineFamily,
|
||||||
|
type PlaybackRecommendationContext,
|
||||||
|
type PlaybackRecommendationTarget,
|
||||||
|
type PlaybackTargetCapability,
|
||||||
|
} from './playback-recommendation.model';
|
||||||
|
import { getInlinePlaybackEngineFamily } from './playback-target-capabilities';
|
||||||
|
|
||||||
|
interface RuntimeCapabilityRecord {
|
||||||
|
readonly target: unknown;
|
||||||
|
readonly kind: unknown;
|
||||||
|
readonly available: unknown;
|
||||||
|
readonly engineFamily?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaybackCapabilityIndex = ReadonlyMap<
|
||||||
|
PlaybackRecommendationTarget,
|
||||||
|
PlaybackTargetCapability
|
||||||
|
>;
|
||||||
|
|
||||||
|
const CANONICAL_TARGETS: readonly PlaybackRecommendationTarget[] = [
|
||||||
|
InlinePlaybackPlayer.VideoJs,
|
||||||
|
InlinePlaybackPlayer.Html5,
|
||||||
|
InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
'mpv',
|
||||||
|
'vlc',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createPlaybackCapabilityIndex(
|
||||||
|
context: PlaybackRecommendationContext
|
||||||
|
): PlaybackCapabilityIndex | null {
|
||||||
|
const index = new Map<
|
||||||
|
PlaybackRecommendationTarget,
|
||||||
|
PlaybackTargetCapability
|
||||||
|
>();
|
||||||
|
for (const capability of context.targetCapabilities) {
|
||||||
|
if (!isValidCapability(context, capability, index)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
index.set(capability.target, capability);
|
||||||
|
}
|
||||||
|
if (index.size !== CANONICAL_TARGETS.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const active = index.get(context.activeTarget);
|
||||||
|
return active?.kind === 'inline' &&
|
||||||
|
active.available &&
|
||||||
|
active.engineFamily !== null
|
||||||
|
? index
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAvailableCapability(
|
||||||
|
capabilityIndex: PlaybackCapabilityIndex | null,
|
||||||
|
target: PlaybackRecommendationTarget
|
||||||
|
): boolean {
|
||||||
|
return capabilityIndex?.get(target)?.available === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttemptedInlineFamilies(
|
||||||
|
context: PlaybackRecommendationContext,
|
||||||
|
capabilityIndex: PlaybackCapabilityIndex | null
|
||||||
|
): ReadonlySet<PlaybackEngineFamily> {
|
||||||
|
const families = new Set<PlaybackEngineFamily>();
|
||||||
|
if (capabilityIndex === null) {
|
||||||
|
return families;
|
||||||
|
}
|
||||||
|
for (const target of context.attemptedTargets) {
|
||||||
|
const capability = capabilityIndex.get(target);
|
||||||
|
if (
|
||||||
|
capability?.kind === 'inline' &&
|
||||||
|
capability.available &&
|
||||||
|
capability.engineFamily !== null
|
||||||
|
) {
|
||||||
|
families.add(capability.engineFamily);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return families;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInlineFamilyAttempted(
|
||||||
|
target: PlaybackRecommendationTarget,
|
||||||
|
capabilityIndex: PlaybackCapabilityIndex | null,
|
||||||
|
attemptedFamilies: ReadonlySet<PlaybackEngineFamily>
|
||||||
|
): boolean {
|
||||||
|
const capability = capabilityIndex?.get(target);
|
||||||
|
return (
|
||||||
|
capability?.kind === 'inline' &&
|
||||||
|
capability.engineFamily !== null &&
|
||||||
|
attemptedFamilies.has(capability.engineFamily)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCapability(
|
||||||
|
context: PlaybackRecommendationContext,
|
||||||
|
capability: unknown,
|
||||||
|
index: PlaybackCapabilityIndex
|
||||||
|
): capability is PlaybackTargetCapability {
|
||||||
|
if (
|
||||||
|
!hasRequiredCapabilityFields(capability) ||
|
||||||
|
!isKnownTarget(capability.target) ||
|
||||||
|
index.has(capability.target) ||
|
||||||
|
typeof capability.available !== 'boolean'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isInlineTarget(capability.target)) {
|
||||||
|
return capability.kind === 'external';
|
||||||
|
}
|
||||||
|
if (capability.kind !== 'inline' || !('engineFamily' in capability)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedFamily = getInlinePlaybackEngineFamily(
|
||||||
|
context.source.kind,
|
||||||
|
capability.target
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
capability.engineFamily === expectedFamily &&
|
||||||
|
!(expectedFamily === null && capability.available)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRequiredCapabilityFields(
|
||||||
|
value: unknown
|
||||||
|
): value is RuntimeCapabilityRecord {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'target' in value &&
|
||||||
|
'kind' in value &&
|
||||||
|
'available' in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownTarget(value: unknown): value is PlaybackRecommendationTarget {
|
||||||
|
return CANONICAL_TARGETS.includes(value as PlaybackRecommendationTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInlineTarget(
|
||||||
|
value: PlaybackRecommendationTarget
|
||||||
|
): value is InlinePlaybackPlayer {
|
||||||
|
return value !== 'mpv' && value !== 'vlc';
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,302 @@
|
|||||||
|
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||||
|
import {
|
||||||
|
InlinePlaybackPlayer,
|
||||||
|
PlaybackDiagnosticCode,
|
||||||
|
PlaybackDiagnosticSource,
|
||||||
|
PlaybackRuntimeSupport,
|
||||||
|
} from './diagnostics/playback-diagnostics.model';
|
||||||
|
import {
|
||||||
|
PlaybackEngineFamily,
|
||||||
|
PlaybackRecommendationReason,
|
||||||
|
PlaybackSourceKind,
|
||||||
|
type PlaybackRecommendation,
|
||||||
|
type PlaybackRecommendationContext,
|
||||||
|
type PlaybackRecommendationTarget,
|
||||||
|
type PlaybackTargetCapability,
|
||||||
|
} from './playback-recommendation.model';
|
||||||
|
import {
|
||||||
|
createPlaybackCapabilityIndex,
|
||||||
|
getAttemptedInlineFamilies,
|
||||||
|
hasAvailableCapability,
|
||||||
|
isInlineFamilyAttempted,
|
||||||
|
type PlaybackCapabilityIndex,
|
||||||
|
} from './playback-recommendation-capabilities';
|
||||||
|
|
||||||
|
type WithoutPriority<T> = T extends unknown ? Omit<T, 'priority'> : never;
|
||||||
|
|
||||||
|
type PlaybackRecommendationCandidate = WithoutPriority<PlaybackRecommendation>;
|
||||||
|
|
||||||
|
type InlineCapability = Extract<
|
||||||
|
PlaybackTargetCapability,
|
||||||
|
{ readonly kind: 'inline' }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type AvailableInlineCapability = InlineCapability & {
|
||||||
|
readonly available: true;
|
||||||
|
readonly engineFamily: PlaybackEngineFamily;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function recommendPlaybackRecovery(
|
||||||
|
context: PlaybackRecommendationContext
|
||||||
|
): readonly PlaybackRecommendation[] {
|
||||||
|
const capabilityIndex = hasPlayerRecoveryEvidence(context)
|
||||||
|
? createPlaybackCapabilityIndex(context)
|
||||||
|
: null;
|
||||||
|
const attemptedInlineFamilies = getAttemptedInlineFamilies(
|
||||||
|
context,
|
||||||
|
capabilityIndex
|
||||||
|
);
|
||||||
|
const seenTargets = new Set<PlaybackRecommendationTarget>();
|
||||||
|
const candidates = buildCandidates(context, capabilityIndex).filter(
|
||||||
|
isCandidate
|
||||||
|
);
|
||||||
|
const filtered = candidates.filter((candidate) => {
|
||||||
|
if (candidate.action !== 'player') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
candidate.target === context.activeTarget ||
|
||||||
|
context.attemptedTargets.has(candidate.target) ||
|
||||||
|
seenTargets.has(candidate.target) ||
|
||||||
|
!hasAvailableCapability(capabilityIndex, candidate.target)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isInlineFamilyAttempted(
|
||||||
|
candidate.target,
|
||||||
|
capabilityIndex,
|
||||||
|
attemptedInlineFamilies
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isExternalTarget(candidate.target) &&
|
||||||
|
!canTransferExternally(context)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenTargets.add(candidate.target);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.slice(0, 3).map((candidate, index) => ({
|
||||||
|
...candidate,
|
||||||
|
priority: index === 0 ? 'primary' : 'secondary',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidates(
|
||||||
|
context: PlaybackRecommendationContext,
|
||||||
|
capabilityIndex: PlaybackCapabilityIndex | null
|
||||||
|
): readonly (PlaybackRecommendationCandidate | null)[] {
|
||||||
|
if (hasPlayerRecoveryEvidence(context) && capabilityIndex === null) {
|
||||||
|
return [retryUnknown(), alternative(context)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShakaBrowserUnsupported(context)) {
|
||||||
|
return [
|
||||||
|
externalCodecSupport('mpv'),
|
||||||
|
externalCodecSupport('vlc'),
|
||||||
|
alternative(context),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (context.diagnostic.code) {
|
||||||
|
case PlaybackDiagnosticCode.NetworkError:
|
||||||
|
return [retryTransient(), alternative(context)];
|
||||||
|
case PlaybackDiagnosticCode.BrowserAccessError:
|
||||||
|
return [
|
||||||
|
external(
|
||||||
|
'mpv',
|
||||||
|
PlaybackRecommendationReason.ExternalBrowserAccess
|
||||||
|
),
|
||||||
|
external(
|
||||||
|
'vlc',
|
||||||
|
PlaybackRecommendationReason.ExternalBrowserAccess
|
||||||
|
),
|
||||||
|
alternative(context),
|
||||||
|
];
|
||||||
|
case PlaybackDiagnosticCode.UnsupportedCodec:
|
||||||
|
case PlaybackDiagnosticCode.UnsupportedContainer:
|
||||||
|
return [
|
||||||
|
externalCodecSupport('mpv'),
|
||||||
|
externalCodecSupport('vlc'),
|
||||||
|
alternative(context),
|
||||||
|
];
|
||||||
|
case PlaybackDiagnosticCode.MediaDecodeError:
|
||||||
|
return [
|
||||||
|
distinctInline(
|
||||||
|
context,
|
||||||
|
capabilityIndex,
|
||||||
|
PlaybackRecommendationReason.DifferentEngineFamily
|
||||||
|
),
|
||||||
|
externalCodecSupport('mpv'),
|
||||||
|
externalCodecSupport('vlc'),
|
||||||
|
alternative(context),
|
||||||
|
];
|
||||||
|
case PlaybackDiagnosticCode.DrmOrEncryption:
|
||||||
|
return [
|
||||||
|
distinctInline(
|
||||||
|
context,
|
||||||
|
capabilityIndex,
|
||||||
|
PlaybackRecommendationReason.CompatibleDrmPath
|
||||||
|
),
|
||||||
|
alternative(context),
|
||||||
|
external('mpv', PlaybackRecommendationReason.CompatibleDrmPath),
|
||||||
|
external('vlc', PlaybackRecommendationReason.CompatibleDrmPath),
|
||||||
|
];
|
||||||
|
case PlaybackDiagnosticCode.UnknownPlaybackError:
|
||||||
|
default:
|
||||||
|
return [retryUnknown(), alternative(context)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryTransient(): PlaybackRecommendationCandidate {
|
||||||
|
return {
|
||||||
|
action: 'retry',
|
||||||
|
reason: PlaybackRecommendationReason.RetryTransientFailure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryUnknown(): PlaybackRecommendationCandidate {
|
||||||
|
return {
|
||||||
|
action: 'retry',
|
||||||
|
reason: PlaybackRecommendationReason.RetryUnknownFailure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function alternative(
|
||||||
|
context: PlaybackRecommendationContext
|
||||||
|
): PlaybackRecommendationCandidate | null {
|
||||||
|
return !Number.isSafeInteger(context.alternativeSourceCount) ||
|
||||||
|
context.alternativeSourceCount <= 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
action: 'alternative-source',
|
||||||
|
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function distinctInline(
|
||||||
|
context: PlaybackRecommendationContext,
|
||||||
|
capabilityIndex: PlaybackCapabilityIndex | null,
|
||||||
|
reason: PlaybackRecommendationCandidate['reason']
|
||||||
|
): PlaybackRecommendationCandidate | null {
|
||||||
|
const activeCapability = getActiveInlineCapability(
|
||||||
|
context,
|
||||||
|
capabilityIndex
|
||||||
|
);
|
||||||
|
if (activeCapability === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalTarget = getCanonicalDistinctInlineTarget(
|
||||||
|
activeCapability.engineFamily
|
||||||
|
);
|
||||||
|
if (canonicalTarget === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const capability = capabilityIndex?.get(canonicalTarget);
|
||||||
|
return capability?.kind !== 'inline' ||
|
||||||
|
!capability.available ||
|
||||||
|
capability.engineFamily === null ||
|
||||||
|
capability.engineFamily === activeCapability.engineFamily
|
||||||
|
? null
|
||||||
|
: { action: 'player', target: capability.target, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanonicalDistinctInlineTarget(
|
||||||
|
activeFamily: PlaybackEngineFamily
|
||||||
|
): InlinePlaybackPlayer | null {
|
||||||
|
if (activeFamily === PlaybackEngineFamily.Vhs) {
|
||||||
|
return InlinePlaybackPlayer.Html5;
|
||||||
|
}
|
||||||
|
return activeFamily === PlaybackEngineFamily.HlsJs
|
||||||
|
? InlinePlaybackPlayer.VideoJs
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveInlineCapability(
|
||||||
|
context: PlaybackRecommendationContext,
|
||||||
|
capabilityIndex: PlaybackCapabilityIndex | null
|
||||||
|
): AvailableInlineCapability | null {
|
||||||
|
const capability = capabilityIndex?.get(context.activeTarget);
|
||||||
|
return capability?.kind === 'inline' &&
|
||||||
|
capability.available &&
|
||||||
|
capability.engineFamily !== null
|
||||||
|
? (capability as AvailableInlineCapability)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalCodecSupport(
|
||||||
|
target: ExternalPlayerName
|
||||||
|
): PlaybackRecommendationCandidate {
|
||||||
|
return external(
|
||||||
|
target,
|
||||||
|
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function external(
|
||||||
|
target: ExternalPlayerName,
|
||||||
|
reason: PlaybackRecommendationCandidate['reason']
|
||||||
|
): PlaybackRecommendationCandidate {
|
||||||
|
return { action: 'player', target, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlayerOrientedDiagnostic(
|
||||||
|
code: PlaybackRecommendationContext['diagnostic']['code']
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
code === PlaybackDiagnosticCode.BrowserAccessError ||
|
||||||
|
code === PlaybackDiagnosticCode.UnsupportedCodec ||
|
||||||
|
code === PlaybackDiagnosticCode.UnsupportedContainer ||
|
||||||
|
code === PlaybackDiagnosticCode.MediaDecodeError ||
|
||||||
|
code === PlaybackDiagnosticCode.DrmOrEncryption
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPlayerRecoveryEvidence(
|
||||||
|
context: PlaybackRecommendationContext
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isPlayerOrientedDiagnostic(context.diagnostic.code) ||
|
||||||
|
isShakaBrowserUnsupported(context)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShakaBrowserUnsupported(
|
||||||
|
context: PlaybackRecommendationContext
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
context.diagnostic.code ===
|
||||||
|
PlaybackDiagnosticCode.UnknownPlaybackError &&
|
||||||
|
context.diagnostic.source === PlaybackDiagnosticSource.Shaka &&
|
||||||
|
context.diagnostic.runtimeSupport ===
|
||||||
|
PlaybackRuntimeSupport.ShakaBrowserUnsupported &&
|
||||||
|
context.source.kind === PlaybackSourceKind.Dash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalTarget(
|
||||||
|
target: PlaybackRecommendationTarget
|
||||||
|
): target is ExternalPlayerName {
|
||||||
|
return target === 'mpv' || target === 'vlc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canTransferExternally(
|
||||||
|
context: PlaybackRecommendationContext
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
context.source.drm !== 'untransferable' &&
|
||||||
|
context.source.externalTransferable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCandidate(
|
||||||
|
candidate: PlaybackRecommendationCandidate | null
|
||||||
|
): candidate is PlaybackRecommendationCandidate {
|
||||||
|
return candidate !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type {
|
||||||
|
ExternalPlayerName,
|
||||||
|
} from '@iptvnator/shared/interfaces';
|
||||||
|
import type {
|
||||||
|
InlinePlaybackPlayer,
|
||||||
|
PlaybackDiagnostic,
|
||||||
|
} from './diagnostics/playback-diagnostics.model';
|
||||||
|
|
||||||
|
export const PlaybackRecommendationReason = {
|
||||||
|
RetryTransientFailure: 'retry-transient-failure',
|
||||||
|
RetryUnknownFailure: 'retry-unknown-failure',
|
||||||
|
AlternativeSourceAvailable: 'alternative-source-available',
|
||||||
|
DifferentEngineFamily: 'different-engine-family',
|
||||||
|
ExternalCodecOrContainerSupport: 'external-codec-or-container-support',
|
||||||
|
ExternalBrowserAccess: 'external-browser-access',
|
||||||
|
CompatibleDrmPath: 'compatible-drm-path',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PlaybackRecommendationReason =
|
||||||
|
(typeof PlaybackRecommendationReason)[keyof typeof PlaybackRecommendationReason];
|
||||||
|
|
||||||
|
export type PlaybackRecommendationPriority = 'primary' | 'secondary';
|
||||||
|
|
||||||
|
export type PlaybackRecommendationTarget =
|
||||||
|
| InlinePlaybackPlayer
|
||||||
|
| ExternalPlayerName;
|
||||||
|
|
||||||
|
export const PlaybackSourceKind = {
|
||||||
|
Hls: 'hls',
|
||||||
|
MpegTs: 'mpegts',
|
||||||
|
Dash: 'dash',
|
||||||
|
Native: 'native',
|
||||||
|
Unknown: 'unknown',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PlaybackSourceKind =
|
||||||
|
(typeof PlaybackSourceKind)[keyof typeof PlaybackSourceKind];
|
||||||
|
|
||||||
|
export const PlaybackEngineFamily = {
|
||||||
|
Vhs: 'vhs',
|
||||||
|
HlsJs: 'hls.js',
|
||||||
|
MpegTsJs: 'mpegts.js',
|
||||||
|
Shaka: 'shaka',
|
||||||
|
NativeMedia: 'native-media',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PlaybackEngineFamily =
|
||||||
|
(typeof PlaybackEngineFamily)[keyof typeof PlaybackEngineFamily];
|
||||||
|
|
||||||
|
export type PlaybackTargetCapability =
|
||||||
|
| {
|
||||||
|
readonly kind: 'inline';
|
||||||
|
readonly target: InlinePlaybackPlayer;
|
||||||
|
readonly available: boolean;
|
||||||
|
readonly engineFamily: PlaybackEngineFamily | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: 'external';
|
||||||
|
readonly target: ExternalPlayerName;
|
||||||
|
readonly available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlaybackRecommendationSourceContext {
|
||||||
|
readonly kind: PlaybackSourceKind;
|
||||||
|
readonly isLive: boolean;
|
||||||
|
readonly drm: 'none' | 'untransferable';
|
||||||
|
readonly externalTransferable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackRecommendationContext {
|
||||||
|
readonly diagnostic: PlaybackDiagnostic;
|
||||||
|
readonly activeTarget: PlaybackRecommendationTarget;
|
||||||
|
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
|
||||||
|
readonly targetCapabilities: readonly PlaybackTargetCapability[];
|
||||||
|
readonly source: PlaybackRecommendationSourceContext;
|
||||||
|
readonly alternativeSourceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaybackRecommendation =
|
||||||
|
| {
|
||||||
|
readonly action: 'retry';
|
||||||
|
readonly reason: PlaybackRecommendationReason;
|
||||||
|
readonly priority: PlaybackRecommendationPriority;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly action: 'alternative-source';
|
||||||
|
readonly reason: PlaybackRecommendationReason;
|
||||||
|
readonly priority: PlaybackRecommendationPriority;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly action: 'player';
|
||||||
|
readonly target: PlaybackRecommendationTarget;
|
||||||
|
readonly reason: PlaybackRecommendationReason;
|
||||||
|
readonly priority: PlaybackRecommendationPriority;
|
||||||
|
};
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import * as playbackSessionKey from './playback-session-key';
|
||||||
|
import type { PlaybackSessionIdentity } from './playback-session-key';
|
||||||
|
|
||||||
|
const { createPlaybackSessionKey } = playbackSessionKey;
|
||||||
|
|
||||||
|
describe('playback session key', () => {
|
||||||
|
it('length-prefixes every identity part', () => {
|
||||||
|
expect(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'list:west|hd',
|
||||||
|
contentId: 'ep:4|finale',
|
||||||
|
seriesId: 'series|2:remaster',
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 4,
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'7:episode|12:list:west|hd|11:ep:4|finale|17:series|2:remaster|1:3|1:4'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
{ kind: 'live', sourceId: 'a', contentId: 'b|c' },
|
||||||
|
{ kind: 'live', sourceId: 'a|b', contentId: 'c' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'vod', sourceId: 'a:1', contentId: '2' },
|
||||||
|
{ kind: 'vod', sourceId: 'a', contentId: '1:2' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'source',
|
||||||
|
contentId: 'content',
|
||||||
|
seriesId: 'series:1|2',
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'source',
|
||||||
|
contentId: 'content',
|
||||||
|
seriesId: 'series',
|
||||||
|
seasonNumber: 1,
|
||||||
|
episodeNumber: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'does not collide delimiter-bearing identities %#',
|
||||||
|
(left, right) => {
|
||||||
|
expect(createPlaybackSessionKey(left)).not.toBe(
|
||||||
|
createPlaybackSessionKey(right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reuses canonical movie identity across provider copies and source URLs', () => {
|
||||||
|
const logicalIdentity: PlaybackSessionIdentity = {
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'catalog:featured',
|
||||||
|
contentId: 'movie:42',
|
||||||
|
};
|
||||||
|
const originalCopy = {
|
||||||
|
providerId: 'provider:one',
|
||||||
|
sourceUrl: 'https://one.example/movie/42',
|
||||||
|
logicalIdentity,
|
||||||
|
};
|
||||||
|
const alternativeCopy = {
|
||||||
|
providerId: 'provider|two',
|
||||||
|
sourceUrl: 'https://two.example/alternate/9001',
|
||||||
|
logicalIdentity,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(createPlaybackSessionKey(originalCopy.logicalIdentity)).toBe(
|
||||||
|
createPlaybackSessionKey(alternativeCopy.logicalIdentity)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ kind: 'live', sourceId: 'playlist:2', contentId: 42 }],
|
||||||
|
[{ kind: 'live', sourceId: 'playlist:1', contentId: 43 }],
|
||||||
|
[{ kind: 'vod', sourceId: 'playlist:1', contentId: 42 }],
|
||||||
|
] as const)(
|
||||||
|
'changes when a live channel identity changes: %o',
|
||||||
|
(changedIdentity) => {
|
||||||
|
const original = createPlaybackSessionKey({
|
||||||
|
kind: 'live',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
|
||||||
|
original
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ kind: 'vod', sourceId: 'playlist:2', contentId: 42 }],
|
||||||
|
[{ kind: 'vod', sourceId: 'playlist:1', contentId: 43 }],
|
||||||
|
] as const)(
|
||||||
|
'changes when a logical movie identity changes: %o',
|
||||||
|
(changedIdentity) => {
|
||||||
|
const original = createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
|
||||||
|
original
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'source',
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist:2',
|
||||||
|
contentId: 42,
|
||||||
|
seriesId: 7,
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'content',
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 43,
|
||||||
|
seriesId: 7,
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'series',
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 42,
|
||||||
|
seriesId: 8,
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'season',
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 42,
|
||||||
|
seriesId: 7,
|
||||||
|
seasonNumber: 4,
|
||||||
|
episodeNumber: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'episode',
|
||||||
|
{
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 42,
|
||||||
|
seriesId: 7,
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'changes when the logical episode %s identity changes',
|
||||||
|
(_field, changedIdentity) => {
|
||||||
|
const original = createPlaybackSessionKey({
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist:1',
|
||||||
|
contentId: 42,
|
||||||
|
seriesId: 7,
|
||||||
|
seasonNumber: 3,
|
||||||
|
episodeNumber: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
|
||||||
|
original
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not expose a provider-scoped content-info adapter', () => {
|
||||||
|
expect(playbackSessionKey).not.toHaveProperty(
|
||||||
|
'createPlaybackSessionKeyFromContentInfo'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Host-owned canonical logical content identity. Source and content IDs must
|
||||||
|
* not come from the currently selected provider copy or playback URL.
|
||||||
|
*/
|
||||||
|
export type PlaybackSessionIdentity =
|
||||||
|
| {
|
||||||
|
readonly kind: 'live';
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly contentId: string | number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: 'vod';
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly contentId: string | number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: 'episode';
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly contentId: string | number;
|
||||||
|
readonly seriesId?: string | number;
|
||||||
|
readonly seasonNumber?: number;
|
||||||
|
readonly episodeNumber?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPlaybackSessionKey(
|
||||||
|
identity: PlaybackSessionIdentity
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
identity.kind,
|
||||||
|
identity.sourceId,
|
||||||
|
String(identity.contentId),
|
||||||
|
identity.kind === 'episode' ? String(identity.seriesId ?? '') : '',
|
||||||
|
identity.kind === 'episode' ? String(identity.seasonNumber ?? '') : '',
|
||||||
|
identity.kind === 'episode' ? String(identity.episodeNumber ?? '') : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return parts.map((part) => `${part.length}:${part}`).join('|');
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import {
|
||||||
|
InlinePlaybackPlayer,
|
||||||
|
PlaybackDiagnosticCode,
|
||||||
|
PlaybackDiagnosticSource,
|
||||||
|
type PlaybackDiagnostic,
|
||||||
|
} from './diagnostics/playback-diagnostics.model';
|
||||||
|
import {
|
||||||
|
PlaybackEngineFamily,
|
||||||
|
PlaybackSourceKind,
|
||||||
|
} from './playback-recommendation.model';
|
||||||
|
import {
|
||||||
|
createPlaybackTargetCapabilities,
|
||||||
|
getInlinePlaybackEngineFamily,
|
||||||
|
resolvePlaybackSourceKind,
|
||||||
|
} from './playback-target-capabilities';
|
||||||
|
|
||||||
|
function diagnostic(
|
||||||
|
overrides: Partial<PlaybackDiagnostic>
|
||||||
|
): PlaybackDiagnostic {
|
||||||
|
return {
|
||||||
|
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||||
|
source: PlaybackDiagnosticSource.Source,
|
||||||
|
sourceUrl: 'https://example.com/stream',
|
||||||
|
container: '',
|
||||||
|
audioCodecs: [],
|
||||||
|
videoCodecs: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('playback target capabilities', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
InlinePlaybackPlayer.VideoJs,
|
||||||
|
PlaybackEngineFamily.Vhs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
InlinePlaybackPlayer.Html5,
|
||||||
|
PlaybackEngineFamily.HlsJs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
PlaybackEngineFamily.HlsJs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.MpegTs,
|
||||||
|
InlinePlaybackPlayer.VideoJs,
|
||||||
|
PlaybackEngineFamily.MpegTsJs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.MpegTs,
|
||||||
|
InlinePlaybackPlayer.Html5,
|
||||||
|
PlaybackEngineFamily.MpegTsJs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.MpegTs,
|
||||||
|
InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
PlaybackEngineFamily.MpegTsJs,
|
||||||
|
],
|
||||||
|
[PlaybackSourceKind.Dash, InlinePlaybackPlayer.VideoJs, null],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Dash,
|
||||||
|
InlinePlaybackPlayer.Html5,
|
||||||
|
PlaybackEngineFamily.Shaka,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Dash,
|
||||||
|
InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
PlaybackEngineFamily.Shaka,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Native,
|
||||||
|
InlinePlaybackPlayer.VideoJs,
|
||||||
|
PlaybackEngineFamily.NativeMedia,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Native,
|
||||||
|
InlinePlaybackPlayer.Html5,
|
||||||
|
PlaybackEngineFamily.NativeMedia,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackSourceKind.Native,
|
||||||
|
InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
PlaybackEngineFamily.NativeMedia,
|
||||||
|
],
|
||||||
|
] as const)('maps %s on %s to %s', (sourceKind, target, expectedFamily) => {
|
||||||
|
expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe(
|
||||||
|
expectedFamily
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[PlaybackDiagnosticSource.Hls, {}, PlaybackSourceKind.Hls],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Vhs,
|
||||||
|
{ container: 'm3u8' },
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Source,
|
||||||
|
{ container: 'm3u' },
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Vhs,
|
||||||
|
{
|
||||||
|
mimeType: ' Application/Vnd.Apple.MPEGURL; charset=UTF-8 ',
|
||||||
|
},
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Source,
|
||||||
|
{ mimeType: 'application/x-mpegurl' },
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Vhs,
|
||||||
|
{ mimeType: 'audio/x-mpegurl' },
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
],
|
||||||
|
[PlaybackDiagnosticSource.Shaka, {}, PlaybackSourceKind.Dash],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Source,
|
||||||
|
{ container: 'mpd' },
|
||||||
|
PlaybackSourceKind.Dash,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Vhs,
|
||||||
|
{ container: 'mpd' },
|
||||||
|
PlaybackSourceKind.Dash,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Source,
|
||||||
|
{ mimeType: ' Application/Dash+XML; profile=live ' },
|
||||||
|
PlaybackSourceKind.Dash,
|
||||||
|
],
|
||||||
|
[PlaybackDiagnosticSource.MpegTs, {}, PlaybackSourceKind.MpegTs],
|
||||||
|
[PlaybackDiagnosticSource.Native, {}, PlaybackSourceKind.Native],
|
||||||
|
] as const)(
|
||||||
|
'resolves %s diagnostics with %o as %s',
|
||||||
|
(source, metadata, expectedKind) => {
|
||||||
|
expect(
|
||||||
|
resolvePlaybackSourceKind(diagnostic({ source, ...metadata }))
|
||||||
|
).toBe(expectedKind);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
diagnostic({ source: PlaybackDiagnosticSource.Source }),
|
||||||
|
diagnostic({
|
||||||
|
source: PlaybackDiagnosticSource.Source,
|
||||||
|
container: 'mp4',
|
||||||
|
mimeType: 'video/mp4',
|
||||||
|
}),
|
||||||
|
diagnostic({
|
||||||
|
source: PlaybackDiagnosticSource.Vhs,
|
||||||
|
container: 'mpd',
|
||||||
|
mimeType: 'application/vnd.apple.mpegurl',
|
||||||
|
}),
|
||||||
|
diagnostic({
|
||||||
|
source: PlaybackDiagnosticSource.Source,
|
||||||
|
container: 'm3u8',
|
||||||
|
mimeType: 'application/dash+xml',
|
||||||
|
}),
|
||||||
|
diagnostic({
|
||||||
|
source: PlaybackDiagnosticSource.Vhs,
|
||||||
|
mimeType: 'application/not-mpegurl',
|
||||||
|
}),
|
||||||
|
])('keeps insufficient or contradictory evidence unknown', (issue) => {
|
||||||
|
expect(resolvePlaybackSourceKind(issue)).toBe(
|
||||||
|
PlaybackSourceKind.Unknown
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Hls,
|
||||||
|
{ container: 'mpd', mimeType: 'application/dash+xml' },
|
||||||
|
PlaybackSourceKind.Hls,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.MpegTs,
|
||||||
|
{ container: 'mpd', mimeType: 'application/dash+xml' },
|
||||||
|
PlaybackSourceKind.MpegTs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Shaka,
|
||||||
|
{
|
||||||
|
container: 'm3u8',
|
||||||
|
mimeType: 'application/vnd.apple.mpegurl',
|
||||||
|
},
|
||||||
|
PlaybackSourceKind.Dash,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
PlaybackDiagnosticSource.Native,
|
||||||
|
{
|
||||||
|
container: 'm3u8',
|
||||||
|
mimeType: 'application/vnd.apple.mpegurl',
|
||||||
|
},
|
||||||
|
PlaybackSourceKind.Native,
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'treats engine-specific %s source evidence as authoritative',
|
||||||
|
(source, metadata, expectedKind) => {
|
||||||
|
expect(
|
||||||
|
resolvePlaybackSourceKind(diagnostic({ source, ...metadata }))
|
||||||
|
).toBe(expectedKind);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('creates capabilities in canonical order', () => {
|
||||||
|
expect(
|
||||||
|
createPlaybackTargetCapabilities({
|
||||||
|
sourceKind: PlaybackSourceKind.Hls,
|
||||||
|
managedExternalPlayersAvailable: true,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.VideoJs,
|
||||||
|
available: true,
|
||||||
|
engineFamily: PlaybackEngineFamily.Vhs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.Html5,
|
||||||
|
available: true,
|
||||||
|
engineFamily: PlaybackEngineFamily.HlsJs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
available: true,
|
||||||
|
engineFamily: PlaybackEngineFamily.HlsJs,
|
||||||
|
},
|
||||||
|
{ kind: 'external', target: 'mpv', available: true },
|
||||||
|
{ kind: 'external', target: 'vlc', available: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks Video.js unavailable for DASH and mirrors external availability', () => {
|
||||||
|
expect(
|
||||||
|
createPlaybackTargetCapabilities({
|
||||||
|
sourceKind: PlaybackSourceKind.Dash,
|
||||||
|
managedExternalPlayersAvailable: false,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.VideoJs,
|
||||||
|
available: false,
|
||||||
|
engineFamily: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.Html5,
|
||||||
|
available: true,
|
||||||
|
engineFamily: PlaybackEngineFamily.Shaka,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
available: true,
|
||||||
|
engineFamily: PlaybackEngineFamily.Shaka,
|
||||||
|
},
|
||||||
|
{ kind: 'external', target: 'mpv', available: false },
|
||||||
|
{ kind: 'external', target: 'vlc', available: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unknown inline source capabilities', () => {
|
||||||
|
expect(
|
||||||
|
createPlaybackTargetCapabilities({
|
||||||
|
sourceKind: PlaybackSourceKind.Unknown,
|
||||||
|
managedExternalPlayersAvailable: true,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.VideoJs,
|
||||||
|
available: false,
|
||||||
|
engineFamily: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.Html5,
|
||||||
|
available: false,
|
||||||
|
engineFamily: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'inline',
|
||||||
|
target: InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
available: false,
|
||||||
|
engineFamily: null,
|
||||||
|
},
|
||||||
|
{ kind: 'external', target: 'mpv', available: true },
|
||||||
|
{ kind: 'external', target: 'vlc', available: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
InlinePlaybackPlayer,
|
||||||
|
PlaybackDiagnosticSource,
|
||||||
|
type PlaybackDiagnostic,
|
||||||
|
} from './diagnostics/playback-diagnostics.model';
|
||||||
|
import {
|
||||||
|
PlaybackEngineFamily,
|
||||||
|
PlaybackSourceKind,
|
||||||
|
type PlaybackTargetCapability,
|
||||||
|
} from './playback-recommendation.model';
|
||||||
|
|
||||||
|
const HLS_MIME_TYPES = new Set([
|
||||||
|
'application/vnd.apple.mpegurl',
|
||||||
|
'application/x-mpegurl',
|
||||||
|
'audio/x-mpegurl',
|
||||||
|
]);
|
||||||
|
const DASH_MIME_TYPE = 'application/dash+xml';
|
||||||
|
|
||||||
|
export function resolvePlaybackSourceKind(
|
||||||
|
diagnostic: PlaybackDiagnostic
|
||||||
|
): PlaybackSourceKind {
|
||||||
|
switch (diagnostic.source) {
|
||||||
|
// Engine-specific sources identify the boundary that emitted the
|
||||||
|
// failure, so generic container/MIME metadata cannot override them.
|
||||||
|
case PlaybackDiagnosticSource.Hls:
|
||||||
|
return PlaybackSourceKind.Hls;
|
||||||
|
case PlaybackDiagnosticSource.MpegTs:
|
||||||
|
return PlaybackSourceKind.MpegTs;
|
||||||
|
case PlaybackDiagnosticSource.Shaka:
|
||||||
|
return PlaybackSourceKind.Dash;
|
||||||
|
case PlaybackDiagnosticSource.Source:
|
||||||
|
case PlaybackDiagnosticSource.Vhs:
|
||||||
|
return resolveSourceOrVhsKind(diagnostic);
|
||||||
|
case PlaybackDiagnosticSource.Native:
|
||||||
|
return PlaybackSourceKind.Native;
|
||||||
|
default:
|
||||||
|
return PlaybackSourceKind.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInlinePlaybackEngineFamily(
|
||||||
|
sourceKind: PlaybackSourceKind,
|
||||||
|
target: InlinePlaybackPlayer
|
||||||
|
): PlaybackEngineFamily | null {
|
||||||
|
switch (sourceKind) {
|
||||||
|
case PlaybackSourceKind.Hls:
|
||||||
|
return target === InlinePlaybackPlayer.VideoJs
|
||||||
|
? PlaybackEngineFamily.Vhs
|
||||||
|
: PlaybackEngineFamily.HlsJs;
|
||||||
|
case PlaybackSourceKind.MpegTs:
|
||||||
|
return PlaybackEngineFamily.MpegTsJs;
|
||||||
|
case PlaybackSourceKind.Dash:
|
||||||
|
return target === InlinePlaybackPlayer.VideoJs
|
||||||
|
? null
|
||||||
|
: PlaybackEngineFamily.Shaka;
|
||||||
|
case PlaybackSourceKind.Native:
|
||||||
|
return PlaybackEngineFamily.NativeMedia;
|
||||||
|
case PlaybackSourceKind.Unknown:
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlaybackTargetCapabilities(options: {
|
||||||
|
readonly sourceKind: PlaybackSourceKind;
|
||||||
|
readonly managedExternalPlayersAvailable: boolean;
|
||||||
|
}): readonly PlaybackTargetCapability[] {
|
||||||
|
const inlineTargets = [
|
||||||
|
InlinePlaybackPlayer.VideoJs,
|
||||||
|
InlinePlaybackPlayer.Html5,
|
||||||
|
InlinePlaybackPlayer.ArtPlayer,
|
||||||
|
] as const;
|
||||||
|
const inlineCapabilities = inlineTargets.map((target) => {
|
||||||
|
const engineFamily = getInlinePlaybackEngineFamily(
|
||||||
|
options.sourceKind,
|
||||||
|
target
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kind: 'inline' as const,
|
||||||
|
target,
|
||||||
|
available: engineFamily !== null,
|
||||||
|
engineFamily,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...inlineCapabilities,
|
||||||
|
{
|
||||||
|
kind: 'external',
|
||||||
|
target: 'mpv',
|
||||||
|
available: options.managedExternalPlayersAvailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'external',
|
||||||
|
target: 'vlc',
|
||||||
|
available: options.managedExternalPlayersAvailable,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSourceOrVhsKind(
|
||||||
|
diagnostic: PlaybackDiagnostic
|
||||||
|
): PlaybackSourceKind {
|
||||||
|
const containerKind = resolveContainerKind(diagnostic.container);
|
||||||
|
const mimeTypeKind = resolveMimeTypeKind(diagnostic.mimeType);
|
||||||
|
|
||||||
|
if (
|
||||||
|
containerKind !== null &&
|
||||||
|
mimeTypeKind !== null &&
|
||||||
|
containerKind !== mimeTypeKind
|
||||||
|
) {
|
||||||
|
return PlaybackSourceKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerKind ?? mimeTypeKind ?? PlaybackSourceKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContainerKind(container: string): PlaybackSourceKind | null {
|
||||||
|
if (container === 'm3u' || container === 'm3u8') {
|
||||||
|
return PlaybackSourceKind.Hls;
|
||||||
|
}
|
||||||
|
return container === 'mpd' ? PlaybackSourceKind.Dash : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMimeTypeKind(
|
||||||
|
mimeType: string | undefined
|
||||||
|
): PlaybackSourceKind | null {
|
||||||
|
const baseMimeType = mimeType?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
||||||
|
if (HLS_MIME_TYPES.has(baseMimeType)) {
|
||||||
|
return PlaybackSourceKind.Hls;
|
||||||
|
}
|
||||||
|
return baseMimeType === DASH_MIME_TYPE ? PlaybackSourceKind.Dash : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, relative } from 'node:path';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src');
|
||||||
|
const FORBIDDEN_IMPORT_PREFIXES = [
|
||||||
|
'@angular/',
|
||||||
|
'@iptvnator/ui/',
|
||||||
|
'electron',
|
||||||
|
'@ngx-pwa/',
|
||||||
|
] as const;
|
||||||
|
const FORBIDDEN_GLOBALS = new Set([
|
||||||
|
'window',
|
||||||
|
'document',
|
||||||
|
'localStorage',
|
||||||
|
'sessionStorage',
|
||||||
|
'MediaSource',
|
||||||
|
'MediaError',
|
||||||
|
'HTMLMediaElement',
|
||||||
|
'HTMLElement',
|
||||||
|
'navigator',
|
||||||
|
'indexedDB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('playback util boundary', () => {
|
||||||
|
it('keeps production sources independent of UI and browser globals', () => {
|
||||||
|
const violations = collectProductionSources(
|
||||||
|
PROJECT_SOURCE_ROOT
|
||||||
|
).flatMap(findBoundaryViolations);
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['import declaration', "import '@angular/core';", '@angular/core'],
|
||||||
|
[
|
||||||
|
're-export declaration',
|
||||||
|
"export * from '@iptvnator/ui/components';",
|
||||||
|
'@iptvnator/ui/components',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'import type node',
|
||||||
|
"type AngularType = import('@angular/core').Type;",
|
||||||
|
'@angular/core',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dynamic import',
|
||||||
|
"void import('@ngx-pwa/service-worker');",
|
||||||
|
'@ngx-pwa/service-worker',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'import-equals declaration',
|
||||||
|
"import Electron = require('electron'); void Electron;",
|
||||||
|
'electron',
|
||||||
|
],
|
||||||
|
])('rejects forbidden sources in a %s', (_label, source, moduleName) => {
|
||||||
|
const violations = findFixtureViolations(source);
|
||||||
|
|
||||||
|
expect(violations).toEqual([expect.stringContaining(moduleName)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows non-forbidden sources in every supported import form', () => {
|
||||||
|
const violations = findFixtureViolations(`
|
||||||
|
import './imported';
|
||||||
|
export * from './exported';
|
||||||
|
type LocalType = import('./typed').Type;
|
||||||
|
void import('./dynamic');
|
||||||
|
import Local = require('./required');
|
||||||
|
void Local;
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['direct identifier', 'void navigator;', 'navigator'],
|
||||||
|
['direct storage identifier', 'void indexedDB;', 'indexedDB'],
|
||||||
|
[
|
||||||
|
'globalThis property access',
|
||||||
|
'void globalThis.MediaSource;',
|
||||||
|
'MediaSource',
|
||||||
|
],
|
||||||
|
['self property access', 'void self.indexedDB;', 'indexedDB'],
|
||||||
|
[
|
||||||
|
'globalThis element access',
|
||||||
|
"void globalThis['localStorage'];",
|
||||||
|
'localStorage',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'self element access',
|
||||||
|
"void self['sessionStorage'];",
|
||||||
|
'sessionStorage',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'globalThis destructuring',
|
||||||
|
'const { navigator } = globalThis;',
|
||||||
|
'navigator',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'self destructuring',
|
||||||
|
'const { indexedDB: database } = self; void database;',
|
||||||
|
'indexedDB',
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'rejects forbidden browser globals through %s',
|
||||||
|
(_label, source, name) => {
|
||||||
|
const violations = findFixtureViolations(source);
|
||||||
|
|
||||||
|
expect(violations).toEqual([expect.stringContaining(name)]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['property access', 'void source.navigator;'],
|
||||||
|
['element access', "void source['indexedDB'];"],
|
||||||
|
['destructuring', 'const { navigator } = source;'],
|
||||||
|
[
|
||||||
|
'property declaration',
|
||||||
|
'const config = { MediaSource: source }; void config;',
|
||||||
|
],
|
||||||
|
['shadowed self', 'const self = source; void self.MediaSource;'],
|
||||||
|
[
|
||||||
|
'local forbidden-name binding',
|
||||||
|
'const navigator = source.navigator; void navigator;',
|
||||||
|
],
|
||||||
|
])('allows ordinary local %s names', (_label, source) => {
|
||||||
|
const violations = findFixtureViolations(source);
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function findFixtureViolations(source: string): string[] {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-'));
|
||||||
|
const filePath = join(directory, 'fixture.ts');
|
||||||
|
try {
|
||||||
|
writeFileSync(filePath, `${source}\nexport {};`);
|
||||||
|
return findBoundaryViolations(filePath);
|
||||||
|
} finally {
|
||||||
|
rmSync(directory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProductionSources(directory: string): string[] {
|
||||||
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return collectProductionSources(path);
|
||||||
|
}
|
||||||
|
return entry.isFile() &&
|
||||||
|
entry.name.endsWith('.ts') &&
|
||||||
|
!entry.name.endsWith('.spec.ts') &&
|
||||||
|
!entry.name.endsWith('.test.ts')
|
||||||
|
? [path]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBoundaryViolations(filePath: string): string[] {
|
||||||
|
const program = ts.createProgram({
|
||||||
|
rootNames: [filePath],
|
||||||
|
options: {
|
||||||
|
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'],
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||||
|
noEmit: true,
|
||||||
|
noResolve: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
target: ts.ScriptTarget.ESNext,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sourceFile = program.getSourceFile(filePath);
|
||||||
|
if (!sourceFile) {
|
||||||
|
throw new Error(`Unable to parse ${filePath}`);
|
||||||
|
}
|
||||||
|
const checker = program.getTypeChecker();
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
|
sourceFile.forEachChild(function visit(node): void {
|
||||||
|
const moduleSpecifier = getExternalModuleSpecifier(node);
|
||||||
|
if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) {
|
||||||
|
violations.push(formatViolation(sourceFile, node, moduleSpecifier));
|
||||||
|
}
|
||||||
|
for (const globalName of getForbiddenGlobalPropertyAccesses(
|
||||||
|
node,
|
||||||
|
checker,
|
||||||
|
sourceFile
|
||||||
|
)) {
|
||||||
|
violations.push(formatViolation(sourceFile, node, globalName));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isIdentifier(node) &&
|
||||||
|
FORBIDDEN_GLOBALS.has(node.text) &&
|
||||||
|
isAmbientIdentifierReference(node, checker, sourceFile)
|
||||||
|
) {
|
||||||
|
violations.push(formatViolation(sourceFile, node, node.text));
|
||||||
|
}
|
||||||
|
node.forEachChild(visit);
|
||||||
|
});
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExternalModuleSpecifier(node: ts.Node): string | undefined {
|
||||||
|
if (
|
||||||
|
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
||||||
|
node.moduleSpecifier
|
||||||
|
) {
|
||||||
|
return getStaticString(node.moduleSpecifier);
|
||||||
|
}
|
||||||
|
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||||
|
return getStaticString(node.argument.literal);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isCallExpression(node) &&
|
||||||
|
node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||||
|
) {
|
||||||
|
return getStaticString(node.arguments[0]);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isImportEqualsDeclaration(node) &&
|
||||||
|
ts.isExternalModuleReference(node.moduleReference)
|
||||||
|
) {
|
||||||
|
return getStaticString(node.moduleReference.expression);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStaticString(node?: ts.Node): string | undefined {
|
||||||
|
return node &&
|
||||||
|
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
||||||
|
? node.text
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean {
|
||||||
|
return FORBIDDEN_IMPORT_PREFIXES.some((prefix) =>
|
||||||
|
moduleSpecifier.startsWith(prefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForbiddenGlobalPropertyAccesses(
|
||||||
|
node: ts.Node,
|
||||||
|
checker: ts.TypeChecker,
|
||||||
|
sourceFile: ts.SourceFile
|
||||||
|
): string[] {
|
||||||
|
if (
|
||||||
|
ts.isPropertyAccessExpression(node) &&
|
||||||
|
isAmbientGlobalRoot(node.expression, checker, sourceFile) &&
|
||||||
|
FORBIDDEN_GLOBALS.has(node.name.text)
|
||||||
|
) {
|
||||||
|
return [node.name.text];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isElementAccessExpression(node) &&
|
||||||
|
isAmbientGlobalRoot(node.expression, checker, sourceFile)
|
||||||
|
) {
|
||||||
|
const propertyName = getStaticString(node.argumentExpression);
|
||||||
|
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
|
||||||
|
? [propertyName]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isVariableDeclaration(node) &&
|
||||||
|
ts.isObjectBindingPattern(node.name) &&
|
||||||
|
node.initializer &&
|
||||||
|
isAmbientGlobalRoot(node.initializer, checker, sourceFile)
|
||||||
|
) {
|
||||||
|
return node.name.elements.flatMap((element) => {
|
||||||
|
const propertyName = getBindingPropertyName(element);
|
||||||
|
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
|
||||||
|
? [propertyName]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindingPropertyName(
|
||||||
|
element: ts.BindingElement
|
||||||
|
): string | undefined {
|
||||||
|
const property = element.propertyName ?? element.name;
|
||||||
|
return ts.isIdentifier(property) || ts.isStringLiteral(property)
|
||||||
|
? property.text
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmbientGlobalRoot(
|
||||||
|
expression: ts.Expression,
|
||||||
|
checker: ts.TypeChecker,
|
||||||
|
sourceFile: ts.SourceFile
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
ts.isIdentifier(expression) &&
|
||||||
|
(expression.text === 'globalThis' || expression.text === 'self') &&
|
||||||
|
isAmbientIdentifier(expression, checker, sourceFile)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmbientIdentifierReference(
|
||||||
|
identifier: ts.Identifier,
|
||||||
|
checker: ts.TypeChecker,
|
||||||
|
sourceFile: ts.SourceFile
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!isPropertyName(identifier) &&
|
||||||
|
isAmbientIdentifier(identifier, checker, sourceFile)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmbientIdentifier(
|
||||||
|
identifier: ts.Identifier,
|
||||||
|
checker: ts.TypeChecker,
|
||||||
|
sourceFile: ts.SourceFile
|
||||||
|
): boolean {
|
||||||
|
const symbol = checker.getSymbolAtLocation(identifier);
|
||||||
|
return !symbol?.declarations?.some(
|
||||||
|
(declaration) => declaration.getSourceFile() === sourceFile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPropertyName(identifier: ts.Identifier): boolean {
|
||||||
|
const { parent } = identifier;
|
||||||
|
return (
|
||||||
|
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
||||||
|
(ts.isBindingElement(parent) &&
|
||||||
|
(parent.name === identifier ||
|
||||||
|
parent.propertyName === identifier)) ||
|
||||||
|
((ts.isPropertyAssignment(parent) ||
|
||||||
|
ts.isPropertyDeclaration(parent) ||
|
||||||
|
ts.isPropertySignature(parent) ||
|
||||||
|
ts.isMethodDeclaration(parent) ||
|
||||||
|
ts.isMethodSignature(parent) ||
|
||||||
|
ts.isEnumMember(parent)) &&
|
||||||
|
parent.name === identifier)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatViolation(
|
||||||
|
sourceFile: ts.SourceFile,
|
||||||
|
node: ts.Node,
|
||||||
|
detail: string
|
||||||
|
): string {
|
||||||
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
||||||
|
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["jest", "node"],
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<app-web-player-view
|
<app-web-player-view
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||||
[title]="inlinePlayerTitle()"
|
[title]="inlinePlayerTitle()"
|
||||||
[playback]="embeddedPlayback()"
|
[playback]="embeddedPlayback()"
|
||||||
@@ -112,7 +113,9 @@
|
|||||||
[programs]="epgPrograms()"
|
[programs]="epgPrograms()"
|
||||||
[channelName]="timelineChannelName()"
|
[channelName]="timelineChannelName()"
|
||||||
[channelLogo]="timelineChannelLogo()"
|
[channelLogo]="timelineChannelLogo()"
|
||||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
[archivePlaybackAvailable]="
|
||||||
|
archivePlaybackAvailable()
|
||||||
|
"
|
||||||
[archiveDays]="epgArchiveDays()"
|
[archiveDays]="epgArchiveDays()"
|
||||||
[activeProgram]="activeEpgProgramOrNull()"
|
[activeProgram]="activeEpgProgramOrNull()"
|
||||||
[isLivePlayback]="!showReturnToLive()"
|
[isLivePlayback]="!showReturnToLive()"
|
||||||
@@ -138,7 +141,9 @@
|
|||||||
[programs]="epgPrograms()"
|
[programs]="epgPrograms()"
|
||||||
[channelName]="timelineChannelName()"
|
[channelName]="timelineChannelName()"
|
||||||
[channelLogo]="timelineChannelLogo()"
|
[channelLogo]="timelineChannelLogo()"
|
||||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
[archivePlaybackAvailable]="
|
||||||
|
archivePlaybackAvailable()
|
||||||
|
"
|
||||||
[archiveDays]="epgArchiveDays()"
|
[archiveDays]="epgArchiveDays()"
|
||||||
[activeProgram]="activeEpgProgramOrNull()"
|
[activeProgram]="activeEpgProgramOrNull()"
|
||||||
[isLivePlayback]="!showReturnToLive()"
|
[isLivePlayback]="!showReturnToLive()"
|
||||||
|
|||||||
+48
-15
@@ -41,12 +41,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
EpgProgram,
|
EpgProgram,
|
||||||
|
ResolvedPortalPlayback,
|
||||||
Settings,
|
Settings,
|
||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||||
import { Overlay } from '@angular/cdk/overlay';
|
import { Overlay } from '@angular/cdk/overlay';
|
||||||
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||||
|
|
||||||
jest.unstable_mockModule('video.js', () => ({
|
jest.unstable_mockModule('video.js', () => ({
|
||||||
@@ -113,6 +115,7 @@ class StubAudioPlayerComponent {
|
|||||||
template: '',
|
template: '',
|
||||||
})
|
})
|
||||||
class StubWebPlayerViewComponent {
|
class StubWebPlayerViewComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly streamUrl = input('');
|
readonly streamUrl = input('');
|
||||||
readonly title = input('');
|
readonly title = input('');
|
||||||
readonly playback = input<unknown>(null);
|
readonly playback = input<unknown>(null);
|
||||||
@@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => {
|
|||||||
} as typeof window.electron;
|
} as typeof window.electron;
|
||||||
|
|
||||||
syncStoreState(null);
|
syncStoreState(null);
|
||||||
|
playlistId.set('playlist-1');
|
||||||
localStorage.removeItem('m3u-sidebar-width');
|
localStorage.removeItem('m3u-sidebar-width');
|
||||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||||
player.set(VideoPlayer.VideoJs);
|
player.set(VideoPlayer.VideoJs);
|
||||||
@@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => {
|
|||||||
fixture.nativeElement.querySelector('app-web-player-view')
|
fixture.nativeElement.querySelector('app-web-player-view')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
||||||
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||||
|
).toBeNull();
|
||||||
expect(headerContext.action()).toBeNull();
|
expect(headerContext.action()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => {
|
|||||||
},
|
},
|
||||||
} as Channel);
|
} as Channel);
|
||||||
|
|
||||||
|
const playback: ResolvedPortalPlayback = {
|
||||||
|
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
|
||||||
|
title: 'Sample TV',
|
||||||
|
isLive: true,
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'IPTVnator Test',
|
||||||
|
Referer: 'https://referrer.example.com',
|
||||||
|
Origin: 'https://origin.example.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
component.handleExternalFallbackRequest({
|
component.handleExternalFallbackRequest({
|
||||||
player: 'mpv',
|
player: 'mpv',
|
||||||
playback: {
|
playback,
|
||||||
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
|
|
||||||
title: 'Archive Sample',
|
|
||||||
},
|
|
||||||
diagnostic: {
|
diagnostic: {
|
||||||
code: 'unsupported-codec',
|
code: 'unsupported-codec',
|
||||||
source: 'hls',
|
source: 'hls',
|
||||||
@@ -577,19 +590,18 @@ describe('VideoPlayerComponent', () => {
|
|||||||
container: 'm3u8',
|
container: 'm3u8',
|
||||||
audioCodecs: ['ac-3'],
|
audioCodecs: ['ac-3'],
|
||||||
videoCodecs: ['avc1.64001f'],
|
videoCodecs: ['avc1.64001f'],
|
||||||
externalFallbackRecommended: true,
|
|
||||||
},
|
},
|
||||||
} satisfies PlaybackFallbackRequest);
|
} satisfies PlaybackFallbackRequest);
|
||||||
|
|
||||||
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
|
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
|
||||||
'OPEN_MPV_PLAYER',
|
'OPEN_MPV_PLAYER',
|
||||||
expect.objectContaining({
|
{
|
||||||
url: 'https://archive.example.com/live.m3u8?utc=1',
|
url: 'https://archive.example.com/live.m3u8?utc=1',
|
||||||
title: 'Sample TV',
|
title: 'Sample TV',
|
||||||
'user-agent': 'IPTVnator Test',
|
'user-agent': 'IPTVnator Test',
|
||||||
referer: 'https://referrer.example.com',
|
referer: 'https://referrer.example.com',
|
||||||
origin: 'https://origin.example.com',
|
origin: 'https://origin.example.com',
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -640,12 +652,36 @@ describe('VideoPlayerComponent', () => {
|
|||||||
By.directive(StubWebPlayerViewComponent)
|
By.directive(StubWebPlayerViewComponent)
|
||||||
);
|
);
|
||||||
expect(playerView).not.toBeNull();
|
expect(playerView).not.toBeNull();
|
||||||
const stub =
|
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
|
||||||
playerView.componentInstance as StubWebPlayerViewComponent;
|
|
||||||
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
|
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
|
||||||
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
|
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('owns a collision-safe live session key that ignores resolved URL changes', () => {
|
||||||
|
playlistId.set('playlist|one');
|
||||||
|
syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel);
|
||||||
|
activePlaybackUrl.set('https://archive.example/first.m3u8');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const playerView = fixture.debugElement.query(
|
||||||
|
By.directive(StubWebPlayerViewComponent)
|
||||||
|
).componentInstance as StubWebPlayerViewComponent;
|
||||||
|
const expected = createPlaybackSessionKey({
|
||||||
|
kind: 'live',
|
||||||
|
sourceId: 'playlist|one',
|
||||||
|
contentId: 'channel|one',
|
||||||
|
});
|
||||||
|
expect(playerView.playbackSessionKey()).toBe(expected);
|
||||||
|
|
||||||
|
activePlaybackUrl.set('https://archive.example/second.m3u8');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(playerView.playbackSessionKey()).toBe(expected);
|
||||||
|
|
||||||
|
syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(playerView.playbackSessionKey()).not.toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
|
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
|
||||||
syncStoreState(sampleChannel);
|
syncStoreState(sampleChannel);
|
||||||
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
|
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
|
||||||
@@ -657,8 +693,7 @@ describe('VideoPlayerComponent', () => {
|
|||||||
By.directive(StubWebPlayerViewComponent)
|
By.directive(StubWebPlayerViewComponent)
|
||||||
);
|
);
|
||||||
expect(playerView).not.toBeNull();
|
expect(playerView).not.toBeNull();
|
||||||
const stub =
|
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
|
||||||
playerView.componentInstance as StubWebPlayerViewComponent;
|
|
||||||
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
|
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -675,9 +710,7 @@ describe('VideoPlayerComponent', () => {
|
|||||||
// The external-player guard declines DASH channels, so the inline
|
// The external-player guard declines DASH channels, so the inline
|
||||||
// player must stay — otherwise the session has no player at all.
|
// player must stay — otherwise the session has no player at all.
|
||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(
|
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
|
||||||
By.directive(StubWebPlayerViewComponent)
|
|
||||||
)
|
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
WebPlayerViewComponent,
|
WebPlayerViewComponent,
|
||||||
} from '@iptvnator/ui/playback';
|
} from '@iptvnator/ui/playback';
|
||||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
|
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
|
||||||
import {
|
import {
|
||||||
DataService,
|
DataService,
|
||||||
@@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
readonly activePlaybackUrl = this.store.selectSignal(
|
readonly activePlaybackUrl = this.store.selectSignal(
|
||||||
selectActivePlaybackUrl
|
selectActivePlaybackUrl
|
||||||
);
|
);
|
||||||
readonly activeEpgProgram = this.store.selectSignal(
|
readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram);
|
||||||
selectActiveEpgProgram
|
|
||||||
);
|
|
||||||
readonly activeEpgProgramOrNull = computed(
|
readonly activeEpgProgramOrNull = computed(
|
||||||
() => this.activeEpgProgram() ?? null
|
() => this.activeEpgProgram() ?? null
|
||||||
);
|
);
|
||||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||||
|
readonly playbackSessionKey = computed(() => {
|
||||||
|
const sourceId = this.activePlaylistId();
|
||||||
|
const contentId = this.activeChannel()?.id;
|
||||||
|
return sourceId && contentId !== undefined
|
||||||
|
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
|
||||||
|
: '';
|
||||||
|
});
|
||||||
readonly channels = this.store.selectSignal(selectChannels);
|
readonly channels = this.store.selectSignal(selectChannels);
|
||||||
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
|
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
|
||||||
readonly archivePlaybackAvailable = computed(() =>
|
readonly archivePlaybackAvailable = computed(() =>
|
||||||
@@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
private readonly epgChannelLogo = toSignal(
|
private readonly epgChannelLogo = toSignal(
|
||||||
toObservable(this.activeChannel).pipe(
|
toObservable(this.activeChannel).pipe(
|
||||||
switchMap((channel) => {
|
switchMap((channel) => {
|
||||||
const key = channel
|
const key = channel ? resolveChannelEpgLookupKey(channel) : '';
|
||||||
? resolveChannelEpgLookupKey(channel)
|
|
||||||
: '';
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return of('');
|
return of('');
|
||||||
}
|
}
|
||||||
@@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentEpgProgram = epgProgram as
|
const currentEpgProgram = epgProgram as
|
||||||
| EpgProgram
|
EpgProgram | null | undefined;
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
const currentIndex = channels.findIndex(
|
const currentIndex = channels.findIndex(
|
||||||
(channel) => channel.url === activeChannel.url
|
(channel) => channel.url === activeChannel.url
|
||||||
);
|
);
|
||||||
|
|||||||
+4
@@ -383,6 +383,10 @@ describe('UnifiedFavoritesDataService', () => {
|
|||||||
'https://example.com/2.m3u8',
|
'https://example.com/2.m3u8',
|
||||||
'https://example.com/1.m3u8',
|
'https://example.com/1.m3u8',
|
||||||
]);
|
]);
|
||||||
|
expect(items.map((item) => item.uid)).toEqual([
|
||||||
|
'm3u::m3u-1::https://example.com/2.m3u8',
|
||||||
|
'm3u::m3u-1::https://example.com/1.m3u8',
|
||||||
|
]);
|
||||||
expect(items[1].channelId).toBe('channel-1');
|
expect(items[1].channelId).toBe('channel-1');
|
||||||
expect(items[0].radio).toBe('true');
|
expect(items[0].radio).toBe('true');
|
||||||
expect(items[0].m3uChannel).toBe(m3uChannels[1]);
|
expect(items[0].m3uChannel).toBe(m3uChannels[1]);
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ describe('UnifiedRecentDataService', () => {
|
|||||||
|
|
||||||
expect(items).toHaveLength(1);
|
expect(items).toHaveLength(1);
|
||||||
expect(items[0]).toMatchObject({
|
expect(items[0]).toMatchObject({
|
||||||
|
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
|
||||||
sourceType: 'm3u',
|
sourceType: 'm3u',
|
||||||
streamUrl: 'https://example.com/2.m3u8',
|
streamUrl: 'https://example.com/2.m3u8',
|
||||||
channelId: 'channel-2',
|
channelId: 'channel-2',
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||||
|
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
|
||||||
|
|
||||||
|
describe('createUnifiedLivePlaybackSessionKey', () => {
|
||||||
|
it('keeps an M3U key across transport changes and separates channels', () => {
|
||||||
|
const first = m3uItem({
|
||||||
|
uid: 'm3u::pl-1::https://old.example/live.m3u8',
|
||||||
|
streamUrl: 'https://old.example/live.m3u8',
|
||||||
|
channelId: 'provider-channel-7',
|
||||||
|
});
|
||||||
|
const expected = createPlaybackSessionKey({
|
||||||
|
kind: 'live',
|
||||||
|
sourceId: 'pl-1',
|
||||||
|
contentId: 'provider-channel-7',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected);
|
||||||
|
expect(
|
||||||
|
createUnifiedLivePlaybackSessionKey({
|
||||||
|
...first,
|
||||||
|
uid: 'm3u::pl-1::https://new.example/timeshift.m3u8',
|
||||||
|
streamUrl: 'https://new.example/timeshift.m3u8',
|
||||||
|
})
|
||||||
|
).toBe(expected);
|
||||||
|
expect(
|
||||||
|
createUnifiedLivePlaybackSessionKey({
|
||||||
|
...first,
|
||||||
|
channelId: 'provider-channel-8',
|
||||||
|
})
|
||||||
|
).not.toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function m3uItem(
|
||||||
|
overrides: Partial<UnifiedCollectionItem>
|
||||||
|
): UnifiedCollectionItem {
|
||||||
|
return {
|
||||||
|
uid: 'm3u::pl-1::channel',
|
||||||
|
name: 'Channel',
|
||||||
|
contentType: 'live',
|
||||||
|
sourceType: 'm3u',
|
||||||
|
playlistId: 'pl-1',
|
||||||
|
playlistName: 'Playlist',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||||
|
|
||||||
|
export function createUnifiedLivePlaybackSessionKey(
|
||||||
|
item: UnifiedCollectionItem | null
|
||||||
|
): string {
|
||||||
|
const sourceId = item?.playlistId.trim() ?? '';
|
||||||
|
const contentId = item ? getUnifiedLiveContentId(item) : '';
|
||||||
|
return sourceId && contentId
|
||||||
|
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnifiedLiveContentId(item: UnifiedCollectionItem): string {
|
||||||
|
switch (item.sourceType) {
|
||||||
|
case 'm3u':
|
||||||
|
return item.channelId?.trim() || item.m3uChannel?.id?.trim() || '';
|
||||||
|
case 'xtream':
|
||||||
|
return item.xtreamId == null ? '' : String(item.xtreamId);
|
||||||
|
case 'stalker':
|
||||||
|
return item.stalkerId == null ? '' : String(item.stalkerId).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -59,6 +59,7 @@
|
|||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<app-web-player-view
|
<app-web-player-view
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[streamUrl]="currentStreamUrl()"
|
[streamUrl]="currentStreamUrl()"
|
||||||
[title]="activeDetail()?.playback?.title ?? ''"
|
[title]="activeDetail()?.playback?.title ?? ''"
|
||||||
[playback]="inlinePlayback()"
|
[playback]="inlinePlayback()"
|
||||||
@@ -86,7 +87,9 @@
|
|||||||
[programs]="timelinePrograms()"
|
[programs]="timelinePrograms()"
|
||||||
[channelName]="timelineChannelName()"
|
[channelName]="timelineChannelName()"
|
||||||
[channelLogo]="timelineChannelLogo()"
|
[channelLogo]="timelineChannelLogo()"
|
||||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
[archivePlaybackAvailable]="
|
||||||
|
timelineArchiveAvailable()
|
||||||
|
"
|
||||||
[archiveDays]="timelineArchiveDays()"
|
[archiveDays]="timelineArchiveDays()"
|
||||||
[activeProgram]="activeTimeshiftProgram()"
|
[activeProgram]="activeTimeshiftProgram()"
|
||||||
[isLivePlayback]="!activeTimeshift()"
|
[isLivePlayback]="!activeTimeshift()"
|
||||||
@@ -113,7 +116,9 @@
|
|||||||
[programs]="timelinePrograms()"
|
[programs]="timelinePrograms()"
|
||||||
[channelName]="timelineChannelName()"
|
[channelName]="timelineChannelName()"
|
||||||
[channelLogo]="timelineChannelLogo()"
|
[channelLogo]="timelineChannelLogo()"
|
||||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
[archivePlaybackAvailable]="
|
||||||
|
timelineArchiveAvailable()
|
||||||
|
"
|
||||||
[archiveDays]="timelineArchiveDays()"
|
[archiveDays]="timelineArchiveDays()"
|
||||||
[activeProgram]="activeTimeshiftProgram()"
|
[activeProgram]="activeTimeshiftProgram()"
|
||||||
[isLivePlayback]="!activeTimeshift()"
|
[isLivePlayback]="!activeTimeshift()"
|
||||||
|
|||||||
+23
-23
@@ -25,10 +25,7 @@ import {
|
|||||||
} from '@iptvnator/ui/epg';
|
} from '@iptvnator/ui/epg';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||||
import {
|
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||||
RuntimeCapabilitiesService,
|
|
||||||
SettingsStore,
|
|
||||||
} from '@iptvnator/services';
|
|
||||||
import {
|
import {
|
||||||
EpgItem,
|
EpgItem,
|
||||||
EpgProgram,
|
EpgProgram,
|
||||||
@@ -46,6 +43,7 @@ import {
|
|||||||
StreamResolverService,
|
StreamResolverService,
|
||||||
UnifiedRecentDataService,
|
UnifiedRecentDataService,
|
||||||
} from '@iptvnator/portal/shared/data-access';
|
} from '@iptvnator/portal/shared/data-access';
|
||||||
|
import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[appResizable]',
|
selector: '[appResizable]',
|
||||||
@@ -125,6 +123,7 @@ class StubAudioPlayerComponent {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
class StubWebPlayerViewComponent {
|
class StubWebPlayerViewComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly streamUrl = input.required<string>();
|
readonly streamUrl = input.required<string>();
|
||||||
readonly title = input('');
|
readonly title = input('');
|
||||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||||
@@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
|
|
||||||
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
|
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
|
||||||
expect(webPlayer.title()).toBe('Xtream Live');
|
expect(webPlayer.title()).toBe('Xtream Live');
|
||||||
|
const sourceId = item.playlistId;
|
||||||
|
const contentId = String(item.xtreamId);
|
||||||
|
const key = sessionKey({ kind: 'live', sourceId, contentId });
|
||||||
|
expect(webPlayer.playbackSessionKey()).toBe(key);
|
||||||
|
component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' });
|
||||||
|
expect(component.playbackSessionKey()).not.toBe(key);
|
||||||
|
component.activeItem.set(item);
|
||||||
const playback = webPlayer.playback();
|
const playback = webPlayer.playback();
|
||||||
expect(playback).toEqual(
|
expect(playback).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
throw new Error('Expected wrapper playback to be set');
|
throw new Error('Expected wrapper playback to be set');
|
||||||
}
|
}
|
||||||
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
|
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
|
||||||
expect(
|
const host = fixture.nativeElement;
|
||||||
fixture.nativeElement.querySelector('app-vjs-player')
|
expect(host.querySelector('app-vjs-player')).toBeNull();
|
||||||
).toBeNull();
|
expect(host.querySelector('app-html-video-player')).toBeNull();
|
||||||
expect(
|
expect(host.querySelector('app-art-player')).toBeNull();
|
||||||
fixture.nativeElement.querySelector('app-html-video-player')
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('app-art-player')
|
|
||||||
).toBeNull();
|
|
||||||
|
|
||||||
webPlayer.externalFallbackRequested.emit({
|
webPlayer.externalFallbackRequested.emit({
|
||||||
player: 'mpv',
|
player: 'mpv',
|
||||||
@@ -618,16 +619,13 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
mimeType: '',
|
mimeType: '',
|
||||||
videoCodecs: [],
|
videoCodecs: [],
|
||||||
audioCodecs: [],
|
audioCodecs: [],
|
||||||
externalFallbackRecommended: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
|
const [forwardedPlayback, forwardedPlayer] =
|
||||||
expect.objectContaining({
|
portalPlayer.openExternalPlayback.mock.calls[0];
|
||||||
streamUrl: 'https://example.com/xtream.m3u8',
|
expect(forwardedPlayback).toBe(playback);
|
||||||
}),
|
expect(forwardedPlayer).toBe('mpv');
|
||||||
'mpv'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not wait for M3U program lookup before opening playback', async () => {
|
it('does not wait for M3U program lookup before opening playback', async () => {
|
||||||
@@ -749,9 +747,9 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
// auth-gated streams) before the audio element gets the URL, and
|
// auth-gated streams) before the audio element gets the URL, and
|
||||||
// clears it again on close.
|
// clears it again on close.
|
||||||
const setUserAgent = jest.fn().mockResolvedValue(true);
|
const setUserAgent = jest.fn().mockResolvedValue(true);
|
||||||
(
|
(window.electron as unknown as Record<string, unknown>)[
|
||||||
window.electron as unknown as Record<string, unknown>
|
'setUserAgent'
|
||||||
)['setUserAgent'] = setUserAgent;
|
] = setUserAgent;
|
||||||
const item = {
|
const item = {
|
||||||
...buildLiveItem('stalker'),
|
...buildLiveItem('stalker'),
|
||||||
name: 'Jazz Radio',
|
name: 'Jazz Radio',
|
||||||
@@ -1177,6 +1175,7 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
'https://example.com/timeshift.m3u8'
|
'https://example.com/timeshift.m3u8'
|
||||||
);
|
);
|
||||||
const item = await selectXtreamArchiveChannel();
|
const item = await selectXtreamArchiveChannel();
|
||||||
|
const key = component.playbackSessionKey();
|
||||||
|
|
||||||
component.onTimelineProgramActivated(timeshiftEvent());
|
component.onTimelineProgramActivated(timeshiftEvent());
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -1192,6 +1191,7 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
'https://example.com/timeshift.m3u8'
|
'https://example.com/timeshift.m3u8'
|
||||||
);
|
);
|
||||||
expect(component.inlinePlayback()?.isLive).toBe(false);
|
expect(component.inlinePlayback()?.isLive).toBe(false);
|
||||||
|
expect(component.playbackSessionKey()).toBe(key);
|
||||||
expect(component.activeTimeshiftProgram()?.title).toBe(
|
expect(component.activeTimeshiftProgram()?.title).toBe(
|
||||||
'Xtream Show'
|
'Xtream Show'
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-8
@@ -62,6 +62,7 @@ import {
|
|||||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||||
|
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-unified-live-tab',
|
selector: 'app-unified-live-tab',
|
||||||
@@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent {
|
|||||||
* portal archive fields (tvArchive/tvArchiveDuration) and to
|
* portal archive fields (tvArchive/tvArchiveDuration) and to
|
||||||
* supply credentials for Xtream catch-up URL resolution. */
|
* supply credentials for Xtream catch-up URL resolution. */
|
||||||
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
|
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
|
||||||
|
readonly playbackSessionKey = computed(() =>
|
||||||
|
createUnifiedLivePlaybackSessionKey(this.activeItem())
|
||||||
|
);
|
||||||
readonly isSelecting = signal(false);
|
readonly isSelecting = signal(false);
|
||||||
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
|
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
|
||||||
readonly progressTick = signal(0);
|
readonly progressTick = signal(0);
|
||||||
@@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent {
|
|||||||
return getLiveEpgPanelSummary(this.activeDetail());
|
return getLiveEpgPanelSummary(this.activeDetail());
|
||||||
});
|
});
|
||||||
readonly liveEpgPanelSummaryLabelKey = computed(() =>
|
readonly liveEpgPanelSummaryLabelKey = computed(() =>
|
||||||
this.activeTimeshift()
|
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
|
||||||
? 'EPG.ARCHIVE_PLAYBACK'
|
|
||||||
: 'EPG.CURRENT_PROGRAM'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
|
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
|
||||||
@@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent {
|
|||||||
program.startTimestamp,
|
program.startTimestamp,
|
||||||
program.start
|
program.start
|
||||||
);
|
);
|
||||||
const stopEpoch = toEpochSeconds(
|
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
|
||||||
program.stopTimestamp,
|
|
||||||
program.stop
|
|
||||||
);
|
|
||||||
if (startEpoch == null || stopEpoch == null) {
|
if (startEpoch == null || stopEpoch == null) {
|
||||||
this.snackBar.open(
|
this.snackBar.open(
|
||||||
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
|
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
|
||||||
@@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+1
@@ -8,6 +8,7 @@
|
|||||||
<app-stalker-series-view [providerOnly]="providerOnly()" />
|
<app-stalker-series-view [providerOnly]="providerOnly()" />
|
||||||
} @else if (vodDetailsItem()) {
|
} @else if (vodDetailsItem()) {
|
||||||
<app-vod-details
|
<app-vod-details
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[item]="vodDetailsItem()!"
|
[item]="vodDetailsItem()!"
|
||||||
[isFavorite]="isSelectedVodFavorite()"
|
[isFavorite]="isSelectedVodFavorite()"
|
||||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||||
|
|||||||
+144
-3
@@ -19,9 +19,21 @@ import { EMPTY, of } from 'rxjs';
|
|||||||
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
||||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||||
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component';
|
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, reject, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
@Component({ selector: 'app-vod-details', template: '' })
|
@Component({ selector: 'app-vod-details', template: '' })
|
||||||
class StubVodDetailsComponent {
|
class StubVodDetailsComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly item = input.required<unknown>();
|
readonly item = input.required<unknown>();
|
||||||
readonly providerOnly = input(false);
|
readonly providerOnly = input(false);
|
||||||
readonly isFavorite = input(false);
|
readonly isFavorite = input(false);
|
||||||
@@ -47,7 +59,15 @@ class StubStalkerSeriesViewComponent {
|
|||||||
|
|
||||||
describe('StalkerCatalogDetailComponent provider presentation', () => {
|
describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||||
let fixture: ComponentFixture<StalkerCatalogDetailComponent>;
|
let fixture: ComponentFixture<StalkerCatalogDetailComponent>;
|
||||||
|
const resolveVodPlayback = jest.fn();
|
||||||
|
const portalPlayer = {
|
||||||
|
isEmbeddedPlayer: jest.fn(() => true),
|
||||||
|
openResolvedPlayback: jest.fn(),
|
||||||
|
openExternalPlayback: jest.fn(),
|
||||||
|
};
|
||||||
const contentType = signal<'vod' | 'series'>('vod');
|
const contentType = signal<'vod' | 'series'>('vod');
|
||||||
|
const catalogPlaylist = signal({ id: 'stalker-1' });
|
||||||
|
const snackBar = { open: jest.fn() };
|
||||||
const selectedItem = signal<unknown>({
|
const selectedItem = signal<unknown>({
|
||||||
id: '42',
|
id: '42',
|
||||||
cmd: '/media/42',
|
cmd: '/media/42',
|
||||||
@@ -61,6 +81,10 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
cmd: '/media/42',
|
cmd: '/media/42',
|
||||||
info: { name: 'Portal movie' },
|
info: { name: 'Portal movie' },
|
||||||
});
|
});
|
||||||
|
resolveVodPlayback.mockReset();
|
||||||
|
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||||
|
catalogPlaylist.set({ id: 'stalker-1' });
|
||||||
|
snackBar.open.mockReset();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StalkerCatalogDetailComponent],
|
imports: [StalkerCatalogDetailComponent],
|
||||||
@@ -70,8 +94,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
contentType,
|
contentType,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
playlist: signal({ id: 'stalker-1' }),
|
playlist: catalogPlaylist,
|
||||||
clearSelectedItem: jest.fn(),
|
clearSelectedItem: jest.fn(),
|
||||||
|
resolveVodPlayback,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,7 +108,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: PORTAL_PLAYER,
|
provide: PORTAL_PLAYER,
|
||||||
useValue: { isEmbeddedPlayer: jest.fn() },
|
useValue: portalPlayer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||||
@@ -99,7 +124,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
},
|
},
|
||||||
{ provide: DownloadsService, useValue: {} },
|
{ provide: DownloadsService, useValue: {} },
|
||||||
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
|
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
|
||||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
{ provide: MatSnackBar, useValue: snackBar },
|
||||||
{
|
{
|
||||||
provide: TranslateService,
|
provide: TranslateService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -139,6 +164,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
).componentInstance as StubVodDetailsComponent;
|
).componentInstance as StubVodDetailsComponent;
|
||||||
expect(fixture.componentInstance.providerOnly()).toBe(true);
|
expect(fixture.componentInstance.providerOnly()).toBe(true);
|
||||||
expect(child.providerOnly()).toBe(true);
|
expect(child.providerOnly()).toBe(true);
|
||||||
|
expect(child.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'stalker-1',
|
||||||
|
contentId: '42',
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
|
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
|
||||||
@@ -154,6 +186,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
).componentInstance as StubVodDetailsComponent;
|
).componentInstance as StubVodDetailsComponent;
|
||||||
expect(fixture.componentInstance.providerOnly()).toBe(false);
|
expect(fixture.componentInstance.providerOnly()).toBe(false);
|
||||||
expect(child.providerOnly()).toBe(false);
|
expect(child.providerOnly()).toBe(false);
|
||||||
|
expect(child.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'stalker-1',
|
||||||
|
contentId: '99',
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -178,4 +217,106 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
|||||||
expect(child.providerOnly()).toBe(true);
|
expect(child.providerOnly()).toBe(true);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('does not mount a VOD resolution after the catalog owner changes', async () => {
|
||||||
|
let resolve!: (value: { streamUrl: string }) => void;
|
||||||
|
resolveVodPlayback.mockReturnValueOnce(
|
||||||
|
new Promise((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.onVodPlay({
|
||||||
|
type: 'stalker',
|
||||||
|
cmd: '/media/42',
|
||||||
|
data: selectedItem(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
selectedItem.set({
|
||||||
|
id: '99',
|
||||||
|
cmd: '/media/99',
|
||||||
|
info: { name: 'Replacement movie' },
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears committed VOD playback when the playlist changes with the same provider id', async () => {
|
||||||
|
const playback = { streamUrl: 'https://a.example/movie.mpg' };
|
||||||
|
resolveVodPlayback.mockResolvedValue(playback);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||||
|
|
||||||
|
catalogPlaylist.set({ id: 'stalker-2' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'stalker-2',
|
||||||
|
contentId: '42',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mount a pending VOD resolution after only the playlist owner changes', async () => {
|
||||||
|
const pending = deferred<{ streamUrl: string }>();
|
||||||
|
resolveVodPlayback.mockReturnValue(pending.promise);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
|
||||||
|
catalogPlaylist.set({ id: 'stalker-2' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses a pending VOD error after the playlist owner changes', async () => {
|
||||||
|
const pending = deferred<{ streamUrl: string }>();
|
||||||
|
resolveVodPlayback.mockReturnValue(pending.promise);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
|
||||||
|
catalogPlaylist.set({ id: 'stalker-2' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
pending.reject(new Error('stale playlist failure'));
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
expect(snackBar.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves committed playback across a same-owner item refresh', async () => {
|
||||||
|
const playback = { streamUrl: 'https://a.example/movie.mpg' };
|
||||||
|
resolveVodPlayback.mockResolvedValue(playback);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
selectedItem.set({
|
||||||
|
...(selectedItem() as Record<string, unknown>),
|
||||||
|
screenshot_uri: 'refreshed.jpg',
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+37
-3
@@ -23,6 +23,7 @@ import {
|
|||||||
createRefreshTrigger,
|
createRefreshTrigger,
|
||||||
isStalkerSeriesFlag,
|
isStalkerSeriesFlag,
|
||||||
isSelectedStalkerVodFavorite,
|
isSelectedStalkerVodFavorite,
|
||||||
|
normalizeStalkerEntityId,
|
||||||
StalkerSelectedVodItem,
|
StalkerSelectedVodItem,
|
||||||
toggleStalkerVodFavorite,
|
toggleStalkerVodFavorite,
|
||||||
} from '@iptvnator/portal/stalker/data-access';
|
} from '@iptvnator/portal/stalker/data-access';
|
||||||
@@ -46,6 +47,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
|||||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||||
|
|
||||||
import { startStalkerVodDownload } from './stalker-vod-download';
|
import { startStalkerVodDownload } from './stalker-vod-download';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-stalker-catalog-detail',
|
selector: 'app-stalker-catalog-detail',
|
||||||
@@ -76,6 +78,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
private readonly downloadsService = inject(DownloadsService);
|
private readonly downloadsService = inject(DownloadsService);
|
||||||
private readonly logger = createLogger('StalkerCatalogDetail');
|
private readonly logger = createLogger('StalkerCatalogDetail');
|
||||||
private readonly favoritesRefresh = createRefreshTrigger();
|
private readonly favoritesRefresh = createRefreshTrigger();
|
||||||
|
private playbackRequestId = 0;
|
||||||
|
private currentPlaybackOwnerKey = '';
|
||||||
|
|
||||||
readonly contentType = this.catalog.contentType;
|
readonly contentType = this.catalog.contentType;
|
||||||
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
|
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
|
||||||
@@ -85,6 +89,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
);
|
);
|
||||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||||
readonly providerOnly = input(false);
|
readonly providerOnly = input(false);
|
||||||
|
readonly playbackSessionKey = computed(() => {
|
||||||
|
const sourceId = this.catalog.playlist()?.id;
|
||||||
|
const contentId = normalizeStalkerEntityId(this.selectedItem()?.id);
|
||||||
|
return sourceId && contentId
|
||||||
|
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
private readonly playbackOwnerKey = computed(() =>
|
||||||
|
JSON.stringify([this.playbackSessionKey(), this.contentType()])
|
||||||
|
);
|
||||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -147,8 +161,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const selectedItemId = this.selectedItem()?.id;
|
const ownerKey = this.playbackOwnerKey();
|
||||||
void selectedItemId;
|
if (ownerKey === this.currentPlaybackOwnerKey) return;
|
||||||
|
this.currentPlaybackOwnerKey = ownerKey;
|
||||||
this.closeInlinePlayer();
|
this.closeInlinePlayer();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,6 +251,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeInlinePlayer(): void {
|
closeInlinePlayer(): void {
|
||||||
|
this.playbackRequestId += 1;
|
||||||
this.inlinePlayback.set(null);
|
this.inlinePlayback.set(null);
|
||||||
this.positionWriter.reset();
|
this.positionWriter.reset();
|
||||||
}
|
}
|
||||||
@@ -305,6 +321,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
thumbnail?: string,
|
thumbnail?: string,
|
||||||
startTime?: number
|
startTime?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const requestId = ++this.playbackRequestId;
|
||||||
|
const sessionKey = this.playbackSessionKey();
|
||||||
|
const ownerKey = this.playbackOwnerKey();
|
||||||
|
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
|
||||||
|
if (usesEmbeddedPlayer && !sessionKey) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playback = await this.catalog.resolveVodPlayback(
|
const playback = await this.catalog.resolveVodPlayback(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -312,9 +334,15 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
requestId !== this.playbackRequestId ||
|
||||||
|
this.playbackOwnerKey() !== ownerKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.positionWriter.reset();
|
this.positionWriter.reset();
|
||||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
if (usesEmbeddedPlayer) {
|
||||||
this.inlinePlayback.set(playback);
|
this.inlinePlayback.set(playback);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -322,6 +350,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
|||||||
this.closeInlinePlayer();
|
this.closeInlinePlayer();
|
||||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestId !== this.playbackRequestId ||
|
||||||
|
this.playbackOwnerKey() !== ownerKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logger.error('Failed to start inline VOD playback', error);
|
this.logger.error('Failed to start inline VOD playback', error);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error && error.message === 'nothing_to_play'
|
error instanceof Error && error.message === 'nothing_to_play'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, input, output, signal } from '@angular/core';
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
|
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
|
||||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-content-hero',
|
selector: 'app-content-hero',
|
||||||
@@ -36,6 +38,7 @@ class StubContentHeroComponent {
|
|||||||
template: '',
|
template: '',
|
||||||
})
|
})
|
||||||
class StubStalkerInlineDetailComponent {
|
class StubStalkerInlineDetailComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||||
readonly seriesItem = input<unknown>(null);
|
readonly seriesItem = input<unknown>(null);
|
||||||
readonly isSeries = input(false);
|
readonly isSeries = input(false);
|
||||||
@@ -89,6 +92,8 @@ describe('StalkerCollectionDetailComponent', () => {
|
|||||||
getAllPlaybackPositions: jest.Mock;
|
getAllPlaybackPositions: jest.Mock;
|
||||||
clearPlaybackPosition: jest.Mock;
|
clearPlaybackPosition: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let snackBar: { open: jest.Mock };
|
||||||
|
let playlistsService: { getPlaylistById: jest.Mock };
|
||||||
|
|
||||||
const playlist = {
|
const playlist = {
|
||||||
_id: 'stalker-1',
|
_id: 'stalker-1',
|
||||||
@@ -140,6 +145,12 @@ describe('StalkerCollectionDetailComponent', () => {
|
|||||||
getAllPlaybackPositions: jest.fn(),
|
getAllPlaybackPositions: jest.fn(),
|
||||||
clearPlaybackPosition: jest.fn(),
|
clearPlaybackPosition: jest.fn(),
|
||||||
};
|
};
|
||||||
|
snackBar = { open: jest.fn() };
|
||||||
|
playlistsService = {
|
||||||
|
getPlaylistById: jest.fn((playlistId: string) =>
|
||||||
|
of({ ...playlist, _id: playlistId })
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StalkerCollectionDetailComponent],
|
imports: [StalkerCollectionDetailComponent],
|
||||||
@@ -167,9 +178,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MatSnackBar,
|
provide: MatSnackBar,
|
||||||
useValue: {
|
useValue: snackBar,
|
||||||
open: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: TranslateService,
|
provide: TranslateService,
|
||||||
@@ -185,7 +194,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: PlaylistsService,
|
provide: PlaylistsService,
|
||||||
useValue: {
|
useValue: {
|
||||||
getPlaylistById: jest.fn(() => of(playlist)),
|
getPlaylistById: playlistsService.getPlaylistById,
|
||||||
getPortalFavorites: jest.fn(() => of([])),
|
getPortalFavorites: jest.fn(() => of([])),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -315,9 +324,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
|||||||
).toEqual([1, 2]);
|
).toEqual([1, 2]);
|
||||||
// The snapshot renders immediately, but a background portal
|
// The snapshot renders immediately, but a background portal
|
||||||
// re-fetch must be triggered so new episodes can appear.
|
// re-fetch must be triggered so new episodes can appear.
|
||||||
expect(
|
expect(stalkerStore.refreshEmbeddedSeriesSelection).toHaveBeenCalled();
|
||||||
stalkerStore.refreshEmbeddedSeriesSelection
|
|
||||||
).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
|
it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
|
||||||
@@ -393,6 +400,131 @@ describe('StalkerCollectionDetailComponent', () => {
|
|||||||
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
|
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
|
||||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||||
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
|
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
|
||||||
|
const detail = fixture.debugElement.query(
|
||||||
|
By.directive(StubStalkerInlineDetailComponent)
|
||||||
|
).componentInstance as StubStalkerInlineDetailComponent;
|
||||||
|
expect(detail.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'stalker-1',
|
||||||
|
contentId: '1701',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a pending payload after the collection detail owner changes and lets the new owner commit', async () => {
|
||||||
|
const pendingA = deferred<ResolvedPortalPlayback>();
|
||||||
|
const pendingB = deferred<ResolvedPortalPlayback>();
|
||||||
|
stalkerStore.resolveVodPlayback.mockImplementation((cmd: string) =>
|
||||||
|
cmd.includes('1701') ? pendingA.promise : pendingB.promise
|
||||||
|
);
|
||||||
|
const itemA = buildCollectionItem({
|
||||||
|
stalkerId: '1701',
|
||||||
|
stalkerItem: buildVodSource('1701', 'Movie A'),
|
||||||
|
});
|
||||||
|
const itemB = buildCollectionItem({
|
||||||
|
stalkerId: '1702',
|
||||||
|
stalkerItem: buildVodSource('1702', 'Movie B'),
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', itemA);
|
||||||
|
await settleDetail(fixture);
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', itemB);
|
||||||
|
await settleDetail(fixture);
|
||||||
|
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||||
|
await settleDetail(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
expect(snackBar.open).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'stalker-1',
|
||||||
|
contentId: '1702',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
|
||||||
|
);
|
||||||
|
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
|
||||||
|
await settleDetail(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||||
|
streamUrl: 'https://streams.test/b.mp4',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses a pending error after the collection playlist owner changes', async () => {
|
||||||
|
const pending = deferred<ResolvedPortalPlayback>();
|
||||||
|
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
|
||||||
|
const source = buildVodSource('1701', 'Movie A');
|
||||||
|
const itemA = buildCollectionItem({
|
||||||
|
playlistId: 'stalker-1',
|
||||||
|
stalkerId: '1701',
|
||||||
|
stalkerItem: source,
|
||||||
|
});
|
||||||
|
const itemB = buildCollectionItem({
|
||||||
|
playlistId: 'stalker-2',
|
||||||
|
stalkerId: '1701',
|
||||||
|
stalkerItem: { ...source },
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', itemA);
|
||||||
|
await settleDetail(fixture);
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', itemB);
|
||||||
|
await settleDetail(fixture);
|
||||||
|
pending.reject(new Error('stale A failure'));
|
||||||
|
await settleDetail(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
expect(snackBar.open).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'stalker-2',
|
||||||
|
contentId: '1701',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a pending request when the same canonical owner is refreshed with a new object', async () => {
|
||||||
|
const pending = deferred<ResolvedPortalPlayback>();
|
||||||
|
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
|
||||||
|
const item = buildCollectionItem({
|
||||||
|
stalkerId: '1701',
|
||||||
|
stalkerItem: buildVodSource('1701', 'Movie A'),
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', item);
|
||||||
|
await settleDetail(fixture);
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
createStalkerVodItem(item.stalkerItem as never, item.playlistId)
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', {
|
||||||
|
...item,
|
||||||
|
stalkerItem: { ...(item.stalkerItem as object) },
|
||||||
|
});
|
||||||
|
await settleDetail(fixture);
|
||||||
|
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||||
|
await settleDetail(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||||
|
streamUrl: 'https://streams.test/a.mp4',
|
||||||
|
});
|
||||||
|
expect(snackBar.open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not load VOD playback position when the playlist id is missing', async () => {
|
it('does not load VOD playback position when the playlist id is missing', async () => {
|
||||||
@@ -444,6 +576,26 @@ function buildCollectionItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVodSource(id: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
category_id: 'vod',
|
||||||
|
cmd: `/media/file_${id}.mpg`,
|
||||||
|
info: { name: title },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
async function settleDetail(
|
async function settleDetail(
|
||||||
fixture: ComponentFixture<StalkerCollectionDetailComponent>
|
fixture: ComponentFixture<StalkerCollectionDetailComponent>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||||
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
|
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
interface StalkerCollectionStateSnapshot {
|
interface StalkerCollectionStateSnapshot {
|
||||||
currentPlaylist: Playlist | undefined;
|
currentPlaylist: Playlist | undefined;
|
||||||
@@ -65,12 +66,19 @@ interface StalkerCollectionDetailMode {
|
|||||||
needsSeriesFetch: boolean;
|
needsSeriesFetch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StalkerCollectionPlaybackOwner {
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly sessionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-stalker-collection-detail',
|
selector: 'app-stalker-collection-detail',
|
||||||
imports: [PortalDetailShellComponent, StalkerInlineDetailComponent],
|
imports: [PortalDetailShellComponent, StalkerInlineDetailComponent],
|
||||||
template: `
|
template: `
|
||||||
@if (inlineDetail().categoryId) {
|
@if (inlineDetail().categoryId) {
|
||||||
<app-stalker-inline-detail
|
<app-stalker-inline-detail
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[categoryId]="inlineDetail().categoryId"
|
[categoryId]="inlineDetail().categoryId"
|
||||||
[seriesItem]="inlineDetail().seriesItem"
|
[seriesItem]="inlineDetail().seriesItem"
|
||||||
[isSeries]="inlineDetail().isSeries"
|
[isSeries]="inlineDetail().isSeries"
|
||||||
@@ -124,6 +132,12 @@ export class StalkerCollectionDetailComponent {
|
|||||||
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
||||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||||
|
private readonly playbackOwner = computed(() =>
|
||||||
|
captureStalkerCollectionPlaybackOwner(this.item())
|
||||||
|
);
|
||||||
|
readonly playbackSessionKey = computed(
|
||||||
|
() => this.playbackOwner()?.sessionKey ?? ''
|
||||||
|
);
|
||||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -149,6 +163,7 @@ export class StalkerCollectionDetailComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private initRequestId = 0;
|
private initRequestId = 0;
|
||||||
|
private currentPlaybackOwnerKey = '';
|
||||||
private readonly vodPlayback = new StalkerVodPlaybackController({
|
private readonly vodPlayback = new StalkerVodPlaybackController({
|
||||||
inlinePlayback: this.inlinePlayback,
|
inlinePlayback: this.inlinePlayback,
|
||||||
selectedVodPosition: this.selectedVodPosition,
|
selectedVodPosition: this.selectedVodPosition,
|
||||||
@@ -158,6 +173,7 @@ export class StalkerCollectionDetailComponent {
|
|||||||
translateService: this.translateService,
|
translateService: this.translateService,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
playbackErrorLogMessage: 'Failed to start collection VOD playback',
|
playbackErrorLogMessage: 'Failed to start collection VOD playback',
|
||||||
|
playbackOwnerKey: () => this.playbackSessionKey(),
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -168,7 +184,12 @@ export class StalkerCollectionDetailComponent {
|
|||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const item = this.item();
|
const item = this.item();
|
||||||
|
const playbackOwnerKey = this.playbackSessionKey();
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
|
if (playbackOwnerKey !== this.currentPlaybackOwnerKey) {
|
||||||
|
this.currentPlaybackOwnerKey = playbackOwnerKey;
|
||||||
|
this.closeInlinePlayer();
|
||||||
|
}
|
||||||
void this.prepareDetail(item);
|
void this.prepareDetail(item);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -490,3 +511,31 @@ export class StalkerCollectionDetailComponent {
|
|||||||
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
|
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function captureStalkerCollectionPlaybackOwner(
|
||||||
|
item: UnifiedCollectionItem | null
|
||||||
|
): StalkerCollectionPlaybackOwner | null {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const sourceId = item.playlistId.trim();
|
||||||
|
const providerItem = item.stalkerItem as
|
||||||
|
{ id?: unknown; stream_id?: unknown } | undefined;
|
||||||
|
const uidParts = item.uid.split('::');
|
||||||
|
const contentId = normalizeStalkerEntityId(
|
||||||
|
providerItem?.id ??
|
||||||
|
providerItem?.stream_id ??
|
||||||
|
item.stalkerId ??
|
||||||
|
uidParts[uidParts.length - 1]
|
||||||
|
);
|
||||||
|
if (!sourceId || !contentId) return null;
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
sourceId,
|
||||||
|
contentId,
|
||||||
|
sessionKey: createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId,
|
||||||
|
contentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+1
@@ -15,6 +15,7 @@
|
|||||||
} @else if (vodDetailsItem(); as item) {
|
} @else if (vodDetailsItem(); as item) {
|
||||||
<!-- Regular VOD item -->
|
<!-- Regular VOD item -->
|
||||||
<app-vod-details
|
<app-vod-details
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[item]="item"
|
[item]="item"
|
||||||
[isFavorite]="isFavorite()"
|
[isFavorite]="isFavorite()"
|
||||||
[playbackPosition]="playbackPosition()"
|
[playbackPosition]="playbackPosition()"
|
||||||
|
|||||||
+6
@@ -8,6 +8,7 @@ import { StalkerInlineDetailComponent } from './stalker-inline-detail.component'
|
|||||||
|
|
||||||
@Component({ selector: 'app-vod-details', template: '' })
|
@Component({ selector: 'app-vod-details', template: '' })
|
||||||
class StubVodDetailsComponent {
|
class StubVodDetailsComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly item = input.required<unknown>();
|
readonly item = input.required<unknown>();
|
||||||
readonly providerOnly = input(false);
|
readonly providerOnly = input(false);
|
||||||
readonly isFavorite = input(false);
|
readonly isFavorite = input(false);
|
||||||
@@ -57,6 +58,10 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
|
|||||||
.compileComponents();
|
.compileComponents();
|
||||||
fixture = TestBed.createComponent(StalkerInlineDetailComponent);
|
fixture = TestBed.createComponent(StalkerInlineDetailComponent);
|
||||||
fixture.componentRef.setInput('providerOnly', true);
|
fixture.componentRef.setInput('providerOnly', true);
|
||||||
|
fixture.componentRef.setInput(
|
||||||
|
'playbackSessionKey',
|
||||||
|
'collection-owned-vod-key'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => fixture.destroy());
|
afterEach(() => fixture.destroy());
|
||||||
@@ -70,6 +75,7 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
|
|||||||
By.directive(StubVodDetailsComponent)
|
By.directive(StubVodDetailsComponent)
|
||||||
).componentInstance as StubVodDetailsComponent;
|
).componentInstance as StubVodDetailsComponent;
|
||||||
expect(child.providerOnly()).toBe(true);
|
expect(child.providerOnly()).toBe(true);
|
||||||
|
expect(child.playbackSessionKey()).toBe('collection-owned-vod-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
+1
@@ -32,6 +32,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class StalkerInlineDetailComponent {
|
export class StalkerInlineDetailComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||||
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
|
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
|
||||||
readonly isSeries = input<boolean>(false);
|
readonly isSeries = input<boolean>(false);
|
||||||
|
|||||||
+10
-3
@@ -32,7 +32,9 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="isFullListLoading()"
|
[disabled]="isFullListLoading()"
|
||||||
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
|
[attr.aria-label]="
|
||||||
|
'PORTALS.REFRESH_CHANNEL_LIST' | translate
|
||||||
|
"
|
||||||
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
|
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
|
||||||
(click)="refreshChannels()"
|
(click)="refreshChannels()"
|
||||||
>
|
>
|
||||||
@@ -83,7 +85,9 @@
|
|||||||
<div class="empty-search-state">
|
<div class="empty-search-state">
|
||||||
<app-portal-empty-state
|
<app-portal-empty-state
|
||||||
icon="tv_off"
|
icon="tv_off"
|
||||||
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
|
[message]="
|
||||||
|
'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -120,7 +124,9 @@
|
|||||||
(clicked)="playChannel(item)"
|
(clicked)="playChannel(item)"
|
||||||
(activated)="playChannel(item, true)"
|
(activated)="playChannel(item, true)"
|
||||||
(favoriteToggled)="toggleFavorite(item)"
|
(favoriteToggled)="toggleFavorite(item)"
|
||||||
(contextMenuRequested)="onChannelContextMenu(item, $event)"
|
(contextMenuRequested)="
|
||||||
|
onChannelContextMenu(item, $event)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +186,7 @@
|
|||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<app-web-player-view
|
<app-web-player-view
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[streamUrl]="streamUrl()"
|
[streamUrl]="streamUrl()"
|
||||||
[title]="activePlayback()?.title ?? ''"
|
[title]="activePlayback()?.title ?? ''"
|
||||||
[playback]="activePlayback()"
|
[playback]="activePlayback()"
|
||||||
|
|||||||
+68
-22
@@ -17,11 +17,11 @@ import {
|
|||||||
ResizableDirective,
|
ResizableDirective,
|
||||||
} from '@iptvnator/portal/shared/util';
|
} from '@iptvnator/portal/shared/util';
|
||||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||||
|
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||||
import {
|
import {
|
||||||
EpgListViewComponent,
|
AudioPlayerComponent,
|
||||||
EpgTimelineComponent,
|
type PlaybackFallbackRequest,
|
||||||
} from '@iptvnator/ui/epg';
|
} from '@iptvnator/ui/playback';
|
||||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
|
||||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||||
import { MockPipe } from 'ng-mocks';
|
import { MockPipe } from 'ng-mocks';
|
||||||
@@ -31,7 +31,10 @@ import {
|
|||||||
RuntimeCapabilitiesService,
|
RuntimeCapabilitiesService,
|
||||||
SettingsStore,
|
SettingsStore,
|
||||||
} from '@iptvnator/services';
|
} from '@iptvnator/services';
|
||||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
import {
|
||||||
|
EpgProgram,
|
||||||
|
ResolvedPortalPlayback,
|
||||||
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||||
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
||||||
@@ -66,10 +69,11 @@ class StubChannelListItemComponent {
|
|||||||
template: '',
|
template: '',
|
||||||
})
|
})
|
||||||
class StubWebPlayerViewComponent {
|
class StubWebPlayerViewComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
readonly streamUrl = input('');
|
readonly streamUrl = input('');
|
||||||
readonly title = input('');
|
readonly title = input('');
|
||||||
readonly playback = input<unknown>(null);
|
readonly playback = input<unknown>(null);
|
||||||
readonly externalFallbackRequested = output<unknown>();
|
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -202,9 +206,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
loaded: number;
|
loaded: number;
|
||||||
total: number;
|
total: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const itvFullChannelList = signal<
|
const itvFullChannelList = signal<ReturnType<typeof defaultItvChannels>>(
|
||||||
ReturnType<typeof defaultItvChannels>
|
[]
|
||||||
>([]);
|
);
|
||||||
const itvSelectedCategoryFromCache = signal(false);
|
const itvSelectedCategoryFromCache = signal(false);
|
||||||
const isPaginatedContentLoading = signal(false);
|
const isPaginatedContentLoading = signal(false);
|
||||||
|
|
||||||
@@ -266,6 +270,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
const portalPlayer = {
|
const portalPlayer = {
|
||||||
isEmbeddedPlayer: jest.fn(() => true),
|
isEmbeddedPlayer: jest.fn(() => true),
|
||||||
openResolvedPlayback: jest.fn(),
|
openResolvedPlayback: jest.fn(),
|
||||||
|
openExternalPlayback: jest.fn(),
|
||||||
};
|
};
|
||||||
const settingsStore = {
|
const settingsStore = {
|
||||||
openStreamOnDoubleClick: signal(false),
|
openStreamOnDoubleClick: signal(false),
|
||||||
@@ -277,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
// The store mock is module-scoped: reset so a failed test can't leak
|
// The store mock is module-scoped: reset so a failed test can't leak
|
||||||
// 'list' into siblings.
|
// 'list' into siblings.
|
||||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||||
|
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
|
||||||
window.electron = {
|
window.electron = {
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||||
@@ -327,6 +333,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
portalPlayer.isEmbeddedPlayer.mockReset();
|
portalPlayer.isEmbeddedPlayer.mockReset();
|
||||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||||
portalPlayer.openResolvedPlayback.mockClear();
|
portalPlayer.openResolvedPlayback.mockClear();
|
||||||
|
portalPlayer.openExternalPlayback.mockClear();
|
||||||
fetchChannelEpg.mockReset();
|
fetchChannelEpg.mockReset();
|
||||||
fetchChannelEpg.mockResolvedValue([]);
|
fetchChannelEpg.mockResolvedValue([]);
|
||||||
ensureBulkItvEpg.mockReset();
|
ensureBulkItvEpg.mockReset();
|
||||||
@@ -407,9 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
supportsEpgMapping: false,
|
supportsEpgMapping: false,
|
||||||
getEpgMapping: jest.fn().mockResolvedValue(null),
|
getEpgMapping: jest.fn().mockResolvedValue(null),
|
||||||
getEpgMappingsBatch: jest
|
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(null),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -503,7 +508,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
fixture.nativeElement.querySelector('app-web-player-view')
|
fixture.nativeElement.querySelector('app-web-player-view')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
||||||
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||||
|
).toBeNull();
|
||||||
const channelRows = fixture.debugElement.queryAll(
|
const channelRows = fixture.debugElement.queryAll(
|
||||||
By.directive(StubChannelListItemComponent)
|
By.directive(StubChannelListItemComponent)
|
||||||
);
|
);
|
||||||
@@ -526,6 +533,43 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards the exact resolved live playback to external fallback', () => {
|
||||||
|
const playback: ResolvedPortalPlayback = {
|
||||||
|
streamUrl: 'https://example.com/stalker-fallback.m3u8',
|
||||||
|
title: 'Alpha TV',
|
||||||
|
isLive: true,
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
contentInfo: {
|
||||||
|
playlistId: 'playlist-1',
|
||||||
|
contentXtreamId: 10001,
|
||||||
|
contentType: 'live',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const request: PlaybackFallbackRequest = {
|
||||||
|
player: 'vlc',
|
||||||
|
playback,
|
||||||
|
diagnostic: {
|
||||||
|
code: 'network-error',
|
||||||
|
player: 'html5',
|
||||||
|
source: 'hls',
|
||||||
|
container: '',
|
||||||
|
mimeType: '',
|
||||||
|
videoCodecs: [],
|
||||||
|
audioCodecs: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
component.handleExternalFallbackRequest(request);
|
||||||
|
|
||||||
|
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
|
||||||
|
playback,
|
||||||
|
'vlc'
|
||||||
|
);
|
||||||
|
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
|
||||||
|
playback
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders radio channel rows with compact no-EPG density', () => {
|
it('renders radio channel rows with compact no-EPG density', () => {
|
||||||
stalkerStore.selectedContentType.set('radio');
|
stalkerStore.selectedContentType.set('radio');
|
||||||
|
|
||||||
@@ -1115,7 +1159,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
|
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
|
||||||
expect(fetchChannelEpg).not.toHaveBeenCalled();
|
expect(fetchChannelEpg).not.toHaveBeenCalled();
|
||||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||||
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||||
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('app-audio-player')
|
fixture.nativeElement.querySelector('app-audio-player')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
@@ -1185,14 +1231,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
);
|
);
|
||||||
// Once: only the apply call gets the pending promise — the destroy
|
// Once: only the apply call gets the pending promise — the destroy
|
||||||
// clear below also calls the bridge and must not steal the resolver.
|
// clear below also calls the bridge and must not steal the resolver.
|
||||||
(
|
(window.electron?.setUserAgent as jest.Mock).mockImplementationOnce(
|
||||||
window.electron?.setUserAgent as jest.Mock
|
() => {
|
||||||
).mockImplementationOnce(() => {
|
signalApplyIssued();
|
||||||
signalApplyIssued();
|
return new Promise<boolean>((resolve) => {
|
||||||
return new Promise<boolean>((resolve) => {
|
resolveApply = () => resolve(true);
|
||||||
resolveApply = () => resolve(true);
|
});
|
||||||
});
|
}
|
||||||
});
|
);
|
||||||
resolveRadioPlayback.mockResolvedValue({
|
resolveRadioPlayback.mockResolvedValue({
|
||||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||||
title: 'Portal FM',
|
title: 'Portal FM',
|
||||||
|
|||||||
+81
-25
@@ -76,12 +76,24 @@ import {
|
|||||||
normalizeStalkerEntityId,
|
normalizeStalkerEntityId,
|
||||||
} from '@iptvnator/portal/stalker/data-access';
|
} from '@iptvnator/portal/stalker/data-access';
|
||||||
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
|
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
type StalkerPlayableChannel = StalkerPortalItem & {
|
type StalkerPlayableChannel = StalkerPortalItem & {
|
||||||
cmd?: string;
|
cmd?: string;
|
||||||
has_files?: unknown;
|
has_files?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface StalkerActiveLivePlaybackIdentity {
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly contentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StalkerPlaybackResolutionOwner {
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Channels rendered per "page" when the full list is served from the cache. */
|
/** Channels rendered per "page" when the full list is served from the cache. */
|
||||||
const FULL_LIST_RENDER_CHUNK = 100;
|
const FULL_LIST_RENDER_CHUNK = 100;
|
||||||
|
|
||||||
@@ -270,6 +282,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
this.portalPlayer.isEmbeddedPlayer()
|
this.portalPlayer.isEmbeddedPlayer()
|
||||||
);
|
);
|
||||||
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||||
|
private readonly activePlaybackIdentity =
|
||||||
|
signal<StalkerActiveLivePlaybackIdentity | null>(null);
|
||||||
|
readonly playbackSessionKey = computed(() => {
|
||||||
|
const identity = this.activePlaybackIdentity();
|
||||||
|
return identity
|
||||||
|
? createPlaybackSessionKey({ kind: 'live', ...identity })
|
||||||
|
: '';
|
||||||
|
});
|
||||||
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
|
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
|
||||||
readonly activePlaybackTitle = computed(
|
readonly activePlaybackTitle = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -366,7 +386,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
/** Stream URL of the radio playback whose header override this layout configured. */
|
/** Stream URL of the radio playback whose header override this layout configured. */
|
||||||
private radioHeaderScopeUrl: string | null = null;
|
private radioHeaderScopeUrl: string | null = null;
|
||||||
private playbackResolution: {
|
private playbackResolution: {
|
||||||
channelId: string;
|
ownerKey: string;
|
||||||
promise: Promise<ResolvedPortalPlayback>;
|
promise: Promise<ResolvedPortalPlayback>;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private lastPlaylistId: string | null | undefined = undefined;
|
private lastPlaylistId: string | null | undefined = undefined;
|
||||||
@@ -414,9 +434,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
untracked(() => {
|
untracked(() => {
|
||||||
if (contentType === 'radio') {
|
if (contentType === 'radio') {
|
||||||
this.stalkerStore.setRadioChannels([]);
|
this.stalkerStore.setRadioChannels([]);
|
||||||
} else if (
|
} else if (!this.stalkerStore.itvSelectedCategoryFromCache()) {
|
||||||
!this.stalkerStore.itvSelectedCategoryFromCache()
|
|
||||||
) {
|
|
||||||
this.stalkerStore.setItvChannels([]);
|
this.stalkerStore.setItvChannels([]);
|
||||||
}
|
}
|
||||||
this.stalkerStore.setPage(0);
|
this.stalkerStore.setPage(0);
|
||||||
@@ -449,8 +467,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
// store dedupes per channel id, so this is cheap on rerenders.
|
// store dedupes per channel id, so this is cheap on rerenders.
|
||||||
if (this.supportsEpgMapping && channels.length > 0) {
|
if (this.supportsEpgMapping && channels.length > 0) {
|
||||||
const channelIds = channels.map((channel) => channel.id);
|
const channelIds = channels.map((channel) => channel.id);
|
||||||
untracked(() =>
|
untracked(
|
||||||
void this.stalkerStore.applyMappedItvEpg(channelIds)
|
() => void this.stalkerStore.applyMappedItvEpg(channelIds)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -567,6 +585,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
// override slot.
|
// override slot.
|
||||||
this.playbackRequestId += 1;
|
this.playbackRequestId += 1;
|
||||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||||
|
this.clearActivePlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelectedChannel(item: StalkerItvChannel): boolean {
|
isSelectedChannel(item: StalkerItvChannel): boolean {
|
||||||
@@ -581,6 +600,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
) {
|
) {
|
||||||
const requestId = ++this.playbackRequestId;
|
const requestId = ++this.playbackRequestId;
|
||||||
const channelId = normalizeStalkerEntityId(item.id);
|
const channelId = normalizeStalkerEntityId(item.id);
|
||||||
|
const sourceId = normalizeStalkerEntityId(
|
||||||
|
this.stalkerStore.currentPlaylist()?._id
|
||||||
|
);
|
||||||
|
const contentType = this.stalkerStore.selectedContentType();
|
||||||
|
const isRadioMode = this.isRadioMode();
|
||||||
this.stalkerStore.setSelectedItem(item);
|
this.stalkerStore.setSelectedItem(item);
|
||||||
this.ensureChannelWithinRenderWindow(channelId);
|
this.ensureChannelWithinRenderWindow(channelId);
|
||||||
// A previously owned radio override must not survive into a
|
// A previously owned radio override must not survive into a
|
||||||
@@ -591,15 +615,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
this.radioHeaderScopeUrl = null;
|
this.radioHeaderScopeUrl = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isRadioMode = this.isRadioMode();
|
|
||||||
const playback = await this.resolvePlaybackForChannel(
|
const playback = await this.resolvePlaybackForChannel(
|
||||||
item,
|
item,
|
||||||
channelId
|
{ sourceId, contentType, channelId },
|
||||||
|
isRadioMode
|
||||||
);
|
);
|
||||||
if (
|
const owner = { sourceId, contentType, channelId };
|
||||||
requestId !== this.playbackRequestId ||
|
if (!this.isPlaybackRequestCurrent(requestId, owner)) {
|
||||||
this.selectedChannelId() !== channelId
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,12 +640,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
const stillCurrent = headerSync ? await headerSync : true;
|
const stillCurrent = headerSync ? await headerSync : true;
|
||||||
if (
|
if (
|
||||||
!stillCurrent ||
|
!stillCurrent ||
|
||||||
requestId !== this.playbackRequestId ||
|
!this.isPlaybackRequestCurrent(requestId, owner)
|
||||||
this.selectedChannelId() !== channelId
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.activePlayback.set(playback);
|
this.setActivePlayback(playback, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,12 +653,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.usesEmbeddedPlayer()) {
|
if (this.usesEmbeddedPlayer()) {
|
||||||
this.activePlayback.set(playback);
|
if (!sourceId || !channelId) return;
|
||||||
|
this.setActivePlayback(playback, {
|
||||||
|
sourceId,
|
||||||
|
contentId: channelId,
|
||||||
|
});
|
||||||
} else if (startPlayback) {
|
} else if (startPlayback) {
|
||||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== this.playbackRequestId) {
|
if (
|
||||||
|
!this.isPlaybackRequestCurrent(requestId, {
|
||||||
|
sourceId,
|
||||||
|
contentType,
|
||||||
|
channelId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,21 +683,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
|
|
||||||
private resolvePlaybackForChannel(
|
private resolvePlaybackForChannel(
|
||||||
item: StalkerItvChannel,
|
item: StalkerItvChannel,
|
||||||
channelId: string
|
owner: StalkerPlaybackResolutionOwner,
|
||||||
|
isRadioMode: boolean
|
||||||
): Promise<ResolvedPortalPlayback> {
|
): Promise<ResolvedPortalPlayback> {
|
||||||
const playbackChannelId = [
|
const ownerKey = JSON.stringify(owner);
|
||||||
this.stalkerStore.selectedContentType(),
|
if (this.playbackResolution?.ownerKey === ownerKey) {
|
||||||
channelId,
|
|
||||||
].join(':');
|
|
||||||
if (this.playbackResolution?.channelId === playbackChannelId) {
|
|
||||||
return this.playbackResolution.promise;
|
return this.playbackResolution.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playableItem = this.toPlayableChannel(item);
|
const playableItem = this.toPlayableChannel(item);
|
||||||
const promise = this.isRadioMode()
|
const promise = isRadioMode
|
||||||
? this.stalkerStore.resolveRadioPlayback(playableItem)
|
? this.stalkerStore.resolveRadioPlayback(playableItem)
|
||||||
: this.stalkerStore.resolveItvPlayback(playableItem);
|
: this.stalkerStore.resolveItvPlayback(playableItem);
|
||||||
this.playbackResolution = { channelId: playbackChannelId, promise };
|
this.playbackResolution = { ownerKey, promise };
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (this.playbackResolution?.promise === promise) {
|
if (this.playbackResolution?.promise === promise) {
|
||||||
@@ -679,6 +708,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPlaybackRequestCurrent(
|
||||||
|
requestId: number,
|
||||||
|
owner: StalkerPlaybackResolutionOwner
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
requestId === this.playbackRequestId &&
|
||||||
|
this.selectedChannelId() === owner.channelId &&
|
||||||
|
normalizeStalkerEntityId(
|
||||||
|
this.stalkerStore.currentPlaylist()?._id
|
||||||
|
) === owner.sourceId &&
|
||||||
|
this.stalkerStore.selectedContentType() === owner.contentType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActivePlayback(
|
||||||
|
playback: ResolvedPortalPlayback,
|
||||||
|
identity: StalkerActiveLivePlaybackIdentity | null
|
||||||
|
): void {
|
||||||
|
this.activePlaybackIdentity.set(identity);
|
||||||
|
this.activePlayback.set(playback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearActivePlayback(): void {
|
||||||
|
this.activePlaybackIdentity.set(null);
|
||||||
|
this.activePlayback.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
toggleFavorite(item: StalkerItvChannel) {
|
toggleFavorite(item: StalkerItvChannel) {
|
||||||
const itemId = normalizeStalkerEntityId(item.id);
|
const itemId = normalizeStalkerEntityId(item.id);
|
||||||
if (this.favorites.has(itemId)) {
|
if (this.favorites.has(itemId)) {
|
||||||
|
|||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
import {
|
||||||
|
LiveLayoutSidebarStateService,
|
||||||
|
PORTAL_PLAYER,
|
||||||
|
} from '@iptvnator/portal/shared/util';
|
||||||
|
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||||
|
import {
|
||||||
|
PlaylistsService,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
SettingsStore,
|
||||||
|
} from '@iptvnator/services';
|
||||||
|
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||||
|
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
|
||||||
|
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, reject, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||||
|
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||||
|
let component: StalkerLiveStreamLayoutComponent;
|
||||||
|
const playlist = signal({ _id: 'playlist-one', title: 'Portal One' });
|
||||||
|
const channels = [
|
||||||
|
{
|
||||||
|
id: 'channel-one',
|
||||||
|
cmd: 'ffrt4://itv/channel-one',
|
||||||
|
name: 'One',
|
||||||
|
o_name: 'One',
|
||||||
|
logo: 'one.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'channel-two',
|
||||||
|
cmd: 'ffrt4://itv/channel-two',
|
||||||
|
name: 'Two',
|
||||||
|
o_name: 'Two',
|
||||||
|
logo: 'two.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const itvChannels = signal(channels);
|
||||||
|
const selectedItvId = signal<string | undefined>(channels[0].id);
|
||||||
|
const selectedItem = signal<(typeof channels)[number] | null>(channels[0]);
|
||||||
|
const selectedContentType = signal<'itv' | 'radio'>('itv');
|
||||||
|
const resolveItvPlayback = jest.fn();
|
||||||
|
const snackBar = { open: jest.fn() };
|
||||||
|
const store = {
|
||||||
|
getSelectedCategoryName: signal('All'),
|
||||||
|
currentPlaylist: playlist,
|
||||||
|
selectedContentType,
|
||||||
|
selectedCategoryId: signal<string | null>('all'),
|
||||||
|
selectedItvId,
|
||||||
|
selectedItem,
|
||||||
|
itvChannels,
|
||||||
|
radioChannels: signal([]),
|
||||||
|
searchPhrase: signal(''),
|
||||||
|
hasMoreChannels: signal(false),
|
||||||
|
itvFullListActive: signal(false),
|
||||||
|
itvSelectedCategoryFromCache: signal(false),
|
||||||
|
itvFullListLoading: signal(false),
|
||||||
|
itvFullListProgress: signal(null),
|
||||||
|
itvFullChannelList: signal([]),
|
||||||
|
isPaginatedContentLoading: signal(false),
|
||||||
|
selectedItvEpgPrograms: signal([]),
|
||||||
|
bulkItvEpgByChannel: signal({}),
|
||||||
|
isLoadingBulkItvEpg: signal(false),
|
||||||
|
setItvChannels: jest.fn(),
|
||||||
|
setRadioChannels: jest.fn(),
|
||||||
|
setPage: jest.fn(),
|
||||||
|
preloadItvChannels: jest.fn(),
|
||||||
|
applyMappedItvEpg: jest.fn(),
|
||||||
|
clearBulkItvEpgCache: jest.fn(),
|
||||||
|
ensureBulkItvEpg: jest.fn(),
|
||||||
|
fetchChannelEpg: jest.fn(),
|
||||||
|
resolveItvPlayback,
|
||||||
|
resolveRadioPlayback: jest.fn(),
|
||||||
|
addToFavorites: jest.fn(),
|
||||||
|
removeFromFavorites: jest.fn(),
|
||||||
|
setSelectedItem: jest.fn((item: (typeof channels)[number]) => {
|
||||||
|
selectedItem.set(item);
|
||||||
|
selectedItvId.set(String(item.id));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
|
||||||
|
selectedContentType.set('itv');
|
||||||
|
selectedItvId.set(channels[0].id);
|
||||||
|
selectedItem.set(channels[0]);
|
||||||
|
resolveItvPlayback.mockReset();
|
||||||
|
snackBar.open.mockReset();
|
||||||
|
store.setSelectedItem.mockClear();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [StalkerLiveStreamLayoutComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: StalkerStore, useValue: store },
|
||||||
|
{
|
||||||
|
provide: RuntimeCapabilitiesService,
|
||||||
|
useValue: {
|
||||||
|
supportsEpg: false,
|
||||||
|
isElectron: false,
|
||||||
|
supportsEpgMapping: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlaylistsService,
|
||||||
|
useValue: { getPortalFavorites: () => of([]) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SettingsStore,
|
||||||
|
useValue: { openStreamOnDoubleClick: signal(false) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PORTAL_PLAYER,
|
||||||
|
useValue: {
|
||||||
|
isEmbeddedPlayer: () => true,
|
||||||
|
openResolvedPlayback: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ElectronStreamHeadersService,
|
||||||
|
useValue: { apply: jest.fn(), clear: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LiveLayoutSidebarStateService,
|
||||||
|
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
|
||||||
|
},
|
||||||
|
{ provide: EpgRuntimeBridgeService, useValue: {} },
|
||||||
|
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||||
|
{ provide: MatSnackBar, useValue: snackBar },
|
||||||
|
{
|
||||||
|
provide: TranslateService,
|
||||||
|
useValue: { instant: (key: string) => key },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(StalkerLiveStreamLayoutComponent, {
|
||||||
|
set: { template: '' },
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => fixture.destroy());
|
||||||
|
|
||||||
|
it('changes identity only when the current embedded playback commits', async () => {
|
||||||
|
const firstPlayback = { streamUrl: 'https://one.example/live.m3u8' };
|
||||||
|
resolveItvPlayback.mockResolvedValueOnce(firstPlayback);
|
||||||
|
await component.playChannel(channels[0]);
|
||||||
|
const firstKey = component.playbackSessionKey();
|
||||||
|
const session = () => [
|
||||||
|
component.activePlayback(),
|
||||||
|
component.playbackSessionKey(),
|
||||||
|
];
|
||||||
|
expect(session()).toEqual([firstPlayback, firstKey]);
|
||||||
|
|
||||||
|
const failed = deferred<ResolvedPortalPlayback>();
|
||||||
|
resolveItvPlayback.mockReturnValueOnce(failed.promise);
|
||||||
|
const failedSelection = component.playChannel(channels[1]);
|
||||||
|
failed.reject(new Error('resolution failed'));
|
||||||
|
await failedSelection;
|
||||||
|
expect(session()).toEqual([firstPlayback, firstKey]);
|
||||||
|
|
||||||
|
const stale = deferred<ResolvedPortalPlayback>();
|
||||||
|
const current = deferred<ResolvedPortalPlayback>();
|
||||||
|
resolveItvPlayback
|
||||||
|
.mockReturnValueOnce(stale.promise)
|
||||||
|
.mockReturnValueOnce(current.promise);
|
||||||
|
const staleSelection = component.playChannel(channels[0]);
|
||||||
|
const currentSelection = component.playChannel(channels[1]);
|
||||||
|
const currentPlayback = {
|
||||||
|
streamUrl: 'https://two.example/live.m3u8',
|
||||||
|
};
|
||||||
|
current.resolve(currentPlayback);
|
||||||
|
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||||
|
await Promise.all([staleSelection, currentSelection]);
|
||||||
|
expect(session()).toEqual([
|
||||||
|
currentPlayback,
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'live',
|
||||||
|
sourceId: 'playlist-one',
|
||||||
|
contentId: 'channel-two',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dedupe the same channel id across playlist owners', async () => {
|
||||||
|
const sourceA = deferred<ResolvedPortalPlayback>();
|
||||||
|
const sourceB = deferred<ResolvedPortalPlayback>();
|
||||||
|
resolveItvPlayback
|
||||||
|
.mockReturnValueOnce(sourceA.promise)
|
||||||
|
.mockReturnValueOnce(sourceB.promise);
|
||||||
|
const shared = { ...channels[0], id: 'shared-channel' };
|
||||||
|
playlist.set({ _id: 'playlist-a', title: 'Portal A' });
|
||||||
|
const requestA = component.playChannel(shared);
|
||||||
|
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
|
||||||
|
const requestB = component.playChannel(shared);
|
||||||
|
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
|
||||||
|
|
||||||
|
sourceB.resolve(playbackB);
|
||||||
|
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
|
||||||
|
await Promise.all([requestA, requestB]);
|
||||||
|
|
||||||
|
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
|
||||||
|
expect(component.activePlayback()).toBe(playbackB);
|
||||||
|
expect(component.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'live',
|
||||||
|
sourceId: 'playlist-b',
|
||||||
|
contentId: 'shared-channel',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the committed pair when source or channel identity is absent', async () => {
|
||||||
|
const valid = { streamUrl: 'https://valid.example/live.m3u8' };
|
||||||
|
resolveItvPlayback.mockResolvedValueOnce(valid);
|
||||||
|
await component.playChannel(channels[0]);
|
||||||
|
const key = component.playbackSessionKey();
|
||||||
|
|
||||||
|
playlist.set({ _id: '', title: 'Missing source' });
|
||||||
|
resolveItvPlayback.mockResolvedValueOnce({
|
||||||
|
streamUrl: 'https://invalid.example/source.m3u8',
|
||||||
|
});
|
||||||
|
await component.playChannel(channels[1]);
|
||||||
|
expect([
|
||||||
|
component.activePlayback(),
|
||||||
|
component.playbackSessionKey(),
|
||||||
|
]).toEqual([valid, key]);
|
||||||
|
|
||||||
|
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
|
||||||
|
resolveItvPlayback.mockResolvedValueOnce({
|
||||||
|
streamUrl: 'https://invalid.example/channel.m3u8',
|
||||||
|
});
|
||||||
|
await component.playChannel({ ...channels[1], id: '' });
|
||||||
|
expect([
|
||||||
|
component.activePlayback(),
|
||||||
|
component.playbackSessionKey(),
|
||||||
|
]).toEqual([valid, key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not commit a pending ITV request after the mode changes to radio with the same channel id', async () => {
|
||||||
|
const pending = deferred<ResolvedPortalPlayback>();
|
||||||
|
resolveItvPlayback.mockReturnValue(pending.promise);
|
||||||
|
const request = component.playChannel(channels[0]);
|
||||||
|
|
||||||
|
selectedContentType.set('radio');
|
||||||
|
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
|
||||||
|
await request;
|
||||||
|
|
||||||
|
expect(component.activePlayback()).toBeNull();
|
||||||
|
expect(component.playbackSessionKey()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses a stale rejection after playlist, mode, and channel ownership change', async () => {
|
||||||
|
const pending = deferred<ResolvedPortalPlayback>();
|
||||||
|
resolveItvPlayback.mockReturnValue(pending.promise);
|
||||||
|
const request = component.playChannel(channels[0]);
|
||||||
|
|
||||||
|
playlist.set({ _id: 'playlist-two', title: 'Portal Two' });
|
||||||
|
selectedContentType.set('radio');
|
||||||
|
selectedItvId.set(channels[1].id);
|
||||||
|
pending.reject(new Error('stale owner failure'));
|
||||||
|
await request;
|
||||||
|
|
||||||
|
expect(snackBar.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports an error for the current full owner', async () => {
|
||||||
|
resolveItvPlayback.mockRejectedValueOnce(new Error('current failure'));
|
||||||
|
|
||||||
|
await component.playChannel(channels[0]);
|
||||||
|
|
||||||
|
expect(snackBar.open).toHaveBeenCalledWith(
|
||||||
|
'PORTALS.PLAYBACK_ERROR',
|
||||||
|
undefined,
|
||||||
|
{ duration: 3000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
@if (showingDetails) {
|
@if (showingDetails) {
|
||||||
<app-stalker-inline-detail
|
<app-stalker-inline-detail
|
||||||
details
|
details
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
[categoryId]="inlineDetail().categoryId"
|
[categoryId]="inlineDetail().categoryId"
|
||||||
[seriesItem]="inlineDetail().seriesItem"
|
[seriesItem]="inlineDetail().seriesItem"
|
||||||
[isSeries]="inlineDetail().isSeries"
|
[isSeries]="inlineDetail().isSeries"
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||||
|
import {
|
||||||
|
PORTAL_EXTERNAL_PLAYBACK,
|
||||||
|
PORTAL_PLAYBACK_POSITIONS,
|
||||||
|
PORTAL_PLAYER,
|
||||||
|
} from '@iptvnator/portal/shared/util';
|
||||||
|
import {
|
||||||
|
StalkerPortalRepairService,
|
||||||
|
StalkerSessionService,
|
||||||
|
StalkerStore,
|
||||||
|
} from '@iptvnator/portal/stalker/data-access';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||||
|
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||||
|
import { StalkerSearchComponent } from './stalker-search.component';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, reject, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-stalker-inline-detail',
|
||||||
|
standalone: true,
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class StubStalkerInlineDetailComponent {
|
||||||
|
readonly playbackSessionKey = input.required<string>();
|
||||||
|
readonly inlinePlayback = input<unknown>(null);
|
||||||
|
readonly inlinePlaybackClosed = output<void>();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StalkerSearchComponent playback session key', () => {
|
||||||
|
let fixture: ComponentFixture<StalkerSearchComponent>;
|
||||||
|
const playlist = signal({
|
||||||
|
_id: 'playlist|one',
|
||||||
|
title: 'Search portal',
|
||||||
|
portalUrl: 'https://stalker.example',
|
||||||
|
macAddress: '00:1A:79:12:34:56',
|
||||||
|
});
|
||||||
|
const selectedItem = signal<unknown>(null);
|
||||||
|
const resolveVodPlayback = jest.fn();
|
||||||
|
const snackBar = { open: jest.fn() };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
playlist.set({
|
||||||
|
_id: 'playlist|one',
|
||||||
|
title: 'Search portal',
|
||||||
|
portalUrl: 'https://stalker.example',
|
||||||
|
macAddress: '00:1A:79:12:34:56',
|
||||||
|
});
|
||||||
|
selectedItem.set(null);
|
||||||
|
resolveVodPlayback.mockReset();
|
||||||
|
snackBar.open.mockReset();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [StalkerSearchComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
queryParamMap: of(convertToParamMap({})),
|
||||||
|
snapshot: {
|
||||||
|
data: {},
|
||||||
|
queryParamMap: convertToParamMap({}),
|
||||||
|
routeConfig: { path: 'search' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: Location, useValue: { back: jest.fn() } },
|
||||||
|
{ provide: DataService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: PlaylistContextFacade,
|
||||||
|
useValue: { activePlaylist: playlist },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlaylistsService,
|
||||||
|
useValue: { getPortalFavorites: () => of([]) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: StalkerStore,
|
||||||
|
useValue: {
|
||||||
|
selectedItem,
|
||||||
|
setSelectedContentType: jest.fn(),
|
||||||
|
setSelectedItem: jest.fn((item) =>
|
||||||
|
selectedItem.set(item)
|
||||||
|
),
|
||||||
|
addToFavorites: jest.fn(),
|
||||||
|
removeFromFavorites: jest.fn(),
|
||||||
|
resolveVodPlayback,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: StalkerSessionService, useValue: {} },
|
||||||
|
{ provide: StalkerPortalRepairService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||||
|
useValue: { activeSession: signal(null) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||||
|
useValue: {
|
||||||
|
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||||
|
savePlaybackPosition: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PORTAL_PLAYER,
|
||||||
|
useValue: {
|
||||||
|
isEmbeddedPlayer: () => true,
|
||||||
|
openResolvedPlayback: jest.fn(),
|
||||||
|
openExternalPlayback: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: MatSnackBar, useValue: snackBar },
|
||||||
|
{
|
||||||
|
provide: TranslateService,
|
||||||
|
useValue: { instant: (key: string) => key },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(StalkerSearchComponent, {
|
||||||
|
set: {
|
||||||
|
imports: [StubStalkerInlineDetailComponent],
|
||||||
|
template: `
|
||||||
|
@if (showingDetails) {
|
||||||
|
<app-stalker-inline-detail
|
||||||
|
[playbackSessionKey]="playbackSessionKey()"
|
||||||
|
[inlinePlayback]="inlinePlayback()"
|
||||||
|
(inlinePlaybackClosed)="closeInlinePlayer()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
fixture = TestBed.createComponent(StalkerSearchComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => fixture?.destroy());
|
||||||
|
|
||||||
|
it('owns the original item identity and threads it unchanged through payload replacement', () => {
|
||||||
|
const first = {
|
||||||
|
id: 'movie|one',
|
||||||
|
cmd: 'ffrt4://movie/one',
|
||||||
|
name: 'Movie One',
|
||||||
|
info: { name: 'Movie One' },
|
||||||
|
};
|
||||||
|
fixture.componentInstance.selectItem(first);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const expected = createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'playlist|one',
|
||||||
|
contentId: 'movie|one',
|
||||||
|
});
|
||||||
|
const detail = fixture.debugElement.query(
|
||||||
|
By.directive(StubStalkerInlineDetailComponent)
|
||||||
|
).componentInstance as StubStalkerInlineDetailComponent;
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||||
|
expect(detail.playbackSessionKey()).toBe(expected);
|
||||||
|
|
||||||
|
fixture.componentInstance.inlinePlayback.set({
|
||||||
|
streamUrl: 'https://alternative.example/replaced.mkv',
|
||||||
|
title: 'Alternative movie',
|
||||||
|
contentInfo: {
|
||||||
|
playlistId: 'alternative-playlist',
|
||||||
|
contentXtreamId: 999,
|
||||||
|
contentType: 'vod',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||||
|
|
||||||
|
fixture.componentInstance.selectItem({
|
||||||
|
...first,
|
||||||
|
id: 'movie|two',
|
||||||
|
});
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
const itemChangedKey = fixture.componentInstance.playbackSessionKey();
|
||||||
|
playlist.set({ ...playlist(), _id: 'playlist|two' });
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||||
|
itemChangedKey
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears committed playback when the route playlist owner changes for the same provider id', async () => {
|
||||||
|
const item = {
|
||||||
|
id: 'movie|one',
|
||||||
|
cmd: 'ffrt4://movie/one',
|
||||||
|
name: 'Movie One',
|
||||||
|
info: { name: 'Movie One' },
|
||||||
|
};
|
||||||
|
resolveVodPlayback.mockResolvedValue({
|
||||||
|
streamUrl: 'https://a.example/movie.mpg',
|
||||||
|
});
|
||||||
|
fixture.componentInstance.selectItem(item);
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
|
||||||
|
|
||||||
|
playlist.set({ ...playlist(), _id: 'playlist|two' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'vod',
|
||||||
|
sourceId: 'playlist|two',
|
||||||
|
contentId: 'movie|one',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses a pending old-playlist error for the same provider id', async () => {
|
||||||
|
const pending = deferred<ResolvedPortalPlayback>();
|
||||||
|
resolveVodPlayback.mockReturnValue(pending.promise);
|
||||||
|
fixture.componentInstance.selectItem({
|
||||||
|
id: 'movie|one',
|
||||||
|
cmd: 'ffrt4://movie/one',
|
||||||
|
name: 'Movie One',
|
||||||
|
info: { name: 'Movie One' },
|
||||||
|
});
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
playlist.set({ ...playlist(), _id: 'playlist|two' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
pending.reject(new Error('stale playlist failure'));
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||||
|
expect(snackBar.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves committed playback when the same canonical owner object refreshes', async () => {
|
||||||
|
const item = {
|
||||||
|
id: 'movie|one',
|
||||||
|
cmd: 'ffrt4://movie/one',
|
||||||
|
name: 'Movie One',
|
||||||
|
info: { name: 'Movie One' },
|
||||||
|
};
|
||||||
|
const playback = { streamUrl: 'https://a.example/movie.mpg' };
|
||||||
|
resolveVodPlayback.mockResolvedValue(playback);
|
||||||
|
fixture.componentInstance.selectItem(item);
|
||||||
|
fixture.componentInstance.onVodPlay(
|
||||||
|
fixture.componentInstance.vodDetailsItem()!
|
||||||
|
);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
selectedItem.set({ ...item, screenshot_uri: 'refreshed.jpg' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
resource,
|
resource,
|
||||||
signal,
|
signal,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Location } from '@angular/common';
|
import { Location } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -57,6 +58,7 @@ import {
|
|||||||
toggleStalkerVodFavorite,
|
toggleStalkerVodFavorite,
|
||||||
} from '@iptvnator/portal/stalker/data-access';
|
} from '@iptvnator/portal/stalker/data-access';
|
||||||
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
|
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
|
||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
|
||||||
interface StalkerFilter {
|
interface StalkerFilter {
|
||||||
key: StalkerSearchContentType;
|
key: StalkerSearchContentType;
|
||||||
@@ -103,6 +105,7 @@ export class StalkerSearchComponent {
|
|||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
private readonly translateService = inject(TranslateService);
|
private readonly translateService = inject(TranslateService);
|
||||||
private readonly logger = createLogger('StalkerSearch');
|
private readonly logger = createLogger('StalkerSearch');
|
||||||
|
private currentPlaybackOwnerKey = '';
|
||||||
|
|
||||||
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
|
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
|
||||||
series: false,
|
series: false,
|
||||||
@@ -141,6 +144,16 @@ export class StalkerSearchComponent {
|
|||||||
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
||||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||||
|
readonly playbackSessionKey = computed(() => {
|
||||||
|
const sourceId = this.currentPlaylist()?._id;
|
||||||
|
const contentId = normalizeStalkerEntityId(this.itemDetails()?.id);
|
||||||
|
return sourceId && contentId
|
||||||
|
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
private readonly playbackOwnerKey = computed(() =>
|
||||||
|
JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()])
|
||||||
|
);
|
||||||
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
|
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
|
||||||
readonly selectedVodPlaybackPosition = computed<number | null>(
|
readonly selectedVodPlaybackPosition = computed<number | null>(
|
||||||
() => this.selectedVodPosition()?.positionSeconds ?? null
|
() => this.selectedVodPosition()?.positionSeconds ?? null
|
||||||
@@ -154,6 +167,7 @@ export class StalkerSearchComponent {
|
|||||||
translateService: this.translateService,
|
translateService: this.translateService,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
playbackErrorLogMessage: 'Failed to start search VOD playback',
|
playbackErrorLogMessage: 'Failed to start search VOD playback',
|
||||||
|
playbackOwnerKey: () => this.playbackOwnerKey(),
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly portalFavorites = createPortalFavoritesResource(
|
readonly portalFavorites = createPortalFavoritesResource(
|
||||||
@@ -197,16 +211,15 @@ export class StalkerSearchComponent {
|
|||||||
// executeStalkerRequest owns the portal-mode decision (shared
|
// executeStalkerRequest owns the portal-mode decision (shared
|
||||||
// predicate with URL fallback for legacy rows) and the lazy
|
// predicate with URL fallback for legacy rows) and the lazy
|
||||||
// portal repair, so search cannot drift from the catalog paths.
|
// portal repair, so search cannot drift from the catalog paths.
|
||||||
const response =
|
const response = await executeStalkerRequest<StalkerSearchResponse>(
|
||||||
await executeStalkerRequest<StalkerSearchResponse>(
|
{
|
||||||
{
|
dataService: this.dataService,
|
||||||
dataService: this.dataService,
|
stalkerSession: this.stalkerSession,
|
||||||
stalkerSession: this.stalkerSession,
|
portalRepair: this.portalRepair,
|
||||||
portalRepair: this.portalRepair,
|
},
|
||||||
},
|
playlist,
|
||||||
playlist,
|
requestParams
|
||||||
requestParams
|
);
|
||||||
);
|
|
||||||
const items = response.js?.data || [];
|
const items = response.js?.data || [];
|
||||||
return items.map((item: StalkerVodSource) =>
|
return items.map((item: StalkerVodSource) =>
|
||||||
this.processItemUrls(item, portalUrl)
|
this.processItemUrls(item, portalUrl)
|
||||||
@@ -217,6 +230,12 @@ export class StalkerSearchComponent {
|
|||||||
readonly isSelectedVodFavorite = signal<boolean>(false);
|
readonly isSelectedVodFavorite = signal<boolean>(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.currentPlaybackOwnerKey = this.playbackOwnerKey();
|
||||||
|
effect(() => {
|
||||||
|
const ownerKey = this.playbackOwnerKey();
|
||||||
|
untracked(() => this.syncPlaybackOwner(ownerKey));
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const routeTerm = this.routeSearchTerm();
|
const routeTerm = this.routeSearchTerm();
|
||||||
if (routeTerm !== this.searchTerm()) {
|
if (routeTerm !== this.searchTerm()) {
|
||||||
@@ -293,7 +312,6 @@ export class StalkerSearchComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectItem(item: StalkerVodSource) {
|
selectItem(item: StalkerVodSource) {
|
||||||
this.closeInlinePlayer();
|
|
||||||
const filterType = this.selectedFilterType();
|
const filterType = this.selectedFilterType();
|
||||||
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
|
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
|
||||||
const needsSeriesFetch =
|
const needsSeriesFetch =
|
||||||
@@ -338,6 +356,7 @@ export class StalkerSearchComponent {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
this.syncPlaybackOwner(this.playbackOwnerKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
onVodPlay(item: VodDetailsItem): void {
|
onVodPlay(item: VodDetailsItem): void {
|
||||||
@@ -430,6 +449,12 @@ export class StalkerSearchComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPlaybackOwner(ownerKey: string): void {
|
||||||
|
if (ownerKey === this.currentPlaybackOwnerKey) return;
|
||||||
|
this.currentPlaybackOwnerKey = ownerKey;
|
||||||
|
this.closeInlinePlayer();
|
||||||
|
}
|
||||||
|
|
||||||
inlineDetail() {
|
inlineDetail() {
|
||||||
return createStalkerInlineDetailState(
|
return createStalkerInlineDetailState(
|
||||||
this.itemDetails(),
|
this.itemDetails(),
|
||||||
|
|||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||||
|
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
|
||||||
|
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
|
||||||
|
import {
|
||||||
|
captureStalkerEpisodePlaybackSessionIdentity,
|
||||||
|
createStalkerEpisodePlaybackSessionKey,
|
||||||
|
resolveStalkerEpisodeStateByIdentity,
|
||||||
|
resolveStalkerEpisodeStateByStructuralIdentity,
|
||||||
|
toStalkerEpisodePlaybackStructuralIdentity,
|
||||||
|
} from './stalker-episode-playback-session-key';
|
||||||
|
|
||||||
|
const episodeState = (
|
||||||
|
episode: Partial<StalkerMappedEpisode>,
|
||||||
|
seasonKey = 'season|1'
|
||||||
|
) => ({
|
||||||
|
seasonKey,
|
||||||
|
seasonNumber: 1,
|
||||||
|
episodeNumber: 2,
|
||||||
|
episode: {
|
||||||
|
id: 'synthesized-32-bit-id',
|
||||||
|
title: 'Episode 2',
|
||||||
|
episode_num: 2,
|
||||||
|
season: 1,
|
||||||
|
...episode,
|
||||||
|
} as StalkerMappedEpisode,
|
||||||
|
previous: null,
|
||||||
|
next: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createStalkerEpisodePlaybackSessionKey', () => {
|
||||||
|
it.each([
|
||||||
|
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
|
||||||
|
])(
|
||||||
|
'keeps %s provider credentials out of the structural key',
|
||||||
|
(seriesMode) => {
|
||||||
|
const originalCmdA =
|
||||||
|
'https://user:password@stream.example/episode.mpg?access_token=secret-a';
|
||||||
|
const originalCmdB =
|
||||||
|
'https://user:password@stream.example/episode.mpg?access_token=secret-b';
|
||||||
|
const stateA = episodeState({ originalCmd: originalCmdA });
|
||||||
|
const stateB = episodeState({ originalCmd: originalCmdB });
|
||||||
|
const composite = JSON.stringify([
|
||||||
|
seriesMode,
|
||||||
|
'parent|series',
|
||||||
|
stateA.seasonKey,
|
||||||
|
stateA.seasonNumber,
|
||||||
|
stateA.episodeNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const keyA = createStalkerEpisodePlaybackSessionKey({
|
||||||
|
sourceId: 'playlist|source',
|
||||||
|
parentSeriesId: 'parent|series',
|
||||||
|
seriesMode,
|
||||||
|
episodeState: stateA,
|
||||||
|
});
|
||||||
|
const keyB = createStalkerEpisodePlaybackSessionKey({
|
||||||
|
sourceId: 'playlist|source',
|
||||||
|
parentSeriesId: 'parent|series',
|
||||||
|
seriesMode,
|
||||||
|
episodeState: stateB,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(keyA).toBe(
|
||||||
|
createPlaybackSessionKey({
|
||||||
|
kind: 'episode',
|
||||||
|
sourceId: 'playlist|source',
|
||||||
|
contentId: composite,
|
||||||
|
seriesId: 'parent|series',
|
||||||
|
seasonNumber: 1,
|
||||||
|
episodeNumber: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(keyB).toBe(keyA);
|
||||||
|
expect(keyA).not.toContain(originalCmdA);
|
||||||
|
expect(keyA).not.toContain('password');
|
||||||
|
expect(keyA).not.toContain('secret-a');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('retains the exact pending guard without a synthesized tracking hash', () => {
|
||||||
|
const originalCmd =
|
||||||
|
'https://stream.example.invalid/episode.mpg?access_token=example-token';
|
||||||
|
const state = episodeState({
|
||||||
|
id: 'command-derived-tracking-hash',
|
||||||
|
originalCmd,
|
||||||
|
});
|
||||||
|
const identity = captureStalkerEpisodePlaybackSessionIdentity({
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: 'series',
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
episodeState: state,
|
||||||
|
});
|
||||||
|
if (!identity) throw new Error('Expected a captured request identity');
|
||||||
|
|
||||||
|
expect(identity).toEqual({
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: 'series',
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
originalEpisodeIdentity: originalCmd,
|
||||||
|
seasonKey: state.seasonKey,
|
||||||
|
seasonNumber: state.seasonNumber,
|
||||||
|
episodeNumber: state.episodeNumber,
|
||||||
|
sessionKey: identity.sessionKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes only credential-free structural fields for a mounted session', () => {
|
||||||
|
const originalCmd =
|
||||||
|
'https://user:password@stream.example/episode.mpg?access_token=secret';
|
||||||
|
const identity = captureStalkerEpisodePlaybackSessionIdentity({
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: 'series',
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
episodeState: episodeState({ originalCmd }),
|
||||||
|
});
|
||||||
|
if (!identity) throw new Error('Expected a captured request identity');
|
||||||
|
|
||||||
|
const retained = toStalkerEpisodePlaybackStructuralIdentity(identity);
|
||||||
|
|
||||||
|
expect(Object.isFrozen(retained)).toBe(true);
|
||||||
|
expect(retained).toEqual({
|
||||||
|
sourceId: identity.sourceId,
|
||||||
|
parentSeriesId: identity.parentSeriesId,
|
||||||
|
seriesMode: identity.seriesMode,
|
||||||
|
seasonKey: identity.seasonKey,
|
||||||
|
seasonNumber: identity.seasonNumber,
|
||||||
|
episodeNumber: identity.episodeNumber,
|
||||||
|
sessionKey: identity.sessionKey,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(retained)).not.toContain(originalCmd);
|
||||||
|
expect(retained).not.toHaveProperty('originalEpisodeIdentity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when structural coordinates match multiple episodes', () => {
|
||||||
|
const state = episodeState({ originalCmd: 'command-a' });
|
||||||
|
const requestIdentity = captureStalkerEpisodePlaybackSessionIdentity({
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: 'series',
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
episodeState: state,
|
||||||
|
});
|
||||||
|
if (!requestIdentity) {
|
||||||
|
throw new Error('Expected a captured request identity');
|
||||||
|
}
|
||||||
|
const identity =
|
||||||
|
toStalkerEpisodePlaybackStructuralIdentity(requestIdentity);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveStalkerEpisodeStateByStructuralIdentity({
|
||||||
|
episodesBySeason: {
|
||||||
|
[state.seasonKey]: [
|
||||||
|
state.episode,
|
||||||
|
{
|
||||||
|
...state.episode,
|
||||||
|
id: 'another-tracking-id',
|
||||||
|
originalCmd: 'command-b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
identity,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => {
|
||||||
|
const stateA = episodeState({ originalId: 'provider|episode:token-a' });
|
||||||
|
const stateB = episodeState({ originalId: 'provider|episode:token-b' });
|
||||||
|
const options = {
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: '50001',
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
|
||||||
|
} as const;
|
||||||
|
const identityA = captureStalkerEpisodePlaybackSessionIdentity({
|
||||||
|
...options,
|
||||||
|
episodeState: stateA,
|
||||||
|
});
|
||||||
|
const keyB = createStalkerEpisodePlaybackSessionKey({
|
||||||
|
...options,
|
||||||
|
episodeState: stateB,
|
||||||
|
});
|
||||||
|
if (!identityA) throw new Error('Expected a captured request identity');
|
||||||
|
|
||||||
|
expect(identityA.originalEpisodeIdentity).toBe(
|
||||||
|
'provider|episode:token-a'
|
||||||
|
);
|
||||||
|
expect(keyB).toBe(identityA.sessionKey);
|
||||||
|
expect(keyB).not.toContain('provider|episode:token-a');
|
||||||
|
expect(keyB).not.toContain('provider|episode:token-b');
|
||||||
|
expect(keyB).not.toContain('synthesized-32-bit-id');
|
||||||
|
expect(
|
||||||
|
resolveStalkerEpisodeStateByIdentity({
|
||||||
|
episodesBySeason: { [stateB.seasonKey]: [stateB.episode] },
|
||||||
|
identity: identityA,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates every structural coordinate without delimiter collisions', () => {
|
||||||
|
const base = {
|
||||||
|
sourceId: 'playlist|a',
|
||||||
|
parentSeriesId: 'parent|a',
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
episodeState: episodeState({ originalCmd: 'cmd|a' }),
|
||||||
|
} as const;
|
||||||
|
const variants = [
|
||||||
|
{ ...base, sourceId: 'playlist|b' },
|
||||||
|
{ ...base, parentSeriesId: 'parent|b' },
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
episodeState: episodeState(
|
||||||
|
{ originalCmd: 'cmd|a' },
|
||||||
|
'season|2'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
episodeState: {
|
||||||
|
...base.episodeState,
|
||||||
|
seasonNumber: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
episodeState: {
|
||||||
|
...base.episodeState,
|
||||||
|
episodeNumber: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const baseKey = createStalkerEpisodePlaybackSessionKey(base);
|
||||||
|
const delimiterVariantKey = createStalkerEpisodePlaybackSessionKey({
|
||||||
|
...base,
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: 'a|parent|a',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
variants.map(createStalkerEpisodePlaybackSessionKey)
|
||||||
|
).not.toContain(baseKey);
|
||||||
|
expect(delimiterVariantKey).not.toBe(baseKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
state: episodeState({}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||||
|
state: episodeState({ originalCmd: '' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
|
||||||
|
state: episodeState({}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
|
||||||
|
state: episodeState({ originalId: '' }),
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'fails closed when original episode identity is absent',
|
||||||
|
({ seriesMode, state }) => {
|
||||||
|
expect(
|
||||||
|
createStalkerEpisodePlaybackSessionKey({
|
||||||
|
sourceId: 'playlist',
|
||||||
|
parentSeriesId: 'series',
|
||||||
|
seriesMode,
|
||||||
|
episodeState: state,
|
||||||
|
})
|
||||||
|
).toBe('');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user