feat(downloads): track live-TV recordings in the download manager (#1452)

* feat(downloads): track live-TV recordings in the download manager

Embedded MPV recordings were written to disk and forgotten: no list, no
reveal/play, no missing-file handling, and the channel/EPG context was lost
the moment the recording stopped. Recordings now live beside downloads:

- New `recordings` table (no unique index, no playlist FK — recordings
  survive source deletion; playlist name stored via playlistDisplayLabel).
- EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus
  a session-snapshot observer for implicit stops (stream-replacement
  auto-stop, frame-copy helper crash, session error/close); startup repair
  turns rows a hard kill left behind into playable `interrupted` partials.
- Channel/EPG metadata is captured at recording START in all four live
  hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers
  renderer-side enrichment with every program overlapping the recorded
  window, keyed by target path — covering recordings that span a program
  boundary. Provider EPG never reaches SQLite, so post-hoc lookup is
  impossible by design.
- Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a
  separate supportsRecordings capability gate (the supportsDownloads
  allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs
  are gated on the recordings table, so the renderer-supplied recording
  directory stays a write-location preference, not a shell-access grant.
- Manager UI: `recording` filter chip, "Recording now" queue section (REC
  pulse, elapsed, live file size — no percentage, the length is unknown),
  16:9 channel-logo Recordings library, Needs attention with Remove only,
  focused detail at /workspace/downloads/recording/:recordingId.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): close the stop-enrichment race and repair player stubs

Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv
acknowledges, while the recording row's terminal-state update is still queued
in the tracker. The renderer answers that snapshot with stop enrichment, whose
handler only accepts a terminal row — so the covered-program metadata could be
silently dropped with "Recording not found".

- EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write
  chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup.
  Regression covered from both sides: the handler must not touch the database
  until the barrier resolves, and the barrier must imply a committed row.

CI also caught spec stubs that had not learned the new player inputs (my local
run-many had been an Nx cache hit, so the failures only surfaced in CI):

- Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the
  `recordingMetadata` input and `recordingStopped` output across the m3u,
  Xtream, Stalker, unified-live-tab and web-player-view specs.
- The races spec now asserts the metadata argument explicitly instead of
  matching a two-argument call.
- Extract the Stalker and unified-live-tab spec stubs into sibling
  `*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs
  stay under the 1200-line test limit without shaving assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(downloads): make the recordings events spec a module

The spec deliberately has no static imports — every dependency is swapped
through jest.doMock before the harness's dynamic import — which also made it a
TS script rather than a module, so its top-level `registeredHandlers` landed in
the global scope and collided with the same-named const in stream-probe.spec.ts
(TS2451). Local per-project runs compile the specs separately and stayed green;
only the Tier A coverage suite builds them into one program, so CI caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): address Codex review on recording lifecycle

Four findings from the Codex review, all real:

- P1: `addon.stopRecording()` only dispatches — native-view uses
  `mpv_set_property_async`, frame-copy writes a helper command — so
  finalizing inside the stop hook could stat a file mpv had not flushed and
  even unlink bytes still being written. The tracker now treats the hook as a
  request and finalizes on the acknowledged inactive snapshot, with a 10 s
  bound so a lost acknowledgement cannot strand the row. Only a recording
  that never went active has its empty reservation removed. Stop enrichment
  follows through `whenFinalized(targetPath)` (bounded) instead of merely
  draining the write queue.
- Live file size: `file_size_bytes` is written at finalization only, so the
  manager's 15 s refresh reported nothing while recording. Active rows are
  now decorated with a current `fs.stat` size.
- Manager-initiated Stop bypassed both player stop paths, so recordings
  spanning program boundaries kept only the start-time program.
  `EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits
  `recordingStopped` for every trigger; the adapter and legacy toggle no
  longer emit it themselves.
- Startup recovery could terminate a row another live instance was still
  writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid`
  and recovery skips those whose owner process is alive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): derive the enrichment wait from the stop fallback

Greptile caught the seam my previous fix left: the enrichment barrier waited
5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a
stop mpv never confirms let the terminal-row lookup expire early and drop the
covered programs with no retry — precisely the case the fallback exists for.
The wait is now derived from the acknowledgement bound (fallback + 1 s), with
a regression test that fails if the two ever drift apart again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): address the second Codex pass on recordings

Four more findings, all real:

- P1 (macOS native-view): `StopRecording` clears `recordingActive` *before*
  dispatching the async property set and restores it if the request is
  rejected, so the first inactive snapshot is optimistic, not an
  acknowledgement — the tracker could finalize (and stat) a file mpv was
  still writing, and a rejected stop would leave the row `completed` while
  recording continued. An inactive snapshot now has to survive a 1.5 s settle
  window (three poll cycles); a revived recording cancels the pending
  finalization.
- Removing a failed row unlinked its path unconditionally, which takes the
  file of a newer recording that reused the freed name within the same
  timestamp second. The cleanup now runs only while no other row claims it.
- The All chip and the header's active badge ignored recordings, so a manager
  holding only recordings read "All 0" and an active recording never showed
  up in the badge.
- Switching channels auto-stops the recording, but by the time the host
  handled the stop its `activeChannel`/EPG already described the NEW channel,
  so the old recording was enriched with the wrong schedule (and an unrelated
  program could be promoted to its title). The stop event now carries the EPG
  key captured while the recording was active and every host compares it
  before enriching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): close the persistence race and two recording UX gaps

- Greptile P1: the enrichment deadline (fallback + 1 s) still raced the
  terminal write — if the tracker queue or the UPDATE took longer than the
  remaining margin, `whenFinalized` returned while the row was still
  `recording` and the one-shot enrichment was dropped. The deadline now
  bounds only the wait for mpv; `finalize()` removes the entry synchronously,
  so once it has started the wait follows the write itself.
- Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per
  process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another
  instance's row could stop an unrelated local recording. Foreign rows are
  now refused.
- Codex: the In progress chip counted active recordings while its filter
  deliberately hid them, so clicking it showed "no matches". Active
  recordings now belong to that filter — a chip whose count disagrees with
  its page is a lie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(downloads): drop the enrichment barrier instead of tuning it

Three review rounds circled the same class: synchronizing mpv's asynchronous
stop acknowledgement with a one-shot program enrichment. Each fix moved the
deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason
a deadline existed at all — the handler insisted on a *terminal* row.

It never needed one. `openSync('wx')` makes the reserved path exclusive while
a recording owns it, so the newest row for that path IS the recording that was
stopped, and `finalize()` writes only status/end time/size and never
`programs_json`. Enrichment and finalization are therefore order-independent:

- `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any
  status and awaits only the tracker's write queue, which exists solely to
  guarantee the INSERT committed (a recording stopped milliseconds after it
  started).
- `whenFinalized`, its deadline constant, and the per-entry finalized promise
  are gone; the tracker keeps only the settle window and fallback that make
  *finalization* itself correct.

No behavior is lost and the whole timing class disappears with the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): bind recording finalization to its entry and shield live rows from startup repair

