mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
commit
174e4cf887
166 files changed
+11417
-729
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user