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

@@ -0,0 +1,10 @@
---
type: feature
area: dashboard
issues: [1441]
---
Continue Watching cards on the dashboard now open the detail page on click,
just like movie cards — no more accidental episode playback. Resuming the
saved episode moved to a new ⋮ menu on each card, next to "Mark as Watched"
and "Remove from history".
+6
View File
@@ -0,0 +1,6 @@
---
type: internal
area: deps
---
Updated ngx-indexed-db (the PWA's IndexedDB storage library) from 21 to 22. No behavior change.
+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: downloads
---
Live-TV recordings made with the Embedded MPV player now live in the download
manager: an in-progress row with elapsed time and Stop, a Recordings library
with channel logos and the programs captured from the EPG at recording time,
a focused detail page, and Needs attention when a file goes missing.
Recordings interrupted by a crash survive as playable partials.
+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: playback
---
The built-in web players' shared controls gain a quality menu: multi-bitrate
HLS and DASH streams can be pinned to a specific rendition ("1080p", "720p", …)
or left on Auto, which keeps the player's adaptive selection. The menu appears
only when the stream actually offers more than one quality, and the choice
lasts for the current playback session.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: portals
issues: [1441]
---
Opening a series now lands on the season you'd actually continue: the
earliest season with unwatched episodes, or the latest season once
everything is watched — instead of always starting at season 1.
+2 -2
View File
@@ -44,7 +44,7 @@ jobs:
# commit checked out above (the modern default); JavaScript is
# interpreted, so no build step is needed before analysis.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.6
uses: github/codeql-action/init@v4.37.7
with:
languages: ${{ matrix.language }}
# Excludes the localhost dev/E2E mock servers from analysis; see the
@@ -56,4 +56,4 @@ jobs:
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.37.6
uses: github/codeql-action/analyze@v4.37.7
+9
View File
@@ -227,6 +227,15 @@ Key files:
`subtitleDisplay` would silently override whatever the vendor menu picks. For
DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,
so the helper only stops re-suppressing afterwards.
- Shared controls include a per-session quality menu (Auto + "1080p"-style
levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR). The
capability derives from the manifest — advertised only when the source
exposes >1 video rendition (multi-variant HLS via hls.js
`nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the
active variant's exact audio stream (`audioId`, language fallback) with ABR
toggled off for manual picks, Video.js via videojs-contrib-quality-levels) —
so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to
Settings, and Embedded MPV/external players report the capability false.
- Embedded MPV ignores the web-player preference. Frame-copy always uses shared
DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while
native-view retains the legacy compositor-safe dock and external MPV/VLC
+36 -2
View File
@@ -696,6 +696,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)
- `playbackPositions` - Resume positions
- `downloads` - Download manager state
- `recordings` - Live-TV recording lifecycle + start-time channel/EPG snapshot (defined in `schema.ts` beside `downloads`)
- `appState` - Key-value app state (also tracks one-off data migrations)
- `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)
- `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`)
@@ -1102,6 +1103,7 @@ engine` (restart required) or
contracts: `docs/architecture/embedded-mpv-native.md` and
`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. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). 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 HTMLine 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. Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. 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 — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific coLine truncated
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
@@ -1245,6 +1247,38 @@ engine` (restart required) or
- If a finalized file disappears while a focused detail is open, the
authoritative download list refreshes and returns to the manager. A failed
redirect leaves an actionable missing-file state with Back and Retry.
- Live-TV recordings (Embedded MPV `stream-record`) are tracked beside
downloads in their own `recordings` table (no unique index, no playlist FK —
recordings survive source deletion with a `playlistDisplayLabel` name
snapshot). `EmbeddedMpvRecordingTracker` persists the lifecycle: start/stop
hooks in `EmbeddedMpvNativeService` plus a session-snapshot observer. The
stop hook only REQUESTS a stop (`addon.stopRecording()` dispatches
asynchronously), so finalization waits for the snapshot reporting the
recording inactive — bounded at 10s — and only a recording that never went
active has its empty reservation unlinked; mpv's bytes are never deleted.
The same observer covers the implicit stops (stream-replacement auto-stop,
helper crash, session error/close). Rows carry `owner_pid`, so startup
repair turns a hard kill's leftovers into playable `interrupted` partials or
`failed` while skipping rows another live instance owns. Channel/EPG metadata is
snapshotted at recording START (each live host passes
`RecordingStartMetadata` down through the player chain; provider EPG never
reaches SQLite so post-hoc lookup is impossible). `EmbeddedMpvPlayerComponent`
owns the active→inactive recording edge and emits `recordingStopped` for
every trigger (including the manager's Stop, which bypasses the player's own
toggle); the host answers with enrichment: programs overlapping the recorded
window, keyed by target path (`RECORDINGS_UPDATE_PROGRAMS`, which awaits only
the tracker's write queue and then matches the newest row for that path in
ANY status — `finalize()` never touches `programs_json`, so the two writes
are order-independent and no deadline can drop the programs) — that covers
recordings spanning a program boundary. Own `RECORDINGS_*` IPC + `RECORDINGS_UPDATE_EVENT` ping
and a separate `supportsRecordings` capability gate (never folded into the
all-or-nothing `supportsDownloads` allowlist). Manager UI: `recording`
filter chip, "Recording now" queue section (REC pulse + elapsed, live size,
Stop, no percentage), 16:9 channel-logo "Recordings" library cards, Needs
attention with Remove only (a broadcast cannot be re-recorded), focused
detail at `/workspace/downloads/recording/:recordingId` listing covered
programs. Reveal/play shell IPCs are gated on the recordings table
(`isManagedRecordingFile`).
- Canonical contract: `docs/architecture/download-manager.md`; provider handoff:
`docs/architecture/portal-detail-navigation.md`.
@@ -1321,9 +1355,9 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
**VOD Multi-Source** (alternative sources for a movie):
@@ -223,20 +223,32 @@ test.describe('Electron Season Watched Toggle', () => {
const restartedWatchedCards = app.mainWindow.locator(
'.episode-card--watched'
);
// With season 1 completed, the fresh mount auto-selects the
// earliest season with unwatched episodes (issue #1441): season 2
// opens unwatched while season 1's tab keeps the check.
await expect(restartedToggle).toBeVisible({ timeout: 20_000 });
await expect(restartedToggle).toContainText(
'Mark season as unwatched'
`Mark season as watched (${seasonEpisodeCount})`
);
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 10_000,
});
const restartedSeasonTabs =
app.mainWindow.locator('.season-tabs__pill');
await expect(
restartedSeasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself was re-read from SQLite fully watched.
await restartedSeasonTabs.first().click();
await expect(restartedToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
);
await expect(restartedWatchedCards).toHaveCount(
seasonEpisodeCount,
{ timeout: 10_000 }
);
await expect(
app.mainWindow
.locator('.season-tabs__pill')
.first()
.locator('.season-tabs__done')
).toBeVisible();
// Unmark: the clear batch removes every row again, in the DOM and
// in SQLite.
@@ -56,6 +56,7 @@ import type {
PlaylistRefreshEvent,
PlaylistRefreshPayload,
PortalDebugEvent,
RecordingProgramSnapshot,
ResolvedPortalPlayback,
Settings,
TmdbCacheEntry,
@@ -1092,6 +1093,29 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler);
return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler);
},
// Live-TV recordings
recordingsGetList: (playlistId?: string) =>
ipcRenderer.invoke('RECORDINGS_GET_LIST', playlistId),
recordingsGet: (recordingId: number) =>
ipcRenderer.invoke('RECORDINGS_GET', recordingId),
recordingsStop: (recordingId: number) =>
ipcRenderer.invoke('RECORDINGS_STOP', recordingId),
recordingsRemove: (recordingId: number) =>
ipcRenderer.invoke('RECORDINGS_REMOVE', recordingId),
recordingsUpdatePrograms: (
targetPath: string,
programs: RecordingProgramSnapshot[]
) =>
ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
recordingsRevealFile: (filePath: string) =>
ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath),
recordingsPlayFile: (filePath: string) =>
ipcRenderer.invoke('RECORDINGS_PLAY_FILE', filePath),
onRecordingsUpdate: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('RECORDINGS_UPDATE_EVENT', handler);
return () => ipcRenderer.off('RECORDINGS_UPDATE_EVENT', handler);
},
};
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));
@@ -15,6 +15,7 @@ export {
epgPrograms,
playbackPositions,
downloads,
recordings,
appState,
// Types
type Playlist,
@@ -37,4 +38,6 @@ export {
type NewPlaybackPosition,
type Download,
type NewDownload,
type Recording,
type NewRecording,
} from '@iptvnator/shared/database';
@@ -13,6 +13,7 @@ import './database/favorites.events';
import './database/playback-position.events';
import './database/playlist.events';
import './database/recently-viewed.events';
import './database/recordings.events';
import './database/tmdb.events';
import './database/vod-sources.events';
import './database/xtream.events';
@@ -0,0 +1,19 @@
import type { BrowserWindow } from 'electron';
// Recordings deliberately get their own bare-ping channel instead of sharing
// DOWNLOADS_UPDATE_EVENT: recording state changes ride the embedded-MPV
// session snapshot cadence, and a shared ping would make every transition
// refetch the (availability-probed) downloads list too.
export const RECORDINGS_UPDATE_EVENT = 'RECORDINGS_UPDATE_EVENT';
let mainWindow: BrowserWindow | null = null;
export function setRecordingsMainWindow(win: BrowserWindow): void {
mainWindow = win;
}
export function broadcastRecordingsUpdate(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(RECORDINGS_UPDATE_EVENT);
}
}
@@ -0,0 +1,101 @@
import {
decodeRecordingPrograms,
sanitizeRecordingPrograms,
} from './recording-programs';
describe('sanitizeRecordingPrograms', () => {
it('fails closed for non-array payloads', () => {
expect(sanitizeRecordingPrograms(undefined)).toBeNull();
expect(sanitizeRecordingPrograms(null)).toBeNull();
expect(sanitizeRecordingPrograms('[]')).toBeNull();
expect(sanitizeRecordingPrograms({ length: 1 })).toBeNull();
});
it('rejects arrays above the hostile-input cap', () => {
const oversized = Array.from({ length: 65 }, (_, index) => ({
title: `Program ${index}`,
start: '2026-08-15T21:00:00+03:00',
stop: '2026-08-15T22:00:00+03:00',
}));
expect(sanitizeRecordingPrograms(oversized)).toBeNull();
});
it('drops invalid entries individually and keeps valid ones', () => {
const sanitized = sanitizeRecordingPrograms([
{
title: ' News ',
description: ' Daily news ',
start: '2026-08-15T21:00:00+03:00',
stop: '2026-08-15T21:45:00+03:00',
},
{ title: '', start: 'x', stop: 'y' },
{ title: 'No times' },
42,
null,
]);
expect(sanitized).toEqual([
{
title: 'News',
description: 'Daily news',
start: '2026-08-15T21:00:00+03:00',
stop: '2026-08-15T21:45:00+03:00',
},
]);
});
it('omits empty descriptions and truncates oversized fields', () => {
const sanitized = sanitizeRecordingPrograms([
{
title: 'a'.repeat(600),
description: ' ',
start: '2026-08-15T21:00:00+03:00',
stop: '2026-08-15T21:45:00+03:00',
},
]);
expect(sanitized).toHaveLength(1);
expect(sanitized?.[0].title).toHaveLength(512);
expect(sanitized?.[0]).not.toHaveProperty('description');
});
it('rejects entries with oversized time strings', () => {
expect(
sanitizeRecordingPrograms([
{
title: 'News',
start: 'x'.repeat(65),
stop: '2026-08-15T21:45:00+03:00',
},
])
).toEqual([]);
});
});
describe('decodeRecordingPrograms', () => {
it('returns undefined for empty, junk, or non-array JSON', () => {
expect(decodeRecordingPrograms(null)).toBeUndefined();
expect(decodeRecordingPrograms('')).toBeUndefined();
expect(decodeRecordingPrograms('not json')).toBeUndefined();
expect(decodeRecordingPrograms('{"title":"x"}')).toBeUndefined();
expect(decodeRecordingPrograms('[]')).toBeUndefined();
});
it('decodes and re-sanitizes persisted programs', () => {
const decoded = decodeRecordingPrograms(
JSON.stringify([
{
title: 'News',
start: '2026-08-15T21:00:00+03:00',
stop: '2026-08-15T21:45:00+03:00',
injected: 'junk',
},
])
);
expect(decoded).toEqual([
{
title: 'News',
start: '2026-08-15T21:00:00+03:00',
stop: '2026-08-15T21:45:00+03:00',
},
]);
});
});
@@ -0,0 +1,71 @@
import type { RecordingProgramSnapshot } from '@iptvnator/shared/interfaces';
// A recording rarely spans more than a handful of programs; the cap is a
// hostile-input bound, not a product limit.
const MAX_PROGRAMS = 64;
const MAX_TITLE_LENGTH = 512;
const MAX_DESCRIPTION_LENGTH = 4_096;
const MAX_TIME_LENGTH = 64;
function sanitizeProgram(value: unknown): RecordingProgramSnapshot | null {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
const title = typeof raw.title === 'string' ? raw.title.trim() : '';
const start = typeof raw.start === 'string' ? raw.start.trim() : '';
const stop = typeof raw.stop === 'string' ? raw.stop.trim() : '';
if (!title || !start || !stop) {
return null;
}
if (start.length > MAX_TIME_LENGTH || stop.length > MAX_TIME_LENGTH) {
return null;
}
const description =
typeof raw.description === 'string' ? raw.description.trim() : '';
return {
title: title.slice(0, MAX_TITLE_LENGTH),
...(description
? { description: description.slice(0, MAX_DESCRIPTION_LENGTH) }
: {}),
start,
stop,
};
}
/**
* Validates the renderer-supplied stop-enrichment payload. Returns `null`
* for a payload that is not an array (fail closed); invalid entries inside a
* valid array are dropped individually.
*/
export function sanitizeRecordingPrograms(
programs: unknown
): RecordingProgramSnapshot[] | null {
if (!Array.isArray(programs) || programs.length > MAX_PROGRAMS) {
return null;
}
const sanitized: RecordingProgramSnapshot[] = [];
for (const entry of programs) {
const program = sanitizeProgram(entry);
if (program) {
sanitized.push(program);
}
}
return sanitized;
}
/** Decodes the persisted `programs_json` column, tolerating legacy junk. */
export function decodeRecordingPrograms(
programsJson: string | null | undefined
): RecordingProgramSnapshot[] | undefined {
if (!programsJson) {
return undefined;
}
try {
const parsed = JSON.parse(programsJson);
const sanitized = sanitizeRecordingPrograms(parsed);
return sanitized && sanitized.length > 0 ? sanitized : undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,374 @@
const mockGetDatabase = jest.fn();
const mockStatSync = jest.fn();
const mockActiveRowIds = jest.fn();
const mockExecFileSync = jest.fn();
const mockBroadcast = jest.fn();
jest.mock('../../database/connection', () => ({
getDatabase: (...args: unknown[]) => mockGetDatabase(...args),
}));
jest.mock('node:fs/promises', () => ({
...jest.requireActual<typeof import('node:fs/promises')>(
'node:fs/promises'
),
stat: (...args: unknown[]) => mockStatSync(...args),
}));
jest.mock('child_process', () => ({
...jest.requireActual<typeof import('child_process')>('child_process'),
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
}));
jest.mock('../../services/embedded-mpv-recording-tracker', () => ({
embeddedMpvRecordingTracker: {
activeRowIds: (...args: unknown[]) => mockActiveRowIds(...args),
},
}));
jest.mock('./recording-broadcast', () => ({
broadcastRecordingsUpdate: (...args: unknown[]) => mockBroadcast(...args),
}));
import { reconcileStaleRecordings } from './recording-recovery';
interface StaleRow {
id: number;
filePath: string;
endedAt: string | null;
ownerPid?: number | null;
startedAt?: string;
}
/** A recording that began an hour ago — older than any recycled pid. */
const ROW_STARTED_AT = new Date(Date.now() - 3_600_000).toISOString();
function mockDb(rows: StaleRow[]) {
const selectWhere = jest.fn().mockResolvedValue(rows);
const updateWhere = jest.fn().mockResolvedValue(undefined);
const updateSet = jest.fn(
(_patch: {
status: string;
fileSizeBytes: number | null;
endedAt: string;
}) => ({ where: updateWhere })
);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({ where: selectWhere })),
})),
update: jest.fn(() => ({ set: updateSet })),
};
mockGetDatabase.mockResolvedValue(db);
return { updateSet, updateWhere };
}
describe('reconcileStaleRecordings', () => {
beforeEach(() => {
jest.clearAllMocks();
mockActiveRowIds.mockResolvedValue(new Set<number>());
// A live peer pid resolves to an IPTVnator-looking process name
// unless a test says otherwise.
mockExecFileSync.mockReturnValue('iptvnator');
});
it('repairs a playable partial as interrupted with its size', async () => {
const { updateSet } = mockDb([
{ id: 1, filePath: '/rec/a.ts', endedAt: null },
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
await reconcileStaleRecordings();
expect(updateSet).toHaveBeenCalledTimes(1);
expect(updateSet.mock.calls[0][0]).toEqual(
expect.objectContaining({
status: 'interrupted',
fileSizeBytes: 2048,
})
);
expect(updateSet.mock.calls[0][0].endedAt).toEqual(expect.any(String));
});
it('marks absent or empty files as failed', async () => {
const { updateSet } = mockDb([
{ id: 1, filePath: '/rec/gone.ts', endedAt: null },
{ id: 2, filePath: '/rec/empty.ts', endedAt: null },
]);
mockStatSync.mockRejectedValueOnce(
Object.assign(new Error('gone'), { code: 'ENOENT' })
);
mockStatSync.mockResolvedValueOnce({ isFile: () => true, size: 0 });
await reconcileStaleRecordings();
expect(updateSet).toHaveBeenCalledTimes(2);
expect(updateSet.mock.calls[0][0].status).toBe('failed');
expect(updateSet.mock.calls[0][0].fileSizeBytes).toBeNull();
expect(updateSet.mock.calls[1][0].status).toBe('failed');
});
it('takes endedAt from the file mtime, not the repair time', async () => {
// The recording ended when mpv last wrote the file; an overnight
// shutdown must not inflate a short capture into hours.
const mtimeMs = Date.parse('2026-08-15T21:05:00Z');
const { updateSet } = mockDb([
{
id: 1,
filePath: '/rec/a.ts',
endedAt: null,
startedAt: '2026-08-15T21:00:00Z',
},
]);
mockStatSync.mockResolvedValue({
isFile: () => true,
size: 2048,
mtimeMs,
});
await reconcileStaleRecordings();
expect(updateSet.mock.calls[0][0].endedAt).toBe(
new Date(mtimeMs).toISOString()
);
});
it('preserves an already-known endedAt', async () => {
const { updateSet } = mockDb([
{ id: 1, filePath: '/rec/a.ts', endedAt: '2026-08-15T21:58:00Z' },
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 10 });
await reconcileStaleRecordings();
expect(updateSet.mock.calls[0][0].endedAt).toBe(
'2026-08-15T21:58:00Z'
);
});
it('leaves rows owned by another live process alone', async () => {
// IPTVNATOR_ALLOW_MULTIPLE_INSTANCES: the other instance is still
// writing that file, and its own finalize is guarded on status
// 'recording' — repairing here would strand the row.
const { updateSet } = mockDb([
{ id: 1, filePath: '/rec/live.ts', endedAt: null, ownerPid: 4242 },
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation(() => true);
await reconcileStaleRecordings();
expect(killSpy).toHaveBeenCalledWith(4242, 0);
expect(updateSet).not.toHaveBeenCalled();
expect(mockBroadcast).not.toHaveBeenCalled();
killSpy.mockRestore();
});
it('repairs a row whose pid was recycled by an unrelated process', async () => {
// kill(pid, 0) alone would report the recycled pid as a live peer
// forever; the process-name check breaks that deadlock.
const { updateSet } = mockDb([
{
id: 1,
filePath: '/rec/stale.ts',
endedAt: null,
ownerPid: 4242,
},
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation(() => true);
mockExecFileSync.mockReturnValue('postgres');
await reconcileStaleRecordings();
expect(updateSet.mock.calls[0][0].status).toBe('interrupted');
killSpy.mockRestore();
});
it('unmasks a recycled pid that landed on another Electron app', async () => {
// The holder's name passes the family check, but it started five
// minutes ago while the recording began an hour ago — a pid frees
// only when its owner dies, so this cannot be the owner.
const { updateSet } = mockDb([
{
id: 1,
filePath: '/rec/stale.ts',
endedAt: null,
ownerPid: 4242,
startedAt: ROW_STARTED_AT,
},
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation(() => true);
mockExecFileSync.mockImplementation((_cmd, args) => {
const joined = Array.isArray(args) ? args.join(' ') : '';
if (joined.includes('etime')) {
return '05:00';
}
if (joined.includes('Get-Process')) {
return new Date(Date.now() - 300_000).toISOString();
}
return 'electron';
});
await reconcileStaleRecordings();
expect(updateSet.mock.calls[0][0].status).toBe('interrupted');
killSpy.mockRestore();
});
it('keeps skipping a same-family peer older than the recording', async () => {
const { updateSet } = mockDb([
{
id: 1,
filePath: '/rec/live.ts',
endedAt: null,
ownerPid: 4242,
startedAt: ROW_STARTED_AT,
},
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation(() => true);
mockExecFileSync.mockImplementation((_cmd, args) => {
const joined = Array.isArray(args) ? args.join(' ') : '';
if (joined.includes('etime')) {
return '02:00:00';
}
if (joined.includes('Get-Process')) {
return new Date(Date.now() - 7_200_000).toISOString();
}
return 'iptvnator';
});
await reconcileStaleRecordings();
expect(updateSet).not.toHaveBeenCalled();
killSpy.mockRestore();
});
it('leaves a row alone when the file cannot be judged', async () => {
// Timeout/permission/I-O errors are not proof of absence: the row
// stays 'recording' so a later startup can repair it honestly.
const { updateSet } = mockDb([
{ id: 1, filePath: '/rec/nas.ts', endedAt: null },
]);
mockStatSync.mockRejectedValue(
Object.assign(new Error('denied'), { code: 'EACCES' })
);
await reconcileStaleRecordings();
expect(updateSet).not.toHaveBeenCalled();
expect(mockBroadcast).not.toHaveBeenCalled();
});
it('keeps skipping when the peer process name cannot be read', async () => {
// Conservative fallback: never repair a row a live peer might own.
const { updateSet } = mockDb([
{
id: 1,
filePath: '/rec/live.ts',
endedAt: null,
ownerPid: 4242,
},
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation(() => true);
mockExecFileSync.mockImplementation(() => {
throw new Error('ps unavailable');
});
await reconcileStaleRecordings();
expect(updateSet).not.toHaveBeenCalled();
killSpy.mockRestore();
});
it('broadcasts once after repairing rows so a loaded renderer refetches', async () => {
mockDb([
{ id: 1, filePath: '/rec/a.ts', endedAt: null },
{ id: 2, filePath: '/rec/b.ts', endedAt: null },
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
await reconcileStaleRecordings();
expect(mockBroadcast).toHaveBeenCalledTimes(1);
});
it('does not broadcast when nothing needed repair', async () => {
mockDb([]);
await reconcileStaleRecordings();
expect(mockBroadcast).not.toHaveBeenCalled();
});
it('repairs rows whose owner process is gone', async () => {
const { updateSet } = mockDb([
{ id: 1, filePath: '/rec/dead.ts', endedAt: null, ownerPid: 4242 },
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('no such process'), {
code: 'ESRCH',
});
});
await reconcileStaleRecordings();
expect(updateSet.mock.calls[0][0].status).toBe('interrupted');
killSpy.mockRestore();
});
it('leaves a row this process is actively tracking alone', async () => {
// The renderer is interactive before this pass runs: a recording
// started during bootstrap has ownerPid === process.pid, so only the
// tracker's own ledger can prove it is live rather than a leftover.
const { updateSet } = mockDb([
{
id: 9,
filePath: '/rec/live-now.ts',
endedAt: null,
ownerPid: process.pid,
},
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
mockActiveRowIds.mockResolvedValue(new Set([9]));
await reconcileStaleRecordings();
expect(updateSet).not.toHaveBeenCalled();
});
it('repairs its own leftovers from a previous run', async () => {
const { updateSet } = mockDb([
{
id: 1,
filePath: '/rec/own.ts',
endedAt: null,
ownerPid: process.pid,
},
]);
mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 });
await reconcileStaleRecordings();
expect(updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('swallows database failures without throwing', async () => {
mockGetDatabase.mockRejectedValue(new Error('db down'));
const consoleSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await expect(reconcileStaleRecordings()).resolves.toBeUndefined();
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,319 @@
import { execFileSync } from 'child_process';
import { and, eq } from 'drizzle-orm';
import { stat } from 'node:fs/promises';
import { basename } from 'path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { embeddedMpvRecordingTracker } from '../../services/embedded-mpv-recording-tracker';
import { broadcastRecordingsUpdate } from './recording-broadcast';
const RECOVERY_STAT_TIMEOUT_MS = 3_000;
type RecordedFileProbe =
| { kind: 'size'; size: number; mtimeMs: number | null }
| { kind: 'missing' }
| { kind: 'unknown' };
/**
* Bounded, asynchronous stat: the recording directory is user-selected and
* can sit on an unreachable network filesystem, which must not block startup
* or fail a row it cannot actually see. Only proven absence
* (`ENOENT`/`ENOTDIR`) reports `missing`; timeouts and every other error
* stay `unknown`.
*/
async function probeRecordedFile(filePath: string): Promise<RecordedFileProbe> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<RecordedFileProbe>((resolve) => {
timeout = setTimeout(
() => resolve({ kind: 'unknown' }),
RECOVERY_STAT_TIMEOUT_MS
);
});
const probe = stat(filePath).then(
(stats): RecordedFileProbe =>
stats.isFile()
? {
kind: 'size',
size: stats.size,
// mpv last touched the file when the killed process
// stopped writing — the honest end of the recording.
mtimeMs:
Number.isFinite(stats.mtimeMs) && stats.mtimeMs > 0
? stats.mtimeMs
: null,
}
: { kind: 'missing' },
(error): RecordedFileProbe => {
const code = (error as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ENOTDIR'
? { kind: 'missing' }
: { kind: 'unknown' };
}
);
try {
return await Promise.race([probe, timedOut]);
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
/**
* True when `pid` belongs to a process that is still running. Signal 0 only
* probes for existence; EPERM means the process exists but is owned by
* someone else, which still counts as alive.
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
/**
* Best-effort check that the live process behind `pid` looks like another
* IPTVnator/Electron instance. After a crash the OS can hand the persisted
* `ownerPid` to an unrelated process before IPTVnator restarts; bare
* `kill(pid, 0)` liveness would then skip the row on every startup while no
* instance can ever finalize it. When the name cannot be read the answer
* stays `true` — never repair a row a live peer might still own.
*/
/**
* Bound for each synchronous `ps`/`tasklist`/PowerShell spawn. The probes
* run on the main thread once per unique pid (memoized per pass), so a hung
* process query degrades to the conservative fallback instead of blocking
* startup indefinitely.
*/
const PROCESS_PROBE_TIMEOUT_MS = 2_000;
function processLooksLikeOwnInstance(pid: number): boolean {
try {
const output =
process.platform === 'win32'
? execFileSync(
'tasklist',
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
{ encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS }
)
: execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
encoding: 'utf8',
timeout: PROCESS_PROBE_TIMEOUT_MS,
});
const name = output.trim().toLowerCase();
if (!name) {
return true;
}
const ownExecutable = basename(process.execPath).toLowerCase();
return (
name.includes('iptvnator') ||
name.includes('electron') ||
(ownExecutable.length > 0 && name.includes(ownExecutable))
);
} catch {
return true;
}
}
/**
* Milliseconds tolerance before a process start time counts as "after" the
* recording start — absorbs `etime`'s one-second granularity and small clock
* drift without weakening the recycled-pid discrimination.
*/
const START_TIME_TOLERANCE_MS = 5_000;
/** `[[dd-]hh:]mm:ss` from `ps -o etime=` → seconds, or null. */
function parseEtimeSeconds(etime: string): number | null {
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/.exec(etime);
if (!match) {
return null;
}
const [, days, hours, minutes, seconds] = match;
return (
(days ? Number(days) * 86_400 : 0) +
(hours ? Number(hours) * 3_600 : 0) +
Number(minutes) * 60 +
Number(seconds)
);
}
function processStartTimeMs(pid: number): number | null {
try {
if (process.platform === 'win32') {
const iso = execFileSync(
'powershell.exe',
[
'-NoProfile',
'-Command',
`(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`,
],
{ encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS }
).trim();
const parsed = Date.parse(iso);
return Number.isFinite(parsed) ? parsed : null;
}
const etime = execFileSync(
'ps',
['-p', String(pid), '-o', 'etime='],
{ encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS }
).trim();
const elapsedSeconds = parseEtimeSeconds(etime);
return elapsedSeconds === null
? null
: Date.now() - elapsedSeconds * 1_000;
} catch {
return null;
}
}
/**
* True only when the process behind `pid` PROVABLY started after the
* recording did. A pid can only be recycled once its previous holder died,
* and the recording's real owner was necessarily already running when it
* created the row — so a same-family process name (any Electron app) cannot
* shield a recycled pid whose holder is younger than the recording.
* Unreadable start times return false (conservative: keep the skip).
*/
function provablyStartedAfter(
processStartMs: number | null,
startedAt: string
): boolean {
const recordingStartMs = Date.parse(startedAt);
if (!Number.isFinite(recordingStartMs)) {
return false;
}
if (processStartMs === null) {
return false;
}
return processStartMs > recordingStartMs + START_TIME_TOLERANCE_MS;
}
/**
* Startup repair for recordings the previous app run left in status
* 'recording' (hard kill, crash, power loss). mpv muxes MPEG-TS
* continuously, so a file with real bytes is a playable partial recording
* ('interrupted'); an absent or empty file means nothing usable was captured
* ('failed').
*
* Rows whose owner process is still alive are skipped — but only when it
* also looks like an IPTVnator/Electron process AND cannot be proven to have
* started after the recording did. A recycled pid's holder is necessarily
* younger than the recording (the pid frees only when its previous owner
* dies), so the start-time check unmasks even a recycled pid that landed on
* another Electron app; unreadable names or start times stay conservative
* and keep the skip. The skip matters with
* IPTVNATOR_ALLOW_MULTIPLE_INSTANCES a second instance shares this database,
* and its startup must not terminate a recording the first one is actively
* writing (the tracker's own update is guarded on status 'recording', so the
* row would never get its real end time or size).
*
* Rows this process is itself tracking are skipped too: the renderer is
* interactive before this pass runs, so a recording started during bootstrap
* carries `ownerPid === process.pid` — indistinguishable by pid from a
* recycled-pid leftover, but alive by the tracker's own ledger.
*/
export async function reconcileStaleRecordings(): Promise<void> {
try {
const db = await getDatabase();
const stale = await db
.select()
.from(schema.recordings)
.where(eq(schema.recordings.status, 'recording'));
// Resolved after the SELECT: any row the query saw was enqueued by a
// tracker entry that already exists, so awaiting the tracked row ids
// here cannot miss it.
const liveRowIds = await embeddedMpvRecordingTracker.activeRowIds();
// Rows from one crashed instance share a pid: memoize the (bounded,
// synchronous) process probes so each unique pid costs at most one
// name query and one start-time query per pass.
const nameVerdicts = new Map<number, boolean>();
const startTimes = new Map<number, number | null>();
const looksLikeOwnInstance = (pid: number): boolean => {
let verdict = nameVerdicts.get(pid);
if (verdict === undefined) {
verdict = processLooksLikeOwnInstance(pid);
nameVerdicts.set(pid, verdict);
}
return verdict;
};
const startTimeOf = (pid: number): number | null => {
if (!startTimes.has(pid)) {
startTimes.set(pid, processStartTimeMs(pid));
}
return startTimes.get(pid) ?? null;
};
const candidates = stale.filter((row) => {
if (liveRowIds.has(row.id)) {
return false;
}
return !(
row.ownerPid !== null &&
row.ownerPid !== undefined &&
row.ownerPid !== process.pid &&
isProcessAlive(row.ownerPid) &&
looksLikeOwnInstance(row.ownerPid) &&
!provablyStartedAfter(
startTimeOf(row.ownerPid),
row.startedAt
)
);
});
// Probe concurrently: each probe carries its own deadline, so a
// batch of rows on a dead mount costs one deadline for the whole
// pass instead of one per row (main.ts awaits this function).
const probes = await Promise.all(
candidates.map((row) => probeRecordedFile(row.filePath))
);
let repairedRows = 0;
for (let i = 0; i < candidates.length; i += 1) {
const row = candidates[i];
const probe = probes[i];
if (probe.kind === 'unknown') {
// Cannot see the file right now — leave the row in
// 'recording' so a later startup can repair it honestly.
continue;
}
const size = probe.kind === 'size' ? probe.size : null;
const playable = size !== null && size > 0;
// The recording ended when mpv last wrote the file, not when
// this repair happens to run — an overnight shutdown must not
// inflate a five-minute capture into hours.
const capturedEndedAt =
probe.kind === 'size' && probe.mtimeMs !== null
? new Date(probe.mtimeMs).toISOString()
: new Date().toISOString();
await db
.update(schema.recordings)
.set({
status: playable ? 'interrupted' : 'failed',
fileSizeBytes: playable ? size : null,
endedAt: row.endedAt ?? capturedEndedAt,
updatedAt: new Date().toISOString(),
})
// Guarded on the status this pass observed: if the tracker's
// finalization committed between the SELECT and here, the
// row is already terminal and must not be relabelled.
.where(
and(
eq(schema.recordings.id, row.id),
eq(schema.recordings.status, 'recording')
)
);
repairedRows += 1;
}
if (repairedRows > 0) {
// The renderer loads before this pass runs and may already hold
// the pre-repair list (with a stale Stop affordance); tell it.
broadcastRecordingsUpdate();
}
} catch (error) {
console.error('[Recordings] Stale-recording repair failed:', error);
}
}
@@ -0,0 +1,609 @@
// Marks the file as a module. Without it the top-level bindings below land in
// the global scope and collide with same-named consts in sibling event specs
// (stream-probe.spec.ts declares its own `registeredHandlers`). The spec
// otherwise has no static imports on purpose: every dependency is swapped
// through `jest.doMock` before the dynamic import in the harness.
export {};
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
const registeredHandlers = new Map<string, IpcHandler>();
const mockGetDatabase = jest.fn();
const mockBroadcast = jest.fn();
const mockStopRecording = jest.fn();
const mockWhenSettled = jest.fn();
const mockGetAvailabilityAsync = jest.fn();
const mockUnlink = jest.fn();
const mockStat = jest.fn();
const mockOpenPath = jest.fn();
const mockShowItemInFolder = jest.fn();
function getHandler(channel: string): IpcHandler {
const handler = registeredHandlers.get(channel);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
async function setupRecordingsEventsHarness(): Promise<void> {
jest.resetModules();
registeredHandlers.clear();
mockGetDatabase.mockReset();
mockBroadcast.mockReset();
mockStopRecording.mockReset();
mockWhenSettled.mockReset().mockResolvedValue(undefined);
mockGetAvailabilityAsync.mockReset().mockResolvedValue('available');
mockUnlink.mockReset().mockResolvedValue(undefined);
mockStat.mockReset().mockResolvedValue({ isFile: () => true, size: 4096 });
mockOpenPath.mockReset().mockResolvedValue('');
mockShowItemInFolder.mockReset();
jest.doMock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
registeredHandlers.set(channel, handler);
}),
},
shell: {
openPath: mockOpenPath,
showItemInFolder: mockShowItemInFolder,
},
}));
jest.doMock('node:fs/promises', () => ({
...jest.requireActual<typeof import('node:fs/promises')>(
'node:fs/promises'
),
stat: mockStat,
unlink: mockUnlink,
}));
jest.doMock('../../database/connection', () => ({
getDatabase: mockGetDatabase,
}));
jest.doMock('../../services/embedded-mpv-native.service', () => ({
embeddedMpvNativeService: { stopRecording: mockStopRecording },
}));
jest.doMock('../../services/embedded-mpv-recording-tracker', () => ({
embeddedMpvRecordingTracker: { whenSettled: mockWhenSettled },
}));
jest.doMock('./recording-broadcast', () => ({
broadcastRecordingsUpdate: mockBroadcast,
}));
jest.doMock('./download-file-availability', () => ({
getDownloadFileAvailabilityWithTimeoutAsync: mockGetAvailabilityAsync,
}));
await import('./recordings.events');
}
function recordingRow(overrides: Record<string, unknown> = {}) {
return {
id: 42,
sessionId: 'session-1',
ownerPid: null,
status: 'completed',
filePath: '/rec/News-20260815-210000.ts',
fileSizeBytes: 1024,
channelName: 'Channel One',
channelLogoUrl: null,
playlistId: 'playlist-a',
playlistName: 'My provider',
sourceType: 'm3u',
epgChannelId: null,
programTitle: 'Evening News',
programDescription: null,
programStart: '2026-08-15T21:00:00Z',
programStop: '2026-08-15T21:45:00Z',
programsJson: null,
errorMessage: null,
startedAt: '2026-08-15T21:00:00Z',
endedAt: '2026-08-15T21:58:00Z',
createdAt: null,
updatedAt: null,
...overrides,
};
}
function mockListDb(rows: unknown[]) {
const orderBy = jest.fn().mockResolvedValue(rows);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ orderBy })),
orderBy,
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, orderBy };
}
function mockRowDb(row: unknown | undefined) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const updateWhere = jest.fn().mockResolvedValue(undefined);
const updateSet = jest.fn((_patch: Record<string, string>) => ({
where: updateWhere,
}));
const limit = jest.fn().mockResolvedValue(row ? [row] : []);
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
update: jest.fn(() => ({ set: updateSet })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit,
orderBy: jest.fn(() => ({ limit })),
})),
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere, updateSet, updateWhere };
}
describe('recordings events', () => {
beforeEach(async () => {
await setupRecordingsEventsHarness();
});
describe('RECORDINGS_GET_LIST', () => {
it('decorates rows with availability and decoded programs', async () => {
mockListDb([
recordingRow({
programsJson: JSON.stringify([
{
title: 'Evening News',
start: '2026-08-15T21:00:00Z',
stop: '2026-08-15T21:45:00Z',
},
{
title: 'Weather',
start: '2026-08-15T21:45:00Z',
stop: '2026-08-15T22:10:00Z',
},
]),
}),
]);
const result = (await getHandler('RECORDINGS_GET_LIST')(
null
)) as Array<Record<string, unknown>>;
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
id: 42,
fileAvailability: 'available',
channelName: 'Channel One',
})
);
expect(result[0].programs).toHaveLength(2);
// Availability probe sees interrupted/completed as completed.
expect(mockGetAvailabilityAsync).toHaveBeenCalledWith(
expect.objectContaining({ status: 'completed' })
);
});
it('reports the live size of an active recording from disk', async () => {
// The tracker only persists file_size_bytes at finalization, so
// the manager's growing size has to come from a stat here.
mockListDb([
recordingRow({
status: 'recording',
fileSizeBytes: null,
endedAt: null,
}),
]);
const result = (await getHandler('RECORDINGS_GET_LIST')(
null
)) as Array<Record<string, unknown>>;
expect(mockStat).toHaveBeenCalledWith(
'/rec/News-20260815-210000.ts'
);
expect(result[0].fileSizeBytes).toBe(4096);
});
it('keeps the persisted size for terminal rows without statting', async () => {
mockListDb([recordingRow()]);
const result = (await getHandler('RECORDINGS_GET_LIST')(
null
)) as Array<Record<string, unknown>>;
expect(mockStat).not.toHaveBeenCalled();
expect(result[0].fileSizeBytes).toBe(1024);
});
it('omits the live size when the active file cannot be statted', async () => {
mockStat.mockRejectedValue(new Error('gone'));
mockListDb([
recordingRow({ status: 'recording', fileSizeBytes: null }),
]);
const result = (await getHandler('RECORDINGS_GET_LIST')(
null
)) as Array<Record<string, unknown>>;
expect(result[0].fileSizeBytes).toBeUndefined();
});
it('passes an inconclusive availability probe through as unknown', async () => {
// Timeout/permission errors are not proof of absence; collapsing
// them to 'missing' would move a good recording on a slow mount
// to Needs attention and hide its file actions.
mockGetAvailabilityAsync.mockResolvedValue('unknown');
mockListDb([recordingRow()]);
const result = (await getHandler('RECORDINGS_GET_LIST')(
null
)) as Array<Record<string, unknown>>;
expect(result[0].fileAvailability).toBe('unknown');
});
it('bounds a hanging live-size stat so the list still resolves', async () => {
// A recording directory on a dead network filesystem can leave
// the stat pending forever; the list awaits every decorator, so
// the probe must time out to "no size" instead of wedging it.
mockStat.mockReturnValue(new Promise(() => undefined));
mockListDb([
recordingRow({
status: 'recording',
fileSizeBytes: null,
endedAt: null,
}),
]);
jest.useFakeTimers();
try {
const pending = getHandler('RECORDINGS_GET_LIST')(null);
await jest.advanceTimersByTimeAsync(1_100);
const result = (await pending) as Array<
Record<string, unknown>
>;
expect(result[0].fileSizeBytes).toBeUndefined();
} finally {
jest.useRealTimers();
}
});
it('probes interrupted rows like completed ones but not failed rows', async () => {
mockListDb([
recordingRow({ status: 'interrupted' }),
recordingRow({ id: 43, status: 'failed' }),
]);
await getHandler('RECORDINGS_GET_LIST')(null);
expect(mockGetAvailabilityAsync.mock.calls[0][0].status).toBe(
'completed'
);
expect(mockGetAvailabilityAsync.mock.calls[1][0].status).toBe(
'failed'
);
});
});
describe('RECORDINGS_STOP', () => {
it('stops through the embedded MPV service for an active row', async () => {
mockRowDb(
recordingRow({ status: 'recording', endedAt: null })
);
await expect(
getHandler('RECORDINGS_STOP')(null, 42)
).resolves.toEqual({ success: true });
expect(mockStopRecording).toHaveBeenCalledWith('session-1');
});
it('refuses rows that are not active or lack a session id', async () => {
mockRowDb(recordingRow());
await expect(
getHandler('RECORDINGS_STOP')(null, 42)
).resolves.toEqual({
error: 'Recording is not active',
success: false,
});
mockRowDb(
recordingRow({ status: 'recording', sessionId: null })
);
await expect(
getHandler('RECORDINGS_STOP')(null, 42)
).resolves.toMatchObject({ success: false });
expect(mockStopRecording).not.toHaveBeenCalled();
});
it('refuses to stop a recording owned by another instance', async () => {
// Session ids restart per process, so dispatching a foreign row's
// id would stop an unrelated local recording.
mockRowDb(
recordingRow({
status: 'recording',
endedAt: null,
ownerPid: process.pid + 1,
})
);
await expect(
getHandler('RECORDINGS_STOP')(null, 42)
).resolves.toMatchObject({ success: false });
expect(mockStopRecording).not.toHaveBeenCalled();
});
it('stops a row this process owns', async () => {
mockRowDb(
recordingRow({
status: 'recording',
endedAt: null,
ownerPid: process.pid,
})
);
await expect(
getHandler('RECORDINGS_STOP')(null, 42)
).resolves.toEqual({ success: true });
expect(mockStopRecording).toHaveBeenCalledWith('session-1');
});
it('normalizes a native stop failure into an error result', async () => {
mockRowDb(recordingRow({ status: 'recording' }));
mockStopRecording.mockImplementation(() => {
throw new Error('addon unavailable');
});
const consoleSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await expect(
getHandler('RECORDINGS_STOP')(null, 42)
).resolves.toEqual({ error: 'addon unavailable', success: false });
consoleSpy.mockRestore();
});
});
describe('RECORDINGS_REMOVE', () => {
it('removes a finished row, keeps its file, and broadcasts', async () => {
const { deleteWhere } = mockRowDb(recordingRow());
await expect(
getHandler('RECORDINGS_REMOVE')(null, 42)
).resolves.toEqual({ success: true });
expect(mockUnlink).not.toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(mockBroadcast).toHaveBeenCalledTimes(1);
});
it('cleans up the leftover file for failed rows, best-effort', async () => {
mockRowDb(recordingRow({ status: 'failed' }));
mockUnlink.mockRejectedValue(new Error('already gone'));
await expect(
getHandler('RECORDINGS_REMOVE')(null, 42)
).resolves.toEqual({ success: true });
expect(mockUnlink).toHaveBeenCalledWith(
'/rec/News-20260815-210000.ts'
);
});
it('keeps the file when another row already owns that path', async () => {
// A retry inside the same timestamp second reuses the freed name;
// unlinking here would take the newer recording's file.
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const limit = jest
.fn()
.mockResolvedValueOnce([recordingRow({ status: 'failed' })])
.mockResolvedValueOnce([{ id: 42 }, { id: 77 }]);
mockGetDatabase.mockResolvedValue({
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
});
await expect(
getHandler('RECORDINGS_REMOVE')(null, 42)
).resolves.toEqual({ success: true });
expect(mockUnlink).not.toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalledTimes(1);
});
it('refuses to remove an active recording', async () => {
const { deleteWhere } = mockRowDb(
recordingRow({ status: 'recording' })
);
await expect(
getHandler('RECORDINGS_REMOVE')(null, 42)
).resolves.toMatchObject({ success: false });
expect(deleteWhere).not.toHaveBeenCalled();
});
it('reports a missing row', async () => {
mockRowDb(undefined);
await expect(
getHandler('RECORDINGS_REMOVE')(null, 42)
).resolves.toEqual({
error: 'Recording not found',
success: false,
});
});
});
describe('RECORDINGS_UPDATE_PROGRAMS', () => {
const programs = [
{
title: 'Evening News',
start: '2026-08-15T21:00:00Z',
stop: '2026-08-15T21:45:00Z',
},
];
it('rejects invalid payloads without touching the database', async () => {
await expect(
getHandler('RECORDINGS_UPDATE_PROGRAMS')(null, '', programs)
).resolves.toMatchObject({ success: false });
await expect(
getHandler('RECORDINGS_UPDATE_PROGRAMS')(
null,
'/rec/a.ts',
'not-an-array'
)
).resolves.toMatchObject({ success: false });
expect(mockGetDatabase).not.toHaveBeenCalled();
});
it('waits for the queued row INSERT before looking the row up', async () => {
// A recording stopped milliseconds after starting: the row may
// still be queued, and an unsynchronized lookup would find
// nothing.
let releaseTracker: (() => void) | undefined;
mockWhenSettled.mockReturnValue(
new Promise<void>((resolve) => {
releaseTracker = resolve;
})
);
const { updateSet } = mockRowDb(recordingRow());
const pending = getHandler('RECORDINGS_UPDATE_PROGRAMS')(
null,
'/rec/News-20260815-210000.ts',
programs
);
await Promise.resolve();
expect(mockGetDatabase).not.toHaveBeenCalled();
releaseTracker?.();
await expect(pending).resolves.toEqual({ success: true });
expect(updateSet).toHaveBeenCalledTimes(1);
});
it('writes programs and keeps an existing headline program', async () => {
const { updateSet } = mockRowDb(recordingRow());
await expect(
getHandler('RECORDINGS_UPDATE_PROGRAMS')(
null,
'/rec/News-20260815-210000.ts',
programs
)
).resolves.toEqual({ success: true });
const patch = updateSet.mock.calls[0][0];
expect(JSON.parse(patch.programsJson)).toHaveLength(1);
expect(patch).not.toHaveProperty('programTitle');
expect(mockBroadcast).toHaveBeenCalledTimes(1);
});
it('backfills the headline program when the start snapshot had none', async () => {
const { updateSet } = mockRowDb(
recordingRow({ programTitle: null })
);
await getHandler('RECORDINGS_UPDATE_PROGRAMS')(
null,
'/rec/News-20260815-210000.ts',
programs
);
expect(updateSet.mock.calls[0][0]).toEqual(
expect.objectContaining({
programTitle: 'Evening News',
programStart: '2026-08-15T21:00:00Z',
programStop: '2026-08-15T21:45:00Z',
})
);
});
it('enriches a row that has not finalized yet', async () => {
// Enrichment and finalization are order-independent: finalize()
// writes status/time/size and never touches programs_json.
const { updateSet } = mockRowDb(
recordingRow({
status: 'recording',
endedAt: null,
programTitle: null,
})
);
await expect(
getHandler('RECORDINGS_UPDATE_PROGRAMS')(
null,
'/rec/News-20260815-210000.ts',
programs
)
).resolves.toEqual({ success: true });
expect(JSON.parse(updateSet.mock.calls[0][0].programsJson)).toEqual(
programs
);
});
it('reports when no row matches the path', async () => {
mockRowDb(undefined);
await expect(
getHandler('RECORDINGS_UPDATE_PROGRAMS')(
null,
'/rec/unknown.ts',
programs
)
).resolves.toEqual({
error: 'Recording not found',
success: false,
});
});
});
describe('file actions', () => {
it('gates reveal on the recordings table and availability', async () => {
mockRowDb(undefined);
await expect(
getHandler('RECORDINGS_REVEAL_FILE')(null, '/rec/foreign.ts')
).resolves.toEqual({ error: 'File not found', success: false });
expect(mockShowItemInFolder).not.toHaveBeenCalled();
mockRowDb(recordingRow());
await expect(
getHandler('RECORDINGS_REVEAL_FILE')(
null,
'/rec/News-20260815-210000.ts'
)
).resolves.toEqual({ success: true });
expect(mockShowItemInFolder).toHaveBeenCalledWith(
'/rec/News-20260815-210000.ts'
);
});
it('refuses managed paths whose file is provenly gone', async () => {
mockRowDb(recordingRow());
mockGetAvailabilityAsync.mockResolvedValue('missing');
await expect(
getHandler('RECORDINGS_PLAY_FILE')(
null,
'/rec/News-20260815-210000.ts'
)
).resolves.toEqual({ error: 'File not found', success: false });
expect(mockOpenPath).not.toHaveBeenCalled();
});
it('lets the shell try when the availability probe is inconclusive', async () => {
// Only proven absence refuses the action: a timed-out probe on
// a slow mount must not block Play — the OS answers honestly.
mockRowDb(recordingRow());
mockGetAvailabilityAsync.mockResolvedValue('unknown');
await expect(
getHandler('RECORDINGS_PLAY_FILE')(
null,
'/rec/News-20260815-210000.ts'
)
).resolves.toEqual({ success: true });
expect(mockOpenPath).toHaveBeenCalledWith(
'/rec/News-20260815-210000.ts'
);
});
it('plays a managed available file', async () => {
mockRowDb(recordingRow());
await expect(
getHandler('RECORDINGS_PLAY_FILE')(
null,
'/rec/News-20260815-210000.ts'
)
).resolves.toEqual({ success: true });
expect(mockOpenPath).toHaveBeenCalledWith(
'/rec/News-20260815-210000.ts'
);
});
});
});
@@ -0,0 +1,387 @@
import type {
ElectronRecordingItem,
RecordingProgramSnapshot,
} from '@iptvnator/shared/interfaces';
import { desc, eq } from 'drizzle-orm';
import { ipcMain, shell } from 'electron';
import { stat, unlink } from 'node:fs/promises';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { embeddedMpvNativeService } from '../../services/embedded-mpv-native.service';
import { embeddedMpvRecordingTracker } from '../../services/embedded-mpv-recording-tracker';
import { getDownloadFileAvailabilityWithTimeoutAsync } from './download-file-availability';
import { broadcastRecordingsUpdate } from './recording-broadcast';
import {
decodeRecordingPrograms,
sanitizeRecordingPrograms,
} from './recording-programs';
type RecordingRow = schema.Recording;
const LIVE_SIZE_PROBE_TIMEOUT_MS = 1_000;
/**
* Coalesced raw stat for an actively recorded file. Mirrors the availability
* probe's contract: caller deadlines never evict the in-flight operation, so
* a stat stalled on a slow network filesystem is not duplicated by every
* manager refresh; completed results are dropped so growth stays visible.
*/
const inFlightSizeProbes = new Map<string, Promise<number | null>>();
function probeLiveFileSize(filePath: string): Promise<number | null> {
const existing = inFlightSizeProbes.get(filePath);
if (existing) {
return existing;
}
const probe = stat(filePath).then(
(stats) => (stats.isFile() ? stats.size : null),
() => null
);
inFlightSizeProbes.set(filePath, probe);
const clear = () => {
if (inFlightSizeProbes.get(filePath) === probe) {
inFlightSizeProbes.delete(filePath);
}
};
void probe.then(clear, clear);
return probe;
}
/**
* Bytes on disk for a recording still being written. The tracker only
* persists `file_size_bytes` at finalization, so the manager's active row
* would otherwise report nothing while mpv keeps growing the file. The stat
* is bounded: `RECORDINGS_GET_LIST` awaits every decorator, so one stat
* hanging on an unreachable filesystem must degrade to "no size" instead of
* wedging every manager refresh.
*/
async function liveFileSizeBytes(filePath: string): Promise<number | null> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), LIVE_SIZE_PROBE_TIMEOUT_MS);
});
try {
return await Promise.race([probeLiveFileSize(filePath), timedOut]);
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
async function decorateRecordingItem(
row: RecordingRow
): Promise<ElectronRecordingItem> {
// Recordings reuse the download availability probe (bounded lstat with
// in-flight coalescing) through the variant that preserves 'unknown':
// a timed-out or errored probe is not proof of absence, and collapsing
// it to 'missing' would move a good recording on a slow mount to Needs
// attention and hide its Play/Reveal actions. 'interrupted' partials
// are playable files, so they are probed exactly like completed rows;
// 'recording' and 'failed' map to a non-completed status and come back
// 'not-applicable'.
const fileAvailability = await getDownloadFileAvailabilityWithTimeoutAsync({
filePath: row.filePath,
status:
row.status === 'completed' || row.status === 'interrupted'
? 'completed'
: 'failed',
});
const fileSizeBytes =
row.status === 'recording'
? ((await liveFileSizeBytes(row.filePath)) ?? undefined)
: (row.fileSizeBytes ?? undefined);
return {
id: row.id,
sessionId: row.sessionId ?? undefined,
status: row.status,
filePath: row.filePath,
fileSizeBytes,
channelName: row.channelName,
channelLogoUrl: row.channelLogoUrl ?? undefined,
playlistId: row.playlistId ?? undefined,
playlistName: row.playlistName ?? undefined,
sourceType: row.sourceType ?? undefined,
epgChannelId: row.epgChannelId ?? undefined,
programTitle: row.programTitle ?? undefined,
programDescription: row.programDescription ?? undefined,
programStart: row.programStart ?? undefined,
programStop: row.programStop ?? undefined,
programs: decodeRecordingPrograms(row.programsJson),
errorMessage: row.errorMessage ?? undefined,
startedAt: row.startedAt,
endedAt: row.endedAt ?? undefined,
createdAt: row.createdAt ?? undefined,
updatedAt: row.updatedAt ?? undefined,
fileAvailability,
};
}
/**
* Shell-touching IPCs may only operate on paths the main process itself
* reserved via `reserveRecordingTargetPath` — the renderer-supplied recording
* directory is a write-location preference, not a shell-access grant.
*/
async function isManagedRecordingFile(filePath: string): Promise<boolean> {
if (!filePath) {
return false;
}
try {
const db = await getDatabase();
const rows = await db
.select({ id: schema.recordings.id })
.from(schema.recordings)
.where(eq(schema.recordings.filePath, filePath))
.limit(1);
return rows.length > 0;
} catch (error) {
console.error('Error verifying managed recording path:', error);
return false;
}
}
ipcMain.handle('RECORDINGS_GET_LIST', async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
const query = db.select().from(schema.recordings);
const rows = await (playlistId
? query
.where(eq(schema.recordings.playlistId, playlistId))
.orderBy(desc(schema.recordings.startedAt))
: query.orderBy(desc(schema.recordings.startedAt)));
return Promise.all(rows.map((row) => decorateRecordingItem(row)));
} catch (error) {
console.error('[Recordings] Error getting recording list:', error);
throw error;
}
});
ipcMain.handle('RECORDINGS_GET', async (_event, recordingId: number) => {
try {
const db = await getDatabase();
const result = await db
.select()
.from(schema.recordings)
.where(eq(schema.recordings.id, recordingId))
.limit(1);
return result[0] ? await decorateRecordingItem(result[0]) : null;
} catch (error) {
console.error('[Recordings] Error getting recording:', error);
throw error;
}
});
ipcMain.handle('RECORDINGS_STOP', async (_event, recordingId: number) => {
try {
const db = await getDatabase();
const result = await db
.select()
.from(schema.recordings)
.where(eq(schema.recordings.id, recordingId))
.limit(1);
const row = result[0];
if (!row || row.status !== 'recording' || !row.sessionId) {
return { error: 'Recording is not active', success: false };
}
// Session ids restart per process, so under
// IPTVNATOR_ALLOW_MULTIPLE_INSTANCES another instance's row can name
// a session id that exists locally as an unrelated recording. Only
// the owning process may stop it.
if (
row.ownerPid !== null &&
row.ownerPid !== undefined &&
row.ownerPid !== process.pid
) {
return {
error: 'Recording belongs to another IPTVnator instance',
success: false,
};
}
// Finalization (status, ended_at, file size, broadcast) happens in
// the recording tracker's stop hook, exactly like a stop from the
// player controls.
embeddedMpvNativeService.stopRecording(row.sessionId);
return { success: true };
} catch (error) {
console.error('[Recordings] Error stopping recording:', error);
return {
error: error instanceof Error ? error.message : 'Stop failed',
success: false,
};
}
});
ipcMain.handle('RECORDINGS_REMOVE', async (_event, recordingId: number) => {
try {
const db = await getDatabase();
const result = await db
.select()
.from(schema.recordings)
.where(eq(schema.recordings.id, recordingId))
.limit(1);
const row = result[0];
if (!row) {
return { error: 'Recording not found', success: false };
}
if (row.status === 'recording') {
return {
error: 'Stop the recording before removing it',
success: false,
};
}
// Finished recordings keep their file on disk (same contract as
// completed downloads); only a failed row's leftover reservation is
// cleaned up best-effort — and only while no other row claims that
// path. A retry within the same timestamp second reuses the freed
// name, and unlinking then would take the newer recording's file
// (mpv would keep writing to an inode nobody can reach).
if (row.status === 'failed' && row.filePath) {
const otherOwners = await db
.select({ id: schema.recordings.id })
.from(schema.recordings)
.where(eq(schema.recordings.filePath, row.filePath))
.limit(2);
const pathIsExclusive = otherOwners.every(
(candidate) => candidate.id === row.id
);
if (pathIsExclusive) {
// Bounded: a hung network unlink must not keep the Remove
// action busy — deleting the row is what matters, and the
// cleanup contract is best-effort anyway.
let unlinkDeadline:
| ReturnType<typeof setTimeout>
| undefined;
await Promise.race([
unlink(row.filePath).catch(() => undefined),
new Promise<void>((resolve) => {
unlinkDeadline = setTimeout(
resolve,
LIVE_SIZE_PROBE_TIMEOUT_MS
);
}),
]);
if (unlinkDeadline !== undefined) {
clearTimeout(unlinkDeadline);
}
}
}
await db
.delete(schema.recordings)
.where(eq(schema.recordings.id, recordingId));
broadcastRecordingsUpdate();
return { success: true };
} catch (error) {
console.error('[Recordings] Error removing recording:', error);
return {
error: error instanceof Error ? error.message : 'Remove failed',
success: false,
};
}
});
ipcMain.handle(
'RECORDINGS_UPDATE_PROGRAMS',
async (
_event,
targetPath: string,
programs: RecordingProgramSnapshot[]
) => {
try {
const sanitized = sanitizeRecordingPrograms(programs);
if (!targetPath || sanitized === null) {
return { error: 'Invalid programs payload', success: false };
}
// Only guarantees the row's INSERT has committed — a recording
// stopped within milliseconds of starting. Enrichment does not
// wait for finalization: the row is found in any status and
// `finalize()` never touches `programs_json`, so the two writes
// are order-independent and no deadline can drop the programs.
await embeddedMpvRecordingTracker.whenSettled();
const db = await getDatabase();
// `openSync('wx')` makes the reserved path exclusive while a
// recording owns it, so the newest row for that path is the
// recording that was just stopped — whatever status it currently
// carries.
const rows = await db
.select()
.from(schema.recordings)
.where(eq(schema.recordings.filePath, targetPath))
.orderBy(desc(schema.recordings.id))
.limit(1);
const row = rows[0];
if (!row) {
return { error: 'Recording not found', success: false };
}
const first = sanitized[0];
await db
.update(schema.recordings)
.set({
programsJson: JSON.stringify(sanitized),
// Backfill the headline program only when the start
// snapshot had none — the program airing at start stays
// authoritative for the title.
...(row.programTitle || !first
? {}
: {
programTitle: first.title,
programDescription: first.description,
programStart: first.start,
programStop: first.stop,
}),
updatedAt: new Date().toISOString(),
})
.where(eq(schema.recordings.id, row.id));
broadcastRecordingsUpdate();
return { success: true };
} catch (error) {
console.error('[Recordings] Error updating programs:', error);
return {
error: error instanceof Error ? error.message : 'Update failed',
success: false,
};
}
}
);
/**
* Bounded pre-shell gate: only PROVEN absence refuses the action. The
* synchronous lstat variant would block the main process on a dead mount,
* and an inconclusive probe (timeout, permission error) must still let the
* shell try — the OS gives the honest answer either way.
*/
async function isRecordingFileMissing(filePath: string): Promise<boolean> {
const availability = await getDownloadFileAvailabilityWithTimeoutAsync({
filePath,
status: 'completed',
});
return availability === 'missing';
}
ipcMain.handle('RECORDINGS_REVEAL_FILE', async (_event, filePath: string) => {
if (
!(await isManagedRecordingFile(filePath)) ||
(await isRecordingFileMissing(filePath))
) {
return { error: 'File not found', success: false };
}
shell.showItemInFolder(filePath);
return { success: true };
});
ipcMain.handle('RECORDINGS_PLAY_FILE', async (_event, filePath: string) => {
if (
!(await isManagedRecordingFile(filePath)) ||
(await isRecordingFileMissing(filePath))
) {
return { error: 'File not found', success: false };
}
const error = await shell.openPath(filePath);
if (!error) {
return { success: true };
}
return (await isRecordingFileMissing(filePath))
? { error: 'File not found', success: false }
: { error, success: false };
});
@@ -31,6 +31,7 @@ import {
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { embeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
@@ -685,9 +686,11 @@ export class EmbeddedMpvNativeService {
options.directory?.trim() || this.getDefaultRecordingFolder();
mkdirSync(directory, { recursive: true });
const fallbackChannelName =
options.title || session.title || 'IPTVnator recording';
const targetPath = this.reserveRecordingTargetPath(
directory,
options.title || session.title || 'IPTVnator recording'
fallbackChannelName
);
try {
addon.startRecording(sessionId, targetPath);
@@ -695,6 +698,15 @@ export class EmbeddedMpvNativeService {
this.releaseReservedRecordingTargetPath(targetPath);
throw error;
}
// Register with the tracker before the refresh below broadcasts the
// first snapshot, so an immediately-active recording (frame-copy) is
// observed as such.
embeddedMpvRecordingTracker.onRecordingStarted({
sessionId,
targetPath,
fallbackChannelName,
metadata: options.metadata,
});
return this.refreshSession(sessionId);
}
@@ -707,6 +719,7 @@ export class EmbeddedMpvNativeService {
);
}
addon.stopRecording(sessionId);
embeddedMpvRecordingTracker.onRecordingStopped(sessionId);
return this.refreshSession(sessionId);
}
@@ -978,6 +991,12 @@ export class EmbeddedMpvNativeService {
}
private sendSessionUpdate(session: EmbeddedMpvSession): void {
// The tracker must see every snapshot — including the synthetic
// closed/error payloads — even when no window is left to notify:
// implicit stops (stream replacement, helper crash, dispose) are only
// observable here.
embeddedMpvRecordingTracker.observeSnapshot(session);
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return;
}
@@ -0,0 +1,579 @@
import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
const mockGetDatabase = jest.fn();
const mockBroadcast = jest.fn();
const mockStatSync = jest.fn();
const mockUnlinkSync = jest.fn();
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => mockGetDatabase(...args),
}));
jest.mock('../events/database/recording-broadcast', () => ({
broadcastRecordingsUpdate: (...args: unknown[]) => mockBroadcast(...args),
}));
jest.mock('node:fs/promises', () => ({
...jest.requireActual<typeof import('node:fs/promises')>(
'node:fs/promises'
),
stat: (...args: unknown[]) => mockStatSync(...args),
unlink: (...args: unknown[]) => mockUnlinkSync(...args),
}));
import { EmbeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker';
interface DbHarness {
insertValues: jest.Mock;
updateSet: jest.Mock;
updateWhere: jest.Mock;
}
function mockDb(): DbHarness {
const insertValues = jest
.fn((_row: Record<string, unknown>) =>
Promise.resolve({ lastInsertRowid: BigInt(7) })
)
.mockResolvedValue({ lastInsertRowid: BigInt(7) });
const updateWhere = jest.fn().mockResolvedValue(undefined);
const updateSet = jest.fn((_patch: Record<string, unknown>) => ({
where: updateWhere,
}));
const db = {
insert: jest.fn(() => ({ values: insertValues })),
update: jest.fn(() => ({ set: updateSet })),
};
mockGetDatabase.mockResolvedValue(db);
return { insertValues, updateSet, updateWhere };
}
function session(overrides: Partial<EmbeddedMpvSession>): EmbeddedMpvSession {
return {
id: 'session-1',
title: 'Channel One',
streamUrl: 'http://stream',
status: 'playing',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt: '2026-08-15T21:00:00Z',
updatedAt: '2026-08-15T21:00:00Z',
...overrides,
};
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const started = (tracker: EmbeddedMpvRecordingTracker) =>
tracker.onRecordingStarted({
sessionId: 'session-1',
targetPath: '/rec/News-20260815-210000.ts',
fallbackChannelName: 'Channel One',
metadata: {
channelName: 'Channel One',
channelLogoUrl: 'http://logo.png',
playlistId: 'playlist-a',
playlistName: 'My provider',
sourceType: 'm3u',
epgChannelId: 'channel.one',
currentProgram: {
title: 'Evening News',
start: '2026-08-15T21:00:00Z',
stop: '2026-08-15T21:45:00Z',
},
},
});
const inactiveSnapshot = (overrides: Partial<EmbeddedMpvSession> = {}) =>
session({
recording: {
active: false,
targetPath: '/rec/News-20260815-210000.ts',
},
...overrides,
});
/**
* Pushes the inactive snapshot and drives the settle window that guards
* against macOS native-view's optimistic pre-reply state.
*/
async function settleStop(
tracker: EmbeddedMpvRecordingTracker,
overrides: Partial<EmbeddedMpvSession> = {}
): Promise<void> {
jest.useFakeTimers();
try {
tracker.observeSnapshot(inactiveSnapshot(overrides));
await jest.advanceTimersByTimeAsync(1_600);
} finally {
jest.useRealTimers();
}
await flush();
}
const activeSnapshot = () =>
session({
recording: {
active: true,
targetPath: '/rec/News-20260815-210000.ts',
startedAt: '2026-08-15T21:00:01Z',
},
});
describe('EmbeddedMpvRecordingTracker', () => {
let tracker: EmbeddedMpvRecordingTracker;
let db: DbHarness;
beforeEach(() => {
jest.clearAllMocks();
db = mockDb();
mockStatSync.mockResolvedValue({ isFile: () => true, size: 1024 });
mockUnlinkSync.mockResolvedValue(undefined);
tracker = new EmbeddedMpvRecordingTracker();
});
it('inserts a row with the start metadata and broadcasts', async () => {
started(tracker);
await flush();
expect(db.insertValues).toHaveBeenCalledTimes(1);
expect(db.insertValues.mock.calls[0][0]).toEqual(
expect.objectContaining({
status: 'recording',
sessionId: 'session-1',
filePath: '/rec/News-20260815-210000.ts',
channelName: 'Channel One',
channelLogoUrl: 'http://logo.png',
playlistId: 'playlist-a',
playlistName: 'My provider',
sourceType: 'm3u',
epgChannelId: 'channel.one',
programTitle: 'Evening News',
programStart: '2026-08-15T21:00:00Z',
programStop: '2026-08-15T21:45:00Z',
})
);
expect(mockBroadcast).toHaveBeenCalledTimes(1);
});
it('falls back to the session title when metadata is absent', async () => {
tracker.onRecordingStarted({
sessionId: 'session-1',
targetPath: '/rec/x.ts',
fallbackChannelName: 'Fallback Channel',
});
await flush();
expect(db.insertValues.mock.calls[0][0].channelName).toBe(
'Fallback Channel'
);
});
it('finalizes an explicit stop only after mpv acknowledges it', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
await flush();
// addon.stopRecording() only dispatches: statting or unlinking here
// could hit a file mpv has not flushed yet.
expect(db.updateSet).not.toHaveBeenCalled();
await settleStop(tracker);
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0]).toEqual(
expect.objectContaining({
status: 'completed',
fileSizeBytes: 1024,
})
);
expect(db.updateSet.mock.calls[0][0].endedAt).toEqual(
expect.any(String)
);
// Start + finalize
expect(mockBroadcast).toHaveBeenCalledTimes(2);
});
it('treats an observed active→inactive flip as the auto-stop', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
await settleStop(tracker);
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
});
it('abandons finalization when a rejected stop revives the recording', async () => {
// macOS native-view clears recordingActive before dispatching the
// async property set and restores it if that request is rejected, so
// the first inactive snapshot is not an acknowledgement.
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
jest.useFakeTimers();
try {
tracker.observeSnapshot(inactiveSnapshot());
await jest.advanceTimersByTimeAsync(500);
// mpv rejected the stop: it is recording again.
tracker.observeSnapshot(activeSnapshot());
await jest.advanceTimersByTimeAsync(3_000);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).not.toHaveBeenCalled();
});
it('ignores inactive snapshots while an async start is still settling', async () => {
started(tracker);
tracker.observeSnapshot(
session({
recording: {
active: false,
targetPath: '/rec/News-20260815-210000.ts',
},
})
);
await flush();
expect(db.updateSet).not.toHaveBeenCalled();
});
it('marks a failed async start and unlinks the empty reservation', async () => {
mockStatSync.mockResolvedValue({ isFile: () => true, size: 0 });
started(tracker);
tracker.observeSnapshot(
session({
recording: {
active: false,
targetPath: '/rec/News-20260815-210000.ts',
error: 'property unavailable',
},
})
);
await flush();
expect(db.updateSet.mock.calls[0][0]).toEqual(
expect.objectContaining({
status: 'failed',
fileSizeBytes: null,
errorMessage: 'property unavailable',
})
);
expect(mockUnlinkSync).toHaveBeenCalledWith(
'/rec/News-20260815-210000.ts'
);
});
/**
* Pushes an error/closed snapshot and drives the teardown flush window
* that lets the frame-copy helper exit and flush before the stat.
*/
async function settleTeardown(
push: () => void,
advanceMs = 2_600
): Promise<void> {
jest.useFakeTimers();
try {
push();
await jest.advanceTimersByTimeAsync(advanceMs);
} finally {
jest.useRealTimers();
}
await flush();
}
it('finalizes as interrupted when the session errors mid-recording', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
await settleTeardown(() =>
tracker.observeSnapshot(session({ status: 'error' }))
);
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('finalizes as interrupted on the synthetic closed dispose snapshot', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
await settleTeardown(() =>
tracker.observeSnapshot(
session({
status: 'closed',
recording: {
active: false,
targetPath: '/rec/News-20260815-210000.ts',
},
})
)
);
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('defers the teardown stat until the helper had time to flush', async () => {
// disposeSession() emits the synthetic snapshot immediately, but the
// frame-copy helper exits up to ~2 s later — statting right away
// would persist a truncated size or misread a short capture.
started(tracker);
tracker.observeSnapshot(activeSnapshot());
jest.useFakeTimers();
try {
tracker.observeSnapshot(session({ status: 'closed' }));
await jest.advanceTimersByTimeAsync(2_000);
// Row must stay 'recording' (recoverable) through the window.
expect(db.updateSet).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(600);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('lets an acknowledged stop finalize as completed despite a dispose', async () => {
// Stop acknowledged (inactive snapshot, settle pending) and then the
// player is closed: the recording ended cleanly, so the armed settle
// timer wins and the row must not be relabelled 'interrupted'.
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
jest.useFakeTimers();
try {
tracker.observeSnapshot(inactiveSnapshot());
tracker.observeSnapshot(session({ status: 'closed' }));
await jest.advanceTimersByTimeAsync(3_000);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
});
it('degrades an interrupted stop to failed when the file is gone', async () => {
mockStatSync.mockRejectedValue(
Object.assign(new Error('gone'), { code: 'ENOENT' })
);
started(tracker);
tracker.observeSnapshot(activeSnapshot());
await settleTeardown(() =>
tracker.observeSnapshot(session({ status: 'closed' }))
);
expect(db.updateSet.mock.calls[0][0].status).toBe('failed');
});
it('keeps the requested status when the file cannot be judged', async () => {
// Only proven absence may fail the row; a permission/I-O error (or
// a stat stalled past its deadline) must not brand a likely-good
// recording on an unreachable mount as failed.
mockStatSync.mockRejectedValue(
Object.assign(new Error('denied'), { code: 'EACCES' })
);
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
await settleStop(tracker);
expect(db.updateSet.mock.calls[0][0]).toEqual(
expect.objectContaining({
status: 'completed',
fileSizeBytes: null,
})
);
expect(mockUnlinkSync).not.toHaveBeenCalled();
});
it('is idempotent: only the first finalize writes', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
tracker.onRecordingStopped('session-1');
await settleTeardown(
() => tracker.observeSnapshot(session({ status: 'closed' })),
11_000
);
expect(db.updateSet).toHaveBeenCalledTimes(1);
});
it('serializes a stop that races the pending insert', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
await settleStop(tracker);
expect(db.insertValues).toHaveBeenCalledTimes(1);
expect(db.updateSet).toHaveBeenCalledTimes(1);
});
it('finalizes a stop mpv never acknowledges once the bound elapses', async () => {
jest.useFakeTimers();
try {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
jest.advanceTimersByTime(10_000);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
});
it('keeps the file of a recording that went active even when it looks empty', async () => {
mockStatSync.mockResolvedValue({ isFile: () => true, size: 0 });
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
await settleStop(tracker);
expect(db.updateSet.mock.calls[0][0].status).toBe('failed');
// Those bytes belong to mpv; only a never-started reservation is
// cleaned up.
expect(mockUnlinkSync).not.toHaveBeenCalled();
});
it('keeps a settle timer from finalizing a restarted recording', async () => {
// Stop → immediate restart on the same session: the old entry's
// settle timer must finalize the OLD row, and the new recording must
// still finalize on its own stop — session-id-bound finalization
// used to end the new row and strand the old one in 'recording'.
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
jest.useFakeTimers();
try {
tracker.observeSnapshot(inactiveSnapshot());
// Restart before the 1.5 s settle window expires.
tracker.onRecordingStarted({
sessionId: 'session-1',
targetPath: '/rec/News-20260815-213000.ts',
fallbackChannelName: 'Channel One',
});
await jest.advanceTimersByTimeAsync(1_600);
// Only the old recording finalized.
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
// The new recording is still tracked: its own stop finalizes it.
tracker.observeSnapshot(
session({
recording: {
active: true,
targetPath: '/rec/News-20260815-213000.ts',
},
})
);
tracker.observeSnapshot(
session({
recording: {
active: false,
targetPath: '/rec/News-20260815-213000.ts',
},
})
);
await jest.advanceTimersByTimeAsync(1_600);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).toHaveBeenCalledTimes(2);
expect(db.insertValues).toHaveBeenCalledTimes(2);
});
it('finalizes a replaced recording even when no stop was ever observed', async () => {
// A new start on the same session replaces the map entry, so
// snapshots can never again reach the old entry — the replacement
// itself must arm the finalization.
started(tracker);
tracker.observeSnapshot(activeSnapshot());
jest.useFakeTimers();
try {
tracker.onRecordingStarted({
sessionId: 'session-1',
targetPath: '/rec/News-20260815-213000.ts',
fallbackChannelName: 'Channel One',
});
await jest.advanceTimersByTimeAsync(1_600);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
});
it('keeps a finalizing row in the ledger until its update commits', async () => {
// finalize() removes the entry from the open map immediately, but
// the row stays persisted as 'recording' until the queued terminal
// update commits — startup recovery must still see it as live.
let releaseUpdate!: () => void;
db.updateWhere.mockReturnValue(
new Promise<void>((resolve) => {
releaseUpdate = () => resolve();
})
);
started(tracker);
tracker.observeSnapshot(activeSnapshot());
tracker.onRecordingStopped('session-1');
jest.useFakeTimers();
try {
tracker.observeSnapshot(inactiveSnapshot());
await jest.advanceTimersByTimeAsync(1_600);
} finally {
jest.useRealTimers();
}
// The update is dispatched but not committed: still in the ledger.
expect(db.updateSet).toHaveBeenCalledTimes(1);
await expect(tracker.activeRowIds()).resolves.toEqual(new Set([7]));
releaseUpdate();
await flush();
await expect(tracker.activeRowIds()).resolves.toEqual(new Set());
});
it('records the owning process so recovery can skip live rows', async () => {
started(tracker);
await flush();
expect(db.insertValues.mock.calls[0][0].ownerPid).toBe(process.pid);
});
it('whenSettled resolves once the queued row INSERT committed', async () => {
// Enrichment relies on this and nothing else: it no longer waits for
// finalization, so there is no deadline left to race.
started(tracker);
await tracker.whenSettled();
expect(db.insertValues).toHaveBeenCalledTimes(1);
});
it('ignores snapshots for sessions without an open recording', async () => {
tracker.observeSnapshot(activeSnapshot());
tracker.observeSnapshot(session({ status: 'closed' }));
await flush();
expect(db.updateSet).not.toHaveBeenCalled();
expect(mockBroadcast).not.toHaveBeenCalled();
});
it('survives a failed insert without finalizing or broadcasting', async () => {
db.insertValues.mockRejectedValue(new Error('db down'));
const consoleSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
started(tracker);
tracker.onRecordingStopped('session-1');
await flush();
expect(db.updateSet).not.toHaveBeenCalled();
expect(mockBroadcast).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,417 @@
import { and, eq } from 'drizzle-orm';
import { stat, unlink } from 'node:fs/promises';
import {
EmbeddedMpvSession,
RecordingStartMetadata,
} from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { broadcastRecordingsUpdate } from '../events/database/recording-broadcast';
interface RecordingStartedEvent {
sessionId: string;
targetPath: string;
fallbackChannelName: string;
metadata?: RecordingStartMetadata;
}
interface OpenRecordingEntry {
sessionId: string;
targetPath: string;
startedAt: string;
/**
* Whether any session snapshot reported this recording as active. A
* successful start may pass through `active: false` snapshots while the
* async mpv property set settles, so `active: false` alone is not a stop.
*/
sawActive: boolean;
/** Set once a stop was requested; the acknowledged snapshot finalizes. */
stopRequested: boolean;
/** Fires if mpv never acknowledges the requested stop. */
stopFallbackTimer?: ReturnType<typeof setTimeout>;
/** Armed by an inactive snapshot; finalizes once the stop looks settled. */
settleTimer?: ReturnType<typeof setTimeout>;
/**
* Set by the first finalize. Timers hold the entry itself, so an entry
* replaced in the map (stop → immediate restart on the same session) can
* still be finalized late — this flag keeps that from happening twice.
*/
finalized: boolean;
rowId: Promise<number | null>;
}
type RecordingFinalStatus = 'completed' | 'interrupted' | 'failed';
/**
* mpv acknowledges a stop asynchronously (`mpv_set_property_async`, or a
* command written to the frame-copy helper), so the tracker waits for the
* snapshot that confirms it. This bound keeps a lost acknowledgement from
* leaving the row `recording` forever.
*/
const STOP_ACKNOWLEDGEMENT_TIMEOUT_MS = 10_000;
/**
* How long an observed inactive recording must stay inactive before it is
* finalized.
*
* macOS native-view clears `recordingActive` *before* dispatching the async
* `stream-record` property set and restores it (with an error) if that
* request is rejected, so the first inactive snapshot is optimistic rather
* than an acknowledgement. Three 500 ms poll cycles are enough for that
* revert to arrive. The frame-copy helper sets the property synchronously and
* is unaffected; it simply finalizes a beat later.
*/
const STOP_SETTLE_WINDOW_MS = 1_500;
/**
* How long a synthetic error/closed snapshot defers finalization.
*
* `disposeSession()` emits that snapshot immediately, while the frame-copy
* helper still has a 0.5 s quit grace and a 2 s SIGTERM grace before SIGKILL
* — statting the file earlier can record a truncated size or mistake a short
* capture for an empty failure. The row stays `recording` (and therefore
* repairable by startup recovery) until the window elapses, so an app quit
* that outruns the timer loses nothing.
*/
const TEARDOWN_FLUSH_WINDOW_MS = 2_500;
/**
* Deadline for the finalization stat. The recording directory is
* user-selected and may sit on a network filesystem; an unbounded stat
* would stall the tracker's serialized queue (blocking new inserts and stop
* enrichment's `whenSettled`). A timed-out or error-shaped probe reports
* `unknown` — the recording is then finalized without branding a
* likely-good file as failed.
*/
const FINALIZE_STAT_TIMEOUT_MS = 3_000;
type FinalizeFileProbe =
| { kind: 'size'; size: number }
| { kind: 'missing' }
| { kind: 'unknown' };
/**
* Persists the lifecycle of embedded-MPV live recordings into the
* `recordings` table.
*
* Explicit start/stop hooks are the only place where the start metadata and
* the reserved target path are visible deterministically, but they are
* requests, not outcomes: `addon.stopRecording()` merely dispatches, so
* finalization always waits for the snapshot that reports the recording
* inactive. That same observer covers the stop paths which never call
* `stopRecording()` at all (stream-replacement auto-stop, a frame-copy helper
* crash that leaves `active: true` behind, session error/close). Rows that
* survive a hard app kill in status 'recording' are repaired by
* `reconcileStaleRecordings()` at startup.
*/
export class EmbeddedMpvRecordingTracker {
private readonly open = new Map<string, OpenRecordingEntry>();
/**
* Entries whose finalization is queued but whose terminal database
* update has not committed yet. `finalize()` removes an entry from
* `open` immediately, so without this ledger `activeRowIds()` would
* omit a row still persisted as `recording` and startup recovery could
* repair it mid-finalization.
*/
private readonly finalizing = new Set<OpenRecordingEntry>();
/** Serializes DB mutations so a fast stop cannot outrun its insert. */
private chain: Promise<void> = Promise.resolve();
onRecordingStarted(event: RecordingStartedEvent): void {
// A session runs at most one recording, so a new start means any
// recording still open on this session has stopped. Once the map
// entry is replaced, snapshots can no longer reach the old entry —
// make sure one of its own timers finalizes it (after the settle
// window, so mpv gets to flush the old file first).
const replaced = this.open.get(event.sessionId);
if (replaced && !replaced.finalized) {
if (replaced.settleTimer !== undefined) {
clearTimeout(replaced.settleTimer);
}
replaced.settleTimer = setTimeout(() => {
this.finalize(replaced, 'completed');
}, STOP_SETTLE_WINDOW_MS);
}
const startedAt = new Date().toISOString();
const metadata = event.metadata;
const rowId = this.enqueue(async () => {
const db = await getDatabase();
const result = await db.insert(schema.recordings).values({
sessionId: event.sessionId,
// Recovery uses this to tell a crashed run's leftovers from a
// row another live process still owns.
ownerPid: process.pid,
status: 'recording',
filePath: event.targetPath,
channelName:
metadata?.channelName?.trim() || event.fallbackChannelName,
channelLogoUrl: metadata?.channelLogoUrl,
playlistId: metadata?.playlistId,
playlistName: metadata?.playlistName,
sourceType: metadata?.sourceType,
epgChannelId: metadata?.epgChannelId,
programTitle: metadata?.currentProgram?.title,
programDescription: metadata?.currentProgram?.description,
programStart: metadata?.currentProgram?.start,
programStop: metadata?.currentProgram?.stop,
startedAt,
});
return Number(result.lastInsertRowid);
});
this.open.set(event.sessionId, {
sessionId: event.sessionId,
targetPath: event.targetPath,
startedAt,
sawActive: false,
stopRequested: false,
finalized: false,
rowId,
});
void rowId.then((id) => {
if (id !== null) {
broadcastRecordingsUpdate();
}
});
}
/**
* Stop requested through the stop IPC/controls. The row is finalized by
* the acknowledged snapshot, not here: stopping is asynchronous, and
* statting (or unlinking) the file before mpv flushed it would report a
* short recording as failed and could delete bytes still being written.
*/
onRecordingStopped(sessionId: string): void {
const entry = this.open.get(sessionId);
if (!entry || entry.stopRequested) {
return;
}
entry.stopRequested = true;
entry.stopFallbackTimer = setTimeout(() => {
// mpv never confirmed; finalize anyway so the row cannot stay
// `recording` for the rest of the session.
this.finalize(entry, 'completed');
}, STOP_ACKNOWLEDGEMENT_TIMEOUT_MS);
}
/**
* Snapshot observer, wired into the service's session-update fan-out. It
* rides the existing 500 ms diffed poll — no timers of its own.
*/
observeSnapshot(session: EmbeddedMpvSession): void {
const entry = this.open.get(session.id);
if (!entry) {
return;
}
if (session.status === 'error' || session.status === 'closed') {
// Teardown may still be flushing the file (the frame-copy helper
// exits up to ~2 s after this synthetic snapshot), so finalize
// after the flush window rather than statting now. An armed
// settle timer means the stop was already acknowledged before
// the session went down — leave it to finalize as 'completed'.
if (entry.settleTimer === undefined) {
entry.settleTimer = setTimeout(() => {
this.finalize(entry, 'interrupted');
}, TEARDOWN_FLUSH_WINDOW_MS);
}
return;
}
const recording = session.recording;
if (!recording) {
return;
}
if (recording.active && recording.targetPath === entry.targetPath) {
entry.sawActive = true;
// A rejected stop puts the recording back: abandon the pending
// finalization instead of ending a row that is still recording.
if (entry.settleTimer !== undefined) {
clearTimeout(entry.settleTimer);
entry.settleTimer = undefined;
}
return;
}
if (recording.active) {
return;
}
if (entry.sawActive) {
// Inactive, but on native-view that state precedes mpv's reply.
// Finalize only once it survived the settle window.
if (entry.settleTimer === undefined) {
entry.settleTimer = setTimeout(() => {
this.finalize(entry, 'completed');
}, STOP_SETTLE_WINDOW_MS);
}
} else if (recording.error) {
// The async start failed before the recording ever activated.
this.finalize(entry, 'failed', recording.error);
}
// else: start still settling — a successful async start passes
// through active:false snapshots before the property set applies.
}
/**
* Resolves once every mutation enqueued so far has committed.
*
* Stop enrichment awaits this so the row's INSERT is guaranteed to exist
* before it is looked up (a recording stopped within milliseconds of
* starting). It deliberately involves no deadline: it waits for work, not
* for a clock, and the enrichment does not care whether the row has
* reached its terminal status yet.
*/
whenSettled(): Promise<void> {
return this.chain;
}
/**
* Row ids of the recordings this process is actively tracking.
*
* Startup recovery consults this because the renderer is interactive
* before the reconciliation pass runs: a recording started in that window
* carries `ownerPid === process.pid`, which the pid heuristic alone would
* misread as a recycled-pid leftover from a previous run.
*/
async activeRowIds(): Promise<Set<number>> {
// Open recordings AND rows whose finalization has not committed yet
// — both are still persisted as `recording` and must not be
// repaired by startup recovery.
const entries = [...this.open.values(), ...this.finalizing];
const ids = await Promise.all(entries.map((entry) => entry.rowId));
return new Set(ids.filter((id): id is number => id !== null));
}
private finalize(
entry: OpenRecordingEntry,
status: RecordingFinalStatus,
errorMessage?: string
): void {
if (entry.finalized) {
return;
}
entry.finalized = true;
// Keep the row visible to activeRowIds() until the terminal update
// commits — it is still persisted as 'recording' meanwhile.
this.finalizing.add(entry);
// The map may already point at a newer recording on the same
// session (stop → immediate restart); only remove the mapping when
// it is still this entry's.
if (this.open.get(entry.sessionId) === entry) {
this.open.delete(entry.sessionId);
}
if (entry.stopFallbackTimer !== undefined) {
clearTimeout(entry.stopFallbackTimer);
}
if (entry.settleTimer !== undefined) {
clearTimeout(entry.settleTimer);
}
void this.enqueue(async () => {
const rowId = await entry.rowId;
if (rowId === null) {
return null;
}
const probe = await this.probeFinalFile(entry.targetPath);
let finalStatus: RecordingFinalStatus = status;
let fileSize: number | null = null;
if (probe.kind === 'size' && probe.size > 0) {
fileSize = probe.size;
} else if (probe.kind === 'unknown') {
// The stat timed out or errored (slow or unreachable
// filesystem): keep the requested status with an unknown
// size instead of branding a likely-good recording failed.
} else {
// Absent or empty — nothing usable reached the disk, so the
// honest terminal state is 'failed'.
finalStatus = 'failed';
if (
probe.kind === 'size' &&
probe.size === 0 &&
!entry.sawActive
) {
// The empty file is the pre-reserved target of a
// recording that never started; a recording that did go
// active keeps its file even when it looks empty, since
// mpv owns those bytes.
this.safeUnlink(entry.targetPath);
}
}
const db = await getDatabase();
await db
.update(schema.recordings)
.set({
status: finalStatus,
endedAt: new Date().toISOString(),
fileSizeBytes: fileSize,
...(errorMessage ? { errorMessage } : {}),
updatedAt: new Date().toISOString(),
})
.where(
and(
eq(schema.recordings.id, rowId),
eq(schema.recordings.status, 'recording')
)
);
return rowId;
}).then((rowId) => {
this.finalizing.delete(entry);
if (rowId !== null) {
broadcastRecordingsUpdate();
}
});
}
private enqueue<T>(work: () => Promise<T>): Promise<T | null> {
const result = this.chain.then(work).catch((error) => {
console.error('[Recordings] tracker persistence failed:', error);
return null;
});
this.chain = result.then(() => undefined);
return result;
}
private async probeFinalFile(filePath: string): Promise<FinalizeFileProbe> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<FinalizeFileProbe>((resolve) => {
timeout = setTimeout(
() => resolve({ kind: 'unknown' }),
FINALIZE_STAT_TIMEOUT_MS
);
});
const probe = stat(filePath).then(
(stats): FinalizeFileProbe =>
stats.isFile()
? { kind: 'size', size: stats.size }
: { kind: 'missing' },
(error): FinalizeFileProbe => {
const code = (error as NodeJS.ErrnoException)?.code;
// Only proven absence may fail the row; every other error
// (permissions, I/O, unreachable mount) stays unknown.
return code === 'ENOENT' || code === 'ENOTDIR'
? { kind: 'missing' }
: { kind: 'unknown' };
}
);
try {
return await Promise.race([probe, timedOut]);
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
private safeUnlink(filePath: string): void {
// Fire-and-forget: best-effort cleanup of the 0-byte reservation
// must not stall the serialized queue on a hung filesystem.
void unlink(filePath).catch(() => undefined);
}
}
export const embeddedMpvRecordingTracker = new EmbeddedMpvRecordingTracker();
+9
View File
@@ -9,6 +9,8 @@ import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from './app/events/database/downloads.events';
import { setRecordingsMainWindow } from './app/events/database/recording-broadcast';
import { reconcileStaleRecordings } from './app/events/database/recording-recovery';
import ElectronEvents from './app/events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
@@ -173,6 +175,7 @@ export default class Main {
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
App.onMainWindowCreated(setRecordingsMainWindow);
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
@@ -196,6 +199,12 @@ export default class Main {
trace('startup', 'reset-stale-downloads:done');
}
await reconcileStaleRecordings();
if (isStartupTraceEnabled()) {
trace('startup', 'reconcile-stale-recordings:done');
}
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
}
+12 -3
View File
@@ -754,15 +754,24 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
);
// PWA persistence: positions live in localStorage, so a reload of the
// detail route must come back fully watched.
// detail route must come back fully watched. With season 1 completed the
// fresh mount auto-selects the earliest season with unwatched episodes
// (issue #1441), so season 2 opens while season 1's tab keeps the check.
await page.reload();
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
await expect(seasonToggle).toContainText('Mark season as unwatched');
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await expect(watchedCards).toHaveCount(0, { timeout: 10_000 });
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself still holds the 8 persisted rows.
await seasonTabs.first().click();
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
+33
View File
@@ -15,6 +15,10 @@ describe('app routes', () => {
runtime: { isElectron: boolean },
router: { parseUrl: (url: string) => unknown }
) => unknown;
let resolveRecordingsCapabilityRoute: (
runtime: { supportsRecordings: boolean },
router: { parseUrl: (url: string) => unknown }
) => unknown;
beforeAll(async () => {
jest.unstable_mockModule(
@@ -55,6 +59,8 @@ describe('app routes', () => {
workspaceChildren = workspaceRoute?.children ?? [];
resolveElectronOnlyGlobalSearchRoute =
appRoutes.resolveElectronOnlyGlobalSearchRoute;
resolveRecordingsCapabilityRoute =
appRoutes.resolveRecordingsCapabilityRoute;
});
afterEach(() => {
@@ -176,6 +182,33 @@ describe('app routes', () => {
expect(result).toBe(redirectTree);
});
it('guards the focused recording detail behind the recordings capability', async () => {
const recordingRoute = workspaceChildren.find(
(route) => route.path === 'downloads/recording/:recordingId'
);
expect(recordingRoute?.canActivate).toHaveLength(1);
});
it('redirects the recording detail to the manager without the capability', async () => {
const redirectTree = { url: '/workspace/downloads' };
const parseUrl = jest.fn().mockReturnValue(redirectTree);
const result = resolveRecordingsCapabilityRoute(
{ supportsRecordings: false },
{ parseUrl }
);
expect(parseUrl).toHaveBeenCalledWith('/workspace/downloads');
expect(result).toBe(redirectTree);
expect(
resolveRecordingsCapabilityRoute(
{ supportsRecordings: true },
{ parseUrl }
)
).toBe(true);
});
it('uses a dynamic redirect for the default /workspace child route', async () => {
const workspaceDefaultRoute = workspaceChildren.find(
(route) => route.path === ''
+30
View File
@@ -33,6 +33,28 @@ const electronOnlyGlobalSearchGuard = () => {
);
};
/**
* The focused recording detail depends on `RecordingsService`, whose list
* never becomes authoritative without the recordings capability — the PWA
* would render a permanently blank workspace instead of the not-found
* redirect. Send it to the manager, which owns its own unavailable state.
*/
export function resolveRecordingsCapabilityRoute(
runtime: Pick<RuntimeCapabilitiesService, 'supportsRecordings'>,
router: Pick<Router, 'parseUrl'>
) {
return runtime.supportsRecordings
? true
: router.parseUrl('/workspace/downloads');
}
const recordingsCapabilityGuard = () => {
return resolveRecordingsCapabilityRoute(
inject(RuntimeCapabilitiesService),
inject(Router)
);
};
export const routes: Routes = [
{
path: '',
@@ -112,6 +134,14 @@ export const routes: Routes = [
(c) => c.GlobalSearchResultsComponent
),
},
{
path: 'downloads/recording/:recordingId',
canActivate: [recordingsCapabilityGuard],
loadComponent: () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.RecordingDetailComponent
),
},
{
path: 'downloads/:downloadId',
loadComponent: () =>
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "مسارات الصوت",
"SUBTITLES": "الترجمات",
"SUBTITLES_OFF": "إيقاف",
"QUALITY": "الجودة",
"QUALITY_AUTO": "تلقائي",
"TRACK_DEFAULT": "افتراضي",
"AUDIO_TRACK_FALLBACK": "صوت {{index}}",
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "فشل",
"CANCELED": "ملغى",
"PAUSED": "متوقف مؤقتًا",
"FILE_MISSING": "الملف مفقود"
"FILE_MISSING": "الملف مفقود",
"RECORDING": "جارٍ التسجيل",
"INTERRUPTED": "انقطع"
},
"ACTIVE_COUNT": "{{count}} نشط",
"TRACKED_DOWNLOADS": "التنزيلات المتتبعة",
@@ -1220,7 +1224,22 @@
"ALL": "الكل",
"MOVIES": "الأفلام",
"SERIES": "المسلسلات",
"IN_PROGRESS": "قيد التقدم"
"IN_PROGRESS": "قيد التقدم",
"RECORDINGS": "التسجيلات"
},
"RECORDING_NOW": "جارٍ التسجيل الآن",
"RECORDINGS": "التسجيلات",
"RECORDING_BADGE": "جارٍ التسجيل",
"STOP_RECORDING": "إيقاف التسجيل",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "إزالة التسجيل من مدير التنزيلات؟",
"MESSAGE": "يؤدي هذا إلى إزالة الإدخال من IPTVnator. ويبقى الملف المسجّل على القرص."
},
"RECORDING_DETAIL": {
"PROGRAMS": "البرامج المسجّلة",
"RECORDED_FROM": "تم التسجيل من {{playlist}}",
"MISSING_BODY": "لم يعد ملف التسجيل موجودًا على القرص. لا يمكن إعادة تسجيل البث المباشر، لكن يمكنك إزالة الإدخال من مدير التنزيلات.",
"FAILED_BODY": "فشل هذا التسجيل ولم ينتج عنه أي ملف قابل للتشغيل."
},
"REMOVE_FROM_MANAGER": "إزالة من مدير التنزيلات",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "ترتيب الخادم",
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
"DASHBOARD": {
"ACTION_FAILED": "تعذر إكمال الإجراء. يرجى المحاولة مرة أخرى.",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"LIVE_FAVORITES": "يُبث الآن على المفضلة لديك",
"LIVE_RECENT": "متابعة البث المباشر",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "مسارات الصوت",
"SUBTITLES": "الترجمة",
"SUBTITLES_OFF": "معطلة",
"QUALITY": "الجودة",
"QUALITY_AUTO": "تلقائي",
"TRACK_DEFAULT": "افتراضي",
"AUDIO_TRACK_FALLBACK": "صوت {{index}}",
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "فشل",
"CANCELED": "تلغى",
"PAUSED": "موقوف مؤقتاً",
"FILE_MISSING": "الملف ناقص"
"FILE_MISSING": "الملف ناقص",
"RECORDING": "جاري التسجيل",
"INTERRUPTED": "تقطع"
},
"ACTIVE_COUNT": "{{count}} نشيط",
"TRACKED_DOWNLOADS": "التحميلات المتتبعة",
@@ -1220,7 +1224,22 @@
"ALL": "الكل",
"MOVIES": "الأفلام",
"SERIES": "السلاسل",
"IN_PROGRESS": "كيتم التحميل"
"IN_PROGRESS": "كيتم التحميل",
"RECORDINGS": "التسجيلات"
},
"RECORDING_NOW": "كيتسجل دابا",
"RECORDINGS": "التسجيلات",
"RECORDING_BADGE": "تسجيل",
"STOP_RECORDING": "وقّف التسجيل",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "نحيدو التسجيل من مدير التحميلات؟",
"MESSAGE": "هاد العملية كتحيد هاد العنصر من IPTVnator. الملف المسجل كيبقى على القرص."
},
"RECORDING_DETAIL": {
"PROGRAMS": "البرامج المسجلة",
"RECORDED_FROM": "تسجل من {{playlist}}",
"MISSING_BODY": "ملف التسجيل ما بقاش على القرص. البث المباشر ما يمكنش يتعاود تسجيلو، ولكن تقدر تحيد هاد العنصر من مدير التحميلات.",
"FAILED_BODY": "هاد التسجيل فشل وما عطى حتى ملف يقدر يتشغل."
},
"REMOVE_FROM_MANAGER": "حيد من مسير التحميلات",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "ترتيب السيرفر",
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
"DASHBOARD": {
"ACTION_FAILED": "ما قدرناش نكملو العملية. عاود حاول مرة أخرى.",
"CONTINUE_WATCHING": "كمل المشاهدة",
"LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك",
"LIVE_RECENT": "كمل مع المباشر",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Аўдыядарожкі",
"SUBTITLES": "Субтытры",
"SUBTITLES_OFF": "Выкл.",
"QUALITY": "Якасць",
"QUALITY_AUTO": "Аўта",
"TRACK_DEFAULT": "Па змаўчанні",
"AUDIO_TRACK_FALLBACK": "Аўдыя {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Няўдала",
"CANCELED": "Скасавана",
"PAUSED": "На паўзе",
"FILE_MISSING": "Файл адсутнічае"
"FILE_MISSING": "Файл адсутнічае",
"RECORDING": "Запісваецца",
"INTERRUPTED": "Перарвана"
},
"ACTIVE_COUNT": "{{count}} актыўныя",
"TRACKED_DOWNLOADS": "Адсочваныя спампоўкі",
@@ -1220,7 +1224,22 @@
"ALL": "Усе",
"MOVIES": "Фільмы",
"SERIES": "Серыялы",
"IN_PROGRESS": "Выконваецца"
"IN_PROGRESS": "Выконваецца",
"RECORDINGS": "Запісы"
},
"RECORDING_NOW": "Зараз запісваецца",
"RECORDINGS": "Запісы",
"RECORDING_BADGE": "Ідзе запіс",
"STOP_RECORDING": "Спыніць запіс",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Выдаліць запіс з менеджара?",
"MESSAGE": "Гэта выдаліць запіс з IPTVnator. Запісаны файл застанецца на дыску."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Запісаныя перадачы",
"RECORDED_FROM": "Запісана з {{playlist}}",
"MISSING_BODY": "Файл запісу больш недаступны на дыску. Прамую трансляцыю нельга запісаць паўторна, але вы можаце выдаліць запіс з менеджара.",
"FAILED_BODY": "Гэты запіс не ўдаўся, таму файла для прайгравання няма."
},
"REMOVE_FROM_MANAGER": "Выдаліць з менеджара спамповак",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Парадак сервера",
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
"DASHBOARD": {
"ACTION_FAILED": "Не атрымалася выканаць дзеянне. Паспрабуйце яшчэ раз.",
"CONTINUE_WATCHING": "Працягнуць прагляд",
"LIVE_FAVORITES": "Цяпер у эфіры на выбраных",
"LIVE_RECENT": "Працягнуць прагляд эфіру",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Tonspuren",
"SUBTITLES": "Untertitel",
"SUBTITLES_OFF": "Aus",
"QUALITY": "Qualität",
"QUALITY_AUTO": "Automatisch",
"TRACK_DEFAULT": "Standard",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Fehlgeschlagen",
"CANCELED": "Abgebrochen",
"PAUSED": "Pausiert",
"FILE_MISSING": "Datei fehlt"
"FILE_MISSING": "Datei fehlt",
"RECORDING": "Wird aufgenommen",
"INTERRUPTED": "Unterbrochen"
},
"ACTIVE_COUNT": "{{count}} aktiv",
"TRACKED_DOWNLOADS": "Überwachte Downloads",
@@ -1220,7 +1224,22 @@
"ALL": "Alle",
"MOVIES": "Filme",
"SERIES": "Serien",
"IN_PROGRESS": "In Bearbeitung"
"IN_PROGRESS": "In Bearbeitung",
"RECORDINGS": "Aufnahmen"
},
"RECORDING_NOW": "Aufnahme läuft",
"RECORDINGS": "Aufnahmen",
"RECORDING_BADGE": "Aufnahme",
"STOP_RECORDING": "Aufnahme stoppen",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Aufnahme aus dem Manager entfernen?",
"MESSAGE": "Dadurch wird der Eintrag aus IPTVnator entfernt. Die aufgenommene Datei bleibt auf der Festplatte erhalten."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Aufgenommene Sendungen",
"RECORDED_FROM": "Aufgenommen aus {{playlist}}",
"MISSING_BODY": "Die Aufnahmedatei ist nicht mehr auf der Festplatte vorhanden. Eine Live-Übertragung kann nicht erneut aufgenommen werden, aber Sie können den Eintrag aus dem Manager entfernen.",
"FAILED_BODY": "Diese Aufnahme ist fehlgeschlagen und hat keine abspielbare Datei erzeugt."
},
"REMOVE_FROM_MANAGER": "Aus dem Download-Manager entfernen",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Serverreihenfolge",
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
"DASHBOARD": {
"ACTION_FAILED": "Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.",
"CONTINUE_WATCHING": "Weiter schauen",
"LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten",
"LIVE_RECENT": "Mit Live-TV weiterschauen",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Κομμάτια ήχου",
"SUBTITLES": "Υπότιτλοι",
"SUBTITLES_OFF": "Ανενεργοί",
"QUALITY": "Ποιότητα",
"QUALITY_AUTO": "Αυτόματη",
"TRACK_DEFAULT": "Προεπιλογή",
"AUDIO_TRACK_FALLBACK": "Ήχος {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Απέτυχε",
"CANCELED": "Ακυρώθηκε",
"PAUSED": "Σε παύση",
"FILE_MISSING": "Λείπει το αρχείο"
"FILE_MISSING": "Λείπει το αρχείο",
"RECORDING": "Εγγραφή",
"INTERRUPTED": "Διακόπηκε"
},
"ACTIVE_COUNT": "{{count}} ενεργά",
"TRACKED_DOWNLOADS": "Παρακολουθούμενες λήψεις",
@@ -1220,7 +1224,22 @@
"ALL": "Όλα",
"MOVIES": "Ταινίες",
"SERIES": "Σειρές",
"IN_PROGRESS": "Σε εξέλιξη"
"IN_PROGRESS": "Σε εξέλιξη",
"RECORDINGS": "Εγγραφές"
},
"RECORDING_NOW": "Εγγραφή σε εξέλιξη",
"RECORDINGS": "Εγγραφές",
"RECORDING_BADGE": "Εγγραφή",
"STOP_RECORDING": "Διακοπή εγγραφής",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Αφαίρεση εγγραφής από τη διαχείριση;",
"MESSAGE": "Αυτό αφαιρεί την καταχώριση από το IPTVnator. Το αρχείο εγγραφής παραμένει στον δίσκο."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Εγγεγραμμένα προγράμματα",
"RECORDED_FROM": "Εγγράφηκε από {{playlist}}",
"MISSING_BODY": "Το αρχείο εγγραφής δεν βρίσκεται πλέον στον δίσκο. Μια ζωντανή μετάδοση δεν μπορεί να εγγραφεί ξανά, αλλά μπορείτε να αφαιρέσετε την καταχώριση από τη διαχείριση.",
"FAILED_BODY": "Αυτή η εγγραφή απέτυχε και δεν δημιούργησε αρχείο που να μπορεί να αναπαραχθεί."
},
"REMOVE_FROM_MANAGER": "Αφαίρεση από τον διαχειριστή λήψεων",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Σειρά διακομιστή",
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
"DASHBOARD": {
"ACTION_FAILED": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας. Δοκιμάστε ξανά.",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας",
"LIVE_RECENT": "Συνέχιση με ζωντανή TV",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Audio tracks",
"SUBTITLES": "Subtitles",
"SUBTITLES_OFF": "Off",
"QUALITY": "Quality",
"QUALITY_AUTO": "Auto",
"TRACK_DEFAULT": "Default",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Failed",
"CANCELED": "Canceled",
"PAUSED": "Paused",
"FILE_MISSING": "File missing"
"FILE_MISSING": "File missing",
"RECORDING": "Recording",
"INTERRUPTED": "Interrupted"
},
"ACTIVE_COUNT": "{{count}} active",
"TRACKED_DOWNLOADS": "Tracked downloads",
@@ -1220,7 +1224,22 @@
"ALL": "All",
"MOVIES": "Movies",
"SERIES": "Series",
"IN_PROGRESS": "In progress"
"IN_PROGRESS": "In progress",
"RECORDINGS": "Recordings"
},
"RECORDING_NOW": "Recording now",
"RECORDINGS": "Recordings",
"RECORDING_BADGE": "Recording",
"STOP_RECORDING": "Stop recording",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Remove recording from manager?",
"MESSAGE": "This removes the entry from IPTVnator. The recorded file remains on disk."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Recorded programs",
"RECORDED_FROM": "Recorded from {{playlist}}",
"MISSING_BODY": "The recording file is no longer on disk. A live broadcast cannot be re-recorded, but you can remove the entry from the manager.",
"FAILED_BODY": "This recording failed and produced no playable file."
},
"REMOVE_FROM_MANAGER": "Remove from manager",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Server sorting",
"CATEGORY_SORT_ARIA": "Sort categories",
"DASHBOARD": {
"ACTION_FAILED": "The action could not be completed. Please try again.",
"CONTINUE_WATCHING": "Continue Watching",
"LIVE_FAVORITES": "Live now on your favorites",
"LIVE_RECENT": "Continue with live TV",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Pistas de audio",
"SUBTITLES": "Subtítulos",
"SUBTITLES_OFF": "Desactivados",
"QUALITY": "Calidad",
"QUALITY_AUTO": "Automática",
"TRACK_DEFAULT": "Predeterminada",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Fallido",
"CANCELED": "Cancelado",
"PAUSED": "En pausa",
"FILE_MISSING": "Falta el archivo"
"FILE_MISSING": "Falta el archivo",
"RECORDING": "Grabando",
"INTERRUPTED": "Interrumpido"
},
"ACTIVE_COUNT": "{{count}} activas",
"TRACKED_DOWNLOADS": "Descargas registradas",
@@ -1220,7 +1224,22 @@
"ALL": "Todas",
"MOVIES": "Películas",
"SERIES": "Series de TV",
"IN_PROGRESS": "En curso"
"IN_PROGRESS": "En curso",
"RECORDINGS": "Grabaciones"
},
"RECORDING_NOW": "Grabando ahora",
"RECORDINGS": "Grabaciones",
"RECORDING_BADGE": "Grabando",
"STOP_RECORDING": "Detener grabación",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "¿Quitar la grabación del gestor?",
"MESSAGE": "Esto elimina la entrada de IPTVnator. El archivo grabado permanece en el disco."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Programas grabados",
"RECORDED_FROM": "Grabado desde {{playlist}}",
"MISSING_BODY": "El archivo de la grabación ya no está en el disco. Una emisión en vivo no se puede volver a grabar, pero puedes quitar la entrada del gestor.",
"FAILED_BODY": "Esta grabación ha fallado y no ha generado ningún archivo reproducible."
},
"REMOVE_FROM_MANAGER": "Quitar del administrador de descargas",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Orden del servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorías",
"DASHBOARD": {
"ACTION_FAILED": "No se pudo completar la acción. Inténtalo de nuevo.",
"CONTINUE_WATCHING": "Continuar viendo",
"LIVE_FAVORITES": "Ahora en vivo en tus favoritos",
"LIVE_RECENT": "Continuar con TV en vivo",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Pistes audio",
"SUBTITLES": "Sous-titres",
"SUBTITLES_OFF": "Désactivés",
"QUALITY": "Qualité",
"QUALITY_AUTO": "Auto",
"TRACK_DEFAULT": "Par défaut",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Échec",
"CANCELED": "Annulé",
"PAUSED": "En pause",
"FILE_MISSING": "Fichier manquant"
"FILE_MISSING": "Fichier manquant",
"RECORDING": "Enregistrement",
"INTERRUPTED": "Interrompu"
},
"ACTIVE_COUNT": "{{count}} actifs",
"TRACKED_DOWNLOADS": "Téléchargements suivis",
@@ -1220,7 +1224,22 @@
"ALL": "Tous",
"MOVIES": "Films",
"SERIES": "Séries",
"IN_PROGRESS": "En cours"
"IN_PROGRESS": "En cours",
"RECORDINGS": "Enregistrements"
},
"RECORDING_NOW": "Enregistrement en cours",
"RECORDINGS": "Enregistrements",
"RECORDING_BADGE": "Enregistrement",
"STOP_RECORDING": "Arrêter l'enregistrement",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Retirer l'enregistrement du gestionnaire ?",
"MESSAGE": "Cela retire l'entrée d'IPTVnator. Le fichier enregistré reste sur le disque."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Programmes enregistrés",
"RECORDED_FROM": "Enregistré depuis {{playlist}}",
"MISSING_BODY": "Le fichier d'enregistrement n'est plus sur le disque. Une diffusion en direct ne peut pas être réenregistrée, mais vous pouvez retirer l'entrée du gestionnaire.",
"FAILED_BODY": "Cet enregistrement a échoué et n'a produit aucun fichier lisible."
},
"REMOVE_FROM_MANAGER": "Retirer du gestionnaire de téléchargements",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Ordre du serveur",
"CATEGORY_SORT_ARIA": "Trier les catégories",
"DASHBOARD": {
"ACTION_FAILED": "L'action n'a pas pu être effectuée. Veuillez réessayer.",
"CONTINUE_WATCHING": "Reprendre la lecture",
"LIVE_FAVORITES": "En direct sur vos favoris",
"LIVE_RECENT": "Reprendre la TV en direct",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Hangsávok",
"SUBTITLES": "Feliratok",
"SUBTITLES_OFF": "Kikapcsolva",
"QUALITY": "Minőség",
"QUALITY_AUTO": "Automatikus",
"TRACK_DEFAULT": "Alapértelmezett",
"AUDIO_TRACK_FALLBACK": "{{index}}. hangsáv",
"SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv",
@@ -1205,7 +1207,9 @@
"FAILED": "Sikertelen",
"CANCELED": "Megszakítva",
"PAUSED": "Paused",
"FILE_MISSING": "Hiányzik a fájl"
"FILE_MISSING": "Hiányzik a fájl",
"RECORDING": "Felvétel folyamatban",
"INTERRUPTED": "Megszakadt"
},
"ACTIVE_COUNT": "{{count}} folyamatban",
"TRACKED_DOWNLOADS": "Letöltési feladatok",
@@ -1220,7 +1224,22 @@
"ALL": "Összes",
"MOVIES": "Filmek",
"SERIES": "Sorozatok",
"IN_PROGRESS": "Folyamatban"
"IN_PROGRESS": "Folyamatban",
"RECORDINGS": "Felvételek"
},
"RECORDING_NOW": "Folyamatban lévő felvételek",
"RECORDINGS": "Felvételek",
"RECORDING_BADGE": "Felvétel",
"STOP_RECORDING": "Felvétel leállítása",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Eltávolítja a felvételt a kezelőből?",
"MESSAGE": "A művelet eltávolítja a bejegyzést az IPTVnator alkalmazásból. A felvett fájl a lemezen marad."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Felvett műsorok",
"RECORDED_FROM": "Felvétel forrása: {{playlist}}",
"MISSING_BODY": "A felvett fájl már nem érhető el a lemezen. Az élő adás nem vehető fel újra, de a bejegyzést eltávolíthatja a kezelőből.",
"FAILED_BODY": "Ez a felvétel meghiúsult, és nem jött létre lejátszható fájl."
},
"REMOVE_FROM_MANAGER": "Eltávolítás a letöltéskezelőből",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Kiszolgálóoldali rendezés",
"CATEGORY_SORT_ARIA": "Kategóriák rendezése",
"DASHBOARD": {
"ACTION_FAILED": "A műveletet nem sikerült végrehajtani. Próbáld újra.",
"CONTINUE_WATCHING": "Lejátszás folytatása",
"LIVE_FAVORITES": "Most a kedvenc csatornákon",
"LIVE_RECENT": "Élő tévénézés folytatása",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Tracce audio",
"SUBTITLES": "Sottotitoli",
"SUBTITLES_OFF": "Disattivati",
"QUALITY": "Qualità",
"QUALITY_AUTO": "Automatica",
"TRACK_DEFAULT": "Predefinita",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Non riuscito",
"CANCELED": "Annullato",
"PAUSED": "In pausa",
"FILE_MISSING": "File mancante"
"FILE_MISSING": "File mancante",
"RECORDING": "In registrazione",
"INTERRUPTED": "Interrotto"
},
"ACTIVE_COUNT": "{{count}} attivi",
"TRACKED_DOWNLOADS": "Download tracciati",
@@ -1220,7 +1224,22 @@
"ALL": "Tutti",
"MOVIES": "Film",
"SERIES": "Serie",
"IN_PROGRESS": "In corso"
"IN_PROGRESS": "In corso",
"RECORDINGS": "Registrazioni"
},
"RECORDING_NOW": "Registrazione in corso",
"RECORDINGS": "Registrazioni",
"RECORDING_BADGE": "In registrazione",
"STOP_RECORDING": "Interrompi registrazione",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Rimuovere la registrazione dal gestore?",
"MESSAGE": "L'elemento viene rimosso da IPTVnator. Il file registrato rimane su disco."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Programmi registrati",
"RECORDED_FROM": "Registrato da {{playlist}}",
"MISSING_BODY": "Il file della registrazione non è più disponibile su disco. Una trasmissione in diretta non può essere registrata di nuovo, ma è possibile rimuovere l'elemento dal gestore.",
"FAILED_BODY": "Questa registrazione non è riuscita e non ha prodotto alcun file riproducibile."
},
"REMOVE_FROM_MANAGER": "Rimuovi dal gestore download",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Ordine del server",
"CATEGORY_SORT_ARIA": "Ordina categorie",
"DASHBOARD": {
"ACTION_FAILED": "Impossibile completare l'azione. Riprova.",
"CONTINUE_WATCHING": "Continua a guardare",
"LIVE_FAVORITES": "Live ora sui tuoi preferiti",
"LIVE_RECENT": "Continua con la TV live",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "音声トラック",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "オフ",
"QUALITY": "画質",
"QUALITY_AUTO": "自動",
"TRACK_DEFAULT": "デフォルト",
"AUDIO_TRACK_FALLBACK": "音声 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "失敗",
"CANCELED": "キャンセル済み",
"PAUSED": "一時停止中",
"FILE_MISSING": "ファイルが見つかりません"
"FILE_MISSING": "ファイルが見つかりません",
"RECORDING": "録画中",
"INTERRUPTED": "中断"
},
"ACTIVE_COUNT": "{{count}} 件が進行中",
"TRACKED_DOWNLOADS": "管理中のダウンロード",
@@ -1220,7 +1224,22 @@
"ALL": "すべて",
"MOVIES": "映画",
"SERIES": "シリーズ",
"IN_PROGRESS": "進行中"
"IN_PROGRESS": "進行中",
"RECORDINGS": "録画"
},
"RECORDING_NOW": "現在録画中",
"RECORDINGS": "録画",
"RECORDING_BADGE": "録画",
"STOP_RECORDING": "録画を停止",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "録画をマネージャーから削除しますか?",
"MESSAGE": "IPTVnatorから項目を削除します。録画ファイルはディスクに残ります。"
},
"RECORDING_DETAIL": {
"PROGRAMS": "録画された番組",
"RECORDED_FROM": "{{playlist}}から録画",
"MISSING_BODY": "録画ファイルはディスク上で見つかりませんでした。ライブ放送は再録画できませんが、項目をマネージャーから削除できます。",
"FAILED_BODY": "この録画は失敗し、再生可能なファイルは作成されませんでした。"
},
"REMOVE_FROM_MANAGER": "マネージャーから削除",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "サーバー順",
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
"DASHBOARD": {
"ACTION_FAILED": "操作を完了できませんでした。もう一度お試しください。",
"CONTINUE_WATCHING": "視聴を再開",
"LIVE_FAVORITES": "お気に入りで現在放送中",
"LIVE_RECENT": "ライブTVの視聴を再開",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "오디오 트랙",
"SUBTITLES": "자막",
"SUBTITLES_OFF": "끄기",
"QUALITY": "화질",
"QUALITY_AUTO": "자동",
"TRACK_DEFAULT": "기본",
"AUDIO_TRACK_FALLBACK": "오디오 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "실패",
"CANCELED": "취소됨",
"PAUSED": "일시정지됨",
"FILE_MISSING": "파일이 없습니다"
"FILE_MISSING": "파일이 없습니다",
"RECORDING": "녹화 중",
"INTERRUPTED": "중단됨"
},
"ACTIVE_COUNT": "{{count}}개 진행 중",
"TRACKED_DOWNLOADS": "관리 중인 다운로드",
@@ -1220,7 +1224,22 @@
"ALL": "전체",
"MOVIES": "영화",
"SERIES": "시리즈",
"IN_PROGRESS": "진행 중"
"IN_PROGRESS": "진행 중",
"RECORDINGS": "녹화"
},
"RECORDING_NOW": "지금 녹화 중",
"RECORDINGS": "녹화",
"RECORDING_BADGE": "녹화 중",
"STOP_RECORDING": "녹화 중지",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "관리 목록에서 녹화를 제거하시겠습니까?",
"MESSAGE": "IPTVnator에서 항목을 제거합니다. 녹화된 파일은 디스크에 그대로 남아 있습니다."
},
"RECORDING_DETAIL": {
"PROGRAMS": "녹화된 프로그램",
"RECORDED_FROM": "{{playlist}}에서 녹화됨",
"MISSING_BODY": "녹화 파일이 더 이상 디스크에 없습니다. 라이브 방송은 다시 녹화할 수 없지만, 관리 목록에서 항목을 제거할 수 있습니다.",
"FAILED_BODY": "이 녹화는 실패하여 재생 가능한 파일이 생성되지 않았습니다."
},
"REMOVE_FROM_MANAGER": "다운로드 관리자에서 제거",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "서버 순서",
"CATEGORY_SORT_ARIA": "카테고리 정렬",
"DASHBOARD": {
"ACTION_FAILED": "작업을 완료할 수 없습니다. 다시 시도해 주세요.",
"CONTINUE_WATCHING": "이어서 시청",
"LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다",
"LIVE_RECENT": "라이브 TV 이어서 시청",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Audiosporen",
"SUBTITLES": "Ondertiteling",
"SUBTITLES_OFF": "Uit",
"QUALITY": "Kwaliteit",
"QUALITY_AUTO": "Automatisch",
"TRACK_DEFAULT": "Standaard",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Mislukt",
"CANCELED": "Geannuleerd",
"PAUSED": "Gepauzeerd",
"FILE_MISSING": "Bestand ontbreekt"
"FILE_MISSING": "Bestand ontbreekt",
"RECORDING": "Opnemen",
"INTERRUPTED": "Onderbroken"
},
"ACTIVE_COUNT": "{{count}} actief",
"TRACKED_DOWNLOADS": "Bijgehouden downloads",
@@ -1220,7 +1224,22 @@
"ALL": "Alles",
"MOVIES": "Films",
"SERIES": "Reeksen",
"IN_PROGRESS": "Bezig"
"IN_PROGRESS": "Bezig",
"RECORDINGS": "Opnames"
},
"RECORDING_NOW": "Nu aan het opnemen",
"RECORDINGS": "Opnames",
"RECORDING_BADGE": "Opname",
"STOP_RECORDING": "Opname stoppen",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Opname uit downloadbeheer verwijderen?",
"MESSAGE": "Hiermee wordt het item uit IPTVnator verwijderd. Het opgenomen bestand blijft op de schijf staan."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Opgenomen programma's",
"RECORDED_FROM": "Opgenomen van {{playlist}}",
"MISSING_BODY": "Het opnamebestand staat niet meer op de schijf. Een live-uitzending kan niet opnieuw worden opgenomen, maar je kunt het item uit downloadbeheer verwijderen.",
"FAILED_BODY": "Deze opname is mislukt en heeft geen afspeelbaar bestand opgeleverd."
},
"REMOVE_FROM_MANAGER": "Uit de downloadbeheerder verwijderen",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Servervolgorde",
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
"DASHBOARD": {
"ACTION_FAILED": "De actie kon niet worden voltooid. Probeer het opnieuw.",
"CONTINUE_WATCHING": "Verder kijken",
"LIVE_FAVORITES": "Nu live op je favorieten",
"LIVE_RECENT": "Verder kijken met live tv",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Ścieżki audio",
"SUBTITLES": "Napisy",
"SUBTITLES_OFF": "Wyłączone",
"QUALITY": "Jakość",
"QUALITY_AUTO": "Automatycznie",
"TRACK_DEFAULT": "Domyślna",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Niepowodzenie",
"CANCELED": "Anulowane",
"PAUSED": "Wstrzymane",
"FILE_MISSING": "Brak pliku"
"FILE_MISSING": "Brak pliku",
"RECORDING": "Nagrywanie",
"INTERRUPTED": "Przerwane"
},
"ACTIVE_COUNT": "{{count}} aktywnych",
"TRACKED_DOWNLOADS": "Śledzone pobrania",
@@ -1220,7 +1224,22 @@
"ALL": "Wszystkie",
"MOVIES": "Filmy",
"SERIES": "Seriale",
"IN_PROGRESS": "W toku"
"IN_PROGRESS": "W toku",
"RECORDINGS": "Nagrania"
},
"RECORDING_NOW": "Trwa nagrywanie",
"RECORDINGS": "Nagrania",
"RECORDING_BADGE": "Nagrywanie",
"STOP_RECORDING": "Zatrzymaj nagrywanie",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Usunąć nagranie z menedżera?",
"MESSAGE": "Spowoduje to usunięcie wpisu z IPTVnator. Nagrany plik pozostanie na dysku."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Nagrane programy",
"RECORDED_FROM": "Nagrano z {{playlist}}",
"MISSING_BODY": "Plik nagrania nie znajduje się już na dysku. Transmisji na żywo nie można nagrać ponownie, ale wpis można usunąć z menedżera.",
"FAILED_BODY": "To nagrywanie nie powiodło się i nie powstał plik możliwy do odtworzenia."
},
"REMOVE_FROM_MANAGER": "Usuń z menedżera pobierania",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Kolejność serwera",
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
"DASHBOARD": {
"ACTION_FAILED": "Nie udało się wykonać akcji. Spróbuj ponownie.",
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"LIVE_FAVORITES": "Na żywo w Twoich ulubionych",
"LIVE_RECENT": "Kontynuuj oglądanie na żywo",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Faixas de áudio",
"SUBTITLES": "Legendas",
"SUBTITLES_OFF": "Desativadas",
"QUALITY": "Qualidade",
"QUALITY_AUTO": "Automática",
"TRACK_DEFAULT": "Padrão",
"AUDIO_TRACK_FALLBACK": "Áudio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Falhou",
"CANCELED": "Cancelado",
"PAUSED": "Pausado",
"FILE_MISSING": "Arquivo ausente"
"FILE_MISSING": "Arquivo ausente",
"RECORDING": "Gravando",
"INTERRUPTED": "Interrompida"
},
"ACTIVE_COUNT": "{{count}} ativos",
"TRACKED_DOWNLOADS": "Downloads acompanhados",
@@ -1220,7 +1224,22 @@
"ALL": "Todos",
"MOVIES": "Filmes",
"SERIES": "Séries",
"IN_PROGRESS": "Em andamento"
"IN_PROGRESS": "Em andamento",
"RECORDINGS": "Gravações"
},
"RECORDING_NOW": "Gravando agora",
"RECORDINGS": "Gravações",
"RECORDING_BADGE": "Gravação",
"STOP_RECORDING": "Parar gravação",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Remover gravação do gerenciador?",
"MESSAGE": "Isso remove a entrada do IPTVnator. O arquivo gravado permanece no disco."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Programas gravados",
"RECORDED_FROM": "Gravado a partir de {{playlist}}",
"MISSING_BODY": "O arquivo da gravação não está mais no disco. Uma transmissão ao vivo não pode ser gravada novamente, mas você pode remover a entrada do gerenciador.",
"FAILED_BODY": "Esta gravação falhou e não gerou nenhum arquivo reproduzível."
},
"REMOVE_FROM_MANAGER": "Remover do gerenciador",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Ordem do servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorias",
"DASHBOARD": {
"ACTION_FAILED": "Não foi possível concluir a ação. Tente novamente.",
"CONTINUE_WATCHING": "Continuar assistindo",
"LIVE_FAVORITES": "Ao vivo agora nos seus favoritos",
"LIVE_RECENT": "Continuar com TV ao vivo",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Аудиодорожки",
"SUBTITLES": "Субтитры",
"SUBTITLES_OFF": "Выкл.",
"QUALITY": "Качество",
"QUALITY_AUTO": "Авто",
"TRACK_DEFAULT": "По умолчанию",
"AUDIO_TRACK_FALLBACK": "Аудио {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Ошибка",
"CANCELED": "Отменено",
"PAUSED": "На паузе",
"FILE_MISSING": "Файл отсутствует"
"FILE_MISSING": "Файл отсутствует",
"RECORDING": "Идёт запись",
"INTERRUPTED": "Прервано"
},
"ACTIVE_COUNT": "{{count}} активно",
"TRACKED_DOWNLOADS": "Отслеживаемые загрузки",
@@ -1220,7 +1224,22 @@
"ALL": "Все",
"MOVIES": "Фильмы",
"SERIES": "Сериалы",
"IN_PROGRESS": "В процессе"
"IN_PROGRESS": "В процессе",
"RECORDINGS": "Записи"
},
"RECORDING_NOW": "Записываются сейчас",
"RECORDINGS": "Записи",
"RECORDING_BADGE": "Запись",
"STOP_RECORDING": "Остановить запись",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Убрать запись из менеджера?",
"MESSAGE": "Запись исчезнет из IPTVnator, но записанный файл останется на диске."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Записанные передачи",
"RECORDED_FROM": "Записано из {{playlist}}",
"MISSING_BODY": "Файл записи больше недоступен на диске. Прямой эфир нельзя записать заново, но запись можно убрать из менеджера.",
"FAILED_BODY": "Эта запись завершилась ошибкой, и файл для воспроизведения не был создан."
},
"REMOVE_FROM_MANAGER": "Удалить из менеджера загрузок",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Порядок сервера",
"CATEGORY_SORT_ARIA": "Сортировать категории",
"DASHBOARD": {
"ACTION_FAILED": "Не удалось выполнить действие. Попробуйте ещё раз.",
"CONTINUE_WATCHING": "Продолжить просмотр",
"LIVE_FAVORITES": "Сейчас в эфире на избранных каналах",
"LIVE_RECENT": "Продолжить просмотр прямого эфира",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "Ses parçaları",
"SUBTITLES": "Altyazılar",
"SUBTITLES_OFF": "Kapalı",
"QUALITY": "Kalite",
"QUALITY_AUTO": "Otomatik",
"TRACK_DEFAULT": "Varsayılan",
"AUDIO_TRACK_FALLBACK": "Ses {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "Başarısız",
"CANCELED": "İptal Edildi",
"PAUSED": "Duraklatıldı",
"FILE_MISSING": "Dosya eksik"
"FILE_MISSING": "Dosya eksik",
"RECORDING": "Kaydediliyor",
"INTERRUPTED": "Kesildi"
},
"ACTIVE_COUNT": "{{count}} etkin",
"TRACKED_DOWNLOADS": "İzlenen indirmeler",
@@ -1220,7 +1224,22 @@
"ALL": "Tümü",
"MOVIES": "Filmler",
"SERIES": "Diziler",
"IN_PROGRESS": "Devam ediyor"
"IN_PROGRESS": "Devam ediyor",
"RECORDINGS": "Kayıtlar"
},
"RECORDING_NOW": "Şu anda kaydediliyor",
"RECORDINGS": "Kayıtlar",
"RECORDING_BADGE": "Kaydediliyor",
"STOP_RECORDING": "Kaydı durdur",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "Kayıt yöneticiden kaldırılsın mı?",
"MESSAGE": "Bu işlem, kaydı IPTVnator'dan kaldırır. Kaydedilen dosya diskte kalır."
},
"RECORDING_DETAIL": {
"PROGRAMS": "Kaydedilen programlar",
"RECORDED_FROM": "{{playlist}} kaynağından kaydedildi",
"MISSING_BODY": "Kayıt dosyası artık diskte mevcut değil. Canlı yayın yeniden kaydedilemez, ancak kaydı yöneticiden kaldırabilirsiniz.",
"FAILED_BODY": "Bu kayıt başarısız oldu ve oynatılabilir bir dosya oluşmadı."
},
"REMOVE_FROM_MANAGER": "İndirme yöneticisinden kaldır",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "Sunucu sıralaması",
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
"DASHBOARD": {
"ACTION_FAILED": "İşlem tamamlanamadı. Lütfen tekrar deneyin.",
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"LIVE_FAVORITES": "Favorilerinizde şu an canlı",
"LIVE_RECENT": "Canlı TV ile devam et",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "音轨",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "关闭",
"QUALITY": "画质",
"QUALITY_AUTO": "自动",
"TRACK_DEFAULT": "默认",
"AUDIO_TRACK_FALLBACK": "音轨 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "失败",
"CANCELED": "已取消",
"PAUSED": "已暂停",
"FILE_MISSING": "文件缺失"
"FILE_MISSING": "文件缺失",
"RECORDING": "录制中",
"INTERRUPTED": "已中断"
},
"ACTIVE_COUNT": "{{count}} 个进行中",
"TRACKED_DOWNLOADS": "下载任务",
@@ -1220,7 +1224,22 @@
"ALL": "全部",
"MOVIES": "电影",
"SERIES": "剧集",
"IN_PROGRESS": "进行中"
"IN_PROGRESS": "进行中",
"RECORDINGS": "录制"
},
"RECORDING_NOW": "正在录制",
"RECORDINGS": "录制内容",
"RECORDING_BADGE": "录制中",
"STOP_RECORDING": "停止录制",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "从管理器中移除录制?",
"MESSAGE": "此操作将从 IPTVnator 中移除该条目。录制文件仍保留在磁盘上。"
},
"RECORDING_DETAIL": {
"PROGRAMS": "已录制的节目",
"RECORDED_FROM": "录制自 {{playlist}}",
"MISSING_BODY": "录制文件已不在磁盘上。直播无法重新录制,但您可以从管理器中移除该条目。",
"FAILED_BODY": "此次录制失败,未生成可播放的文件。"
},
"REMOVE_FROM_MANAGER": "从管理器中移除",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "服务器排序",
"CATEGORY_SORT_ARIA": "排序分类",
"DASHBOARD": {
"ACTION_FAILED": "无法完成该操作。请重试。",
"CONTINUE_WATCHING": "继续观看",
"LIVE_FAVORITES": "收藏频道正在播放",
"LIVE_RECENT": "继续观看直播",
+22 -2
View File
@@ -534,6 +534,8 @@
"AUDIO_TRACKS": "音軌",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "關閉",
"QUALITY": "畫質",
"QUALITY_AUTO": "自動",
"TRACK_DEFAULT": "預設",
"AUDIO_TRACK_FALLBACK": "音軌 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
@@ -1205,7 +1207,9 @@
"FAILED": "失敗",
"CANCELED": "已取消",
"PAUSED": "已暫停",
"FILE_MISSING": "檔案遺失"
"FILE_MISSING": "檔案遺失",
"RECORDING": "錄製中",
"INTERRUPTED": "已中斷"
},
"ACTIVE_COUNT": "{{count}} 個進行中",
"TRACKED_DOWNLOADS": "追蹤的下載",
@@ -1220,7 +1224,22 @@
"ALL": "全部",
"MOVIES": "電影",
"SERIES": "影集",
"IN_PROGRESS": "進行中"
"IN_PROGRESS": "進行中",
"RECORDINGS": "錄製檔"
},
"RECORDING_NOW": "正在錄製",
"RECORDINGS": "錄製檔",
"RECORDING_BADGE": "錄製中",
"STOP_RECORDING": "停止錄製",
"REMOVE_RECORDING_DIALOG": {
"TITLE": "從管理員中移除錄製檔?",
"MESSAGE": "這會從 IPTVnator 移除此項目。錄製的檔案仍會保留在磁碟上。"
},
"RECORDING_DETAIL": {
"PROGRAMS": "已錄製的節目",
"RECORDED_FROM": "錄製自 {{playlist}}",
"MISSING_BODY": "錄製檔已不在磁碟上。直播節目無法重新錄製,但您可以從管理員中移除此項目。",
"FAILED_BODY": "此次錄製失敗,未產生可播放的檔案。"
},
"REMOVE_FROM_MANAGER": "從管理器移除",
"REMOVE_COMPLETED_DIALOG": {
@@ -1302,6 +1321,7 @@
"SORT_SERVER": "伺服器排序",
"CATEGORY_SORT_ARIA": "排序分類",
"DASHBOARD": {
"ACTION_FAILED": "無法完成該操作。請重試。",
"CONTINUE_WATCHING": "繼續觀看",
"LIVE_FAVORITES": "我的最愛正在直播",
"LIVE_RECENT": "繼續觀看直播電視",
+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
@@ -103,9 +103,11 @@
: (playerSettings.player ?? null)
"
[volume]="volume()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(recordingStopped)="onRecordingStopped($event)"
/>
}
</div>
@@ -42,6 +42,8 @@ import {
Channel,
EpgProgram,
ExternalPlayerSession,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
@@ -122,7 +124,9 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly volume = input(1);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
@@ -103,6 +103,7 @@ import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
@@ -112,11 +113,16 @@ import {
createDevLogger,
EpgProgram,
ExternalPlayerSession,
filterRecordingProgramsOverlap,
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
PLAYLIST_PARSE_BY_URL,
M3uRecentlyViewedItem,
playlistDisplayLabel,
PlaylistMeta,
RecordingStartMetadata,
RecordingStoppedEvent,
toRecordingProgramSnapshot,
ResolvedPortalPlayback,
STORE_KEY,
Settings,
@@ -411,6 +417,79 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
);
private readonly activePlaylistMeta =
this.store.selectSignal(selectActivePlaylist);
private readonly recordingsService = inject(RecordingsService);
/**
* Channel/EPG snapshot for the embedded-MPV recording tracker, captured
* at recording start (EPG cannot be reconstructed after the fact).
*/
readonly recordingMetadata = computed<RecordingStartMetadata | null>(() => {
const channel = this.activeChannel();
if (!channel) {
return null;
}
// Derive the airing program from the ACTIVE channel's own schedule
// and the 30 s clock — the NgRx `currentEpgProgram` retains its last
// value across a channel switch and through EPG gaps (the mirror
// effect below only dispatches when a program exists), so it can
// still describe the previous channel when recording starts. An EPG
// gap must snapshot no program, not a stale one — stop enrichment
// deliberately never overwrites a persisted start title.
const program =
findCurrentEpgProgram(this.epgPrograms(), this.epgNowMs()) ?? null;
const playlistName = playlistDisplayLabel(
this.activePlaylistMeta()?.title
);
return {
channelName:
channel.name?.trim() || channel.tvg?.name?.trim() || 'Live TV',
channelLogoUrl: this.timelineChannelLogo() || undefined,
playlistId: this.activePlaylistId() || undefined,
playlistName: playlistName || undefined,
sourceType: 'm3u',
epgChannelId: resolveChannelEpgLookupKey(channel) || undefined,
// The EPG lookup key can be shared by several channels (same
// tvgId, or the name fallback); the channel id is unique.
sourceItemKey: channel.id,
currentProgram: program
? toRecordingProgramSnapshot(program)
: undefined,
};
});
/**
* Stop enrichment: report every program overlapping the recorded window
* from the in-memory multi-day schedule (a recording can span a program
* boundary). Fire-and-forget — a failed update leaves the start snapshot.
*/
onRecordingStopped(event: RecordingStoppedEvent): void {
// A channel switch auto-stops the recording, and by now this host
// already describes the new channel — enriching then would attach the
// wrong schedule (and could promote an unrelated program to the
// recording's title).
if (
event.epgChannelId &&
event.epgChannelId !== this.recordingMetadata()?.epgChannelId
) {
return;
}
// The EPG key alone cannot tell two same-keyed channels apart —
// the channel id must also match the exact recorded selection.
if (
event.sourceItemKey &&
event.sourceItemKey !== this.recordingMetadata()?.sourceItemKey
) {
return;
}
const programs = filterRecordingProgramsOverlap(
this.epgPrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt
);
if (programs.length === 0) {
return;
}
void this.recordingsService.updatePrograms(event.targetPath, programs);
}
/**
* Without a single configured XMLTV source the whole playlist has no EPG,
* so the panel's empty state should point at the EPG settings page
@@ -1,2 +1,3 @@
export * from './lib/downloads.component';
export * from './lib/offline-detail/download-offline-detail.component';
export * from './lib/recording-detail/recording-detail.component';
@@ -74,7 +74,7 @@ function libraryItemIds(entities: readonly DownloadLibraryEntity[]): number[] {
}
describe('normalizeDownloadFilter', () => {
it.each(['all', 'movie', 'series', 'in-progress'] as const)(
it.each(['all', 'movie', 'series', 'in-progress', 'recording'] as const)(
'keeps the known %s filter',
(filter) => {
expect(normalizeDownloadFilter(filter)).toBe(filter);
@@ -159,6 +159,20 @@ describe('buildDownloadManagerViewModel', () => {
expect(inProgress.library).toEqual([]);
});
it('hides every download row under the recording filter but keeps counts', () => {
const downloads = [
download(1, { status: 'queued' }),
download(2, { status: 'failed' }),
download(3, { status: 'completed' }),
];
const model = build(downloads, { filter: 'recording' });
expect(model.active).toEqual([]);
expect(model.attention).toEqual([]);
expect(model.library).toEqual([]);
// Chip counts stay derived from the unfiltered scoped set.
expect(model.counts.all).toBe(3);
});
it('keeps queued, downloading, and paused rows in the in-progress filter only', () => {
const result = build(
[
@@ -17,7 +17,12 @@ export type {
DownloadSeriesCardViewModel,
} from './download-library.viewmodel';
export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress';
export type DownloadFilterId =
| 'all'
| 'movie'
| 'series'
| 'in-progress'
| 'recording';
export type DownloadAttentionReason = 'file-missing' | 'transfer';
@@ -59,6 +64,7 @@ export function normalizeDownloadFilter(
case 'movie':
case 'series':
case 'in-progress':
case 'recording':
return value;
default:
return 'all';
@@ -147,6 +153,11 @@ function matchesFilter(
if (filter === 'series') {
return row.item.contentType === 'episode';
}
// Recordings live in their own view model; the chip hides download rows
// symmetrically to how the download chips hide recordings.
if (filter === 'recording') {
return false;
}
return true;
}
@@ -5,14 +5,14 @@
<h1 id="downloads-title">
{{ 'DOWNLOADS.TITLE' | translate }}
</h1>
@if (model().activeCount > 0) {
@if (activeCount() > 0) {
<span
class="downloads__active-count"
data-test-id="downloads-active-count"
>
{{
'DOWNLOADS.ACTIVE_COUNT'
| translate: { count: model().activeCount }
| translate: { count: activeCount() }
}}
</span>
}
@@ -227,6 +227,13 @@
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</p>
</div>
} @else {
<app-recording-queue
[activeItems]="recordingModel().active"
[attentionItems]="recordingModel().attention"
[pendingIds]="recordingPendingIds()"
(itemAction)="runRecordingAction($event)"
(openRequested)="openRecordingDetail($event)"
/>
<app-download-queue
[activeItems]="model().active"
[attentionItems]="model().attention"
@@ -241,6 +248,12 @@
(openRequested)="openOfflineDetail($event)"
(episodesOpened)="openDownloadedSeries($event)"
/>
<app-recording-library
[items]="recordingModel().library"
[pendingIds]="recordingPendingIds()"
(itemAction)="runRecordingAction($event)"
(openRequested)="openRecordingDetail($event)"
/>
}
</main>
</section>
@@ -16,6 +16,8 @@ import {
type DownloadItem,
DownloadsService,
PlaylistsService,
type RecordingItem,
RecordingsService,
} from '@iptvnator/services';
import {
PORTAL_SHELL_ACTIONS,
@@ -27,6 +29,7 @@ import { BehaviorSubject, type Observable, Subject } from 'rxjs';
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
import type { DownloadActionResult } from './download-actions';
import { DownloadManagerActionsService } from './download-manager-actions.service';
import { RecordingManagerActionsService } from './recording-manager-actions.service';
import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel';
import { DownloadsComponent } from './downloads.component';
@@ -67,6 +70,23 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve };
}
function recording(
id: number,
overrides: Partial<RecordingItem> = {}
): RecordingItem {
return {
id,
status: 'completed',
filePath: `/recordings/${id}.ts`,
channelName: `Channel ${id}`,
startedAt: '2026-08-15T21:00:00Z',
endedAt: '2026-08-15T21:58:00Z',
fileAvailability: 'available',
playlistId: 'playlist-a',
...overrides,
};
}
function download(
id: number,
overrides: Partial<DownloadItem> = {}
@@ -141,11 +161,39 @@ describe('DownloadsComponent', () => {
};
let snackBar: { open: jest.Mock };
let matDialog: { open: jest.Mock };
let recordings: ReturnType<typeof signal<RecordingItem[]>>;
let recordingsService: {
readonly recordings: typeof recordings;
readonly isAvailable: ReturnType<typeof signal<boolean>>;
readonly hasLoadedRecordings: ReturnType<typeof signal<boolean>>;
readonly hasAuthoritativeRecordingList: ReturnType<
typeof signal<boolean>
>;
readonly isLoadingRecordings: ReturnType<typeof signal<boolean>>;
loadRecordings: jest.Mock;
stopRecording: jest.Mock;
removeRecording: jest.Mock;
playFile: jest.Mock;
revealFile: jest.Mock;
};
const success = async () => ({ success: true });
beforeEach(async () => {
downloads = signal<DownloadItem[]>([]);
recordings = signal<RecordingItem[]>([]);
recordingsService = {
recordings,
isAvailable: signal(false),
hasLoadedRecordings: signal(true),
hasAuthoritativeRecordingList: signal(true),
isLoadingRecordings: signal(false),
loadRecordings: jest.fn(async () => undefined),
stopRecording: jest.fn(success),
removeRecording: jest.fn(success),
playFile: jest.fn(success),
revealFile: jest.fn(success),
};
routeParams = new BehaviorSubject(convertToParamMap({}));
queryParams = new BehaviorSubject(convertToParamMap({}));
activatedRoute = {
@@ -244,6 +292,10 @@ describe('DownloadsComponent', () => {
provide: DownloadsService,
useValue: downloadsService,
},
{
provide: RecordingsService,
useValue: recordingsService,
},
{
provide: PlaylistsService,
useValue: {
@@ -275,6 +327,7 @@ describe('DownloadsComponent', () => {
},
{ provide: MatDialog, useValue: matDialog },
DownloadManagerActionsService,
RecordingManagerActionsService,
],
},
})
@@ -429,6 +482,32 @@ describe('DownloadsComponent', () => {
expect(downloads()).toBe(globalItems);
});
it('counts recordings in the All chip and the active badge', () => {
// A manager listing only recordings must not read "All 0", and an
// active recording belongs in the header badge.
recordingsService.isAvailable.set(true);
recordings.set([
recording(1),
recording(2, { status: 'recording', endedAt: undefined }),
]);
fixture.detectChanges();
const all = component
.categories()
.find(({ category_id }) => category_id === 'all');
const inProgress = component
.categories()
.find(({ category_id }) => category_id === 'in-progress');
const recordingsChip = component
.categories()
.find(({ category_id }) => category_id === 'recording');
expect(all?.count).toBe(2);
expect(inProgress?.count).toBe(1);
expect(recordingsChip?.count).toBe(2);
expect(component.activeCount()).toBe(1);
});
it('keeps retained offline downloads visible after the last source is removed', () => {
playlistItems.next([]);
downloads.set([
@@ -18,6 +18,8 @@ import {
type DownloadItem,
DownloadsService,
PlaylistsService,
type RecordingItem,
RecordingsService,
} from '@iptvnator/services';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import {
@@ -43,14 +45,26 @@ import {
} from './download-manager.viewmodel';
import { DownloadQueueComponent } from './download-queue.component';
import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.component';
import type {
RecordingActionResult,
RecordingItemAction,
} from './recording-actions';
import { RecordingLibraryComponent } from './recording-library.component';
import { RecordingManagerActionsService } from './recording-manager-actions.service';
import { buildRecordingManagerViewModel } from './recording-manager.viewmodel';
import { RecordingQueueComponent } from './recording-queue.component';
const FILTER_KEYS = {
all: 'DOWNLOADS.FILTER.ALL',
movie: 'DOWNLOADS.FILTER.MOVIES',
series: 'DOWNLOADS.FILTER.SERIES',
inProgress: 'DOWNLOADS.FILTER.IN_PROGRESS',
recording: 'DOWNLOADS.FILTER.RECORDINGS',
} as const;
/** Cadence for refreshing the live file size of an active recording. */
const ACTIVE_RECORDING_REFRESH_MS = 15_000;
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
@@ -59,6 +73,7 @@ const FILTER_KEYS = {
providers: [
DownloadLibraryNavigationService,
DownloadManagerActionsService,
RecordingManagerActionsService,
],
imports: [
DownloadLibraryComponent,
@@ -68,6 +83,8 @@ const FILTER_KEYS = {
MatDialogModule,
MatIcon,
MatTooltip,
RecordingLibraryComponent,
RecordingQueueComponent,
TranslatePipe,
],
})
@@ -83,6 +100,8 @@ export class DownloadsComponent {
private readonly shellActions = inject(PORTAL_SHELL_ACTIONS);
private readonly destroyRef = inject(DestroyRef);
readonly downloadsService = inject(DownloadsService);
readonly recordingsService = inject(RecordingsService);
private readonly recordingActions = inject(RecordingManagerActionsService);
readonly pendingIds = this.actions.pendingIds;
readonly isClearing = this.actions.isClearing;
@@ -139,20 +158,46 @@ export class DownloadsComponent {
searchTerm: this.searchTerm(),
})
);
readonly recordingsAvailable = this.recordingsService.isAvailable;
readonly recordingPendingIds = this.recordingActions.pendingIds;
readonly recordingModel = computed(() =>
buildRecordingManagerViewModel({
recordings: this.recordingsAvailable()
? this.recordingsService.recordings()
: [],
scopePlaylistId: this.playlistId() || undefined,
filter: this.selectedCategoryId(),
searchTerm: this.searchTerm(),
})
);
/**
* Header badge and All chip must count everything the page can list —
* otherwise a manager holding only recordings reads "All 0" and an active
* recording never reaches the active-count badge.
*/
readonly activeCount = computed(
() => this.model().activeCount + this.recordingModel().active.length
);
readonly categories = computed(() => this.buildCategories());
readonly collectionContext = createPortalCollectionContext({
ctx: this.collectionCtx,
categories: this.categories,
});
readonly hasScopedDownloads = computed(
() => this.model().scopedItems.length > 0
() =>
this.model().scopedItems.length > 0 ||
this.recordingModel().count > 0
);
readonly hasVisibleDownloads = computed(() => {
const model = this.model();
const recordings = this.recordingModel();
return (
model.active.length +
model.attention.length +
model.library.length >
model.library.length +
recordings.active.length +
recordings.attention.length +
recordings.library.length >
0
);
});
@@ -166,6 +211,21 @@ export class DownloadsComponent {
constructor() {
void this.downloadsService.loadDownloads();
if (this.recordingsAvailable()) {
void this.recordingsService.loadRecordings();
}
// Live file-size for an active recording: the backend only pings on
// transitions, so a modest poll keeps the growing size honest.
effect((onCleanup) => {
if (this.recordingModel().active.length === 0) {
return;
}
const intervalId = window.setInterval(
() => void this.recordingsService.loadRecordings(),
ACTIVE_RECORDING_REFRESH_MS
);
onCleanup(() => window.clearInterval(intervalId));
});
effect(() => {
const routeFilter = this.routeFilter();
untracked(() => this.collectionContext.setCategoryId(routeFilter));
@@ -219,6 +279,24 @@ export class DownloadsComponent {
return this.actions.run(action);
}
async runRecordingAction(
action: RecordingItemAction
): Promise<RecordingActionResult> {
return this.recordingActions.run(action);
}
openRecordingDetail(item: RecordingItem): void {
if (this.recordingPendingIds().has(item.id)) {
return;
}
// Recordings have a single global detail route in v1 — portal-scoped
// managers navigate there too.
void this.router.navigate(
['/workspace', 'downloads', 'recording', String(item.id)],
{ state: { returnUrl: this.router.url } }
);
}
clearFinished(): void {
this.actions.clearFinished(this.playlistId() || undefined);
}
@@ -278,6 +356,9 @@ export class DownloadsComponent {
private buildCategories(): XtreamCategory[] {
this.languageChange();
const counts = this.model().counts;
const recordingCount = this.recordingsAvailable()
? this.recordingModel().count
: 0;
const categories = buildStandardCollectionCategories({
labels: {
all: this.translate.instant(FILTER_KEYS.all),
@@ -287,13 +368,28 @@ export class DownloadsComponent {
},
counts,
});
const allCategory = categories.find(
({ category_id }) => category_id === 'all'
);
if (allCategory) {
allCategory.count = (allCategory.count ?? 0) + recordingCount;
}
categories.push({
id: 4,
category_id: 'in-progress',
category_name: this.translate.instant(FILTER_KEYS.inProgress),
count: counts.inProgress,
count: counts.inProgress + this.recordingModel().active.length,
parent_id: 0,
});
if (this.recordingsAvailable()) {
categories.push({
id: 5,
category_id: 'recording',
category_name: this.translate.instant(FILTER_KEYS.recording),
count: recordingCount,
parent_id: 0,
});
}
return categories;
}
}
@@ -0,0 +1,19 @@
import type { RecordingItem } from '@iptvnator/services';
export type RecordingItemActionType =
| 'open-detail'
| 'play'
| 'remove'
| 'reveal'
| 'stop';
export type RecordingActionResult =
| 'success'
| 'file-missing'
| 'failed'
| 'ignored';
export interface RecordingItemAction {
readonly type: RecordingItemActionType;
readonly item: RecordingItem;
}
@@ -0,0 +1,191 @@
@if (item(); as recording) {
<app-portal-detail-shell
[title]="title()"
[description]="recording.programDescription ?? ''"
[posterUrl]="recording.channelLogoUrl ?? ''"
[backLabel]="'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate"
[errorMessage]="
isMissing() || isFailed()
? ((isMissing()
? 'DOWNLOADS.RECORDING_DETAIL.MISSING_BODY'
: 'DOWNLOADS.RECORDING_DETAIL.FAILED_BODY'
) | translate)
: undefined
"
(backClicked)="goBack()"
>
@if (isMissing() || isFailed()) {
<div
hero-error-actions
class="recording-detail__error-actions"
data-testid="recording-error-actions"
>
<button
mat-stroked-button
type="button"
data-testid="recording-error-back"
(click)="goBack()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate }}
</button>
<button
mat-stroked-button
type="button"
data-testid="recording-error-remove"
[disabled]="isPending()"
(click)="runAction('remove')"
>
<mat-icon aria-hidden="true">remove_circle_outline</mat-icon>
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
</button>
</div>
} @else {
<ng-template appDetailTags>
<span class="details__tag recording-detail__badge">
<mat-icon aria-hidden="true">fiber_manual_record</mat-icon>
{{ 'DOWNLOADS.RECORDING_BADGE' | translate }}
</span>
<span class="details__tag">{{ recording.channelName }}</span>
@if (durationLabel()) {
<span class="details__tag">{{ durationLabel() }}</span>
}
@if (sizeLabel()) {
<span class="details__tag">{{ sizeLabel() }}</span>
}
<span class="details__tag">MPEG-TS</span>
@if (recording.status === 'interrupted') {
<span class="details__tag details__tag--status">
{{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }}
</span>
}
</ng-template>
<ng-template appDetailMeta>
<div class="details__meta-item">
<span class="value">
{{ recording.startedAt | date: 'longDate' }}
· {{ recording.startedAt | date: 'HH:mm' }}
@if (recording.endedAt) {
– {{ recording.endedAt | date: 'HH:mm' }}
}
</span>
</div>
@if (recording.playlistName; as playlistName) {
<div class="details__meta-item">
<span class="value">
{{
'DOWNLOADS.RECORDING_DETAIL.RECORDED_FROM'
| translate: { playlist: playlistName }
}}
</span>
</div>
}
<div class="details__meta-item">
<span class="value recording-detail__path">
{{ recording.filePath }}
</span>
</div>
</ng-template>
<ng-template appDetailActions>
@if (isActive()) {
<button
mat-flat-button
class="recording-detail__stop-action"
type="button"
data-testid="recording-stop"
[disabled]="isPending()"
(click)="runAction('stop')"
>
<mat-icon aria-hidden="true">stop</mat-icon>
{{ 'DOWNLOADS.STOP_RECORDING' | translate }}
</button>
} @else {
<button
mat-flat-button
class="recording-detail__primary-action"
type="button"
data-testid="recording-play"
[disabled]="isPending()"
(click)="runAction('play')"
>
<mat-icon aria-hidden="true">play_arrow</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.PLAY' | translate }}
</button>
}
<button
mat-stroked-button
type="button"
data-testid="recording-reveal"
[disabled]="isPending()"
(click)="runAction('reveal')"
>
<mat-icon aria-hidden="true">folder_open</mat-icon>
{{ 'DOWNLOADS.REVEAL' | translate }}
</button>
<button
mat-icon-button
type="button"
data-testid="recording-overflow"
[matMenuTriggerFor]="detailMenu"
[attr.aria-label]="
'DOWNLOADS.MORE_ACTIONS' | translate
"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #detailMenu="matMenu" xPosition="before">
<button
mat-menu-item
data-testid="recording-remove"
[disabled]="isActive()"
(click)="runAction('remove')"
>
<mat-icon>remove_circle_outline</mat-icon>
<span>
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
</span>
</button>
</mat-menu>
</ng-template>
}
@if (coveredPrograms().length > 0) {
<section
detail-episodes
class="recording-detail__programs"
data-testid="recording-programs"
>
<h3>
{{ 'DOWNLOADS.RECORDING_DETAIL.PROGRAMS' | translate }}
<span class="app-count-badge" aria-hidden="true">
{{ coveredPrograms().length }}
</span>
</h3>
<ol class="recording-detail__program-list">
@for (program of coveredPrograms(); track program.start) {
<li class="recording-detail__program">
<time>
{{ program.start | date: 'HH:mm' }} –
{{ program.stop | date: 'HH:mm' }}
</time>
<div>
<div class="recording-detail__program-title">
{{ program.title }}
</div>
@if (program.description) {
<div
class="recording-detail__program-desc"
>
{{ program.description }}
</div>
}
</div>
</li>
}
</ol>
</section>
}
</app-portal-detail-shell>
}
@@ -0,0 +1,94 @@
:host {
display: block;
height: 100%;
}
.recording-detail__badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--mat-sys-error) 18%, transparent);
color: var(--mat-sys-error);
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
}
.recording-detail__path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
opacity: 0.75;
word-break: break-all;
}
.recording-detail__stop-action {
background: var(--mat-sys-error);
color: var(--mat-sys-on-error);
}
.recording-detail__error-actions {
display: flex;
gap: 10px;
justify-content: center;
}
.recording-detail__programs {
padding: 18px 0 8px;
h3 {
display: inline-flex;
align-items: baseline;
gap: 8px;
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
}
.recording-detail__program-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.recording-detail__program {
display: grid;
grid-template-columns: 108px minmax(0, 1fr);
gap: 14px;
align-items: baseline;
padding: 10px 12px;
border: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 64%,
transparent
);
border-radius: 10px;
background: var(--mat-sys-surface-container-low);
time {
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
}
.recording-detail__program-title {
font-size: 0.9rem;
font-weight: 600;
}
.recording-detail__program-desc {
margin-top: 2px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
}
@@ -0,0 +1,153 @@
import { DatePipe, Location } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { RecordingsService } from '@iptvnator/services';
import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
PortalDetailShellComponent,
} from '@iptvnator/ui/components';
import { map } from 'rxjs';
import { formatDownloadBytes } from '../download-queue.component';
import type { RecordingItemActionType } from '../recording-actions';
import { RecordingManagerActionsService } from '../recording-manager-actions.service';
import {
recordingDurationLabel,
recordingDurationSeconds,
} from '../recording-manager.viewmodel';
/**
* Focused detail for one live-TV recording
* (`/workspace/downloads/recording/:recordingId`). Rendered on the shared
* portal-detail-shell like the offline download details; the workspace shell
* hides the context panel and route search for this route.
*/
@Component({
selector: 'app-recording-detail',
templateUrl: './recording-detail.component.html',
styleUrls: [
'../../../../../../ui/components/src/lib/styles/detail-view.scss',
'./recording-detail.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [RecordingManagerActionsService],
imports: [
DatePipe,
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatButtonModule,
MatIcon,
MatMenuModule,
PortalDetailShellComponent,
TranslatePipe,
],
})
export class RecordingDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly recordings = inject(RecordingsService);
private readonly actions = inject(RecordingManagerActionsService);
private readonly returnUrl: string | null =
(this.router.getCurrentNavigation()?.extras.state?.[
'returnUrl'
] as string) ?? null;
readonly pendingIds = this.actions.pendingIds;
readonly recordingId = toSignal(
this.route.paramMap.pipe(
map((params) => Number(params.get('recordingId')))
),
{ initialValue: Number(this.route.snapshot.params['recordingId']) }
);
readonly item = computed(
() =>
this.recordings
.recordings()
.find((row) => row.id === this.recordingId()) ?? null
);
readonly isMissing = computed(
() => this.item()?.fileAvailability === 'missing'
);
readonly isFailed = computed(() => this.item()?.status === 'failed');
readonly isActive = computed(() => this.item()?.status === 'recording');
readonly title = computed(() => {
const item = this.item();
return item ? item.programTitle?.trim() || item.channelName : '';
});
readonly displayTitleNeedsTime = computed(
() => !this.item()?.programTitle?.trim()
);
readonly durationSeconds = computed(() => {
const item = this.item();
return item ? recordingDurationSeconds(item) : null;
});
readonly durationLabel = computed(() =>
recordingDurationLabel(this.durationSeconds())
);
readonly sizeLabel = computed(() => {
const bytes = this.item()?.fileSizeBytes;
return bytes ? formatDownloadBytes(bytes) : '';
});
/** ≥2 covered programs — the boundary-crossing case worth listing. */
readonly coveredPrograms = computed(() => {
const programs = this.item()?.programs ?? [];
return programs.length >= 2 ? programs : [];
});
constructor() {
void this.recordings.loadRecordings();
// Row gone after an authoritative load (removed here, removed
// elsewhere, or a bogus id) → return to the manager.
effect(() => {
if (
this.recordings.hasLoadedRecordings() &&
this.recordings.hasAuthoritativeRecordingList() &&
!this.recordings.isLoadingRecordings() &&
this.item() === null
) {
this.goBack();
}
});
}
goBack(): void {
// A validated returnUrl means the manager is the previous history
// entry: step back to it instead of pushing a third entry (which
// would make the browser Back button reopen this detail). Router
// navigation is the fallback for direct links with no usable
// history — matching the offline-detail navigation.
if (this.returnUrl) {
this.location.back();
return;
}
void this.router.navigateByUrl('/workspace/downloads');
}
runAction(type: RecordingItemActionType): void {
const item = this.item();
if (!item) {
return;
}
void this.actions.run({ type, item });
}
isPending(): boolean {
const item = this.item();
return item !== null && this.pendingIds().has(item.id);
}
}
@@ -0,0 +1,162 @@
@if (items().length > 0) {
<section
class="recording-library"
data-test-id="recordings-library-section"
aria-labelledby="recordings-library-heading"
>
<header class="recording-library__header">
<h2 id="recordings-library-heading">
<span>{{ 'DOWNLOADS.RECORDINGS' | translate }}</span>
<span class="app-count-badge" aria-hidden="true">
{{ items().length }}
</span>
</h2>
</header>
<div class="recording-library__grid" role="list">
@for (row of items(); track row.item.id) {
<article
class="recording-library__card"
role="listitem"
[attr.data-test-id]="'recording-card-' + row.item.id"
[attr.aria-busy]="isPending(row.item.id) ? 'true' : null"
>
<div class="recording-library__artwork">
<button
type="button"
class="recording-library__artwork-button"
[attr.aria-label]="
'DOWNLOADS.ARIA.OPEN'
| translate
: {
title:
row.programTitle ||
row.channelName,
}
"
[disabled]="isPending(row.item.id)"
(click)="requestOpen(row.item)"
>
@if (row.item.channelLogoUrl) {
<img
[src]="row.item.channelLogoUrl"
alt=""
loading="lazy"
decoding="async"
/>
} @else {
<span
class="recording-library__placeholder"
aria-hidden="true"
>
<mat-icon>live_tv</mat-icon>
</span>
}
</button>
<span
class="recording-library__type"
aria-hidden="true"
>
{{ 'DOWNLOADS.RECORDING_BADGE' | translate }}
</span>
@if (durationLabel(row)) {
<span
class="recording-library__duration"
aria-hidden="true"
>
{{ durationLabel(row) }}
@if (row.interrupted) {
· {{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }}
}
</span>
} @else if (row.interrupted) {
<span
class="recording-library__duration"
aria-hidden="true"
>
{{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }}
</span>
}
<button
type="button"
mat-icon-button
class="recording-library__more"
[matMenuTriggerFor]="cardMenu"
[attr.aria-label]="
'DOWNLOADS.MORE_ACTIONS' | translate
"
[disabled]="isPending(row.item.id)"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #cardMenu="matMenu" xPosition="before">
<app-download-source-menu-header
[sourceName]="row.item.playlistName ?? ''"
/>
<button
mat-menu-item
data-test-action="play-recording"
(click)="emitAction('play', row.item)"
>
<mat-icon>play_arrow</mat-icon>
<span>{{ 'DOWNLOADS.PLAY' | translate }}</span>
</button>
<button
mat-menu-item
data-test-action="reveal-recording"
(click)="emitAction('reveal', row.item)"
>
<mat-icon>folder_open</mat-icon>
<span>{{ 'DOWNLOADS.REVEAL' | translate }}</span>
</button>
<button
mat-menu-item
data-test-action="open-recording-detail"
(click)="requestOpen(row.item)"
>
<mat-icon>info_outline</mat-icon>
<span>
{{ 'DOWNLOADS.OPEN_DETAILS' | translate }}
</span>
</button>
<button
mat-menu-item
data-test-action="remove-recording"
(click)="emitAction('remove', row.item)"
>
<mat-icon>remove_circle_outline</mat-icon>
<span>
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
</span>
</button>
</mat-menu>
</div>
<div class="recording-library__body">
<button
type="button"
class="recording-library__title-button"
[disabled]="isPending(row.item.id)"
(click)="requestOpen(row.item)"
>
{{
row.programTitle ||
row.channelName +
' — ' +
(row.item.startedAt
| date: 'd MMM, HH:mm')
}}
</button>
<span class="recording-library__facts">
{{ row.channelName }}
· {{ row.item.startedAt | date: 'd MMM' }}
@if (row.item.fileSizeBytes) {
· {{ formatBytes(row.item.fileSizeBytes) }}
}
</span>
</div>
</article>
}
</div>
</section>
}
@@ -0,0 +1,210 @@
@use '../../../../shared/ui/src/lib/styles/content-grid' as grid;
:host {
display: block;
}
.recording-library {
display: grid;
gap: 12px;
&__header h2 {
display: inline-flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 0 2px;
color: var(--app-heading-color);
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.012em;
line-height: 1.2;
}
// Wider tiles than the poster grid: recording artwork is a 16:9 channel
// tile, so the min width is ~1.55× the cover token.
&__grid {
@include grid.content-grid(
$min-width: min(
100%,
calc(var(--cover-grid-min-width, 148px) * 1.55)
),
$gap: var(--cover-gap, 16px)
);
}
&__card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
&:hover .recording-library__artwork,
&:focus-within .recording-library__artwork {
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 55%,
var(--app-separator, var(--mat-sys-outline-variant))
);
transform: translateY(-2px);
}
}
&__artwork {
position: relative;
overflow: hidden;
aspect-ratio: 16 / 9;
border: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 78%,
transparent
);
border-radius: 14px;
background: radial-gradient(
130% 160% at 28% 18%,
color-mix(
in srgb,
var(--mat-sys-surface-container-highest) 82%,
var(--mat-sys-primary)
)
0%,
var(--mat-sys-surface-container-low) 68%
);
transition:
transform 160ms ease,
border-color 160ms ease;
}
&__artwork-button {
display: grid;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: none;
place-items: center;
cursor: pointer;
img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
padding: 14px;
box-sizing: border-box;
}
}
&__placeholder {
display: grid;
place-items: center;
color: var(--mat-sys-on-surface-variant);
mat-icon {
width: 34px;
height: 34px;
font-size: 34px;
}
}
&__type {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--mat-sys-error) 28%, rgb(0 0 0 / 62%));
color: var(--mat-sys-on-error, #fff);
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
backdrop-filter: blur(4px);
}
&__duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 62%);
color: #d6dde4;
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
backdrop-filter: blur(4px);
}
&__more {
position: absolute;
top: 6px;
right: 6px;
width: 32px;
height: 32px;
color: #d6dde4;
&::before {
content: '';
position: absolute;
inset: 2px;
border-radius: 50%;
background: rgb(0 0 0 / 48%);
}
mat-icon {
position: relative;
}
}
&__body {
display: grid;
gap: 2px;
min-width: 0;
padding: 0 2px;
}
&__title-button {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
min-width: 0;
padding: 0;
border: 0;
background: none;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
line-height: 1.3;
text-align: start;
cursor: pointer;
&:hover:not(:disabled) {
text-decoration: underline;
}
}
&__facts {
overflow: hidden;
color: var(--mat-sys-on-surface-variant);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (prefers-reduced-motion: reduce) {
.recording-library {
&__artwork {
transition: none;
}
&__card:hover .recording-library__artwork,
&__card:focus-within .recording-library__artwork {
transform: none;
}
}
}
@@ -0,0 +1,273 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import type { RecordingItem } from '@iptvnator/services';
import type { RecordingItemAction } from './recording-actions';
import type { RecordingRowViewModel } from './recording-manager.viewmodel';
import { RecordingLibraryComponent } from './recording-library.component';
const TEST_TRANSLATIONS = {
DOWNLOADS: {
RECORDINGS: 'Recordings',
RECORDING_BADGE: 'Recording',
PLAY: 'Play',
REVEAL: 'Show in folder',
OPEN_DETAILS: 'Open details',
REMOVE_FROM_MANAGER: 'Remove from manager',
MORE_ACTIONS: 'More actions',
STATUS: {
INTERRUPTED: 'Interrupted',
},
ARIA: {
OPEN: 'Open {{title}}',
},
},
PORTALS: {
MULTI_SOURCE: {
SOURCE: 'Source',
},
},
};
function createItem(
id: number,
overrides: Partial<RecordingItem> = {}
): RecordingItem {
return {
id,
status: 'completed',
filePath: `/recordings/${id}.ts`,
channelName: `Channel ${id}`,
startedAt: '2026-08-15T12:00:00Z',
fileAvailability: 'available',
...overrides,
};
}
function createRow(
item: RecordingItem,
overrides: Partial<RecordingRowViewModel> = {}
): RecordingRowViewModel {
return {
item,
programTitle: item.programTitle?.trim() ?? '',
channelName: item.channelName,
attentionReason: null,
durationSeconds: null,
interrupted: false,
...overrides,
};
}
describe('RecordingLibraryComponent', () => {
let fixture: ComponentFixture<RecordingLibraryComponent>;
let overlayContainer: OverlayContainer;
function render(
items: readonly RecordingRowViewModel[],
pendingIds: ReadonlySet<number> = new Set()
): void {
fixture.componentRef.setInput('items', items);
fixture.componentRef.setInput('pendingIds', pendingIds);
fixture.detectChanges();
}
function card(id: number): HTMLElement {
const element = fixture.nativeElement.querySelector(
`[data-test-id="recording-card-${id}"]`
);
expect(element).not.toBeNull();
return element;
}
async function openCardMenu(host: HTMLElement): Promise<HTMLElement> {
const more = host.querySelector<HTMLButtonElement>(
'.recording-library__more'
);
expect(more).not.toBeNull();
more?.click();
fixture.detectChanges();
await fixture.whenStable();
return overlayContainer.getContainerElement();
}
function menuButton(menu: ParentNode, action: string): HTMLButtonElement {
const button = menu.querySelector<HTMLButtonElement>(
`[data-test-action="${action}"]`
);
expect(button).not.toBeNull();
return button as HTMLButtonElement;
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RecordingLibraryComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', TEST_TRANSLATIONS);
translate.use('en');
overlayContainer = TestBed.inject(OverlayContainer);
fixture = TestBed.createComponent(RecordingLibraryComponent);
});
afterEach(() => {
overlayContainer.getContainerElement().replaceChildren();
});
it('renders no section without items', () => {
render([]);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="recordings-library-section"]'
)
).toBeNull();
expect(fixture.nativeElement.querySelector('section')).toBeNull();
});
it('renders cards with badge, duration chip, interrupted marker, and facts', () => {
const finished = createRow(
createItem(1, {
programTitle: 'Evening news',
fileSizeBytes: 1.5 * 1024 ** 2,
}),
{ durationSeconds: 3480 }
);
const interrupted = createRow(
createItem(2, { status: 'interrupted' }),
{ interrupted: true }
);
render([finished, interrupted]);
const section: HTMLElement = fixture.nativeElement.querySelector(
'[data-test-id="recordings-library-section"]'
);
expect(section).not.toBeNull();
expect(section.querySelector('h2 span')?.textContent?.trim()).toBe(
'Recordings'
);
expect(
section.querySelector('.app-count-badge')?.textContent?.trim()
).toBe('2');
const finishedCard = card(1);
expect(
finishedCard
.querySelector('.recording-library__type')
?.textContent?.trim()
).toBe('Recording');
const duration = finishedCard.querySelector<HTMLElement>(
'.recording-library__duration'
);
expect(duration?.textContent).toContain('58 min');
expect(duration?.textContent).not.toContain('Interrupted');
const facts = finishedCard
.querySelector('.recording-library__facts')
?.textContent?.replace(/\s+/g, ' ')
.trim();
expect(facts).toContain('Channel 1');
expect(facts).toContain('Aug');
expect(facts).toContain('1.5 MB');
expect(
finishedCard
.querySelector('.recording-library__title-button')
?.textContent?.trim()
).toBe('Evening news');
expect(
card(2)
.querySelector('.recording-library__duration')
?.textContent?.trim()
).toBe('Interrupted');
});
it('opens the item from the artwork and title buttons', () => {
const item = createItem(3, { programTitle: 'Late movie' });
const opened: RecordingItem[] = [];
fixture.componentInstance.openRequested.subscribe((openedItem) =>
opened.push(openedItem)
);
render([createRow(item)]);
const host = card(3);
const artwork = host.querySelector<HTMLButtonElement>(
'.recording-library__artwork-button'
);
expect(artwork?.getAttribute('aria-label')).toBe('Open Late movie');
artwork?.click();
host.querySelector<HTMLButtonElement>(
'.recording-library__title-button'
)?.click();
expect(opened).toEqual([item, item]);
expect(opened[0]).toBe(item);
expect(opened[1]).toBe(item);
});
it('routes play, reveal, remove, and details through the card menu', async () => {
const item = createItem(4, {
programTitle: 'Menu movie',
playlistName: 'Alpha Source',
});
const emitted: RecordingItemAction[] = [];
const opened = jest.fn();
fixture.componentInstance.itemAction.subscribe((action) =>
emitted.push(action)
);
fixture.componentInstance.openRequested.subscribe(opened);
render([createRow(item)]);
const firstMenu = await openCardMenu(card(4));
expect(
firstMenu
.querySelector('.download-source-menu-header')
?.textContent?.replace(/\s+/g, ' ')
.trim()
).toBe('Source Alpha Source');
const menuActions = [
['play-recording', 'play'],
['reveal-recording', 'reveal'],
['remove-recording', 'remove'],
] as const;
let menu = firstMenu;
for (const [testAction] of menuActions) {
menuButton(menu, testAction).click();
fixture.detectChanges();
await fixture.whenStable();
menu = await openCardMenu(card(4));
}
expect(emitted).toEqual(
menuActions.map(([, type]) => ({ type, item }))
);
expect(opened).not.toHaveBeenCalled();
menuButton(menu, 'open-recording-detail').click();
expect(opened).toHaveBeenCalledTimes(1);
expect(opened).toHaveBeenCalledWith(item);
});
it('falls back to channel name and start date for an untitled recording', () => {
const item = createItem(9);
render([createRow(item)]);
const title = card(9)
.querySelector('.recording-library__title-button')
?.textContent?.replace(/\s+/g, ' ')
.trim();
expect(title?.startsWith('Channel 9 —')).toBe(true);
expect(title).toContain('Aug');
expect(
card(9)
.querySelector('.recording-library__artwork-button')
?.getAttribute('aria-label')
).toBe('Open Channel 9');
});
});
@@ -0,0 +1,73 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import type { RecordingItem } from '@iptvnator/services';
import { formatDownloadBytes } from './download-queue.component';
import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component';
import type {
RecordingItemAction,
RecordingItemActionType,
} from './recording-actions';
import {
recordingDurationLabel,
type RecordingRowViewModel,
} from './recording-manager.viewmodel';
/**
* "Recordings" library section: 16:9 channel-logo cards in the manager's
* rail card language. Recordings keep their own section below "Ready to
* watch" because their geometry (wide logo tile) cannot share a grid with
* 2:3 posters.
*/
@Component({
selector: 'app-recording-library',
imports: [
DatePipe,
MatButtonModule,
MatIcon,
MatMenuModule,
TranslatePipe,
DownloadSourceMenuHeaderComponent,
],
templateUrl: './recording-library.component.html',
styleUrl: './recording-library.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecordingLibraryComponent {
readonly items = input.required<readonly RecordingRowViewModel[]>();
readonly pendingIds = input<ReadonlySet<number>>(new Set());
readonly itemAction = output<RecordingItemAction>();
readonly openRequested = output<RecordingItem>();
emitAction(type: RecordingItemActionType, item: RecordingItem): void {
if (!this.isPending(item.id)) {
this.itemAction.emit({ type, item });
}
}
requestOpen(item: RecordingItem): void {
if (!this.isPending(item.id)) {
this.openRequested.emit(item);
}
}
isPending(id: number): boolean {
return this.pendingIds().has(id);
}
durationLabel(row: RecordingRowViewModel): string {
return recordingDurationLabel(row.durationSeconds);
}
formatBytes(bytes: number | undefined): string {
return formatDownloadBytes(bytes);
}
}
@@ -0,0 +1,154 @@
import { Injectable, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { type RecordingItem, RecordingsService } from '@iptvnator/services';
import { DialogService } from '@iptvnator/ui/components';
import type {
RecordingActionResult,
RecordingItemAction,
} from './recording-actions';
interface RecordingOperationResult {
readonly success: boolean;
readonly error?: string;
}
/** Mirror of DownloadManagerActionsService for recording rows. */
@Injectable()
export class RecordingManagerActionsService {
private readonly recordings = inject(RecordingsService);
private readonly dialogs = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly pendingIds = signal<ReadonlySet<number>>(new Set());
async run(action: RecordingItemAction): Promise<RecordingActionResult> {
const { item, type } = action;
if (this.pendingIds().has(item.id)) {
return 'ignored';
}
switch (type) {
case 'remove':
this.confirmRemove(item);
return 'ignored';
case 'stop':
return this.withPending(item.id, () =>
this.recordings.stopRecording(item.id)
);
case 'play':
case 'reveal':
return this.runFileAction(type, item);
case 'open-detail':
return 'ignored';
}
}
showActionError(error?: string): void {
const base = this.translate.instant('DOWNLOADS.ACTION_FAILED');
this.snackBar.open(error ? `${base}: ${error}` : base, undefined, {
duration: 4000,
horizontalPosition: 'start',
});
}
private showMessage(key: string, duration: number): void {
this.snackBar.open(this.translate.instant(key), undefined, {
duration,
horizontalPosition: 'start',
});
}
private confirmRemove(item: RecordingItem): void {
this.dialogs.openConfirmDialog({
title: this.translate.instant(
'DOWNLOADS.REMOVE_RECORDING_DIALOG.TITLE'
),
message: this.translate.instant(
'DOWNLOADS.REMOVE_RECORDING_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant(
'DOWNLOADS.REMOVE_FROM_MANAGER'
),
onConfirm: () =>
this.withPending(item.id, () =>
this.recordings.removeRecording(item.id)
),
});
}
private async runFileAction(
type: 'play' | 'reveal',
item: RecordingItem
): Promise<RecordingActionResult> {
const filePath = item.filePath;
if (!filePath) {
this.showMessage('DOWNLOADS.FILE_NOT_FOUND', 3000);
return 'file-missing';
}
return this.withPending(
item.id,
() =>
type === 'play'
? this.recordings.playFile(filePath)
: this.recordings.revealFile(filePath),
async (error) => {
if (error === 'File not found') {
// Refresh so the row moves to Needs attention honestly.
await this.recordings.loadRecordings();
}
this.showMessage(
error === 'File not found'
? 'DOWNLOADS.FILE_NOT_FOUND'
: 'DOWNLOADS.FILE_ACTION_ERROR',
3000
);
},
(error) => (error === 'File not found' ? 'file-missing' : 'failed')
);
}
private async withPending(
itemId: number,
operation: () => Promise<RecordingOperationResult>,
onFailure: (error?: string) => void | Promise<void> = (error) =>
this.showActionError(error),
failureResult: (error?: string) => RecordingActionResult = () =>
'failed'
): Promise<RecordingActionResult> {
if (this.pendingIds().has(itemId)) {
return 'ignored';
}
this.pendingIds.update((ids) => new Set(ids).add(itemId));
try {
let failure: string | undefined;
let failed = false;
try {
const result = await operation();
failed = !result.success;
failure = result.error;
} catch (error) {
failed = true;
failure =
error instanceof Error ? error.message : String(error);
}
if (failed) {
try {
await onFailure(failure);
} catch (error) {
this.showActionError(
error instanceof Error ? error.message : String(error)
);
}
return failureResult(failure);
}
return 'success';
} finally {
this.pendingIds.update((ids) => {
const next = new Set(ids);
next.delete(itemId);
return next;
});
}
}
}
@@ -0,0 +1,179 @@
import type { RecordingItem } from '@iptvnator/services';
import {
buildRecordingManagerViewModel,
recordingDurationLabel,
recordingDurationSeconds,
} from './recording-manager.viewmodel';
let nextId = 1;
function recording(overrides: Partial<RecordingItem> = {}): RecordingItem {
return {
id: nextId++,
status: 'completed',
filePath: `/rec/file-${nextId}.ts`,
channelName: 'Channel One',
startedAt: '2026-08-15T21:00:00Z',
endedAt: '2026-08-15T21:58:00Z',
fileAvailability: 'available',
...overrides,
};
}
describe('recordingDurationSeconds', () => {
it('computes whole seconds from the recorded interval', () => {
expect(recordingDurationSeconds(recording())).toBe(58 * 60);
});
it('returns null while recording or for broken timestamps', () => {
expect(
recordingDurationSeconds(recording({ endedAt: undefined }))
).toBeNull();
expect(
recordingDurationSeconds(recording({ endedAt: 'garbage' }))
).toBeNull();
expect(
recordingDurationSeconds(
recording({ endedAt: '2026-08-15T20:00:00Z' })
)
).toBeNull();
});
});
describe('buildRecordingManagerViewModel', () => {
beforeEach(() => {
nextId = 1;
});
it('partitions active, attention, and library rows', () => {
const model = buildRecordingManagerViewModel({
recordings: [
recording({ status: 'recording', endedAt: undefined }),
recording({ status: 'completed' }),
recording({ status: 'interrupted' }),
recording({ status: 'failed' }),
recording({ fileAvailability: 'missing' }),
],
filter: 'all',
});
expect(model.active).toHaveLength(1);
expect(model.library.map(({ item }) => item.status)).toEqual(
expect.arrayContaining(['completed', 'interrupted'])
);
expect(model.library).toHaveLength(2);
expect(
model.attention.map(({ attentionReason }) => attentionReason)
).toEqual(expect.arrayContaining(['failed', 'file-missing']));
expect(model.count).toBe(5);
});
it('marks interrupted rows and keeps them playable', () => {
const model = buildRecordingManagerViewModel({
recordings: [recording({ status: 'interrupted' })],
filter: 'recording',
});
expect(model.library[0].interrupted).toBe(true);
});
it('keeps active recordings under the in-progress filter', () => {
// The chip counts them, so the page must list them.
const model = buildRecordingManagerViewModel({
recordings: [
recording({ status: 'recording', endedAt: undefined }),
recording({ status: 'completed' }),
recording({ status: 'failed' }),
],
filter: 'in-progress',
});
expect(model.active).toHaveLength(1);
expect(model.library).toEqual([]);
expect(model.attention).toEqual([]);
});
it('hides recordings under download-type filters but keeps the count', () => {
for (const filter of ['movie', 'series']) {
const model = buildRecordingManagerViewModel({
recordings: [recording()],
filter,
});
expect(model.active).toEqual([]);
expect(model.attention).toEqual([]);
expect(model.library).toEqual([]);
expect(model.count).toBe(1);
}
});
it('shows recordings under the all and recording filters', () => {
for (const filter of ['all', 'recording', undefined]) {
const model = buildRecordingManagerViewModel({
recordings: [recording()],
filter,
});
expect(model.library).toHaveLength(1);
}
});
it('scopes to the playlist, excluding rows without a playlist id', () => {
const model = buildRecordingManagerViewModel({
recordings: [
recording({ playlistId: 'playlist-a' }),
recording({ playlistId: 'playlist-b' }),
recording({ playlistId: undefined }),
],
scopePlaylistId: 'playlist-a',
filter: 'all',
});
expect(model.count).toBe(1);
expect(model.library).toHaveLength(1);
expect(model.library[0].item.playlistId).toBe('playlist-a');
});
it('matches search against program, channel, playlist, and error text', () => {
const rows = [
recording({ programTitle: 'Evening News' }),
recording({ channelName: 'Discovery' }),
recording({ playlistName: 'My provider' }),
recording({ status: 'failed', errorMessage: 'disk full' }),
];
const search = (term: string) =>
buildRecordingManagerViewModel({
recordings: rows,
filter: 'all',
searchTerm: term,
});
expect(search('evening').library).toHaveLength(1);
expect(search('discovery').library).toHaveLength(1);
expect(search('provider').library).toHaveLength(1);
expect(search('disk').attention).toHaveLength(1);
expect(search('nothing-matches').library).toHaveLength(0);
});
it('sorts newest first by startedAt with id tiebreak', () => {
const model = buildRecordingManagerViewModel({
recordings: [
recording({ startedAt: '2026-08-13T10:00:00Z' }),
recording({ startedAt: '2026-08-15T10:00:00Z' }),
recording({ startedAt: '2026-08-14T10:00:00Z' }),
],
filter: 'all',
});
expect(
model.library.map(({ item }) => item.startedAt)
).toEqual([
'2026-08-15T10:00:00Z',
'2026-08-14T10:00:00Z',
'2026-08-13T10:00:00Z',
]);
});
it('rounds duration minutes on the total before splitting hours', () => {
// 59:45 must read "1 h", never "60 min" — and 1:59:45 must read
// "2 h", never "1 h 60 min".
expect(recordingDurationLabel(59 * 60 + 45)).toBe('1 h');
expect(recordingDurationLabel(3600 + 59 * 60 + 45)).toBe('2 h');
expect(recordingDurationLabel(3600 + 5 * 60)).toBe('1 h 5 min');
expect(recordingDurationLabel(45)).toBe('1 min');
expect(recordingDurationLabel(0)).toBe('');
expect(recordingDurationLabel(null)).toBe('');
});
});
@@ -0,0 +1,169 @@
import type { RecordingItem } from '@iptvnator/services';
import { normalizeDownloadFilter } from './download-manager.viewmodel';
export type RecordingAttentionReason = 'file-missing' | 'failed';
export interface RecordingRowViewModel {
readonly item: RecordingItem;
/** Program title when EPG knew one; empty otherwise (template falls back
* to channel + start time, which needs the locale-aware date pipe). */
readonly programTitle: string;
readonly channelName: string;
readonly attentionReason: RecordingAttentionReason | null;
/** endedAt − startedAt in whole seconds; null while recording/unknown. */
readonly durationSeconds: number | null;
readonly interrupted: boolean;
}
export interface RecordingManagerViewModel {
readonly active: readonly RecordingRowViewModel[];
readonly attention: readonly RecordingRowViewModel[];
readonly library: readonly RecordingRowViewModel[];
/** Chip badge: every scoped recording regardless of the active filter. */
readonly count: number;
}
export interface BuildRecordingManagerViewModelInput {
readonly recordings: readonly RecordingItem[];
readonly scopePlaylistId?: string;
readonly filter: string | null | undefined;
readonly searchTerm?: string;
}
export function recordingDurationSeconds(
item: RecordingItem
): number | null {
if (!item.endedAt) {
return null;
}
const started = Date.parse(item.startedAt);
const ended = Date.parse(item.endedAt);
if (
!Number.isFinite(started) ||
!Number.isFinite(ended) ||
ended <= started
) {
return null;
}
return Math.floor((ended - started) / 1000);
}
/**
* Shared "1 h 5 min" formatter for recording durations. Minutes are rounded
* on the TOTAL before splitting into hours, so a 59:45 recording reads
* "1 h", never "60 min" (or "1 h 60 min" an hour later).
*/
export function recordingDurationLabel(
totalSeconds: number | null
): string {
if (totalSeconds === null || totalSeconds <= 0) {
return '';
}
const totalMinutes = Math.round(totalSeconds / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) {
return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`;
}
return `${Math.max(1, minutes)} min`;
}
function isPlayableStatus(item: RecordingItem): boolean {
return item.status === 'completed' || item.status === 'interrupted';
}
function attentionReason(
item: RecordingItem
): RecordingAttentionReason | null {
if (item.status === 'failed') {
return 'failed';
}
if (isPlayableStatus(item) && item.fileAvailability === 'missing') {
return 'file-missing';
}
return null;
}
function toRow(item: RecordingItem): RecordingRowViewModel {
return {
item,
programTitle: item.programTitle?.trim() ?? '',
channelName: item.channelName,
attentionReason: attentionReason(item),
durationSeconds: recordingDurationSeconds(item),
interrupted: item.status === 'interrupted',
};
}
function matchesSearch(row: RecordingRowViewModel, searchTerm: string): boolean {
const query = searchTerm.trim().toLowerCase();
return (
query.length === 0 ||
[
row.programTitle,
row.channelName,
row.item.playlistName ?? '',
row.item.errorMessage ?? '',
].some((value) => value.toLowerCase().includes(query))
);
}
function compareNewestFirst(
left: RecordingRowViewModel,
right: RecordingRowViewModel
): number {
return (
Date.parse(right.item.startedAt) - Date.parse(left.item.startedAt) ||
right.item.id - left.item.id
);
}
/**
* Sibling of buildDownloadManagerViewModel for live-TV recordings.
*
* The 'movie'/'series' chips hide recordings symmetrically to how the
* 'recording' chip hides downloads. 'in-progress' is about what is running
* right now, so it keeps the active recordings (and only those) — its chip
* counts them, and a chip whose count does not match its page is a lie.
*/
export function buildRecordingManagerViewModel({
recordings,
scopePlaylistId,
filter,
searchTerm = '',
}: BuildRecordingManagerViewModelInput): RecordingManagerViewModel {
const scoped = recordings.filter(
(item) =>
scopePlaylistId === undefined || item.playlistId === scopePlaylistId
);
const activeFilter = normalizeDownloadFilter(filter);
const showsRecordings =
activeFilter === 'all' ||
activeFilter === 'recording' ||
activeFilter === 'in-progress';
const visible = showsRecordings
? scoped.map(toRow).filter((row) => matchesSearch(row, searchTerm))
: [];
const activeOnly = activeFilter === 'in-progress';
return {
active: visible
.filter(({ item }) => item.status === 'recording')
.sort(compareNewestFirst),
attention: activeOnly
? []
: visible
.filter((row) => row.attentionReason !== null)
.sort(compareNewestFirst),
library: activeOnly
? []
: visible
.filter(
(row) =>
row.attentionReason === null &&
isPlayableStatus(row.item)
)
.sort(compareNewestFirst),
count: scoped.length,
};
}
@@ -0,0 +1,216 @@
@for (section of sections(); track section.testId) {
<section
class="recording-queue"
[attr.data-test-id]="section.testId"
[attr.aria-labelledby]="section.headingId"
>
<h2 class="recording-queue__heading" [id]="section.headingId">
<span class="recording-queue__heading-label">
{{ section.headingKey | translate }}
</span>
<span class="app-count-badge" aria-hidden="true">
{{ section.items.length }}
</span>
</h2>
<div class="recording-queue__list" role="list">
@for (row of section.items; track row.item.id) {
<article
class="recording-queue__item"
[class.recording-queue__item--pending]="
isPending(row.item.id)
"
[class.recording-queue__item--attention]="
row.attentionReason !== null
"
[attr.aria-busy]="isPending(row.item.id) ? 'true' : null"
[attr.data-test-id]="'recording-queue-item-' + row.item.id"
role="listitem"
>
<button
type="button"
class="recording-queue__artwork"
data-test-control="artwork"
[attr.aria-label]="
'DOWNLOADS.ARIA.OPEN'
| translate
: {
title:
row.programTitle ||
row.channelName,
}
"
[disabled]="isPending(row.item.id)"
(click)="requestOpen(row.item)"
>
@if (row.item.channelLogoUrl) {
<img
[src]="row.item.channelLogoUrl"
alt=""
loading="lazy"
decoding="async"
/>
} @else {
<span
class="recording-queue__logo-placeholder"
aria-hidden="true"
>
<mat-icon>live_tv</mat-icon>
</span>
}
</button>
<div class="recording-queue__content">
<div class="recording-queue__title-line">
@if (row.item.status === 'recording') {
<span
class="recording-queue__rec-chip"
role="status"
aria-live="off"
>
<span
class="recording-queue__rec-dot"
aria-hidden="true"
></span>
REC&nbsp;{{ elapsedLabel(row.item) }}
</span>
}
<button
type="button"
class="recording-queue__title"
data-test-control="title"
[disabled]="isPending(row.item.id)"
(click)="requestOpen(row.item)"
>
{{
row.programTitle ||
row.channelName +
' — ' +
(row.item.startedAt
| date: 'd MMM, HH:mm')
}}
</button>
</div>
<div class="recording-queue__metadata">
@if (row.attentionReason !== null) {
<span class="recording-queue__warn-chip">
<mat-icon aria-hidden="true">
{{
row.attentionReason ===
'file-missing'
? 'file_off'
: 'error'
}}
</mat-icon>
{{ attentionStatusKey(row) | translate }}
</span>
}
<span class="recording-queue__channel">
{{ row.channelName }}
</span>
@if (row.item.status === 'recording') {
<span class="recording-queue__bytes">
{{ formatBytes(row.item.fileSizeBytes) }}
</span>
} @else if (row.item.fileSizeBytes) {
<span class="recording-queue__bytes">
{{ formatBytes(row.item.fileSizeBytes) }}
</span>
}
@if (row.item.errorMessage?.trim(); as error) {
<span class="recording-queue__error">
{{ error }}
</span>
}
</div>
@if (row.item.status === 'recording') {
<div
class="recording-queue__progress"
aria-hidden="true"
></div>
}
</div>
<div class="recording-queue__actions">
@if (row.item.status === 'recording') {
<button
type="button"
mat-icon-button
class="recording-queue__stop"
data-test-action="stop-recording"
[matTooltip]="
'DOWNLOADS.STOP_RECORDING' | translate
"
[attr.aria-label]="
'DOWNLOADS.STOP_RECORDING' | translate
"
[disabled]="isPending(row.item.id)"
(click)="emitAction('stop', row.item)"
>
<mat-icon>stop</mat-icon>
</button>
} @else {
<button
type="button"
mat-stroked-button
data-test-action="remove-recording"
[disabled]="isPending(row.item.id)"
(click)="emitAction('remove', row.item)"
>
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
</button>
}
<button
type="button"
mat-icon-button
class="recording-queue__more"
data-test-action="more"
[matMenuTriggerFor]="rowMenu"
[attr.aria-label]="
'DOWNLOADS.MORE_ACTIONS' | translate
"
[disabled]="isPending(row.item.id)"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #rowMenu="matMenu" xPosition="before">
<app-download-source-menu-header
[sourceName]="row.item.playlistName ?? ''"
/>
@if (row.attentionReason === null) {
<button
mat-menu-item
data-test-action="reveal-recording"
(click)="emitAction('reveal', row.item)"
>
<mat-icon>folder_open</mat-icon>
<span>
{{ 'DOWNLOADS.REVEAL' | translate }}
</span>
</button>
}
@if (row.item.status !== 'recording') {
<button
mat-menu-item
data-test-action="menu-remove-recording"
(click)="emitAction('remove', row.item)"
>
<mat-icon>remove_circle_outline</mat-icon>
<span>
{{
'DOWNLOADS.REMOVE_FROM_MANAGER'
| translate
}}
</span>
</button>
}
</mat-menu>
</div>
</article>
}
</div>
</section>
}
@@ -0,0 +1,279 @@
:host {
display: block;
container-type: inline-size;
}
.recording-queue {
& + & {
margin-top: 20px;
}
// Mirrors .download-queue__heading (dashboard rail title contract).
&__heading {
display: inline-flex;
align-items: baseline;
gap: 8px;
margin: 0 0 10px;
padding: 0 2px;
color: var(--app-heading-color, var(--mat-sys-on-surface));
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.012em;
line-height: 1.2;
}
&__list {
display: grid;
overflow: hidden;
border: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 78%,
transparent
);
border-radius: 14px;
background: var(--mat-sys-surface-container-low);
}
&__item {
display: grid;
grid-template-columns: 86px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-width: 0;
padding: 12px 14px;
& + & {
border-top: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 64%,
transparent
);
}
&--pending {
cursor: progress;
.recording-queue__artwork,
.recording-queue__actions {
opacity: 0.68;
}
}
&--attention {
background: color-mix(
in srgb,
var(--mat-sys-tertiary-container) 18%,
var(--mat-sys-surface-container-low)
);
.recording-queue__artwork {
filter: saturate(0.42);
}
}
}
// 16:9 channel-logo tile — recordings carry a channel logo, not a poster.
&__artwork {
position: relative;
width: 86px;
height: 52px;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 9px;
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface-variant);
cursor: pointer;
transition:
transform 140ms ease,
box-shadow 140ms ease;
img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
box-sizing: border-box;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--mat-sys-level1);
}
}
&__logo-placeholder {
display: grid;
width: 100%;
height: 100%;
place-items: center;
background: color-mix(
in srgb,
var(--mat-sys-primary-container) 44%,
var(--mat-sys-surface-container-highest)
);
color: var(--mat-sys-on-primary-container);
}
&__content {
display: grid;
gap: 4px;
min-width: 0;
}
&__title-line {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
&__title {
overflow: hidden;
min-width: 0;
padding: 0;
border: 0;
background: none;
color: var(--mat-sys-on-surface);
font: inherit;
font-weight: 600;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover:not(:disabled) {
text-decoration: underline;
}
}
&__rec-chip {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--mat-sys-error) 16%,
transparent
);
color: var(--mat-sys-error);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: 0.04em;
}
&__rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mat-sys-error);
animation: recording-queue-pulse 1.4s ease-in-out infinite;
}
&__warn-chip {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--mat-sys-on-tertiary-container);
font-size: 0.8rem;
font-weight: 600;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}
&__metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
}
&__bytes {
font-variant-numeric: tabular-nums;
}
&__error {
overflow: hidden;
max-width: 100%;
color: var(--mat-sys-error);
text-overflow: ellipsis;
white-space: nowrap;
}
// Indeterminate strip: the recording length is unknown by nature, so a
// percentage can never exist — a sliding pulse communicates "running".
&__progress {
position: relative;
overflow: hidden;
height: 3px;
margin-top: 4px;
border-radius: 2px;
background: color-mix(in srgb, var(--mat-sys-error) 18%, transparent);
&::after {
content: '';
position: absolute;
inset: 0;
width: 32%;
border-radius: 2px;
background: var(--mat-sys-error);
animation: recording-queue-slide 1.6s ease-in-out infinite;
}
}
&__actions {
display: flex;
align-items: center;
gap: 6px;
}
&__stop {
background: color-mix(in srgb, var(--mat-sys-error) 14%, transparent);
color: var(--mat-sys-error);
}
}
@keyframes recording-queue-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.8);
}
}
@keyframes recording-queue-slide {
0% {
left: -32%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.recording-queue {
&__rec-dot,
&__progress::after {
animation: none;
}
}
}
@@ -0,0 +1,308 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import type { RecordingItem } from '@iptvnator/services';
import type { RecordingItemAction } from './recording-actions';
import type { RecordingRowViewModel } from './recording-manager.viewmodel';
import { RecordingQueueComponent } from './recording-queue.component';
const TEST_TRANSLATIONS = {
DOWNLOADS: {
RECORDING_NOW: 'Recording now',
NEEDS_ATTENTION: 'Needs attention',
STOP_RECORDING: 'Stop recording',
REMOVE_FROM_MANAGER: 'Remove from manager',
REVEAL: 'Show in folder',
MORE_ACTIONS: 'More actions',
STATUS: {
FILE_MISSING: 'File missing',
FAILED: 'Failed',
},
ARIA: {
OPEN: 'Open {{title}}',
},
},
PORTALS: {
MULTI_SOURCE: {
SOURCE: 'Source',
},
},
};
function createItem(
id: number,
status: RecordingItem['status'],
overrides: Partial<RecordingItem> = {}
): RecordingItem {
return {
id,
status,
filePath: `/recordings/${id}.ts`,
channelName: `Channel ${id}`,
startedAt: '2026-08-15T12:00:00Z',
fileAvailability: 'available',
...overrides,
};
}
function createRow(
item: RecordingItem,
overrides: Partial<RecordingRowViewModel> = {}
): RecordingRowViewModel {
return {
item,
programTitle: item.programTitle?.trim() ?? '',
channelName: item.channelName,
attentionReason: null,
durationSeconds: null,
interrupted: false,
...overrides,
};
}
describe('RecordingQueueComponent', () => {
let fixture: ComponentFixture<RecordingQueueComponent>;
let overlayContainer: OverlayContainer;
function render(
activeItems: readonly RecordingRowViewModel[] = [],
attentionItems: readonly RecordingRowViewModel[] = [],
pendingIds: ReadonlySet<number> = new Set()
): void {
fixture.componentRef.setInput('activeItems', activeItems);
fixture.componentRef.setInput('attentionItems', attentionItems);
fixture.componentRef.setInput('pendingIds', pendingIds);
fixture.detectChanges();
}
function row(id: number): HTMLElement {
const element = fixture.nativeElement.querySelector(
`[data-test-id="recording-queue-item-${id}"]`
);
expect(element).not.toBeNull();
return element;
}
function actionButton(host: ParentNode, action: string): HTMLButtonElement {
const button = host.querySelector<HTMLButtonElement>(
`[data-test-action="${action}"]`
);
expect(button).not.toBeNull();
return button as HTMLButtonElement;
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RecordingQueueComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', TEST_TRANSLATIONS);
translate.use('en');
overlayContainer = TestBed.inject(OverlayContainer);
fixture = TestBed.createComponent(RecordingQueueComponent);
});
afterEach(() => {
overlayContainer.getContainerElement().replaceChildren();
jest.useRealTimers();
});
it('renders no sections for empty inputs', () => {
render([], []);
expect(fixture.nativeElement.querySelector('section')).toBeNull();
});
it('renders an active recording with REC elapsed chip and stop as the only primary action', () => {
jest.useFakeTimers().setSystemTime(
new Date('2026-08-15T12:01:30Z')
);
const item = createItem(1, 'recording', {
programTitle: 'Evening news',
fileSizeBytes: 1.5 * 1024 ** 2,
});
const emitted: RecordingItemAction[] = [];
fixture.componentInstance.itemAction.subscribe((action) =>
emitted.push(action)
);
render([createRow(item)], []);
const section: HTMLElement = fixture.nativeElement.querySelector(
'[data-test-id="recordings-active-section"]'
);
expect(section).not.toBeNull();
expect(
section
.querySelector('.recording-queue__heading-label')
?.textContent?.trim()
).toBe('Recording now');
expect(
section.querySelector('.app-count-badge')?.textContent?.trim()
).toBe('1');
expect(
fixture.nativeElement.querySelector(
'[data-test-id="recordings-attention-section"]'
)
).toBeNull();
const host = row(1);
const recChip = host.querySelector<HTMLElement>(
'.recording-queue__rec-chip'
);
expect(recChip?.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'REC 01:30'
);
expect(
host.querySelector('.recording-queue__progress')
).not.toBeNull();
expect(
host.querySelector('.recording-queue__bytes')?.textContent
).toContain('1.5 MB');
expect(
host.querySelector('[data-test-action="remove-recording"]')
).toBeNull();
jest.advanceTimersByTime(1000);
fixture.detectChanges();
expect(
host
.querySelector('.recording-queue__rec-chip')
?.textContent?.replace(/\s+/g, ' ')
.trim()
).toBe('REC 01:31');
actionButton(host, 'stop-recording').click();
expect(emitted).toEqual([{ type: 'stop', item }]);
});
it('renders a missing-file recording as attention with remove and no reveal', async () => {
const item = createItem(2, 'completed', {
programTitle: 'Vanished show',
fileAvailability: 'missing',
playlistName: 'Alpha Source',
});
const viewModel = createRow(item, { attentionReason: 'file-missing' });
const emitted: RecordingItemAction[] = [];
fixture.componentInstance.itemAction.subscribe((action) =>
emitted.push(action)
);
render([], [viewModel]);
const section: HTMLElement = fixture.nativeElement.querySelector(
'[data-test-id="recordings-attention-section"]'
);
expect(section).not.toBeNull();
expect(
section
.querySelector('.recording-queue__heading-label')
?.textContent?.trim()
).toBe('Needs attention');
expect(
fixture.nativeElement.querySelector(
'[data-test-id="recordings-active-section"]'
)
).toBeNull();
const host = row(2);
const warnChip = host.querySelector<HTMLElement>(
'.recording-queue__warn-chip'
);
expect(warnChip?.textContent).toContain('File missing');
expect(warnChip?.querySelector('mat-icon')?.textContent?.trim()).toBe(
'file_off'
);
expect(host.querySelector('.recording-queue__progress')).toBeNull();
expect(
host.querySelector('[data-test-action="stop-recording"]')
).toBeNull();
actionButton(host, 'remove-recording').click();
expect(emitted).toEqual([{ type: 'remove', item }]);
actionButton(host, 'more').click();
fixture.detectChanges();
await fixture.whenStable();
const menu = overlayContainer.getContainerElement();
expect(
menu.querySelector('[data-test-action="reveal-recording"]')
).toBeNull();
actionButton(menu, 'menu-remove-recording').click();
expect(emitted).toEqual([
{ type: 'remove', item },
{ type: 'remove', item },
]);
});
it('shows the failed status chip for failed recordings', () => {
const item = createItem(3, 'failed', {
errorMessage: 'stream dropped',
});
render([], [createRow(item, { attentionReason: 'failed' })]);
const host = row(3);
const warnChip = host.querySelector<HTMLElement>(
'.recording-queue__warn-chip'
);
expect(warnChip?.textContent).toContain('Failed');
expect(warnChip?.querySelector('mat-icon')?.textContent?.trim()).toBe(
'error'
);
expect(
host.querySelector('.recording-queue__error')?.textContent?.trim()
).toBe('stream dropped');
});
it('falls back to channel name and start time for an untitled recording', () => {
const item = createItem(4, 'completed');
const opened: RecordingItem[] = [];
fixture.componentInstance.openRequested.subscribe((openedItem) =>
opened.push(openedItem)
);
render([], [createRow(item, { attentionReason: 'failed' })]);
const title = row(4).querySelector<HTMLButtonElement>(
'[data-test-control="title"]'
);
const text = title?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
expect(text.startsWith('Channel 4 —')).toBe(true);
expect(text).toContain('Aug');
expect(
row(4)
.querySelector('[data-test-control="artwork"]')
?.getAttribute('aria-label')
).toBe('Open Channel 4');
title?.click();
expect(opened).toEqual([item]);
expect(opened[0]).toBe(item);
});
it('disables every control and suppresses emissions while pending', () => {
const item = createItem(6, 'recording');
const emitted = jest.fn();
const opened = jest.fn();
fixture.componentInstance.itemAction.subscribe(emitted);
fixture.componentInstance.openRequested.subscribe(opened);
render([createRow(item)], [], new Set([6]));
const host = row(6);
expect(host.getAttribute('aria-busy')).toBe('true');
const buttons = Array.from(
host.querySelectorAll<HTMLButtonElement>('button')
);
expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every(({ disabled }) => disabled)).toBe(true);
fixture.componentInstance.emitAction('stop', item);
fixture.componentInstance.requestOpen(item);
expect(emitted).not.toHaveBeenCalled();
expect(opened).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,138 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import type { RecordingItem } from '@iptvnator/services';
import { formatDownloadBytes } from './download-queue.component';
import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component';
import type {
RecordingItemAction,
RecordingItemActionType,
} from './recording-actions';
import type { RecordingRowViewModel } from './recording-manager.viewmodel';
interface RecordingQueueSection {
readonly headingId: string;
readonly headingKey: string;
readonly items: readonly RecordingRowViewModel[];
readonly testId: string;
}
/**
* Queue-area sections for live-TV recordings: "Recording now" (pulsing REC,
* elapsed time, live file size — never a percentage, the length is unknown)
* and a recordings-only "Needs attention" list (no re-download: a broadcast
* cannot be re-recorded, so Remove is the only affordance).
*/
@Component({
selector: 'app-recording-queue',
imports: [
DatePipe,
MatButtonModule,
MatIcon,
MatMenuModule,
MatTooltip,
TranslatePipe,
DownloadSourceMenuHeaderComponent,
],
templateUrl: './recording-queue.component.html',
styleUrl: './recording-queue.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecordingQueueComponent {
readonly activeItems = input.required<readonly RecordingRowViewModel[]>();
readonly attentionItems =
input.required<readonly RecordingRowViewModel[]>();
readonly pendingIds = input<ReadonlySet<number>>(new Set());
readonly itemAction = output<RecordingItemAction>();
readonly openRequested = output<RecordingItem>();
private readonly elapsedTick = signal(Date.now());
readonly sections = computed<readonly RecordingQueueSection[]>(() => {
const sections: RecordingQueueSection[] = [
{
headingId: 'recordings-active-heading',
headingKey: 'DOWNLOADS.RECORDING_NOW',
items: this.activeItems(),
testId: 'recordings-active-section',
},
{
headingId: 'recordings-attention-heading',
headingKey: 'DOWNLOADS.NEEDS_ATTENTION',
items: this.attentionItems(),
testId: 'recordings-attention-section',
},
];
return sections.filter(({ items }) => items.length > 0);
});
constructor() {
effect((onCleanup) => {
if (this.activeItems().length === 0) {
return;
}
untracked(() => this.elapsedTick.set(Date.now()));
const intervalId = window.setInterval(
() => this.elapsedTick.set(Date.now()),
1000
);
onCleanup(() => window.clearInterval(intervalId));
});
}
emitAction(type: RecordingItemActionType, item: RecordingItem): void {
if (!this.isPending(item.id)) {
this.itemAction.emit({ type, item });
}
}
requestOpen(item: RecordingItem): void {
if (!this.isPending(item.id)) {
this.openRequested.emit(item);
}
}
isPending(id: number): boolean {
return this.pendingIds().has(id);
}
/** Elapsed REC time from started_at against wall clock — no accumulator. */
elapsedLabel(item: RecordingItem): string {
this.elapsedTick();
const startedMs = Date.parse(item.startedAt);
if (!Number.isFinite(startedMs)) {
return '00:00';
}
const total = Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const mmss = `${String(minutes).padStart(2, '0')}:${String(
seconds
).padStart(2, '0')}`;
return hours > 0 ? `${hours}:${mmss}` : mmss;
}
attentionStatusKey(row: RecordingRowViewModel): string {
return row.attentionReason === 'file-missing'
? 'DOWNLOADS.STATUS.FILE_MISSING'
: 'DOWNLOADS.STATUS.FAILED';
}
formatBytes(bytes: number | undefined): string {
return formatDownloadBytes(bytes);
}
}
@@ -64,9 +64,11 @@
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="inlinePlayback()"
[playerOverride]="player()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(recordingStopped)="onRecordingStopped($event)"
/>
}
</div>
@@ -1,11 +1,4 @@
import {
ChangeDetectionStrategy,
Component,
Directive,
input,
output,
signal,
} from '@angular/core';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
@@ -13,7 +6,6 @@ import { GlobalFavoritesListComponent } from '../global-favorites-list/global-fa
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import {
AudioPlayerComponent,
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import {
@@ -29,107 +21,24 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
PORTAL_PLAYER,
FavoritesChannelSortMode,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util';
@Directive({
selector: '[appResizable]',
})
class StubResizableDirective {
readonly minWidth = input<number>();
readonly maxWidth = input<number>();
readonly defaultWidth = input<number>();
readonly storageKey = input<string>('');
}
@Component({
selector: 'app-global-favorites-list',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubGlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly showEpg = input(true);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input(0);
readonly activeUid = input<string | null>(null);
readonly searchTermInput = input('');
readonly draggable = input(true);
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline, app-epg-list-view',
template: '<div class="stub-epg-timeline"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly summary = input<any>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
}
@Component({
selector: 'app-audio-player',
template: '<div class="stub-audio-player"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubAudioPlayerComponent {
readonly icon = input('');
readonly url = input.required<string>();
readonly channelName = input('');
}
@Component({
selector: 'app-web-player-view',
template: '<div class="stub-web-player-view"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
import {
StubAudioPlayerComponent,
StubEpgTimelineComponent,
StubGlobalFavoritesListComponent,
StubResizableDirective,
StubWebPlayerViewComponent,
} from './unified-live-tab.spec-stubs';
describe('UnifiedLiveTabComponent', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
@@ -62,8 +62,21 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
EpgProgram,
filterRecordingProgramsOverlap,
playlistDisplayLabel,
RecordingStartMetadata,
RecordingStoppedEvent,
toRecordingProgramSnapshot,
} from '@iptvnator/shared/interfaces';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
@Component({
@@ -274,6 +287,96 @@ export class UnifiedLiveTabComponent {
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
);
private readonly recordingsService = inject(RecordingsService);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = computed<RecordingStartMetadata | null>(() => {
const item = this.activeItem();
if (!item) {
return null;
}
// Track the 30 s tick: without it this computed caches its
// Date.now() verdict, and a recording started after an EPG boundary
// would snapshot the previous show.
this.progressTick();
const now = Date.now();
const program =
this.timelinePrograms().find((candidate) => {
const start = Date.parse(candidate.start);
const stop = Date.parse(candidate.stop);
return (
Number.isFinite(start) &&
Number.isFinite(stop) &&
start <= now &&
now < stop
);
}) ?? null;
return {
channelName: item.name?.trim() || 'Live TV',
channelLogoUrl:
this.timelineChannelLogo() || item.logo || undefined,
playlistId: item.playlistId,
playlistName: playlistDisplayLabel(item.playlistName) || undefined,
sourceType: item.sourceType,
epgChannelId: this.recordingEpgChannelId(item),
// The EPG key is not unique for M3U items (shared tvgId, or the
// display-name fallback); the uid names the exact selection.
sourceItemKey: item.uid,
currentProgram: program
? toRecordingProgramSnapshot(program)
: undefined,
};
});
/** Stop enrichment: programs overlapping the recorded window. */
onRecordingStopped(event: RecordingStoppedEvent): void {
// A channel switch auto-stops the recording, and by now this host
// already describes the new channel — enriching then would attach the
// wrong schedule (and could promote an unrelated program to the
// recording's title).
if (
event.epgChannelId &&
event.epgChannelId !== this.recordingMetadata()?.epgChannelId
) {
return;
}
// The EPG key alone cannot tell two same-keyed M3U items apart —
// the uid must also match the exact recorded selection.
if (
event.sourceItemKey &&
event.sourceItemKey !== this.recordingMetadata()?.sourceItemKey
) {
return;
}
const programs = filterRecordingProgramsOverlap(
this.timelinePrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt
);
if (programs.length === 0) {
return;
}
void this.recordingsService.updatePrograms(event.targetPath, programs);
}
private recordingEpgChannelId(
item: UnifiedCollectionItem
): string | undefined {
switch (item.sourceType) {
case 'm3u':
return item.tvgId?.trim() || item.name?.trim() || undefined;
case 'xtream':
return item.xtreamId !== undefined
? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId)
: undefined;
case 'stalker':
return item.stalkerId !== undefined
? buildStalkerEpgMappingKey(
item.playlistId,
String(item.stalkerId)
)
: undefined;
}
}
/**
* The channel list in exactly the order the sidebar renders it
@@ -0,0 +1,115 @@
import {
ChangeDetectionStrategy,
Component,
Directive,
input,
output,
} from '@angular/core';
import { type PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { EpgProgramActivationEvent } from '@iptvnator/ui/epg';
import {
EpgProgram,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
/**
* Stand-ins for the components UnifiedLiveTabComponent renders. They mirror
* the real inputs/outputs so a spec can assert what the host hands each child
* without pulling in the players, EPG timeline or the Electron bridge.
*/
@Directive({
selector: '[appResizable]',
})
export class StubResizableDirective {
readonly minWidth = input<number>();
readonly maxWidth = input<number>();
readonly defaultWidth = input<number>();
readonly storageKey = input<string>('');
}
@Component({
selector: 'app-global-favorites-list',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StubGlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly showEpg = input(true);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input(0);
readonly activeUid = input<string | null>(null);
readonly searchTermInput = input('');
readonly draggable = input(true);
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline, app-epg-list-view',
template: '<div class="stub-epg-timeline"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly summary = input<any>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
}
@Component({
selector: 'app-audio-player',
template: '<div class="stub-audio-player"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StubAudioPlayerComponent {
readonly icon = input('');
readonly url = input.required<string>();
readonly channelName = input('');
}
@Component({
selector: 'app-web-player-view',
template: '<div class="stub-web-player-view"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
@@ -53,7 +53,15 @@ export interface CollectionViewState {
export function getRecentItemNavigation(
item: PortalRecentItem,
seriesResume?: SeriesResumeTarget | null
seriesResume?: SeriesResumeTarget | null,
options?: {
/**
* Use `seriesResume` only to rewrite an episode-keyed recent row to
* its parent series identity, without carrying the auto-play resume
* handoff in the navigation state (detail-only clicks, issue #1441).
*/
resumeIdentityOnly?: boolean;
}
): WorkspaceNavigationTarget {
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
@@ -85,7 +93,9 @@ export function getRecentItemNavigation(
return buildGlobalCollectionDetailNavigationTarget(
'recent',
detailItem,
item.type === 'series' ? seriesResume : null
item.type === 'series' && !options?.resumeIdentityOnly
? seriesResume
: null
);
}
@@ -190,9 +190,11 @@
[streamUrl]="streamUrl()"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(recordingStopped)="onRecordingStopped($event)"
/>
}
</div>
@@ -1,11 +1,4 @@
import {
Component,
Directive,
EventEmitter,
input,
output,
signal,
} from '@angular/core';
import { EventEmitter, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -18,10 +11,7 @@ import {
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import {
AudioPlayerComponent,
type PlaybackFallbackRequest,
} from '@iptvnator/ui/playback';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
@@ -40,103 +30,14 @@ import { MatDialog } from '@angular/material/dialog';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
@Component({
selector: 'app-channel-list-item',
standalone: true,
template: '',
})
class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly showEpg = input(true);
readonly isRadio = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly showDetailsContextMenu = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly activated = output<void>();
readonly favoriteToggled = output<void>();
readonly contextMenuRequested = output<MouseEvent>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
@Component({
selector: 'app-audio-player',
standalone: true,
template: '',
})
class StubAudioPlayerComponent {
readonly url = input.required<string>();
readonly icon = input('');
readonly channelName = input('');
readonly dispatchAdjacentChannelAction = input(true);
readonly channelSwitchRequested = output<'next' | 'previous'>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
`,
})
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(false);
readonly loading = input(false);
readonly emptyReason = input<string>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<{ title?: string } | null>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
readonly programActivated = output<EpgProgram>();
readonly returnToLive = output<void>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
}
@Component({
selector: 'app-portal-empty-state',
standalone: true,
template: '',
})
class StubPortalEmptyStateComponent {
readonly icon = input('');
readonly message = input('');
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
class StubResizableDirective {}
import {
StubAudioPlayerComponent,
StubChannelListItemComponent,
StubEpgTimelineComponent,
StubPortalEmptyStateComponent,
StubResizableDirective,
StubWebPlayerViewComponent,
} from './stalker-live-stream-layout.spec-stubs';
describe('StalkerLiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
@@ -31,6 +31,7 @@ import {
} from '@iptvnator/ui/components';
import {
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
@@ -39,8 +40,13 @@ import {
Channel,
EpgItem,
EpgProgram,
filterRecordingProgramsOverlap,
playlistDisplayLabel,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
StalkerPortalItem,
toRecordingProgramSnapshot,
} from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
@@ -340,9 +346,77 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
fallbackPrograms
);
});
readonly currentProgram = computed(() =>
this.findCurrentProgram(this.activeEpgPrograms())
);
/**
* 30 s clock read by `currentProgram`: `findCurrentProgram` compares
* against Date.now(), which a computed would otherwise cache — a
* recording started after an EPG boundary (or the panel summary and
* external-player metadata reading this) would keep the previous show.
*/
private readonly epgClockTick = signal(0);
private epgClockTimer: ReturnType<typeof setInterval> | null = null;
readonly currentProgram = computed(() => {
this.epgClockTick();
return this.findCurrentProgram(this.activeEpgPrograms());
});
private readonly recordingsService = inject(RecordingsService);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = computed<RecordingStartMetadata | null>(() => {
const selectedItem = this.stalkerStore.selectedItem();
if (!selectedItem?.id) {
return null;
}
const playlist = this.stalkerStore.currentPlaylist();
const selectedType = this.stalkerStore.selectedContentType();
// ITV only: the bulk EPG cache is ITV-keyed and Ministra reuses small
// integer ids across itv/radio, so a radio recording gets no program.
const program =
selectedType === 'itv' ? (this.currentProgram() ?? null) : null;
return {
channelName:
selectedItem.o_name?.trim() ||
selectedItem.name?.trim() ||
'Live TV',
channelLogoUrl: this.activePlaybackArtwork() || undefined,
playlistId: playlist?._id,
playlistName: playlistDisplayLabel(playlist?.title) || undefined,
sourceType: 'stalker',
epgChannelId: playlist?._id
? buildStalkerEpgMappingKey(
playlist._id,
String(selectedItem.id)
)
: undefined,
currentProgram: program
? toRecordingProgramSnapshot(program)
: undefined,
};
});
/** Stop enrichment: programs overlapping the recorded window. */
onRecordingStopped(event: RecordingStoppedEvent): void {
if (this.stalkerStore.selectedContentType() !== 'itv') {
return;
}
// A channel switch auto-stops the recording, and by now this host
// already describes the new channel — enriching then would attach the
// wrong schedule (and could promote an unrelated program to the
// recording's title).
if (
event.epgChannelId &&
event.epgChannelId !== this.recordingMetadata()?.epgChannelId
) {
return;
}
const programs = filterRecordingProgramsOverlap(
this.activeEpgPrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt
);
if (programs.length === 0) {
return;
}
void this.recordingsService.updatePrograms(event.targetPath, programs);
}
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
@@ -431,6 +505,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private lastPlaylistId: string | null | undefined = undefined;
constructor() {
this.epgClockTimer = setInterval(
() => this.epgClockTick.update((tick) => tick + 1),
30_000
);
// Load favorites for current playlist
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (playlistId) {
@@ -644,6 +723,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
REMOTE_CONTROL_RESET_STATUS
);
this.removeScrollListener();
if (this.epgClockTimer !== null) {
clearInterval(this.epgClockTimer);
this.epgClockTimer = null;
}
if (this.epgPreviewRefreshTimer !== null) {
clearTimeout(this.epgPreviewRefreshTimer);
this.epgPreviewRefreshTimer = null;
@@ -0,0 +1,112 @@
import { Component, Directive, input, output } from '@angular/core';
import { type PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
EpgProgram,
RecordingStartMetadata,
RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
/**
* Stand-ins for the children StalkerLiveStreamLayoutComponent renders, mirroring
* the real inputs/outputs so specs can assert what the host hands each child
* without pulling in the players or the EPG timeline.
*/
@Component({
selector: 'app-channel-list-item',
standalone: true,
template: '',
})
export class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly showEpg = input(true);
readonly isRadio = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly showDetailsContextMenu = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly activated = output<void>();
readonly favoriteToggled = output<void>();
readonly contextMenuRequested = output<MouseEvent>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
export class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
@Component({
selector: 'app-audio-player',
standalone: true,
template: '',
})
export class StubAudioPlayerComponent {
readonly url = input.required<string>();
readonly icon = input('');
readonly channelName = input('');
readonly dispatchAdjacentChannelAction = input(true);
readonly channelSwitchRequested = output<'next' | 'previous'>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
`,
})
export class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(false);
readonly loading = input(false);
readonly emptyReason = input<string>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<{ title?: string } | null>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
readonly programActivated = output<EpgProgram>();
readonly returnToLive = output<void>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
}
@Component({
selector: 'app-portal-empty-state',
standalone: true,
template: '',
})
export class StubPortalEmptyStateComponent {
readonly icon = input('');
readonly message = input('');
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class StubResizableDirective {}
@@ -37,6 +37,35 @@ const initialEpgState: EpgState = {
* - Loading EPG for selected stream
* - Loading channel EPG for preview
*/
/**
* Pure current-program selection over `epgItems`: first item (by start) with
* `start <= now < stop`. Exported for clock-driven callers — the live
* layout's recording start snapshot re-evaluates it against its own 30 s
* tick, because the `currentEpgItem` computed below caches its `Date.now()`
* verdict until `epgItems` changes and would hand a recording started after
* an EPG boundary the previous show.
*/
export function findCurrentEpgItem(
items: readonly EpgItem[],
nowMs: number
): EpgItem | null {
const sorted = [...items].sort(
(left, right) =>
getEpgTimestampMs(left.start, left.start_timestamp) -
getEpgTimestampMs(right.start, right.start_timestamp)
);
return (
sorted.find((item) => {
const start = getEpgTimestampMs(item.start, item.start_timestamp);
const stop = getEpgTimestampMs(
item.stop ?? item.end,
item.stop_timestamp
);
return nowMs >= start && nowMs < stop;
}) ?? null
);
}
export function withEpg() {
const logger = createLogger('withEpg');
type ParentSelectionStoreLike = {
@@ -50,28 +79,9 @@ export function withEpg() {
return signalStoreFeature(
withState<EpgState>(initialEpgState),
withComputed((store) => ({
currentEpgItem: computed(() => {
const now = Date.now();
const items = [...store.epgItems()].sort(
(left, right) =>
getEpgTimestampMs(left.start, left.start_timestamp) -
getEpgTimestampMs(right.start, right.start_timestamp)
);
return (
items.find((item) => {
const start = getEpgTimestampMs(
item.start,
item.start_timestamp
);
const stop = getEpgTimestampMs(
item.stop ?? item.end,
item.stop_timestamp
);
return now >= start && now < stop;
}) ?? null
);
}),
currentEpgItem: computed(() =>
findCurrentEpgItem(store.epgItems(), Date.now())
),
})),
withMethods((store) => {
@@ -100,9 +100,11 @@
[streamUrl]="streamUrl"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(recordingStopped)="onRecordingStopped($event)"
/>
</div>
}
@@ -36,6 +36,8 @@ import {
import {
EpgItem,
EpgProgram,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
@@ -86,7 +88,9 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
@@ -45,6 +45,7 @@ import {
import {
FavoriteItem,
FavoritesService,
findCurrentEpgItem,
XtreamUrlService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
@@ -63,13 +64,23 @@ import {
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import {
buildXtreamEpgMappingKey,
EpgItem,
EpgProgram,
filterRecordingProgramsOverlap,
playlistDisplayLabel,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
toRecordingProgramSnapshot,
} from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@@ -169,6 +180,67 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly controlledEpgPrograms = computed<EpgProgram[]>(() =>
this.epgItems().map((program) => this.toControlledEpgProgram(program))
);
private readonly recordingsService = inject(RecordingsService);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = computed<RecordingStartMetadata | null>(() => {
const item = this.selectedLiveItem();
if (!item) {
return null;
}
const playlist = this.xtreamStore.currentPlaylist();
// Re-select against the 30 s tick: the store's `currentEpgItem`
// caches its Date.now() verdict until epgItems changes, so a
// recording started after an EPG boundary would snapshot the
// previous show.
const program = findCurrentEpgItem(
this.epgItems(),
this.currentTimeMs()
);
return {
channelName: item.title?.trim() || item.name?.trim() || 'Live TV',
channelLogoUrl:
item.poster_url?.trim() || item.stream_icon?.trim() || undefined,
playlistId: playlist?.id,
playlistName:
playlistDisplayLabel(playlist?.name ?? playlist?.title) ||
undefined,
sourceType: 'xtream',
epgChannelId: playlist
? buildXtreamEpgMappingKey(playlist.id, item.xtream_id)
: undefined,
currentProgram: program
? toRecordingProgramSnapshot({
title: program.title,
description: program.description,
start: program.start,
stop: program.stop ?? program.end,
})
: undefined,
};
});
/** Stop enrichment: programs overlapping the recorded window. */
onRecordingStopped(event: RecordingStoppedEvent): void {
// A channel switch auto-stops the recording, and by now this host
// already describes the new channel — enriching then would attach the
// wrong schedule (and could promote an unrelated program to the
// recording's title).
if (
event.epgChannelId &&
event.epgChannelId !== this.recordingMetadata()?.epgChannelId
) {
return;
}
const programs = filterRecordingProgramsOverlap(
this.controlledEpgPrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt
);
if (programs.length === 0) {
return;
}
void this.recordingsService.updatePrograms(event.targetPath, programs);
}
private readonly currentTimeMs = signal(Date.now());
readonly activeCatchupProgram = signal<EpgProgram | null>(null);
readonly controlledArchiveDays = computed(() =>
+1
View File
@@ -4,6 +4,7 @@ export * from './lib/data.service';
export * from './lib/host-connectivity-reset';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/recordings.service';
export * from './lib/playback-position-runtime-bridge.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
+7 -5
View File
@@ -71,16 +71,18 @@ export class DownloadsService implements OnDestroy {
return;
}
// Subscribe BEFORE the initial load, so a transition pinged while
// that request is pending is coalesced into a trailing refresh
// instead of being lost with the pre-transition response.
this.unsubscribe = window.electron.onDownloadsUpdate(() => {
this.loadDownloads();
});
// Load initial download list
await this.loadDownloads();
// Load download folder
await this.loadDownloadFolder();
// Subscribe to download updates
this.unsubscribe = window.electron.onDownloadsUpdate(() => {
this.loadDownloads();
});
}
ngOnDestroy() {
@@ -0,0 +1,14 @@
import type { ElectronRecordingItem } from '@iptvnator/shared/interfaces';
/**
* Renderer-facing live-TV recording row. The bridge type is already
* camelCased and carries the decoded `programs` array plus the derived
* `fileAvailability`, so the renderer model is a direct alias.
*/
export type RecordingItem = ElectronRecordingItem;
export type {
RecordingProgramSnapshot,
RecordingSourceType,
RecordingStatus,
} from '@iptvnator/shared/interfaces';
@@ -0,0 +1,348 @@
import {
EnvironmentInjector,
Injector,
Signal,
WritableSignal,
createEnvironmentInjector,
runInInjectionContext,
signal,
} from '@angular/core';
import type { ElectronBridgeErrorResult } from '@iptvnator/shared/interfaces';
import { DownloadListLoadState } from './download-list-load-state';
import { RecordingsService } from './recordings.service';
import type { RecordingItem } from './recordings.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
type TestRecordingsService = {
recordings: WritableSignal<RecordingItem[]>;
isAvailable: () => boolean;
isLoadingRecordings: Signal<boolean>;
hasLoadedRecordings: Signal<boolean>;
hasAuthoritativeRecordingList: Signal<boolean>;
listLoadState: DownloadListLoadState;
loadRecordings: RecordingsService['loadRecordings'];
stopRecording: RecordingsService['stopRecording'];
removeRecording: RecordingsService['removeRecording'];
updatePrograms: RecordingsService['updatePrograms'];
revealFile: RecordingsService['revealFile'];
playFile: RecordingsService['playFile'];
};
type RecordingsElectronStub = {
recordingsGetList?: jest.Mock;
recordingsStop?: jest.Mock;
recordingsRemove?: jest.Mock;
recordingsUpdatePrograms?: jest.Mock;
recordingsRevealFile?: jest.Mock;
recordingsPlayFile?: jest.Mock;
onRecordingsUpdate?: jest.Mock;
};
describe('RecordingsService', () => {
const testWindow = window as unknown as {
electron?: RecordingsElectronStub;
};
const originalElectron = testWindow.electron;
afterEach(() => {
testWindow.electron = originalElectron;
jest.restoreAllMocks();
});
function createRecording(
id: number,
status: RecordingItem['status'] = 'completed',
overrides: Partial<RecordingItem> = {}
): RecordingItem {
return {
id,
status,
filePath: `/recordings/${id}.ts`,
channelName: `Channel ${id}`,
startedAt: '2026-08-15T12:00:00Z',
fileAvailability: 'available',
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
/** Constructor-driven instance: init() runs against the given capability. */
function createInjectedService(supportsRecordings: boolean) {
const injector = createEnvironmentInjector(
[
RecordingsService,
{
provide: RuntimeCapabilitiesService,
useValue: { supportsRecordings },
},
],
Injector.NULL as unknown as EnvironmentInjector
);
const service = runInInjectionContext(
injector,
() => new RecordingsService()
);
return { injector, service };
}
/** Prototype-based instance: no constructor, so init() cannot interfere. */
function createService(initial: RecordingItem[] = []) {
const listLoadState = new DownloadListLoadState();
const service = Object.create(
RecordingsService.prototype
) as TestRecordingsService;
Object.assign(service, {
recordings: signal(initial),
isAvailable: () => true,
listLoadState,
isLoadingRecordings: listLoadState.isLoading,
hasLoadedRecordings: listLoadState.hasLoaded,
hasAuthoritativeRecordingList: listLoadState.hasAuthoritativeList,
});
return service;
}
const flush = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
it('never touches the bridge when recordings are unavailable', async () => {
const electron: RecordingsElectronStub = {
recordingsGetList: jest.fn(async () => [createRecording(1)]),
recordingsStop: jest.fn(async () => ({ success: true })),
onRecordingsUpdate: jest.fn(),
};
testWindow.electron = electron;
const { injector, service } = createInjectedService(false);
try {
await flush();
await service.loadRecordings();
expect(service.isAvailable()).toBe(false);
expect(service.recordings()).toEqual([]);
expect(service.hasRecordings()).toBe(false);
expect(electron.recordingsGetList).not.toHaveBeenCalled();
expect(electron.onRecordingsUpdate).not.toHaveBeenCalled();
await expect(service.stopRecording(1)).resolves.toEqual({
error: 'Recordings are not available',
success: false,
});
expect(electron.recordingsStop).not.toHaveBeenCalled();
} finally {
injector.destroy();
}
});
it('loads once on init, subscribes to update pings, and unsubscribes on destroy', async () => {
const initial = createRecording(1);
const updated = createRecording(2, 'recording');
let pushUpdate: (() => void) | undefined;
const unsubscribe = jest.fn();
const electron: RecordingsElectronStub = {
recordingsGetList: jest
.fn()
.mockResolvedValueOnce([initial])
.mockResolvedValueOnce([updated]),
onRecordingsUpdate: jest.fn((callback: () => void) => {
pushUpdate = callback;
return unsubscribe;
}),
};
testWindow.electron = electron;
const { injector, service } = createInjectedService(true);
try {
await flush();
expect(electron.recordingsGetList).toHaveBeenCalledTimes(1);
expect(electron.onRecordingsUpdate).toHaveBeenCalledTimes(1);
expect(service.recordings()).toEqual([initial]);
expect(service.hasLoadedRecordings()).toBe(true);
expect(service.hasAuthoritativeRecordingList()).toBe(true);
pushUpdate?.();
await flush();
expect(electron.recordingsGetList).toHaveBeenCalledTimes(2);
expect(service.recordings()).toEqual([updated]);
service.ngOnDestroy();
expect(unsubscribe).toHaveBeenCalledTimes(1);
} finally {
injector.destroy();
}
});
it('coalesces overlapping loads into one in-flight plus one trailing refresh', async () => {
const inFlightItem = createRecording(1);
const trailingItem = createRecording(2);
const first = createDeferred<RecordingItem[]>();
const second = createDeferred<RecordingItem[]>();
const electron: RecordingsElectronStub = {
recordingsGetList: jest
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise),
};
testWindow.electron = electron;
const service = createService();
const firstRequest = service.loadRecordings();
const secondRequest = service.loadRecordings();
const thirdRequest = service.loadRecordings();
expect(service.isLoadingRecordings()).toBe(true);
expect(electron.recordingsGetList).toHaveBeenCalledTimes(1);
first.resolve([inFlightItem]);
await firstRequest;
expect(service.recordings()).toEqual([inFlightItem]);
expect(service.isLoadingRecordings()).toBe(true);
expect(electron.recordingsGetList).toHaveBeenCalledTimes(2);
second.resolve([trailingItem]);
await secondRequest;
await thirdRequest;
expect(service.recordings()).toEqual([trailingItem]);
expect(service.isLoadingRecordings()).toBe(false);
expect(service.hasAuthoritativeRecordingList()).toBe(true);
expect(electron.recordingsGetList).toHaveBeenCalledTimes(2);
});
it('marks a failed list load non-authoritative while preserving the list and logging', async () => {
const existing = createRecording(1);
const error = new Error('recordings query failed');
jest.spyOn(console, 'error').mockImplementation(() => undefined);
testWindow.electron = {
recordingsGetList: jest.fn().mockRejectedValue(error),
};
const service = createService([existing]);
service.listLoadState.markSucceeded();
await service.loadRecordings();
expect(service.recordings()).toEqual([existing]);
expect(service.isLoadingRecordings()).toBe(false);
expect(service.hasAuthoritativeRecordingList()).toBe(false);
expect(service.hasLoadedRecordings()).toBe(true);
expect(console.error).toHaveBeenCalledWith(
'[RecordingsService] Error loading recordings:',
error
);
});
it('normalizes missing bridge methods to a safe failure result', async () => {
const electron: RecordingsElectronStub = {
recordingsGetList: jest.fn(async () => []),
};
testWindow.electron = electron;
const service = createService();
const unavailable: ElectronBridgeErrorResult = {
error: 'Recordings bridge unavailable',
success: false,
};
await expect(service.stopRecording(1)).resolves.toEqual(unavailable);
await expect(service.removeRecording(1)).resolves.toEqual(unavailable);
await expect(
service.updatePrograms('/recordings/1.ts', [])
).resolves.toEqual(unavailable);
await expect(
service.revealFile('/recordings/1.ts')
).resolves.toEqual(unavailable);
await expect(
service.playFile('/recordings/1.ts')
).resolves.toEqual(unavailable);
expect(electron.recordingsGetList).not.toHaveBeenCalled();
});
it('passes through action results and refreshes only after a successful remove', async () => {
const remaining = createRecording(2);
const electron: RecordingsElectronStub = {
recordingsGetList: jest.fn(async () => [remaining]),
recordingsStop: jest.fn(async () => ({ success: true })),
recordingsRemove: jest.fn(async () => ({ success: true })),
};
testWindow.electron = electron;
const service = createService([
createRecording(1),
createRecording(2),
]);
await expect(service.stopRecording(1)).resolves.toEqual({
success: true,
});
expect(electron.recordingsStop).toHaveBeenCalledWith(1);
expect(electron.recordingsGetList).not.toHaveBeenCalled();
await expect(service.removeRecording(1)).resolves.toEqual({
success: true,
});
expect(electron.recordingsRemove).toHaveBeenCalledWith(1);
expect(electron.recordingsGetList).toHaveBeenCalledTimes(1);
expect(service.recordings()).toEqual([remaining]);
});
it('does not refresh after a failed remove and reports a thrown action safely', async () => {
const error = new Error('stop failed');
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const electron: RecordingsElectronStub = {
recordingsGetList: jest.fn(async () => []),
recordingsRemove: jest.fn(async () => ({
error: 'row is busy',
success: false,
})),
recordingsStop: jest.fn().mockRejectedValue(error),
};
testWindow.electron = electron;
const service = createService();
await expect(service.removeRecording(1)).resolves.toEqual({
error: 'row is busy',
success: false,
});
expect(electron.recordingsGetList).not.toHaveBeenCalled();
await expect(service.stopRecording(1)).resolves.toEqual({
error: 'stop failed',
success: false,
});
expect(console.error).toHaveBeenCalledWith(
'[RecordingsService] Error (stop):',
error
);
});
it('picks the row with status recording as the active recording', () => {
const { injector, service } = createInjectedService(false);
try {
const active = createRecording(2, 'recording');
service.recordings.set([
createRecording(1),
active,
createRecording(3, 'failed'),
]);
expect(service.activeRecording()).toBe(active);
expect(service.hasRecordings()).toBe(true);
service.recordings.set([createRecording(1)]);
expect(service.activeRecording()).toBeNull();
} finally {
injector.destroy();
}
});
});
+161
View File
@@ -0,0 +1,161 @@
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
import type {
ElectronBridgeErrorResult,
RecordingProgramSnapshot,
} from '@iptvnator/shared/interfaces';
import { DownloadListLoadState } from './download-list-load-state';
import type { RecordingItem } from './recordings.models';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
export type { RecordingItem } from './recordings.models';
/**
* Global store for live-TV recordings, mirroring DownloadsService: one
* authoritative list refreshed on RECORDINGS_UPDATE_EVENT pings, with
* overlapping loads coalesced behind one serialized trailing refresh.
*/
@Injectable({ providedIn: 'root' })
export class RecordingsService implements OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private unsubscribe?: () => void;
private readonly listLoadState = new DownloadListLoadState();
readonly recordings = signal<RecordingItem[]>([]);
readonly isLoadingRecordings = this.listLoadState.isLoading;
readonly hasLoadedRecordings = this.listLoadState.hasLoaded;
readonly hasAuthoritativeRecordingList =
this.listLoadState.hasAuthoritativeList;
/** Whether the recordings feature is available (Electron only). */
readonly isAvailable = computed(() => this.runtime.supportsRecordings);
readonly hasRecordings = computed(() => this.recordings().length > 0);
/** The recording currently in progress, if any. */
readonly activeRecording = computed(
() => this.recordings().find((r) => r.status === 'recording') ?? null
);
constructor() {
this.init();
}
private async init() {
if (!this.isAvailable()) {
return;
}
// Subscribe BEFORE the initial load: a recording transition during
// that request pings while the response still reflects the older
// database state, and the load-state coalescing can only queue the
// trailing refresh if the listener already exists. Recording pings
// are rare, so a missed one would not self-heal until the 15 s poll
// (armed only once an active row is already visible).
this.unsubscribe = window.electron.onRecordingsUpdate?.(() => {
this.loadRecordings();
});
await this.loadRecordings();
}
ngOnDestroy() {
this.unsubscribe?.();
}
async loadRecordings(): Promise<void> {
if (!this.isAvailable()) return;
return this.listLoadState.run(async () => {
try {
const list = await window.electron.recordingsGetList?.();
this.recordings.set(list ?? []);
this.listLoadState.markSucceeded();
} catch (error) {
console.error(
'[RecordingsService] Error loading recordings:',
error
);
this.listLoadState.markFailed();
}
});
}
async getRecording(recordingId: number): Promise<RecordingItem | null> {
if (!this.isAvailable()) return null;
try {
return (await window.electron.recordingsGet?.(recordingId)) ?? null;
} catch (error) {
console.error(
'[RecordingsService] Error getting recording:',
error
);
return null;
}
}
async stopRecording(
recordingId: number
): Promise<ElectronBridgeErrorResult> {
return this.runAction('stop', () =>
window.electron.recordingsStop?.(recordingId)
);
}
async removeRecording(
recordingId: number
): Promise<ElectronBridgeErrorResult> {
return this.runAction(
'remove',
() => window.electron.recordingsRemove?.(recordingId),
// The ping fires from the backend too, but refreshing directly
// keeps the UI honest if the broadcast is missed.
true
);
}
async updatePrograms(
targetPath: string,
programs: RecordingProgramSnapshot[]
): Promise<ElectronBridgeErrorResult> {
return this.runAction('update programs', () =>
window.electron.recordingsUpdatePrograms?.(targetPath, programs)
);
}
async revealFile(filePath: string): Promise<ElectronBridgeErrorResult> {
return this.runAction('reveal', () =>
window.electron.recordingsRevealFile?.(filePath)
);
}
async playFile(filePath: string): Promise<ElectronBridgeErrorResult> {
return this.runAction('play', () =>
window.electron.recordingsPlayFile?.(filePath)
);
}
private async runAction(
label: string,
action: () => Promise<ElectronBridgeErrorResult> | undefined,
refreshAfter = false
): Promise<ElectronBridgeErrorResult> {
if (!this.isAvailable()) {
return { error: 'Recordings are not available', success: false };
}
try {
const result = (await action()) ?? {
error: 'Recordings bridge unavailable',
success: false,
};
if (refreshAfter && result.success) {
await this.loadRecordings();
}
return result;
} catch (error) {
console.error(`[RecordingsService] Error (${label}):`, error);
return {
error: error instanceof Error ? error.message : String(error),
success: false,
};
}
}
}
@@ -395,6 +395,39 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsDownloads).toBe(true);
});
it('gates recordings separately from the downloads allowlist', () => {
testWindow.electron = {
recordingsGetList: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsRecordings).toBe(false);
testWindow.electron = {
recordingsGetList: jest.fn(),
recordingsGet: jest.fn(),
recordingsStop: jest.fn(),
recordingsRemove: jest.fn(),
recordingsUpdatePrograms: jest.fn(),
recordingsRevealFile: jest.fn(),
recordingsPlayFile: jest.fn(),
};
expect(service.supportsRecordings).toBe(false);
testWindow.electron = {
...testWindow.electron,
onRecordingsUpdate: jest.fn(),
};
expect(service.supportsRecordings).toBe(true);
// An older build without the recordings bridge keeps the download
// manager: the downloads allowlist is untouched by recordings.
expect(service.supportsDownloads).toBe(false);
});
it('requires both desktop file-save preload methods', () => {
testWindow.electron = {
saveFileDialog: jest.fn(),
@@ -204,6 +204,22 @@ export class RuntimeCapabilitiesService {
].every((methodName) => this.hasElectronMethod(methodName));
}
// Deliberately separate from supportsDownloads: that allowlist is
// all-or-nothing, and folding the recordings bridge into it would make
// older Electron builds lose the entire download manager.
get supportsRecordings(): boolean {
return [
'recordingsGetList',
'recordingsGet',
'recordingsStop',
'recordingsRemove',
'recordingsUpdatePrograms',
'recordingsRevealFile',
'recordingsPlayFile',
'onRecordingsUpdate',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsPortalActivityStorage(): boolean {
return [
'dbGetRecentlyViewed',
@@ -119,6 +119,37 @@ const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
// No unique index: re-recording the same channel is a normal workflow, and
// playlist_id carries no FK so recordings survive source deletion.
const RECORDINGS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
owner_pid INTEGER,
status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')),
file_path TEXT NOT NULL,
file_size_bytes INTEGER,
channel_name TEXT NOT NULL,
channel_logo_url TEXT,
playlist_id TEXT,
playlist_name TEXT,
source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')),
epg_channel_id TEXT,
program_title TEXT,
program_description TEXT,
program_start TEXT,
program_stop TEXT,
programs_json TEXT,
error_message TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`;
const RECORDINGS_INDEX_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status)`,
`CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path)`,
`CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id)`,
];
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
@@ -344,6 +375,9 @@ const CREATE_TABLE_STATEMENTS = [
// Downloads table
DOWNLOADS_TABLE_SQL,
...DOWNLOADS_INDEX_STATEMENTS,
// Live-TV recordings table
RECORDINGS_TABLE_SQL,
...RECORDINGS_INDEX_STATEMENTS,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
+55
View File
@@ -371,6 +371,61 @@ export const downloads = sqliteTable(
export type Download = typeof downloads.$inferSelect;
export type NewDownload = typeof downloads.$inferInsert;
// Live-TV recordings table. Rows are created by the embedded-MPV recording
// tracker, not by the download queue: a recording has no source URL to
// re-fetch, no byte totals, and no retry/resume semantics, so it lives beside
// `downloads` instead of inside it. playlist_id has no FK on purpose —
// recordings outlive the deletion of their source playlist.
export const recordings = sqliteTable(
'recordings',
{
id: integer('id').primaryKey({ autoIncrement: true }),
// Transient embedded-MPV session id, correlation/debugging only.
sessionId: text('session_id'),
// PID of the process that opened the recording. Startup recovery uses
// it to leave rows owned by another live instance alone
// (IPTVNATOR_ALLOW_MULTIPLE_INSTANCES).
ownerPid: integer('owner_pid'),
status: text('status', {
enum: ['recording', 'completed', 'interrupted', 'failed'],
})
.notNull()
.default('recording'),
filePath: text('file_path').notNull(),
fileSizeBytes: integer('file_size_bytes'),
// Metadata snapshot captured at recording start (EPG is
// time-sensitive; it cannot be reconstructed after the fact).
channelName: text('channel_name').notNull(),
channelLogoUrl: text('channel_logo_url'),
playlistId: text('playlist_id'),
playlistName: text('playlist_name'),
sourceType: text('source_type', {
enum: ['m3u', 'xtream', 'stalker'],
}),
epgChannelId: text('epg_channel_id'),
programTitle: text('program_title'),
programDescription: text('program_description'),
programStart: text('program_start'),
programStop: text('program_stop'),
// Stop-time enrichment: JSON array of program snapshots overlapping
// [started_at, ended_at] (a recording can span program boundaries).
programsJson: text('programs_json'),
errorMessage: text('error_message'),
startedAt: text('started_at').notNull(),
endedAt: text('ended_at'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
statusIdx: index('recordings_status_idx').on(table.status),
filePathIdx: index('recordings_file_path_idx').on(table.filePath),
playlistIdx: index('recordings_playlist_idx').on(table.playlistId),
})
);
export type Recording = typeof recordings.$inferSelect;
export type NewRecording = typeof recordings.$inferInsert;
// TMDB metadata cache table.
// Two row kinds share the table, discriminated by the lookup_key prefix:
// - 'id:<tmdbId>' → full TMDB details payload (JSON)
+1 -1
View File
@@ -6,7 +6,7 @@
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"ngx-indexed-db": "^21.0.0",
"ngx-indexed-db": "^22.0.0",
"tslib": "^2.3.0"
}
}
+2
View File
@@ -41,6 +41,8 @@ export * from './lib/portal-playback.interface';
export * from './lib/provider-import-detection.util';
export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
export * from './lib/recording-program-overlap.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-auth-failure.util';
@@ -16,6 +16,11 @@ import {
GlobalSearchResultSource,
} from './global-search-result.interface';
import { M3uFavoriteChannel } from './m3u-favorite-channel.interface';
import {
RecordingProgramSnapshot,
RecordingSourceType,
RecordingStatus,
} from './recording-metadata.interface';
import { PlaybackPositionData } from './playback-position.interface';
import { AutoUpdatePlaylistsResult } from './playlist-auto-update.interface';
import {
@@ -601,6 +606,41 @@ export interface ElectronDownloadItem {
updatedAt?: string;
}
/**
* Renderer-facing live-TV recording row. `fileAvailability` is derived at
* read time (never persisted) and `programs` is the decoded `programs_json`
* column. Recordings reuse the download availability vocabulary so the
* manager UI can share its missing-file affordances — extended by
* `'unknown'` for an inconclusive probe (timeout, permission or I/O error):
* only proven absence may move a recording to Needs attention or hide its
* file actions, so consumers gate on `=== 'missing'`, never on
* `!== 'available'`.
*/
export interface ElectronRecordingItem {
id: number;
sessionId?: string;
status: RecordingStatus;
filePath: string;
fileSizeBytes?: number;
channelName: string;
channelLogoUrl?: string;
playlistId?: string;
playlistName?: string;
sourceType?: RecordingSourceType;
epgChannelId?: string;
programTitle?: string;
programDescription?: string;
programStart?: string;
programStop?: string;
programs?: RecordingProgramSnapshot[];
errorMessage?: string;
startedAt: string;
endedAt?: string;
createdAt?: string;
updatedAt?: string;
fileAvailability: ElectronDownloadFileAvailability | 'unknown';
}
export interface ElectronBridgeApi {
onPortalDebugEvent?: (
callback: (data: PortalDebugEvent) => void
@@ -1201,4 +1241,28 @@ export interface ElectronBridgeApi {
playlistId?: string
) => Promise<ElectronBridgeResult>;
onDownloadsUpdate: (callback: () => void) => () => void;
// Live-TV recordings surface. Optional: older Electron builds have no
// recordings bridge, and `supportsRecordings` (not `supportsDownloads`)
// gates every consumer.
recordingsGetList?: (
playlistId?: string
) => Promise<ElectronRecordingItem[]>;
recordingsGet?: (
recordingId: number
) => Promise<ElectronRecordingItem | null>;
recordingsStop?: (recordingId: number) => Promise<ElectronBridgeErrorResult>;
recordingsRemove?: (
recordingId: number
) => Promise<ElectronBridgeErrorResult>;
recordingsUpdatePrograms?: (
targetPath: string,
programs: RecordingProgramSnapshot[]
) => Promise<ElectronBridgeErrorResult>;
recordingsRevealFile?: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
recordingsPlayFile?: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
onRecordingsUpdate?: (callback: () => void) => () => void;
}
@@ -1,4 +1,5 @@
import type { PlayerSubtitleStyle } from './subtitle-style.util';
import type { RecordingStartMetadata } from './recording-metadata.interface';
export type EmbeddedMpvSessionStatus =
| 'idle'
@@ -99,6 +100,12 @@ export interface EmbeddedMpvRecordingState {
export interface EmbeddedMpvRecordingStartOptions {
directory?: string;
title?: string;
/**
* Channel/EPG snapshot captured by the live host at recording start; the
* main-process recording tracker persists it. See
* recording-metadata.interface.ts for why capture must happen up front.
*/
metadata?: RecordingStartMetadata;
}
export interface EmbeddedMpvSession {
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more