Two races from the Codex review:

- Tracker timers finalized by reusable session id, so a stop followed by an
  immediate restart on the same session let the old settle timer finalize
  the NEW row (marked completed while mpv kept writing) and strand the old
  row in 'recording'. Finalization is now bound to the exact open entry,
  and replacing a session's entry arms the old entry's settle timer so an
  unobserved stop still finalizes it.

- reconcileStaleRecordings() runs after the renderer is interactive; a
  recording started during bootstrap has ownerPid === process.pid and was
  repaired to interrupted/failed mid-write. Recovery now skips rows the
  tracker reports as actively tracked (activeRowIds()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): harden recording startup repair against recycled pids and stale renderer lists

Second Codex pass on the recovery path:

- A live ownerPid alone no longer shields a row: after a crash the OS can
  recycle the pid for an unrelated process, which would park the row in
  'recording' with no instance able to finalize it. Recovery now also
  checks (best-effort, ps/tasklist) that the process looks like an
  IPTVnator/Electron instance; an unreadable name stays conservative and
  keeps the skip.

- The renderer loads before the repair pass runs and may already hold the
  pre-repair list with a stale Stop affordance; recovery now broadcasts
  one RECORDINGS_UPDATE_EVENT after changing any rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat

Third Codex pass:

- A synthetic error/closed snapshot from disposeSession() arrives while
  the frame-copy helper may still be flushing (0.5 s quit grace + 2 s
  SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write
  — short captures became terminal 'failed', longer rows persisted a
  truncated size, and startup recovery could repair neither. The tracker
  now defers that finalization behind a 2.5 s flush window; the row stays
  'recording' (repairable) meanwhile, and an already-acknowledged stop's
  settle timer keeps its 'completed' verdict instead of being relabelled
  'interrupted'.

- The active row's live file size used a bare await stat(): one stat
  hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST.
  The probe now mirrors the availability probe's contract — in-flight
  coalescing plus a 1 s deadline degrading to no size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes

Fourth Codex pass:

- Recycled-pid discrimination no longer stops at the process-name family
  check (any Electron app could shield the row): a live holder must also
  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; unreadable
  evidence stays conservative.

- /workspace/downloads/recording/:recordingId gets a supportsRecordings
  capability guard redirecting the PWA to the manager — RecordingsService
  never becomes authoritative there, so the detail rendered a permanently
  blank workspace.

- Finalization and startup repair stat through a bounded async probe (3 s
  deadline, ENOENT/ENOTDIR as the only proof of absence) instead of
  main-thread statSync: a dead network mount no longer freezes the main
  thread or the tracker queue, repair leaves unjudgeable rows recoverable,
  and finalization keeps the requested status with an unknown size rather
  than branding a likely-good file failed. The 0-byte reservation unlink
  is fire-and-forget for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches

Fifth Codex pass:

- Recording list decoration now uses the bounded availability variant that
  preserves 'unknown': a timed-out or permission-errored probe is not
  proof of absence, so a good recording on a slow mount no longer lands in
  Needs attention with its Play/Reveal hidden.
  ElectronRecordingItem.fileAvailability widens accordingly; consumers
  already gate on === 'missing'.

- Startup repair probes its whole batch concurrently, so main.ts awaits
  roughly one 3 s deadline instead of one per stale row.

Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES
stays out of scope (debug-only flag, same single-window design as
DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes

Sixth Codex pass:

- The recording duration formatter rounded minutes after flooring hours,
  so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared
  recordingDurationLabel() now rounds the total minutes before splitting
  (both the detail page and the library card used a duplicated copy).

- Startup repair's synchronous ps/tasklist/PowerShell ownership probes get
  a 2 s spawn timeout and are memoized per unique pid, so a batch of rows
  from one crashed instance costs at most one name query and one
  start-time query, and a hung process query degrades to the conservative
  fallback instead of blocking the main thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): return to the manager through history from the recording detail

Seventh Codex pass (single finding): with a validated returnUrl the manager
is already the previous history entry, so Back now uses Location.back()
instead of pushing a third entry that made the browser Back button reopen
the detail; router navigation remains the fallback for direct links —
matching the offline-detail navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime

Eighth Codex pass:

- RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed
  row's leftover reservation: cleanup is raced against the 1 s deadline,
  so a hung network unlink cannot keep the Remove action busy — the row
  deletion is what matters.

- Reveal/Play swap the synchronous lstat gate for the bounded async
  availability probe: a dead mount no longer blocks the main process, and
  only PROVEN absence refuses the action — an inconclusive probe lets the
  shell try and answer honestly.

- Startup repair dates an interrupted row's endedAt from the captured
  file's mtime (mpv's last write) instead of the repair time, so an
  overnight shutdown no longer inflates a five-minute capture into an
  hours-long recording; the repair-time fallback remains when mtime is
  unreadable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): keep recording-start program metadata fresh across EPG boundaries

