Merge origin/master into claude/priceless-sutherland-9015e8

Resolves conflicts with #1470 (quality selection in shared player controls):
both features' capability/state/command members, menu keys, adapter options,
spec mocks, and doc paragraphs are kept side by side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-23 08:47:53 +02:00
commit 174e4cf887
166 files changed
+11417 -729

No files matched your search

+143 -2
View File
@@ -282,7 +282,148 @@ variants, contextual buttons, and theme-aware styling.
ignores search/category filtering while honoring route scope, so hiding a
card never makes its disk footprint appear to vanish.
## Live-TV recordings
Recordings made with the embedded MPV player live beside downloads, not inside
them: a recording has no source URL to re-fetch, no byte totals, and no
retry/resume semantics, so it gets its own `recordings` SQLite table (no
unique index — re-recording a channel is normal; `playlist_id` carries no FK
so recordings survive source deletion, with `playlist_name` stored as a
display snapshot via `playlistDisplayLabel`).
- **Lifecycle tracking** is owned by `EmbeddedMpvRecordingTracker`
(`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`):
explicit start/stop hooks in `EmbeddedMpvNativeService` plus a
session-snapshot observer. The stop hook is a *request*, not an outcome —
`addon.stopRecording()` only dispatches (async mpv property set, or a
command written to the frame-copy helper), so finalization always waits for
the snapshot reporting the recording inactive; statting or unlinking
earlier would report a short recording as failed and could delete bytes mpv
is still flushing. macOS native-view clears `recordingActive` *before*
dispatching the async property set and restores it if that request is
rejected, so an inactive snapshot must additionally survive a 1.5 s settle
window (three poll cycles) before it counts as an acknowledgement; a revived
recording cancels the pending finalization. A 10 s bound finalizes anyway if
the acknowledgement never arrives. None of this timing is load-bearing for
stop enrichment — that path does not wait for finalization at all. The same observer covers the stop paths that never call
`stopRecording()` at all (stream-replacement auto-stop, frame-copy helper
crash, session error/close). A synthetic error/closed snapshot arrives
while teardown may still be flushing (the frame-copy helper exits up to
~2 s after `disposeSession()`), so it defers finalization behind a 2.5 s
flush window instead of statting immediately — the row stays `recording`
(startup-repairable) through the window, and an already-armed stop settle
timer keeps its `completed` verdict. Statuses: `recording` → `completed` (acknowledged
stop, `fs.stat` size) / `interrupted` (implicit stop with a playable partial
— MPEG-TS is streamable) / `failed` (start error or absent/empty file). Only
a recording that never went active has its empty pre-reserved file unlinked;
once mpv owned the file, the bytes are left alone. Rows carry `owner_pid`,
so startup repair (`recording-recovery.ts`, `reconcileStaleRecordings`)
resolves what a hard kill left in `recording` while skipping rows another
live instance still owns (`IPTVNATOR_ALLOW_MULTIPLE_INSTANCES`) and rows
the tracker itself is still tracking (`activeRowIds()`) — the renderer is
interactive before this pass runs, so a recording started during bootstrap
carries this process's own pid and only the tracker's ledger proves it
live. Bare pid liveness is not ownership: the holder must also look like
an IPTVnator/Electron process (`ps`/`tasklist` name, best-effort) and must
not provably have started after the recording did (`ps -o etime=` /
PowerShell `StartTime`) — a pid frees only when its previous owner dies,
so a recycled pid's holder is always younger than the recording, even
when it landed on another Electron app; unreadable evidence stays
conservative and keeps the skip. Repair changes broadcast one
`RECORDINGS_UPDATE_EVENT` because the renderer may already hold the
pre-repair list. Both repair and tracker finalization stat through a
bounded async probe (3 s deadline; only `ENOENT`/`ENOTDIR` proves
absence): repair leaves an unjudgeable row in `recording` for a later
startup, finalization keeps the requested status with an unknown size —
a recording directory on a dead network mount must neither block the
main thread/queue nor brand a likely-good file `failed`. Repair probes
its whole batch concurrently, so `main.ts` awaits roughly one deadline,
not one per row. List decoration likewise preserves an inconclusive
probe: `ElectronRecordingItem.fileAvailability` adds `'unknown'`, and
consumers gate on `=== 'missing'` so only proven absence reaches Needs
attention or hides file actions. The focused
recording detail route is guarded by the `supportsRecordings` capability
(redirect to the manager) since `RecordingsService` never becomes
authoritative in the PWA. Tracker finalization is bound to the exact open entry, never the
reusable session id: a stop → immediate restart on the same session leaves
the old entry to its own settle timer, and replacing the map entry arms
that timer if no stop was ever observed, so neither row can be finalized
by the other's lifecycle.
- **Metadata is captured at recording start** (EPG is time-sensitive and
Xtream/Stalker EPG never reaches SQLite): each live host — M3U player,
Xtream live layout, Stalker ITV layout, unified live tab — assembles a
`RecordingStartMetadata` (channel name/logo, playlist id + display-label
snapshot, source type, EPG key, current program) that flows
`WebPlayerViewComponent → EmbeddedMpvPlayerComponent →
EmbeddedMpvControlsAdapter → EmbeddedMpvRecordingStartOptions.metadata`.
`EmbeddedMpvPlayerComponent` watches the session snapshot for the
active→inactive recording edge and emits `recordingStopped` — one owner for
every trigger, including a Stop clicked in the download manager, which
talks to the main process directly and never reaches the player's own
toggle. The event carries the EPG key captured while the recording was
active, because a channel switch auto-stops the recording and the host's own
state already describes the new channel by the time the stop is handled;
each host enriches only when that key still matches its current channel.
The EPG key is not unique for M3U items (a `tvgId`, or the display-name
fallback, can be shared by several list entries), so hosts with M3U
selections additionally set `RecordingStartMetadata.sourceItemKey` (the
unified tab's `item.uid`, the M3U player's `channel.id`) — captured and
carried through the stop event the same way, and compared before
enriching, so switching between two same-keyed items cannot attach the
second item's schedule to the first item's recording. The host answers with **stop enrichment**: it filters its in-memory
program list to the programs overlapping `[startedAt, endedAt]`
(`filterRecordingProgramsOverlap` in `@iptvnator/shared/interfaces`) and
sends them through `RECORDINGS_UPDATE_PROGRAMS`, keyed by the unique
target path — that is how a recording spanning a program boundary lists
every covered show. The handler is deliberately independent of finalization: it
looks the row up in **any** status (the newest for that path — `openSync
('wx')` keeps a reserved path exclusive while its recording owns it) and
`finalize()` never touches `programs_json`, so the two writes commit in
either order. The only wait is the tracker's queue drain, which guarantees
the row's INSERT exists — no deadline, and therefore no way for a one-shot
enrichment to be dropped by a clock. A recording stopped while no player is mounted on that
channel keeps its start snapshot.
- **IPC surface** (`recordings.events.ts`): `RECORDINGS_GET_LIST/GET/STOP/
REMOVE/UPDATE_PROGRAMS/REVEAL_FILE/PLAY_FILE` plus the dedicated
`RECORDINGS_UPDATE_EVENT` bare ping (not shared with downloads, so
recording transitions do not force availability-probed download refetches).
Active rows are decorated with a live `fs.stat` size — `file_size_bytes` is
only persisted at finalization, so the manager's growing size comes from
there. Recording totals also feed the manager-wide All chip and the header's
active badge, so a page listing only recordings never reads "All 0".
Reveal/play are gated by `isManagedRecordingFile` — the path must exist in
the recordings table, mirroring `isManagedDownloadFile`, so the
renderer-supplied recording directory stays a write-location preference
rather than a shell-access grant. `RECORDINGS_STOP` resolves the row's
`session_id` and stops through `EmbeddedMpvNativeService`, so the manager
can stop a recording without knowing about MPV sessions — but only for rows
this process owns: session ids restart per process, so dispatching a
foreign row's id would stop an unrelated local recording. Remove keeps
finished files on disk (same contract as downloads) and cleans up a failed
row's leftover reservation only while no other row claims that path — a
retry within the same timestamp second reuses the freed name. Renderer gate: a separate
`supportsRecordings` capability allowlist — deliberately NOT folded into
`supportsDownloads`, which would strip older builds of the whole manager.
- **UI**: `RecordingsService` mirrors `DownloadsService` (one global list,
coalesced refreshes via `DownloadListLoadState`, refetch on ping). The
manager adds a `recording` filter chip; `recording-manager.viewmodel.ts`
partitions rows into a "Recording now" queue section (pulsing REC chip with
elapsed time and live file size — never a percentage, the length is
unknown; the size comes from a bounded, in-flight-coalesced `stat` with a
1 s deadline so a recording directory on a dead network filesystem
degrades to "no size" instead of wedging every list load), a recordings-only Needs attention list (Remove only: a broadcast
cannot be re-recorded), and a "Recordings" library section of 16:9
channel-logo cards (`recording-queue.component.*`,
`recording-library.component.*`). Card titles use the captured program
title, falling back to channel + start time. The focused detail
(`recording-detail/`, route `/workspace/downloads/recording/:recordingId`,
context panel and route search hidden via `workspace-shell-route.utils.ts`)
shows the recorded time range, covered programs when a recording spans ≥2
shows, file path, and Play/Reveal/Stop/Remove; a missing file degrades to
Back + Remove.
Keeping the backend queue, IPC handlers, shared schema, and renderer signals
synchronized minimizes drift between platform rules and the UI. Future work
might cover recordings, queue reordering, bulk pause/cancel actions, disk-free
space telemetry, or playback analytics.
might cover queue reordering, bulk pause/cancel actions, disk-free space
telemetry, playback analytics, or frame-copy screenshot posters for
recordings.
@@ -250,7 +250,12 @@ Season navigation inside `SeasonContainerComponent` uses season tabs
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
seasons-grid + "Back to seasons" level. A season is auto-selected
(inline-playing episode's season → most recently updated in-progress
episode's season → first) and the auto-selection emits `seasonSelected`,
episode's season → earliest season with unwatched episodes → latest
non-empty season; Stalker lazy-VOD series with unhydrated seasons pin
the fallback to the first season because their watched state is unknown,
and the empty→loaded positions flip caused by the session's own watched
toggles never re-resolves the selection) and the
auto-selection emits `seasonSelected`,
so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch,
TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open
without a click. Switching tabs never stops playback; a "back to playing
+1 -1
View File
@@ -532,7 +532,7 @@ replacement, engine handoff, or component destruction cancels pending
ownership, timers, and stale feedback. Native-view retains its existing
recording UI and timer path.
The default recording folder is `app.getPath('downloads')`, matching the desktop download manager's fallback. Users can override it in Settings through `Settings.recordingFolder`; an empty setting means system Downloads. Recordings are intentionally not inserted into the Downloads database or queue in v1 because MPV writes from the active playback session while the download manager owns independent backend download jobs.
The default recording folder is `app.getPath('downloads')`, matching the desktop download manager's fallback. Users can override it in Settings through `Settings.recordingFolder`; an empty setting means system Downloads. Recordings never enter the Downloads queue (MPV writes from the active playback session while the download manager owns independent backend download jobs), but their lifecycle IS tracked: `EmbeddedMpvRecordingTracker` (`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`) persists each recording into the dedicated `recordings` SQLite table — start hook and explicit-stop hook from `EmbeddedMpvNativeService`, plus a session-snapshot observer that catches the three implicit stop paths (stream-replacement auto-stop, frame-copy helper crash leaving `active: true` behind, session error/close). Rows left in `recording` by a hard app kill are repaired at startup into playable `interrupted` partials or `failed`. The download manager surfaces these rows; see `docs/architecture/download-manager.md` ("Live-TV recordings").
mpv's own caveats apply: the output container is inferred from the target extension, and seeking or switching streams while recording can produce broken output. IPTVnator limits the UI to live streams and stops recording on playback replacement to avoid the most obvious corruption path, but the feature should still be treated as an experimental embedded MPV capability.
@@ -175,6 +175,8 @@ interface PlayerController {
`audioTracks`, `subtitles`, `externalSubtitles`, `subtitleDelay`,
`subtitleStyle`, `playbackSpeed`, `aspectRatio`, `recording`,
`pictureInPicture`, `fullscreen`, and `seriesNavigation`.
`audioTracks`, `subtitles`, `qualityLevels`, `playbackSpeed`, `aspectRatio`,
`recording`, `pictureInPicture`, `fullscreen`, and `seriesNavigation`.
The default is all-false. An adapter enables only features that its engine and
current runtime support. Capability flags primarily control whether optional UI
@@ -189,6 +191,7 @@ is rendered; state such as `canSeek`, `canPreviousEpisode`, and
- current position, optional duration, live/VOD classification, and seekability;
- volume;
- pre-labelled audio/subtitle tracks and subtitle-enabled state;
- pre-labelled quality levels and the ABR/auto flag;
- playback speed and aspect-ratio selections/presets;
- recording state;
- picture-in-picture active state and runtime availability; and
@@ -210,6 +213,7 @@ owner.
- `setVolume`
- `setAudioTrack` / `setSubtitleTrack`
- `addExternalSubtitleFile` / `setSubtitleDelay` / `setSubtitleStyle`
- `setQualityLevel` (`AUTO_QUALITY_LEVEL_ID` = `-1` re-enables auto)
- `setPlaybackSpeed`
- `setAspectRatio`
- `toggleRecording`
@@ -489,6 +493,64 @@ control chrome. Subtitle rendering in that surface is browser-dependent.
AirPlay, Cast, Document Picture-in-Picture, a PiP keyboard shortcut, and an
Embedded MPV popup or native mini-window are out of scope.
### Quality (bitrate/level) selection
Quality selection is part of the shared controls and therefore rides the same
default-off `WEB_PLAYER_SHARED_CONTROLS` rollout. The contract exposes:
- capability `qualityLevels`;
- state `qualityLevels` (pre-labelled options such as "1080p") and
`qualityAutoEnabled`; and
- command `setQualityLevel(id)`, where `AUTO_QUALITY_LEVEL_ID` (`-1`)
re-enables the engine's adaptive (ABR) selection.
The capability derives from the manifest, not the content type: it is
advertised only when the current source exposes **more than one** video
rendition, so single-bitrate Xtream VOD files and raw MPEG-TS streams never
show the menu, while multi-variant live HLS does. The menu renders next to the
audio/subtitle menus with an Auto entry first; Auto is the default, a level
reports `selected` only while a manual choice is active, and the choice is
per-session — nothing is persisted to Settings.
Labels come from one shared helper (`quality-level-labels.ts` in
`web-video-support/`): frame height first ("1080p"), a 16:9 projection when
only the width is known, the bitrate when no dimension is known, and a bitrate
suffix only when two levels would otherwise collide on the same label.
Engine mechanics:
- **hls.js** (HTML5 and ArtPlayer via the neutral source bridge): levels are
`hls.levels` with list-index ids; a manual switch assigns `hls.nextLevel`
(switches at the next fragment instead of flushing the buffer), `-1` restores
auto, and the selected level is read from the public `manualLevel`. The HLS
refresh-event list additionally observes `MANIFEST_PARSED`,
`LEVELS_UPDATED`, and `LEVEL_SWITCHED`.
- **Shaka (DASH)**: options are variant tracks pinned to the active variant's
exact audio stream — variants are audio+video combinations, and picking a
quality must not switch the audio track. The filter matches the active
variant's `audioId` when Shaka reports one (two same-language audio tracks
such as main vs. commentary share a language but never an id) and falls back
to the language only when no id is available — sorted by resolution then
bandwidth. Manual
selection disables ABR via `configure({abr: {enabled: false}})` before
`selectVariantTrack(track, true)`; the auto sentinel re-enables ABR. Manual
state is keyed to the exact player instance, so a session restart (which
creates a fresh player with ABR on) can never render a stale manual
selection.
- **Video.js**: `VjsQualityLevels` projects the videojs-contrib-quality-levels
list (registered by the component's plugin import). VHS has no manual-level
setter, so a manual selection enables exactly one level and auto re-enables
all. Manual intent is tracked explicitly by the picked level object — VHS
also flips `enabled` off for renditions it temporarily excludes after
delivery errors, so counting enabled levels would misreport a manual
selection. A picked level that leaves the list, a source change, and
`clear()` all revert to auto. A missing or throwing plugin degrades to no
capability.
- **Embedded MPV and external players**: `qualityLevels` stays false —
single-program transport streams have no rendition list to offer and no HLS
level API is surfaced there. `EmbeddedMpvControlsAdapter.setQualityLevel` is
a no-op.
Native media events refresh the adapter automatically. An engine host must call
the public `refresh()` hook after engine-specific getters change
without a corresponding media event, including track lists, corrected duration,
@@ -791,8 +853,10 @@ The neutral web-video source support shared by HTML5 and ArtPlayer lives in:
```text
libs/ui/playback/src/lib/web-video-support/
├── quality-level-labels.ts
├── web-video-hls-controls.ts
├── web-video-native-text-tracks.ts
├── web-video-shaka-controls.ts
├── web-video-source-tracks.ts
└── web-video-source-controls.bridge.ts
```
@@ -831,6 +895,7 @@ libs/ui/playback/src/lib/vjs-player/
├── vjs-audio-tracks.ts
├── vjs-legacy-tracks.ts
├── vjs-mpegts-session.ts
├── vjs-quality-levels.ts
├── vjs-player-controls.bridge.ts
├── vjs-player-reset-coordinator.ts
├── vjs-player-setup.ts
@@ -23,12 +23,15 @@ Related:
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
Stalker movies/series into the matching global collection route with detail
pre-opened.
- Dashboard Continue Watching handoffs for Xtream series may additionally carry
a one-shot season/episode resume target. The collection-owned Xtream detail
consumes it after its episode positions load and starts that exact episode;
opening the series from the collection grid itself remains detail-only. If the
positions load fails, the target stays unconsumed and the handoff degrades to
detail-only rather than starting the episode at offset zero.
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume
episode" ⋮ action for Xtream series carry a one-shot season/episode resume
target. The collection-owned Xtream detail consumes it after its episode
positions load and starts that exact episode; the cards' default click is
detail-only (movie-like), as is opening the series from the collection grid
itself. If the positions load fails, the target stays unconsumed and the
handoff degrades to detail-only rather than starting the episode at offset
zero. Continue Watching cards also expose "Mark as Watched" (maxes out the
tracked position row) and "Remove from history" in the same ⋮ menu.
- Ready Download Manager cards open one of the three focused
`downloads/:downloadId` routes. These local details hide the workspace
context panel, play only finalized local files, and show only locally