Ninth Codex pass (single finding): the unified live tab's
recordingMetadata computed cached its Date.now() verdict — starting a
recording after an EPG boundary snapshotted the previous show. It now
tracks the existing 30 s progress tick. The Stalker live layout's
currentProgram had the same memoization (feeding recording metadata, the
EPG panel summary, and external-player metadata); it gains a 30 s clock
tick with interval cleanup in ngOnDestroy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): re-select the Xtream current program against the 30 s tick at recording start

Tenth Codex pass (single finding): the Xtream live layout's recording
snapshot read withEpg().currentEpgItem, a computed whose Date.now()
verdict stays cached until epgItems changes — a recording started after
an EPG boundary snapshotted the previous show. The selection logic is
extracted as the pure findCurrentEpgItem(items, nowMs), the store
computed delegates to it unchanged, and recordingMetadata re-selects
with the layout's existing 30 s currentTimeMs tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): scope stop enrichment to the exact recorded list item

Eleventh Codex pass (single finding): the stop-enrichment guard compared
only the EPG key, which is not unique for M3U items — two list entries
sharing a tvgId (or the display-name fallback) could hand the first
item's recording the second item's schedule after a switch-triggered
auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque
sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured
while the recording is active exactly like the EPG key, carried through
the player's stop edge, and compared by the hosts before enriching.
Xtream/Stalker keys are already playlist+id-scoped and need no extra key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): derive the M3U start-snapshot program from the active channel's schedule

Twelfth Codex pass (single finding): the M3U recording snapshot read the
NgRx currentEpgProgram, which retains its last value across a channel
switch and through EPG gaps (the mirror effect only dispatches when a
program exists) — a recording started on a channel with no airing
program could persist the previous channel's title, which stop
enrichment deliberately never overwrites. The snapshot now derives the
program from the active channel's own schedule against the existing 30 s
clock, and an EPG gap snapshots no program.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update

Thirteenth Codex pass (single finding): finalize() removes an entry from
the open map before its queued terminal update commits, so
activeRowIds() briefly omitted a row still persisted as 'recording' —
startup recovery overlapping a clean stop could relabel it interrupted,
after which the tracker's status-guarded update could not restore
'completed'. Finalizing entries now stay in a dedicated ledger until the
update settles, and the repair UPDATE itself is guarded on
status='recording' as a second belt against a finalization that commits
between recovery's SELECT and its write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): register update listeners before the initial list load

Fourteenth Codex pass (single finding): RecordingsService awaited its
initial RECORDINGS_GET_LIST before subscribing to the update ping — a
recording transition during that request pinged into the void while the
response still reflected the pre-transition state, and recording pings
are rare enough that nothing self-healed until the 15 s poll (armed only
once an active row is visible). The listener now registers first so the
load-state coalescing queues the trailing refresh. DownloadsService had
the same latent window and gets the same reorder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
authored and GitHub committed 2026-08-23 08:05:25 +02:00
1 parent 50871e581f
commit 5b2eb515d1
105 files changed
+8329 -299

No files matched your search

@@ -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>();
}
@@ -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)
+2
View File
@@ -40,6 +40,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';
@@ -15,6 +15,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 {
@@ -600,6 +605,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
@@ -1185,4 +1225,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,3 +1,5 @@
import type { RecordingStartMetadata } from './recording-metadata.interface';
export type EmbeddedMpvSessionStatus =
| 'idle'
| 'loading'
@@ -83,6 +85,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 {
@@ -0,0 +1,82 @@
/**
* Metadata captured for a live-TV recording.
*
* EPG is time-sensitive: what is airing on a channel cannot be reconstructed
* after the fact (Xtream/Stalker EPG never reaches SQLite), so the renderer
* snapshots everything it knows at recording START and hands it down with the
* start IPC. When the recording stops cleanly, the renderer additionally
* filters its in-memory program list to the programs overlapping the recorded
* interval — that is how a recording spanning a program boundary lists every
* covered program. An implicit stop (session dispose, crash) keeps the start
* snapshot as-is.
*/
export interface RecordingProgramSnapshot {
title: string;
description?: string;
/** ISO datetime string, same convention as `epg_programs.start`. */
start: string;
/** ISO datetime string, same convention as `epg_programs.stop`. */
stop: string;
}
export type RecordingSourceType = 'm3u' | 'xtream' | 'stalker';
export interface RecordingStartMetadata {
channelName: string;
channelLogoUrl?: string;
playlistId?: string;
/** Display label snapshot — recordings outlive playlist deletion. */
playlistName?: string;
sourceType?: RecordingSourceType;
/** Resolved EPG lookup/mapping key, when the host knows one. */
epgChannelId?: string;
/**
* Host-scoped identity of the exact recorded selection. The EPG key is
* not unique for M3U items (`tvgId`, or the display-name fallback, can
* be shared by several list entries), so stop enrichment additionally
* compares this key — otherwise switching between two same-keyed items
* while recording would attach the second item's schedule to the first
* item's recording. Opaque to everything below the host.
*/
sourceItemKey?: string;
/** The program airing when the recording started, if EPG knew it. */
currentProgram?: RecordingProgramSnapshot;
}
/**
* Emitted by the player layer when a recording stopped cleanly. The host
* component answers it with the programs overlapping [startedAt, endedAt]
* from its in-memory EPG state (stop enrichment). Implicit stops (session
* dispose, crash) never emit — the start snapshot stands.
*/
export interface RecordingStoppedEvent {
targetPath: string;
/** Recording start (ISO); null when the snapshot never carried it. */
startedAt: string | null;
/** Observation time of the stop (ISO). */
endedAt: string;
/**
* EPG key of the channel that was being recorded, captured when the
* recording went active. Switching channels auto-stops the recording, and
* by the time the host handles the stop its own state already describes
* the NEW channel — the host compares this key before enriching, so a
* recording is never given another channel's schedule.
*/
epgChannelId?: string | null;
/**
* `RecordingStartMetadata.sourceItemKey` captured the same way. The EPG
* key alone cannot tell two same-keyed M3U items apart, so hosts that
* set a source-item key also compare it before enriching.
*/
sourceItemKey?: string | null;
}
export const RECORDING_STATUSES = [
'recording',
'completed',
'interrupted',
'failed',
] as const;
export type RecordingStatus = (typeof RECORDING_STATUSES)[number];
@@ -0,0 +1,115 @@
import {
filterRecordingProgramsOverlap,
toRecordingProgramSnapshot,
} from './recording-program-overlap.util';
const program = (title: string, start: string, stop: string) => ({
title,
start,
stop,
});
describe('filterRecordingProgramsOverlap', () => {
const programs = [
program('Late show', '2026-08-15T22:10:00Z', '2026-08-15T23:00:00Z'),
program('News', '2026-08-15T21:00:00Z', '2026-08-15T21:45:00Z'),
program('Weather', '2026-08-15T21:45:00Z', '2026-08-15T22:10:00Z'),
program('Morning', '2026-08-15T08:00:00Z', '2026-08-15T09:00:00Z'),
];
it('fails closed without a recording start', () => {
expect(
filterRecordingProgramsOverlap(
programs,
null,
'2026-08-15T22:00:00Z'
)
).toEqual([]);
});
it('fails closed on unparseable or inverted windows', () => {
expect(
filterRecordingProgramsOverlap(
programs,
'garbage',
'2026-08-15T22:00:00Z'
)
).toEqual([]);
expect(
filterRecordingProgramsOverlap(
programs,
'2026-08-15T22:00:00Z',
'2026-08-15T21:00:00Z'
)
).toEqual([]);
});
it('keeps every overlapping program sorted by start (boundary case)', () => {
const result = filterRecordingProgramsOverlap(
programs,
'2026-08-15T21:30:00Z',
'2026-08-15T21:58:00Z'
);
expect(result.map(({ title }) => title)).toEqual(['News', 'Weather']);
});
it('excludes programs that only touch the window edges', () => {
// Weather starts exactly at window end; News stops exactly at start.
const result = filterRecordingProgramsOverlap(
programs,
'2026-08-15T21:45:00Z',
'2026-08-15T21:45:00Z'
);
expect(result).toEqual([]);
});
it('drops programs with unparseable times', () => {
const result = filterRecordingProgramsOverlap(
[program('Broken', 'nope', 'also nope'), programs[1]],
'2026-08-15T21:10:00Z',
'2026-08-15T21:20:00Z'
);
expect(result.map(({ title }) => title)).toEqual(['News']);
});
});
describe('toRecordingProgramSnapshot', () => {
it('prefers description over desc and trims it', () => {
expect(
toRecordingProgramSnapshot({
title: 'News',
description: ' Daily ',
desc: 'legacy',
start: 'a',
stop: 'b',
})
).toEqual({ title: 'News', description: 'Daily', start: 'a', stop: 'b' });
});
it('falls back to the XMLTV desc field', () => {
expect(
toRecordingProgramSnapshot({
title: 'News',
desc: 'From XMLTV',
start: 'a',
stop: 'b',
})
).toEqual({
title: 'News',
description: 'From XMLTV',
start: 'a',
stop: 'b',
});
});
it('omits description entirely when both sources are empty', () => {
expect(
toRecordingProgramSnapshot({
title: 'News',
desc: null,
start: 'a',
stop: 'b',
})
).toEqual({ title: 'News', start: 'a', stop: 'b' });
});
});
@@ -0,0 +1,63 @@
import type { RecordingProgramSnapshot } from './recording-metadata.interface';
/**
* Normalizes the hosts' program shapes (XMLTV `EpgProgram.desc`, Xtream
* `EpgItem.description`) into the recording snapshot form.
*/
export function toRecordingProgramSnapshot(program: {
title: string;
desc?: string | null;
description?: string | null;
start: string;
stop: string;
}): RecordingProgramSnapshot {
const description = (program.description ?? program.desc ?? '').trim();
return {
title: program.title,
...(description ? { description } : {}),
start: program.start,
stop: program.stop,
};
}
/**
* Filters normalized program snapshots to the ones overlapping the recorded
* interval, sorted by start. Used by the live hosts' stop-enrichment
* handlers, so a recording spanning a program boundary reports every covered
* program. Fails closed: an unknown recording start yields an empty list
* (the start snapshot then stands).
*/
export function filterRecordingProgramsOverlap(
programs: readonly RecordingProgramSnapshot[],
startedAt: string | null,
endedAt: string
): RecordingProgramSnapshot[] {
if (!startedAt) {
return [];
}
const windowStart = Date.parse(startedAt);
const windowEnd = Date.parse(endedAt);
if (
!Number.isFinite(windowStart) ||
!Number.isFinite(windowEnd) ||
windowEnd <= windowStart
) {
return [];
}
return programs
.map((program) => ({
program,
start: Date.parse(program.start),
stop: Date.parse(program.stop),
}))
.filter(
({ start, stop }) =>
Number.isFinite(start) &&
Number.isFinite(stop) &&
start < windowEnd &&
stop > windowStart
)
.sort((a, b) => a.start - b.start)
.map(({ program }) => program);
}
@@ -259,6 +259,19 @@ describe('EmbeddedMpvCommandRunner', () => {
expect(session()?.recording?.active).toBe(true);
});
it('startRecording forwards the channel metadata snapshot in the options', async () => {
const metadata = {
channelName: 'Channel One',
playlistId: 'playlist-a',
sourceType: 'xtream' as const,
};
await runner.startRecording('/custom', 'My Show', metadata);
expect(electron.startEmbeddedMpvRecording).toHaveBeenCalledWith(
'mpv-1',
{ directory: '/custom', title: 'My Show', metadata }
);
});
it('does not start recording after the session changes during folder lookup', async () => {
let resolveFolder: ((folder: string) => void) | null = null;
electron.getEmbeddedMpvDefaultRecordingFolder.mockImplementationOnce(
@@ -1,5 +1,8 @@
import { Signal, WritableSignal } from '@angular/core';
import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvSession,
RecordingStartMetadata,
} from '@iptvnator/shared/interfaces';
type ElectronBridge = Window['electron'];
@@ -107,7 +110,8 @@ export class EmbeddedMpvCommandRunner {
async startRecording(
directory: string | undefined,
title: string
title: string,
metadata?: RecordingStartMetadata
): Promise<EmbeddedMpvSession['recording'] | null> {
const id = this.ctx.sessionId();
const electron = this.bridge();
@@ -126,6 +130,7 @@ export class EmbeddedMpvCommandRunner {
startEmbeddedMpvRecording(id, {
directory: resolvedDirectory,
title,
...(metadata ? { metadata } : {}),
})
);
return updated?.recording ?? null;
@@ -1,5 +1,6 @@
import type {
EmbeddedMpvSession,
RecordingStartMetadata,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
@@ -44,6 +45,8 @@ export interface RecordingToggleContext {
readonly playback: ResolvedPortalPlayback;
readonly playbackIdentity: string;
readonly session: EmbeddedMpvSession;
/** Channel/EPG snapshot forwarded to the main-process recording tracker. */
readonly metadata?: RecordingStartMetadata | null;
}
export class EmbeddedMpvControlsRecording {
@@ -94,7 +97,8 @@ export class EmbeddedMpvControlsRecording {
kind === RECORDING_OPERATION.START
? this.controller.startRecording(
context.folder,
context.playback.title
context.playback.title,
context.metadata ?? undefined
)
: this.controller.stopRecording();
void command.then(
@@ -248,6 +248,35 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
expect(adapter.state().audioTracks[0].label).toBe('DE Audio 1');
});
it('forwards the host recording metadata into the start command', async () => {
const metadata = {
channelName: 'Channel One',
playlistId: 'playlist-a',
sourceType: 'm3u' as const,
currentProgram: {
title: 'Evening News',
start: '2026-07-16T10:00:00.000Z',
stop: '2026-07-16T11:00:00.000Z',
},
};
adapter.configure({
playback,
seriesNavigation,
recordingFolder,
recordingMetadata: signal(metadata),
});
TestBed.tick();
adapter.commands.toggleRecording();
await flushPromises();
expect(controller.startRecording).toHaveBeenCalledWith(
'/recordings',
LIVE_PLAYBACK.title,
metadata
);
});
it('relocalizes saved feedback for every translate event source', async () => {
const targetPath = '/recordings/live.ts';
controller.session.set(
@@ -185,7 +185,9 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
expect(controller.startRecording).toHaveBeenCalledWith(
'/recordings',
LIVE_PLAYBACK.title
LIVE_PLAYBACK.title,
// No host recording metadata configured in this harness.
undefined
);
expect(adapter.state().recording.message).toBeNull();
controller.session.update((current) => ({
@@ -12,6 +12,8 @@ import { toSignal } from '@angular/core/rxjs-interop';
import {
EmbeddedMpvSession,
EmbeddedMpvSupport,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
@@ -43,6 +45,8 @@ export interface EmbeddedMpvControlsContext {
readonly playback: Signal<ResolvedPortalPlayback>;
readonly seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
readonly recordingFolder: Signal<string>;
/** Channel/EPG snapshot the live host captured for the recording tracker. */
readonly recordingMetadata?: Signal<RecordingStartMetadata | null>;
}
interface MappedPlayerStatus {
@@ -349,6 +353,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
playback: context.playback(),
playbackIdentity,
session,
metadata: context.recordingMetadata?.() ?? null,
});
}
}
@@ -380,4 +380,133 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
expect(startRecording).not.toHaveBeenCalled();
});
it('emits recordingStopped once when an active recording goes inactive', () => {
const { fixture, component, controller } = render();
const stopped: Array<{
targetPath: string;
startedAt: string | null;
}> = [];
component.recordingStopped.subscribe((event) =>
stopped.push({
targetPath: event.targetPath,
startedAt: event.startedAt,
})
);
controller.session.set({
...READY_SESSION,
recording: {
active: true,
targetPath: '/rec/news.ts',
startedAt: '2026-07-16T21:00:00Z',
},
});
fixture.detectChanges();
expect(stopped).toHaveLength(0);
// Stopping from the download manager talks to the main process
// directly, so the only signal this component sees is the snapshot
// flipping inactive — it must still drive stop enrichment.
controller.session.set({
...READY_SESSION,
recording: { active: false, targetPath: '/rec/news.ts' },
});
fixture.detectChanges();
expect(stopped).toEqual([
{ targetPath: '/rec/news.ts', startedAt: '2026-07-16T21:00:00Z' },
]);
// Later inactive snapshots are not another stop.
controller.session.set({
...READY_SESSION,
positionSeconds: 44,
recording: { active: false, targetPath: '/rec/news.ts' },
});
fixture.detectChanges();
expect(stopped).toHaveLength(1);
});
it('carries the recorded channel identity through the stop event', () => {
const { fixture, component, controller } = render();
fixture.componentRef.setInput('recordingMetadata', {
channelName: 'Channel One',
epgChannelId: 'channel.one',
});
const stopped: Array<string | null | undefined> = [];
component.recordingStopped.subscribe((event) =>
stopped.push(event.epgChannelId)
);
controller.session.set({
...READY_SESSION,
recording: { active: true, targetPath: '/rec/news.ts' },
});
fixture.detectChanges();
// A channel switch auto-stops the recording and moves the host on;
// the event must still name the channel that was recorded.
fixture.componentRef.setInput('recordingMetadata', {
channelName: 'Channel Two',
epgChannelId: 'channel.two',
});
controller.session.set({
...READY_SESSION,
recording: { active: false, targetPath: '/rec/news.ts' },
});
fixture.detectChanges();
expect(stopped).toEqual(['channel.one']);
});
it('carries the recorded source-item key through the stop event', () => {
// The EPG key is not unique for M3U items (shared tvgId or the
// name fallback): two list entries can share it, so the exact
// selection identity must survive the switch the same way.
const { fixture, component, controller } = render();
fixture.componentRef.setInput('recordingMetadata', {
channelName: 'News HD',
epgChannelId: 'news.hd',
sourceItemKey: 'item-1',
});
const stopped: Array<string | null | undefined> = [];
component.recordingStopped.subscribe((event) =>
stopped.push(event.sourceItemKey)
);
controller.session.set({
...READY_SESSION,
recording: { active: true, targetPath: '/rec/news.ts' },
});
fixture.detectChanges();
// Same EPG key, different list item — the uid must not follow.
fixture.componentRef.setInput('recordingMetadata', {
channelName: 'News HD',
epgChannelId: 'news.hd',
sourceItemKey: 'item-2',
});
controller.session.set({
...READY_SESSION,
recording: { active: false, targetPath: '/rec/news.ts' },
});
fixture.detectChanges();
expect(stopped).toEqual(['item-1']);
});
it('does not emit recordingStopped without a preceding active recording', () => {
const { fixture, component, controller } = render();
const stopped: unknown[] = [];
component.recordingStopped.subscribe((event) => stopped.push(event));
controller.session.set({
...READY_SESSION,
recording: { active: false, targetPath: '/rec/stale.ts' },
});
fixture.detectChanges();
expect(stopped).toHaveLength(0);
});
});
@@ -21,6 +21,8 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
import {
EmbeddedMpvAudioTrack,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
@@ -77,6 +79,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly playback = input.required<ResolvedPortalPlayback>();
readonly showControls = input(true);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
@@ -87,6 +90,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
/** Clean recording stop — the host answers with EPG stop enrichment. */
readonly recordingStopped = output<RecordingStoppedEvent>();
private readonly overlayVisibility = inject(
EmbeddedMpvOverlayVisibilityService
@@ -372,6 +377,54 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
playback: this.playback,
seriesNavigation: this.seriesNavigation,
recordingFolder: this.recordingFolder,
recordingMetadata: this.recordingMetadata,
});
// Single owner of the "a recording just stopped" edge, for every
// engine and every trigger — the controls button, the download
// manager's Stop (which talks to the main process directly and never
// reaches this component's own toggle), or mpv stopping on its own.
// The host answers with EPG stop enrichment.
let previousRecording: {
targetPath: string;
startedAt: string | null;
epgChannelId: string | null;
sourceItemKey: string | null;
} | null = null;
effect(() => {
const recording = this.controller.session()?.recording;
untracked(() => {
if (recording?.active && recording.targetPath) {
previousRecording = {
targetPath: recording.targetPath,
startedAt: recording.startedAt ?? null,
// Captured while it is still the recorded channel: a
// channel switch auto-stops the recording and moves
// the host's state on before the stop is handled.
epgChannelId:
previousRecording?.epgChannelId ??
this.recordingMetadata()?.epgChannelId ??
null,
sourceItemKey:
previousRecording?.sourceItemKey ??
this.recordingMetadata()?.sourceItemKey ??
null,
};
return;
}
if (!previousRecording) {
return;
}
const stopped = previousRecording;
previousRecording = null;
this.recordingStopped.emit({
targetPath: stopped.targetPath,
startedAt: stopped.startedAt,
endedAt: new Date().toISOString(),
epgChannelId: stopped.epgChannelId,
sourceItemKey: stopped.sourceItemKey,
});
});
});
if (typeof document !== 'undefined') {
@@ -685,6 +738,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
if (this.isRecording()) {
const recording = await this.controller.stopRecording();
if (recording?.targetPath) {
// recordingStopped is emitted by the session watcher in the
// constructor, which also covers stops triggered elsewhere.
this.setRecordingMessage(
this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', {
path: recording.targetPath,
@@ -718,7 +773,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.setRecordingMessage(null);
const recording = await this.controller.startRecording(
this.recordingFolder(),
this.playback().title
this.playback().title,
this.recordingMetadata() ?? undefined
);
if (recording?.active) {
this.feedback.flash(
@@ -11,6 +11,7 @@ import {
EmbeddedMpvBounds,
EmbeddedMpvSession,
EmbeddedMpvSupport,
RecordingStartMetadata,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
@@ -306,9 +307,10 @@ export class EmbeddedMpvSessionController {
this.commands.setAspect(aspect);
readonly startRecording = (
directory: string | undefined,
title: string
title: string,
metadata?: RecordingStartMetadata
): Promise<EmbeddedMpvSession['recording'] | null> =>
this.commands.startRecording(directory, title);
this.commands.startRecording(directory, title, metadata);
readonly stopRecording = (): Promise<
EmbeddedMpvSession['recording'] | null
> => this.commands.stopRecording();
@@ -86,11 +86,13 @@
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[recordingMetadata]="recordingMetadata()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
(recordingStopped)="recordingStopped.emit($event)"
/>
}
}
@@ -13,7 +13,11 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import {
VideoPlayer,
type RecordingStartMetadata,
type RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ErrorDetails, ErrorTypes } from 'hls.js';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
@@ -106,11 +110,13 @@ class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
describe('WebPlayerViewComponent', () => {
@@ -22,6 +22,8 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type Channel,
type RecordingStartMetadata,
type RecordingStoppedEvent,
type ResolvedPortalPlayback,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
@@ -110,6 +112,8 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -122,6 +126,8 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
/** Re-emitted embedded-MPV clean recording stop (stop enrichment). */
readonly recordingStopped = output<RecordingStoppedEvent>();
readonly showCaptions = computed(
() => this.settingsStore.showCaptions?.() ?? false
@@ -1,5 +1,9 @@
import { Component, input, output } from '@angular/core';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import type {
RecordingStartMetadata,
RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
/**
* Player stand-ins for WebPlayerViewComponent specs. They mirror the real
@@ -74,9 +78,11 @@ export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
@@ -76,6 +76,30 @@ describe('workspace-shell-route.utils', () => {
})
);
expect(
parseWorkspaceShellRoute('/workspace/downloads/recording/7')
).toEqual(
expect.objectContaining({
kind: 'downloads',
context: null,
section: null,
contextPanel: 'none',
searchMode: 'none',
usesQuerySearch: false,
})
);
// The bare recording segment without an id is NOT a focused detail.
expect(
parseWorkspaceShellRoute('/workspace/downloads/recording')
).toEqual(
expect.objectContaining({
kind: 'downloads',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads/42')
).toEqual(
@@ -303,8 +303,17 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
: page === 'downloads'
? 'downloads'
: 'unknown';
// Focused detail pages hide the context panel and route search:
// /workspace/downloads/:downloadId (3 segments) and the recording detail
// /workspace/downloads/recording/:recordingId (4 segments).
const isFocusedDownloadDetail =
kind === 'downloads' && segments.length === 3 && Boolean(segments[2]);
kind === 'downloads' &&
((segments.length === 3 &&
segments[2] !== 'recording' &&
Boolean(segments[2])) ||
(segments.length === 4 &&
segments[2] === 'recording' &&
Boolean(segments[3])));
const searchMode = isFocusedDownloadDetail
? 'none'
: resolveRouteSearchMode(kind, null, null);