diff --git a/.changes/downloads-live-tv-recordings.md b/.changes/downloads-live-tv-recordings.md new file mode 100644 index 000000000..b17364cf9 --- /dev/null +++ b/.changes/downloads-live-tv-recordings.md @@ -0,0 +1,10 @@ +--- +type: feature +area: downloads +--- + +Live-TV recordings made with the Embedded MPV player now live in the download +manager: an in-progress row with elapsed time and Stop, a Recordings library +with channel logos and the programs captured from the EPG at recording time, +a focused detail page, and Needs attention when a file goes missing. +Recordings interrupted by a crash survive as playable partials. diff --git a/CLAUDE.md b/CLAUDE.md index 37afba79e..6084f1fdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -696,6 +696,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`) - `playbackPositions` - Resume positions - `downloads` - Download manager state + - `recordings` - Live-TV recording lifecycle + start-time channel/EPG snapshot (defined in `schema.ts` beside `downloads`) - `appState` - Key-value app state (also tracks one-off data migrations) - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language) - `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`) @@ -1245,6 +1246,38 @@ engine` (restart required) or - If a finalized file disappears while a focused detail is open, the authoritative download list refreshes and returns to the manager. A failed redirect leaves an actionable missing-file state with Back and Retry. +- Live-TV recordings (Embedded MPV `stream-record`) are tracked beside + downloads in their own `recordings` table (no unique index, no playlist FK — + recordings survive source deletion with a `playlistDisplayLabel` name + snapshot). `EmbeddedMpvRecordingTracker` persists the lifecycle: start/stop + hooks in `EmbeddedMpvNativeService` plus a session-snapshot observer. The + stop hook only REQUESTS a stop (`addon.stopRecording()` dispatches + asynchronously), so finalization waits for the snapshot reporting the + recording inactive — bounded at 10s — and only a recording that never went + active has its empty reservation unlinked; mpv's bytes are never deleted. + The same observer covers the implicit stops (stream-replacement auto-stop, + helper crash, session error/close). Rows carry `owner_pid`, so startup + repair turns a hard kill's leftovers into playable `interrupted` partials or + `failed` while skipping rows another live instance owns. Channel/EPG metadata is + snapshotted at recording START (each live host passes + `RecordingStartMetadata` down through the player chain; provider EPG never + reaches SQLite so post-hoc lookup is impossible). `EmbeddedMpvPlayerComponent` + owns the active→inactive recording edge and emits `recordingStopped` for + every trigger (including the manager's Stop, which bypasses the player's own + toggle); the host answers with enrichment: programs overlapping the recorded + window, keyed by target path (`RECORDINGS_UPDATE_PROGRAMS`, which awaits only + the tracker's write queue and then matches the newest row for that path in + ANY status — `finalize()` never touches `programs_json`, so the two writes + are order-independent and no deadline can drop the programs) — that covers + recordings spanning a program boundary. Own `RECORDINGS_*` IPC + `RECORDINGS_UPDATE_EVENT` ping + and a separate `supportsRecordings` capability gate (never folded into the + all-or-nothing `supportsDownloads` allowlist). Manager UI: `recording` + filter chip, "Recording now" queue section (REC pulse + elapsed, live size, + Stop, no percentage), 16:9 channel-logo "Recordings" library cards, Needs + attention with Remove only (a broadcast cannot be re-recorded), focused + detail at `/workspace/downloads/recording/:recordingId` listing covered + programs. Reveal/play shell IPCs are gated on the recordings table + (`isManagedRecordingFile`). - Canonical contract: `docs/architecture/download-manager.md`; provider handoff: `docs/architecture/portal-detail-navigation.md`. diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 260d8f71b..a5a24af21 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -55,6 +55,7 @@ import type { PlaylistRefreshEvent, PlaylistRefreshPayload, PortalDebugEvent, + RecordingProgramSnapshot, ResolvedPortalPlayback, Settings, TmdbCacheEntry, @@ -1066,6 +1067,29 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler); return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler); }, + // Live-TV recordings + recordingsGetList: (playlistId?: string) => + ipcRenderer.invoke('RECORDINGS_GET_LIST', playlistId), + recordingsGet: (recordingId: number) => + ipcRenderer.invoke('RECORDINGS_GET', recordingId), + recordingsStop: (recordingId: number) => + ipcRenderer.invoke('RECORDINGS_STOP', recordingId), + recordingsRemove: (recordingId: number) => + ipcRenderer.invoke('RECORDINGS_REMOVE', recordingId), + recordingsUpdatePrograms: ( + targetPath: string, + programs: RecordingProgramSnapshot[] + ) => + ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs), + recordingsRevealFile: (filePath: string) => + ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath), + recordingsPlayFile: (filePath: string) => + ipcRenderer.invoke('RECORDINGS_PLAY_FILE', filePath), + onRecordingsUpdate: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on('RECORDINGS_UPDATE_EVENT', handler); + return () => ipcRenderer.off('RECORDINGS_UPDATE_EVENT', handler); + }, }; contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi)); diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index 5cbc118ec..ff07c4aaa 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -15,6 +15,7 @@ export { epgPrograms, playbackPositions, downloads, + recordings, appState, // Types type Playlist, @@ -37,4 +38,6 @@ export { type NewPlaybackPosition, type Download, type NewDownload, + type Recording, + type NewRecording, } from '@iptvnator/shared/database'; diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index 54c15c51a..c82099a11 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -13,6 +13,7 @@ import './database/favorites.events'; import './database/playback-position.events'; import './database/playlist.events'; import './database/recently-viewed.events'; +import './database/recordings.events'; import './database/tmdb.events'; import './database/vod-sources.events'; import './database/xtream.events'; diff --git a/apps/electron-backend/src/app/events/database/recording-broadcast.ts b/apps/electron-backend/src/app/events/database/recording-broadcast.ts new file mode 100644 index 000000000..741b68a4d --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recording-broadcast.ts @@ -0,0 +1,19 @@ +import type { BrowserWindow } from 'electron'; + +// Recordings deliberately get their own bare-ping channel instead of sharing +// DOWNLOADS_UPDATE_EVENT: recording state changes ride the embedded-MPV +// session snapshot cadence, and a shared ping would make every transition +// refetch the (availability-probed) downloads list too. +export const RECORDINGS_UPDATE_EVENT = 'RECORDINGS_UPDATE_EVENT'; + +let mainWindow: BrowserWindow | null = null; + +export function setRecordingsMainWindow(win: BrowserWindow): void { + mainWindow = win; +} + +export function broadcastRecordingsUpdate(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(RECORDINGS_UPDATE_EVENT); + } +} diff --git a/apps/electron-backend/src/app/events/database/recording-programs.spec.ts b/apps/electron-backend/src/app/events/database/recording-programs.spec.ts new file mode 100644 index 000000000..bc65d1940 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recording-programs.spec.ts @@ -0,0 +1,101 @@ +import { + decodeRecordingPrograms, + sanitizeRecordingPrograms, +} from './recording-programs'; + +describe('sanitizeRecordingPrograms', () => { + it('fails closed for non-array payloads', () => { + expect(sanitizeRecordingPrograms(undefined)).toBeNull(); + expect(sanitizeRecordingPrograms(null)).toBeNull(); + expect(sanitizeRecordingPrograms('[]')).toBeNull(); + expect(sanitizeRecordingPrograms({ length: 1 })).toBeNull(); + }); + + it('rejects arrays above the hostile-input cap', () => { + const oversized = Array.from({ length: 65 }, (_, index) => ({ + title: `Program ${index}`, + start: '2026-08-15T21:00:00+03:00', + stop: '2026-08-15T22:00:00+03:00', + })); + expect(sanitizeRecordingPrograms(oversized)).toBeNull(); + }); + + it('drops invalid entries individually and keeps valid ones', () => { + const sanitized = sanitizeRecordingPrograms([ + { + title: ' News ', + description: ' Daily news ', + start: '2026-08-15T21:00:00+03:00', + stop: '2026-08-15T21:45:00+03:00', + }, + { title: '', start: 'x', stop: 'y' }, + { title: 'No times' }, + 42, + null, + ]); + expect(sanitized).toEqual([ + { + title: 'News', + description: 'Daily news', + start: '2026-08-15T21:00:00+03:00', + stop: '2026-08-15T21:45:00+03:00', + }, + ]); + }); + + it('omits empty descriptions and truncates oversized fields', () => { + const sanitized = sanitizeRecordingPrograms([ + { + title: 'a'.repeat(600), + description: ' ', + start: '2026-08-15T21:00:00+03:00', + stop: '2026-08-15T21:45:00+03:00', + }, + ]); + expect(sanitized).toHaveLength(1); + expect(sanitized?.[0].title).toHaveLength(512); + expect(sanitized?.[0]).not.toHaveProperty('description'); + }); + + it('rejects entries with oversized time strings', () => { + expect( + sanitizeRecordingPrograms([ + { + title: 'News', + start: 'x'.repeat(65), + stop: '2026-08-15T21:45:00+03:00', + }, + ]) + ).toEqual([]); + }); +}); + +describe('decodeRecordingPrograms', () => { + it('returns undefined for empty, junk, or non-array JSON', () => { + expect(decodeRecordingPrograms(null)).toBeUndefined(); + expect(decodeRecordingPrograms('')).toBeUndefined(); + expect(decodeRecordingPrograms('not json')).toBeUndefined(); + expect(decodeRecordingPrograms('{"title":"x"}')).toBeUndefined(); + expect(decodeRecordingPrograms('[]')).toBeUndefined(); + }); + + it('decodes and re-sanitizes persisted programs', () => { + const decoded = decodeRecordingPrograms( + JSON.stringify([ + { + title: 'News', + start: '2026-08-15T21:00:00+03:00', + stop: '2026-08-15T21:45:00+03:00', + injected: 'junk', + }, + ]) + ); + expect(decoded).toEqual([ + { + title: 'News', + start: '2026-08-15T21:00:00+03:00', + stop: '2026-08-15T21:45:00+03:00', + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/recording-programs.ts b/apps/electron-backend/src/app/events/database/recording-programs.ts new file mode 100644 index 000000000..034ca5853 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recording-programs.ts @@ -0,0 +1,71 @@ +import type { RecordingProgramSnapshot } from '@iptvnator/shared/interfaces'; + +// A recording rarely spans more than a handful of programs; the cap is a +// hostile-input bound, not a product limit. +const MAX_PROGRAMS = 64; +const MAX_TITLE_LENGTH = 512; +const MAX_DESCRIPTION_LENGTH = 4_096; +const MAX_TIME_LENGTH = 64; + +function sanitizeProgram(value: unknown): RecordingProgramSnapshot | null { + if (!value || typeof value !== 'object') { + return null; + } + const raw = value as Record; + const title = typeof raw.title === 'string' ? raw.title.trim() : ''; + const start = typeof raw.start === 'string' ? raw.start.trim() : ''; + const stop = typeof raw.stop === 'string' ? raw.stop.trim() : ''; + if (!title || !start || !stop) { + return null; + } + if (start.length > MAX_TIME_LENGTH || stop.length > MAX_TIME_LENGTH) { + return null; + } + const description = + typeof raw.description === 'string' ? raw.description.trim() : ''; + return { + title: title.slice(0, MAX_TITLE_LENGTH), + ...(description + ? { description: description.slice(0, MAX_DESCRIPTION_LENGTH) } + : {}), + start, + stop, + }; +} + +/** + * Validates the renderer-supplied stop-enrichment payload. Returns `null` + * for a payload that is not an array (fail closed); invalid entries inside a + * valid array are dropped individually. + */ +export function sanitizeRecordingPrograms( + programs: unknown +): RecordingProgramSnapshot[] | null { + if (!Array.isArray(programs) || programs.length > MAX_PROGRAMS) { + return null; + } + const sanitized: RecordingProgramSnapshot[] = []; + for (const entry of programs) { + const program = sanitizeProgram(entry); + if (program) { + sanitized.push(program); + } + } + return sanitized; +} + +/** Decodes the persisted `programs_json` column, tolerating legacy junk. */ +export function decodeRecordingPrograms( + programsJson: string | null | undefined +): RecordingProgramSnapshot[] | undefined { + if (!programsJson) { + return undefined; + } + try { + const parsed = JSON.parse(programsJson); + const sanitized = sanitizeRecordingPrograms(parsed); + return sanitized && sanitized.length > 0 ? sanitized : undefined; + } catch { + return undefined; + } +} diff --git a/apps/electron-backend/src/app/events/database/recording-recovery.spec.ts b/apps/electron-backend/src/app/events/database/recording-recovery.spec.ts new file mode 100644 index 000000000..b292354bd --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recording-recovery.spec.ts @@ -0,0 +1,374 @@ +const mockGetDatabase = jest.fn(); +const mockStatSync = jest.fn(); +const mockActiveRowIds = jest.fn(); +const mockExecFileSync = jest.fn(); +const mockBroadcast = jest.fn(); + +jest.mock('../../database/connection', () => ({ + getDatabase: (...args: unknown[]) => mockGetDatabase(...args), +})); +jest.mock('node:fs/promises', () => ({ + ...jest.requireActual( + 'node:fs/promises' + ), + stat: (...args: unknown[]) => mockStatSync(...args), +})); +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), +})); +jest.mock('../../services/embedded-mpv-recording-tracker', () => ({ + embeddedMpvRecordingTracker: { + activeRowIds: (...args: unknown[]) => mockActiveRowIds(...args), + }, +})); +jest.mock('./recording-broadcast', () => ({ + broadcastRecordingsUpdate: (...args: unknown[]) => mockBroadcast(...args), +})); + +import { reconcileStaleRecordings } from './recording-recovery'; + +interface StaleRow { + id: number; + filePath: string; + endedAt: string | null; + ownerPid?: number | null; + startedAt?: string; +} + +/** A recording that began an hour ago — older than any recycled pid. */ +const ROW_STARTED_AT = new Date(Date.now() - 3_600_000).toISOString(); + +function mockDb(rows: StaleRow[]) { + const selectWhere = jest.fn().mockResolvedValue(rows); + const updateWhere = jest.fn().mockResolvedValue(undefined); + const updateSet = jest.fn( + (_patch: { + status: string; + fileSizeBytes: number | null; + endedAt: string; + }) => ({ where: updateWhere }) + ); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ where: selectWhere })), + })), + update: jest.fn(() => ({ set: updateSet })), + }; + mockGetDatabase.mockResolvedValue(db); + return { updateSet, updateWhere }; +} + +describe('reconcileStaleRecordings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActiveRowIds.mockResolvedValue(new Set()); + // A live peer pid resolves to an IPTVnator-looking process name + // unless a test says otherwise. + mockExecFileSync.mockReturnValue('iptvnator'); + }); + + it('repairs a playable partial as interrupted with its size', async () => { + const { updateSet } = mockDb([ + { id: 1, filePath: '/rec/a.ts', endedAt: null }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + + await reconcileStaleRecordings(); + + expect(updateSet).toHaveBeenCalledTimes(1); + expect(updateSet.mock.calls[0][0]).toEqual( + expect.objectContaining({ + status: 'interrupted', + fileSizeBytes: 2048, + }) + ); + expect(updateSet.mock.calls[0][0].endedAt).toEqual(expect.any(String)); + }); + + it('marks absent or empty files as failed', async () => { + const { updateSet } = mockDb([ + { id: 1, filePath: '/rec/gone.ts', endedAt: null }, + { id: 2, filePath: '/rec/empty.ts', endedAt: null }, + ]); + mockStatSync.mockRejectedValueOnce( + Object.assign(new Error('gone'), { code: 'ENOENT' }) + ); + mockStatSync.mockResolvedValueOnce({ isFile: () => true, size: 0 }); + + await reconcileStaleRecordings(); + + expect(updateSet).toHaveBeenCalledTimes(2); + expect(updateSet.mock.calls[0][0].status).toBe('failed'); + expect(updateSet.mock.calls[0][0].fileSizeBytes).toBeNull(); + expect(updateSet.mock.calls[1][0].status).toBe('failed'); + }); + + it('takes endedAt from the file mtime, not the repair time', async () => { + // The recording ended when mpv last wrote the file; an overnight + // shutdown must not inflate a short capture into hours. + const mtimeMs = Date.parse('2026-08-15T21:05:00Z'); + const { updateSet } = mockDb([ + { + id: 1, + filePath: '/rec/a.ts', + endedAt: null, + startedAt: '2026-08-15T21:00:00Z', + }, + ]); + mockStatSync.mockResolvedValue({ + isFile: () => true, + size: 2048, + mtimeMs, + }); + + await reconcileStaleRecordings(); + + expect(updateSet.mock.calls[0][0].endedAt).toBe( + new Date(mtimeMs).toISOString() + ); + }); + + it('preserves an already-known endedAt', async () => { + const { updateSet } = mockDb([ + { id: 1, filePath: '/rec/a.ts', endedAt: '2026-08-15T21:58:00Z' }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 10 }); + + await reconcileStaleRecordings(); + + expect(updateSet.mock.calls[0][0].endedAt).toBe( + '2026-08-15T21:58:00Z' + ); + }); + + it('leaves rows owned by another live process alone', async () => { + // IPTVNATOR_ALLOW_MULTIPLE_INSTANCES: the other instance is still + // writing that file, and its own finalize is guarded on status + // 'recording' — repairing here would strand the row. + const { updateSet } = mockDb([ + { id: 1, filePath: '/rec/live.ts', endedAt: null, ownerPid: 4242 }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation(() => true); + + await reconcileStaleRecordings(); + + expect(killSpy).toHaveBeenCalledWith(4242, 0); + expect(updateSet).not.toHaveBeenCalled(); + expect(mockBroadcast).not.toHaveBeenCalled(); + killSpy.mockRestore(); + }); + + it('repairs a row whose pid was recycled by an unrelated process', async () => { + // kill(pid, 0) alone would report the recycled pid as a live peer + // forever; the process-name check breaks that deadlock. + const { updateSet } = mockDb([ + { + id: 1, + filePath: '/rec/stale.ts', + endedAt: null, + ownerPid: 4242, + }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation(() => true); + mockExecFileSync.mockReturnValue('postgres'); + + await reconcileStaleRecordings(); + + expect(updateSet.mock.calls[0][0].status).toBe('interrupted'); + killSpy.mockRestore(); + }); + + it('unmasks a recycled pid that landed on another Electron app', async () => { + // The holder's name passes the family check, but it started five + // minutes ago while the recording began an hour ago — a pid frees + // only when its owner dies, so this cannot be the owner. + const { updateSet } = mockDb([ + { + id: 1, + filePath: '/rec/stale.ts', + endedAt: null, + ownerPid: 4242, + startedAt: ROW_STARTED_AT, + }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation(() => true); + mockExecFileSync.mockImplementation((_cmd, args) => { + const joined = Array.isArray(args) ? args.join(' ') : ''; + if (joined.includes('etime')) { + return '05:00'; + } + if (joined.includes('Get-Process')) { + return new Date(Date.now() - 300_000).toISOString(); + } + return 'electron'; + }); + + await reconcileStaleRecordings(); + + expect(updateSet.mock.calls[0][0].status).toBe('interrupted'); + killSpy.mockRestore(); + }); + + it('keeps skipping a same-family peer older than the recording', async () => { + const { updateSet } = mockDb([ + { + id: 1, + filePath: '/rec/live.ts', + endedAt: null, + ownerPid: 4242, + startedAt: ROW_STARTED_AT, + }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation(() => true); + mockExecFileSync.mockImplementation((_cmd, args) => { + const joined = Array.isArray(args) ? args.join(' ') : ''; + if (joined.includes('etime')) { + return '02:00:00'; + } + if (joined.includes('Get-Process')) { + return new Date(Date.now() - 7_200_000).toISOString(); + } + return 'iptvnator'; + }); + + await reconcileStaleRecordings(); + + expect(updateSet).not.toHaveBeenCalled(); + killSpy.mockRestore(); + }); + + it('leaves a row alone when the file cannot be judged', async () => { + // Timeout/permission/I-O errors are not proof of absence: the row + // stays 'recording' so a later startup can repair it honestly. + const { updateSet } = mockDb([ + { id: 1, filePath: '/rec/nas.ts', endedAt: null }, + ]); + mockStatSync.mockRejectedValue( + Object.assign(new Error('denied'), { code: 'EACCES' }) + ); + + await reconcileStaleRecordings(); + + expect(updateSet).not.toHaveBeenCalled(); + expect(mockBroadcast).not.toHaveBeenCalled(); + }); + + it('keeps skipping when the peer process name cannot be read', async () => { + // Conservative fallback: never repair a row a live peer might own. + const { updateSet } = mockDb([ + { + id: 1, + filePath: '/rec/live.ts', + endedAt: null, + ownerPid: 4242, + }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation(() => true); + mockExecFileSync.mockImplementation(() => { + throw new Error('ps unavailable'); + }); + + await reconcileStaleRecordings(); + + expect(updateSet).not.toHaveBeenCalled(); + killSpy.mockRestore(); + }); + + it('broadcasts once after repairing rows so a loaded renderer refetches', async () => { + mockDb([ + { id: 1, filePath: '/rec/a.ts', endedAt: null }, + { id: 2, filePath: '/rec/b.ts', endedAt: null }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + + await reconcileStaleRecordings(); + + expect(mockBroadcast).toHaveBeenCalledTimes(1); + }); + + it('does not broadcast when nothing needed repair', async () => { + mockDb([]); + + await reconcileStaleRecordings(); + + expect(mockBroadcast).not.toHaveBeenCalled(); + }); + + it('repairs rows whose owner process is gone', async () => { + const { updateSet } = mockDb([ + { id: 1, filePath: '/rec/dead.ts', endedAt: null, ownerPid: 4242 }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('no such process'), { + code: 'ESRCH', + }); + }); + + await reconcileStaleRecordings(); + + expect(updateSet.mock.calls[0][0].status).toBe('interrupted'); + killSpy.mockRestore(); + }); + + it('leaves a row this process is actively tracking alone', async () => { + // The renderer is interactive before this pass runs: a recording + // started during bootstrap has ownerPid === process.pid, so only the + // tracker's own ledger can prove it is live rather than a leftover. + const { updateSet } = mockDb([ + { + id: 9, + filePath: '/rec/live-now.ts', + endedAt: null, + ownerPid: process.pid, + }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + mockActiveRowIds.mockResolvedValue(new Set([9])); + + await reconcileStaleRecordings(); + + expect(updateSet).not.toHaveBeenCalled(); + }); + + it('repairs its own leftovers from a previous run', async () => { + const { updateSet } = mockDb([ + { + id: 1, + filePath: '/rec/own.ts', + endedAt: null, + ownerPid: process.pid, + }, + ]); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 2048 }); + + await reconcileStaleRecordings(); + + expect(updateSet.mock.calls[0][0].status).toBe('interrupted'); + }); + + it('swallows database failures without throwing', async () => { + mockGetDatabase.mockRejectedValue(new Error('db down')); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + await expect(reconcileStaleRecordings()).resolves.toBeUndefined(); + consoleSpy.mockRestore(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/recording-recovery.ts b/apps/electron-backend/src/app/events/database/recording-recovery.ts new file mode 100644 index 000000000..84d0569da --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recording-recovery.ts @@ -0,0 +1,319 @@ +import { execFileSync } from 'child_process'; +import { and, eq } from 'drizzle-orm'; +import { stat } from 'node:fs/promises'; +import { basename } from 'path'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { embeddedMpvRecordingTracker } from '../../services/embedded-mpv-recording-tracker'; +import { broadcastRecordingsUpdate } from './recording-broadcast'; + +const RECOVERY_STAT_TIMEOUT_MS = 3_000; + +type RecordedFileProbe = + | { kind: 'size'; size: number; mtimeMs: number | null } + | { kind: 'missing' } + | { kind: 'unknown' }; + +/** + * Bounded, asynchronous stat: the recording directory is user-selected and + * can sit on an unreachable network filesystem, which must not block startup + * or fail a row it cannot actually see. Only proven absence + * (`ENOENT`/`ENOTDIR`) reports `missing`; timeouts and every other error + * stay `unknown`. + */ +async function probeRecordedFile(filePath: string): Promise { + let timeout: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timeout = setTimeout( + () => resolve({ kind: 'unknown' }), + RECOVERY_STAT_TIMEOUT_MS + ); + }); + const probe = stat(filePath).then( + (stats): RecordedFileProbe => + stats.isFile() + ? { + kind: 'size', + size: stats.size, + // mpv last touched the file when the killed process + // stopped writing — the honest end of the recording. + mtimeMs: + Number.isFinite(stats.mtimeMs) && stats.mtimeMs > 0 + ? stats.mtimeMs + : null, + } + : { kind: 'missing' }, + (error): RecordedFileProbe => { + const code = (error as NodeJS.ErrnoException)?.code; + return code === 'ENOENT' || code === 'ENOTDIR' + ? { kind: 'missing' } + : { kind: 'unknown' }; + } + ); + try { + return await Promise.race([probe, timedOut]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +/** + * True when `pid` belongs to a process that is still running. Signal 0 only + * probes for existence; EPERM means the process exists but is owned by + * someone else, which still counts as alive. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** + * Best-effort check that the live process behind `pid` looks like another + * IPTVnator/Electron instance. After a crash the OS can hand the persisted + * `ownerPid` to an unrelated process before IPTVnator restarts; bare + * `kill(pid, 0)` liveness would then skip the row on every startup while no + * instance can ever finalize it. When the name cannot be read the answer + * stays `true` — never repair a row a live peer might still own. + */ +/** + * Bound for each synchronous `ps`/`tasklist`/PowerShell spawn. The probes + * run on the main thread once per unique pid (memoized per pass), so a hung + * process query degrades to the conservative fallback instead of blocking + * startup indefinitely. + */ +const PROCESS_PROBE_TIMEOUT_MS = 2_000; + +function processLooksLikeOwnInstance(pid: number): boolean { + try { + const output = + process.platform === 'win32' + ? execFileSync( + 'tasklist', + ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], + { encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS } + ) + : execFileSync('ps', ['-p', String(pid), '-o', 'comm='], { + encoding: 'utf8', + timeout: PROCESS_PROBE_TIMEOUT_MS, + }); + const name = output.trim().toLowerCase(); + if (!name) { + return true; + } + const ownExecutable = basename(process.execPath).toLowerCase(); + return ( + name.includes('iptvnator') || + name.includes('electron') || + (ownExecutable.length > 0 && name.includes(ownExecutable)) + ); + } catch { + return true; + } +} + +/** + * Milliseconds tolerance before a process start time counts as "after" the + * recording start — absorbs `etime`'s one-second granularity and small clock + * drift without weakening the recycled-pid discrimination. + */ +const START_TIME_TOLERANCE_MS = 5_000; + +/** `[[dd-]hh:]mm:ss` from `ps -o etime=` → seconds, or null. */ +function parseEtimeSeconds(etime: string): number | null { + const match = /^(?:(\d+)-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/.exec(etime); + if (!match) { + return null; + } + const [, days, hours, minutes, seconds] = match; + return ( + (days ? Number(days) * 86_400 : 0) + + (hours ? Number(hours) * 3_600 : 0) + + Number(minutes) * 60 + + Number(seconds) + ); +} + +function processStartTimeMs(pid: number): number | null { + try { + if (process.platform === 'win32') { + const iso = execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`, + ], + { encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS } + ).trim(); + const parsed = Date.parse(iso); + return Number.isFinite(parsed) ? parsed : null; + } + const etime = execFileSync( + 'ps', + ['-p', String(pid), '-o', 'etime='], + { encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS } + ).trim(); + const elapsedSeconds = parseEtimeSeconds(etime); + return elapsedSeconds === null + ? null + : Date.now() - elapsedSeconds * 1_000; + } catch { + return null; + } +} + +/** + * True only when the process behind `pid` PROVABLY started after the + * recording did. A pid can only be recycled once its previous holder died, + * and the recording's real owner was necessarily already running when it + * created the row — so a same-family process name (any Electron app) cannot + * shield a recycled pid whose holder is younger than the recording. + * Unreadable start times return false (conservative: keep the skip). + */ +function provablyStartedAfter( + processStartMs: number | null, + startedAt: string +): boolean { + const recordingStartMs = Date.parse(startedAt); + if (!Number.isFinite(recordingStartMs)) { + return false; + } + if (processStartMs === null) { + return false; + } + return processStartMs > recordingStartMs + START_TIME_TOLERANCE_MS; +} + +/** + * Startup repair for recordings the previous app run left in status + * 'recording' (hard kill, crash, power loss). mpv muxes MPEG-TS + * continuously, so a file with real bytes is a playable partial recording + * ('interrupted'); an absent or empty file means nothing usable was captured + * ('failed'). + * + * Rows whose owner process is still alive are skipped — but only when it + * also looks like an IPTVnator/Electron process AND cannot be proven to have + * started after the recording did. A recycled pid's holder is necessarily + * younger than the recording (the pid frees only when its previous owner + * dies), so the start-time check unmasks even a recycled pid that landed on + * another Electron app; unreadable names or start times stay conservative + * and keep the skip. The skip matters with + * IPTVNATOR_ALLOW_MULTIPLE_INSTANCES a second instance shares this database, + * and its startup must not terminate a recording the first one is actively + * writing (the tracker's own update is guarded on status 'recording', so the + * row would never get its real end time or size). + * + * Rows this process is itself tracking are skipped too: the renderer is + * interactive before this pass runs, so a recording started during bootstrap + * carries `ownerPid === process.pid` — indistinguishable by pid from a + * recycled-pid leftover, but alive by the tracker's own ledger. + */ +export async function reconcileStaleRecordings(): Promise { + try { + const db = await getDatabase(); + const stale = await db + .select() + .from(schema.recordings) + .where(eq(schema.recordings.status, 'recording')); + // Resolved after the SELECT: any row the query saw was enqueued by a + // tracker entry that already exists, so awaiting the tracked row ids + // here cannot miss it. + const liveRowIds = await embeddedMpvRecordingTracker.activeRowIds(); + + // Rows from one crashed instance share a pid: memoize the (bounded, + // synchronous) process probes so each unique pid costs at most one + // name query and one start-time query per pass. + const nameVerdicts = new Map(); + const startTimes = new Map(); + const looksLikeOwnInstance = (pid: number): boolean => { + let verdict = nameVerdicts.get(pid); + if (verdict === undefined) { + verdict = processLooksLikeOwnInstance(pid); + nameVerdicts.set(pid, verdict); + } + return verdict; + }; + const startTimeOf = (pid: number): number | null => { + if (!startTimes.has(pid)) { + startTimes.set(pid, processStartTimeMs(pid)); + } + return startTimes.get(pid) ?? null; + }; + + const candidates = stale.filter((row) => { + if (liveRowIds.has(row.id)) { + return false; + } + return !( + row.ownerPid !== null && + row.ownerPid !== undefined && + row.ownerPid !== process.pid && + isProcessAlive(row.ownerPid) && + looksLikeOwnInstance(row.ownerPid) && + !provablyStartedAfter( + startTimeOf(row.ownerPid), + row.startedAt + ) + ); + }); + + // Probe concurrently: each probe carries its own deadline, so a + // batch of rows on a dead mount costs one deadline for the whole + // pass instead of one per row (main.ts awaits this function). + const probes = await Promise.all( + candidates.map((row) => probeRecordedFile(row.filePath)) + ); + + let repairedRows = 0; + for (let i = 0; i < candidates.length; i += 1) { + const row = candidates[i]; + const probe = probes[i]; + if (probe.kind === 'unknown') { + // Cannot see the file right now — leave the row in + // 'recording' so a later startup can repair it honestly. + continue; + } + const size = probe.kind === 'size' ? probe.size : null; + const playable = size !== null && size > 0; + // The recording ended when mpv last wrote the file, not when + // this repair happens to run — an overnight shutdown must not + // inflate a five-minute capture into hours. + const capturedEndedAt = + probe.kind === 'size' && probe.mtimeMs !== null + ? new Date(probe.mtimeMs).toISOString() + : new Date().toISOString(); + await db + .update(schema.recordings) + .set({ + status: playable ? 'interrupted' : 'failed', + fileSizeBytes: playable ? size : null, + endedAt: row.endedAt ?? capturedEndedAt, + updatedAt: new Date().toISOString(), + }) + // Guarded on the status this pass observed: if the tracker's + // finalization committed between the SELECT and here, the + // row is already terminal and must not be relabelled. + .where( + and( + eq(schema.recordings.id, row.id), + eq(schema.recordings.status, 'recording') + ) + ); + repairedRows += 1; + } + + if (repairedRows > 0) { + // The renderer loads before this pass runs and may already hold + // the pre-repair list (with a stale Stop affordance); tell it. + broadcastRecordingsUpdate(); + } + } catch (error) { + console.error('[Recordings] Stale-recording repair failed:', error); + } +} diff --git a/apps/electron-backend/src/app/events/database/recordings.events.spec.ts b/apps/electron-backend/src/app/events/database/recordings.events.spec.ts new file mode 100644 index 000000000..5afcbf0ee --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recordings.events.spec.ts @@ -0,0 +1,609 @@ +// Marks the file as a module. Without it the top-level bindings below land in +// the global scope and collide with same-named consts in sibling event specs +// (stream-probe.spec.ts declares its own `registeredHandlers`). The spec +// otherwise has no static imports on purpose: every dependency is swapped +// through `jest.doMock` before the dynamic import in the harness. +export {}; + +type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; + +const registeredHandlers = new Map(); +const mockGetDatabase = jest.fn(); +const mockBroadcast = jest.fn(); +const mockStopRecording = jest.fn(); +const mockWhenSettled = jest.fn(); +const mockGetAvailabilityAsync = jest.fn(); +const mockUnlink = jest.fn(); +const mockStat = jest.fn(); +const mockOpenPath = jest.fn(); +const mockShowItemInFolder = jest.fn(); + +function getHandler(channel: string): IpcHandler { + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + return handler; +} + +async function setupRecordingsEventsHarness(): Promise { + jest.resetModules(); + registeredHandlers.clear(); + mockGetDatabase.mockReset(); + mockBroadcast.mockReset(); + mockStopRecording.mockReset(); + mockWhenSettled.mockReset().mockResolvedValue(undefined); + mockGetAvailabilityAsync.mockReset().mockResolvedValue('available'); + mockUnlink.mockReset().mockResolvedValue(undefined); + mockStat.mockReset().mockResolvedValue({ isFile: () => true, size: 4096 }); + mockOpenPath.mockReset().mockResolvedValue(''); + mockShowItemInFolder.mockReset(); + + jest.doMock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + registeredHandlers.set(channel, handler); + }), + }, + shell: { + openPath: mockOpenPath, + showItemInFolder: mockShowItemInFolder, + }, + })); + jest.doMock('node:fs/promises', () => ({ + ...jest.requireActual( + 'node:fs/promises' + ), + stat: mockStat, + unlink: mockUnlink, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: mockGetDatabase, + })); + jest.doMock('../../services/embedded-mpv-native.service', () => ({ + embeddedMpvNativeService: { stopRecording: mockStopRecording }, + })); + jest.doMock('../../services/embedded-mpv-recording-tracker', () => ({ + embeddedMpvRecordingTracker: { whenSettled: mockWhenSettled }, + })); + jest.doMock('./recording-broadcast', () => ({ + broadcastRecordingsUpdate: mockBroadcast, + })); + jest.doMock('./download-file-availability', () => ({ + getDownloadFileAvailabilityWithTimeoutAsync: mockGetAvailabilityAsync, + })); + + await import('./recordings.events'); +} + +function recordingRow(overrides: Record = {}) { + return { + id: 42, + sessionId: 'session-1', + ownerPid: null, + status: 'completed', + filePath: '/rec/News-20260815-210000.ts', + fileSizeBytes: 1024, + channelName: 'Channel One', + channelLogoUrl: null, + playlistId: 'playlist-a', + playlistName: 'My provider', + sourceType: 'm3u', + epgChannelId: null, + programTitle: 'Evening News', + programDescription: null, + programStart: '2026-08-15T21:00:00Z', + programStop: '2026-08-15T21:45:00Z', + programsJson: null, + errorMessage: null, + startedAt: '2026-08-15T21:00:00Z', + endedAt: '2026-08-15T21:58:00Z', + createdAt: null, + updatedAt: null, + ...overrides, + }; +} + +function mockListDb(rows: unknown[]) { + const orderBy = jest.fn().mockResolvedValue(rows); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ orderBy })), + orderBy, + })), + })), + }; + mockGetDatabase.mockResolvedValue(db); + return { db, orderBy }; +} + +function mockRowDb(row: unknown | undefined) { + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const updateWhere = jest.fn().mockResolvedValue(undefined); + const updateSet = jest.fn((_patch: Record) => ({ + where: updateWhere, + })); + const limit = jest.fn().mockResolvedValue(row ? [row] : []); + const db = { + delete: jest.fn(() => ({ where: deleteWhere })), + update: jest.fn(() => ({ set: updateSet })), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit, + orderBy: jest.fn(() => ({ limit })), + })), + })), + })), + }; + mockGetDatabase.mockResolvedValue(db); + return { db, deleteWhere, updateSet, updateWhere }; +} + +describe('recordings events', () => { + beforeEach(async () => { + await setupRecordingsEventsHarness(); + }); + + describe('RECORDINGS_GET_LIST', () => { + it('decorates rows with availability and decoded programs', async () => { + mockListDb([ + recordingRow({ + programsJson: JSON.stringify([ + { + title: 'Evening News', + start: '2026-08-15T21:00:00Z', + stop: '2026-08-15T21:45:00Z', + }, + { + title: 'Weather', + start: '2026-08-15T21:45:00Z', + stop: '2026-08-15T22:10:00Z', + }, + ]), + }), + ]); + + const result = (await getHandler('RECORDINGS_GET_LIST')( + null + )) as Array>; + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + id: 42, + fileAvailability: 'available', + channelName: 'Channel One', + }) + ); + expect(result[0].programs).toHaveLength(2); + // Availability probe sees interrupted/completed as completed. + expect(mockGetAvailabilityAsync).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ); + }); + + it('reports the live size of an active recording from disk', async () => { + // The tracker only persists file_size_bytes at finalization, so + // the manager's growing size has to come from a stat here. + mockListDb([ + recordingRow({ + status: 'recording', + fileSizeBytes: null, + endedAt: null, + }), + ]); + + const result = (await getHandler('RECORDINGS_GET_LIST')( + null + )) as Array>; + + expect(mockStat).toHaveBeenCalledWith( + '/rec/News-20260815-210000.ts' + ); + expect(result[0].fileSizeBytes).toBe(4096); + }); + + it('keeps the persisted size for terminal rows without statting', async () => { + mockListDb([recordingRow()]); + + const result = (await getHandler('RECORDINGS_GET_LIST')( + null + )) as Array>; + + expect(mockStat).not.toHaveBeenCalled(); + expect(result[0].fileSizeBytes).toBe(1024); + }); + + it('omits the live size when the active file cannot be statted', async () => { + mockStat.mockRejectedValue(new Error('gone')); + mockListDb([ + recordingRow({ status: 'recording', fileSizeBytes: null }), + ]); + + const result = (await getHandler('RECORDINGS_GET_LIST')( + null + )) as Array>; + + expect(result[0].fileSizeBytes).toBeUndefined(); + }); + + it('passes an inconclusive availability probe through as unknown', async () => { + // Timeout/permission errors are not proof of absence; collapsing + // them to 'missing' would move a good recording on a slow mount + // to Needs attention and hide its file actions. + mockGetAvailabilityAsync.mockResolvedValue('unknown'); + mockListDb([recordingRow()]); + + const result = (await getHandler('RECORDINGS_GET_LIST')( + null + )) as Array>; + + expect(result[0].fileAvailability).toBe('unknown'); + }); + + it('bounds a hanging live-size stat so the list still resolves', async () => { + // A recording directory on a dead network filesystem can leave + // the stat pending forever; the list awaits every decorator, so + // the probe must time out to "no size" instead of wedging it. + mockStat.mockReturnValue(new Promise(() => undefined)); + mockListDb([ + recordingRow({ + status: 'recording', + fileSizeBytes: null, + endedAt: null, + }), + ]); + + jest.useFakeTimers(); + try { + const pending = getHandler('RECORDINGS_GET_LIST')(null); + await jest.advanceTimersByTimeAsync(1_100); + const result = (await pending) as Array< + Record + >; + expect(result[0].fileSizeBytes).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + it('probes interrupted rows like completed ones but not failed rows', async () => { + mockListDb([ + recordingRow({ status: 'interrupted' }), + recordingRow({ id: 43, status: 'failed' }), + ]); + await getHandler('RECORDINGS_GET_LIST')(null); + expect(mockGetAvailabilityAsync.mock.calls[0][0].status).toBe( + 'completed' + ); + expect(mockGetAvailabilityAsync.mock.calls[1][0].status).toBe( + 'failed' + ); + }); + }); + + describe('RECORDINGS_STOP', () => { + it('stops through the embedded MPV service for an active row', async () => { + mockRowDb( + recordingRow({ status: 'recording', endedAt: null }) + ); + await expect( + getHandler('RECORDINGS_STOP')(null, 42) + ).resolves.toEqual({ success: true }); + expect(mockStopRecording).toHaveBeenCalledWith('session-1'); + }); + + it('refuses rows that are not active or lack a session id', async () => { + mockRowDb(recordingRow()); + await expect( + getHandler('RECORDINGS_STOP')(null, 42) + ).resolves.toEqual({ + error: 'Recording is not active', + success: false, + }); + + mockRowDb( + recordingRow({ status: 'recording', sessionId: null }) + ); + await expect( + getHandler('RECORDINGS_STOP')(null, 42) + ).resolves.toMatchObject({ success: false }); + expect(mockStopRecording).not.toHaveBeenCalled(); + }); + + it('refuses to stop a recording owned by another instance', async () => { + // Session ids restart per process, so dispatching a foreign row's + // id would stop an unrelated local recording. + mockRowDb( + recordingRow({ + status: 'recording', + endedAt: null, + ownerPid: process.pid + 1, + }) + ); + await expect( + getHandler('RECORDINGS_STOP')(null, 42) + ).resolves.toMatchObject({ success: false }); + expect(mockStopRecording).not.toHaveBeenCalled(); + }); + + it('stops a row this process owns', async () => { + mockRowDb( + recordingRow({ + status: 'recording', + endedAt: null, + ownerPid: process.pid, + }) + ); + await expect( + getHandler('RECORDINGS_STOP')(null, 42) + ).resolves.toEqual({ success: true }); + expect(mockStopRecording).toHaveBeenCalledWith('session-1'); + }); + + it('normalizes a native stop failure into an error result', async () => { + mockRowDb(recordingRow({ status: 'recording' })); + mockStopRecording.mockImplementation(() => { + throw new Error('addon unavailable'); + }); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + await expect( + getHandler('RECORDINGS_STOP')(null, 42) + ).resolves.toEqual({ error: 'addon unavailable', success: false }); + consoleSpy.mockRestore(); + }); + }); + + describe('RECORDINGS_REMOVE', () => { + it('removes a finished row, keeps its file, and broadcasts', async () => { + const { deleteWhere } = mockRowDb(recordingRow()); + await expect( + getHandler('RECORDINGS_REMOVE')(null, 42) + ).resolves.toEqual({ success: true }); + expect(mockUnlink).not.toHaveBeenCalled(); + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(mockBroadcast).toHaveBeenCalledTimes(1); + }); + + it('cleans up the leftover file for failed rows, best-effort', async () => { + mockRowDb(recordingRow({ status: 'failed' })); + mockUnlink.mockRejectedValue(new Error('already gone')); + await expect( + getHandler('RECORDINGS_REMOVE')(null, 42) + ).resolves.toEqual({ success: true }); + expect(mockUnlink).toHaveBeenCalledWith( + '/rec/News-20260815-210000.ts' + ); + }); + + it('keeps the file when another row already owns that path', async () => { + // A retry inside the same timestamp second reuses the freed name; + // unlinking here would take the newer recording's file. + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const limit = jest + .fn() + .mockResolvedValueOnce([recordingRow({ status: 'failed' })]) + .mockResolvedValueOnce([{ id: 42 }, { id: 77 }]); + mockGetDatabase.mockResolvedValue({ + delete: jest.fn(() => ({ where: deleteWhere })), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + }); + + await expect( + getHandler('RECORDINGS_REMOVE')(null, 42) + ).resolves.toEqual({ success: true }); + + expect(mockUnlink).not.toHaveBeenCalled(); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it('refuses to remove an active recording', async () => { + const { deleteWhere } = mockRowDb( + recordingRow({ status: 'recording' }) + ); + await expect( + getHandler('RECORDINGS_REMOVE')(null, 42) + ).resolves.toMatchObject({ success: false }); + expect(deleteWhere).not.toHaveBeenCalled(); + }); + + it('reports a missing row', async () => { + mockRowDb(undefined); + await expect( + getHandler('RECORDINGS_REMOVE')(null, 42) + ).resolves.toEqual({ + error: 'Recording not found', + success: false, + }); + }); + }); + + describe('RECORDINGS_UPDATE_PROGRAMS', () => { + const programs = [ + { + title: 'Evening News', + start: '2026-08-15T21:00:00Z', + stop: '2026-08-15T21:45:00Z', + }, + ]; + + it('rejects invalid payloads without touching the database', async () => { + await expect( + getHandler('RECORDINGS_UPDATE_PROGRAMS')(null, '', programs) + ).resolves.toMatchObject({ success: false }); + await expect( + getHandler('RECORDINGS_UPDATE_PROGRAMS')( + null, + '/rec/a.ts', + 'not-an-array' + ) + ).resolves.toMatchObject({ success: false }); + expect(mockGetDatabase).not.toHaveBeenCalled(); + }); + + it('waits for the queued row INSERT before looking the row up', async () => { + // A recording stopped milliseconds after starting: the row may + // still be queued, and an unsynchronized lookup would find + // nothing. + let releaseTracker: (() => void) | undefined; + mockWhenSettled.mockReturnValue( + new Promise((resolve) => { + releaseTracker = resolve; + }) + ); + const { updateSet } = mockRowDb(recordingRow()); + + const pending = getHandler('RECORDINGS_UPDATE_PROGRAMS')( + null, + '/rec/News-20260815-210000.ts', + programs + ); + await Promise.resolve(); + expect(mockGetDatabase).not.toHaveBeenCalled(); + + releaseTracker?.(); + await expect(pending).resolves.toEqual({ success: true }); + expect(updateSet).toHaveBeenCalledTimes(1); + }); + + it('writes programs and keeps an existing headline program', async () => { + const { updateSet } = mockRowDb(recordingRow()); + await expect( + getHandler('RECORDINGS_UPDATE_PROGRAMS')( + null, + '/rec/News-20260815-210000.ts', + programs + ) + ).resolves.toEqual({ success: true }); + const patch = updateSet.mock.calls[0][0]; + expect(JSON.parse(patch.programsJson)).toHaveLength(1); + expect(patch).not.toHaveProperty('programTitle'); + expect(mockBroadcast).toHaveBeenCalledTimes(1); + }); + + it('backfills the headline program when the start snapshot had none', async () => { + const { updateSet } = mockRowDb( + recordingRow({ programTitle: null }) + ); + await getHandler('RECORDINGS_UPDATE_PROGRAMS')( + null, + '/rec/News-20260815-210000.ts', + programs + ); + expect(updateSet.mock.calls[0][0]).toEqual( + expect.objectContaining({ + programTitle: 'Evening News', + programStart: '2026-08-15T21:00:00Z', + programStop: '2026-08-15T21:45:00Z', + }) + ); + }); + + it('enriches a row that has not finalized yet', async () => { + // Enrichment and finalization are order-independent: finalize() + // writes status/time/size and never touches programs_json. + const { updateSet } = mockRowDb( + recordingRow({ + status: 'recording', + endedAt: null, + programTitle: null, + }) + ); + + await expect( + getHandler('RECORDINGS_UPDATE_PROGRAMS')( + null, + '/rec/News-20260815-210000.ts', + programs + ) + ).resolves.toEqual({ success: true }); + expect(JSON.parse(updateSet.mock.calls[0][0].programsJson)).toEqual( + programs + ); + }); + + it('reports when no row matches the path', async () => { + mockRowDb(undefined); + await expect( + getHandler('RECORDINGS_UPDATE_PROGRAMS')( + null, + '/rec/unknown.ts', + programs + ) + ).resolves.toEqual({ + error: 'Recording not found', + success: false, + }); + }); + }); + + describe('file actions', () => { + it('gates reveal on the recordings table and availability', async () => { + mockRowDb(undefined); + await expect( + getHandler('RECORDINGS_REVEAL_FILE')(null, '/rec/foreign.ts') + ).resolves.toEqual({ error: 'File not found', success: false }); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + + mockRowDb(recordingRow()); + await expect( + getHandler('RECORDINGS_REVEAL_FILE')( + null, + '/rec/News-20260815-210000.ts' + ) + ).resolves.toEqual({ success: true }); + expect(mockShowItemInFolder).toHaveBeenCalledWith( + '/rec/News-20260815-210000.ts' + ); + }); + + it('refuses managed paths whose file is provenly gone', async () => { + mockRowDb(recordingRow()); + mockGetAvailabilityAsync.mockResolvedValue('missing'); + await expect( + getHandler('RECORDINGS_PLAY_FILE')( + null, + '/rec/News-20260815-210000.ts' + ) + ).resolves.toEqual({ error: 'File not found', success: false }); + expect(mockOpenPath).not.toHaveBeenCalled(); + }); + + it('lets the shell try when the availability probe is inconclusive', async () => { + // Only proven absence refuses the action: a timed-out probe on + // a slow mount must not block Play — the OS answers honestly. + mockRowDb(recordingRow()); + mockGetAvailabilityAsync.mockResolvedValue('unknown'); + await expect( + getHandler('RECORDINGS_PLAY_FILE')( + null, + '/rec/News-20260815-210000.ts' + ) + ).resolves.toEqual({ success: true }); + expect(mockOpenPath).toHaveBeenCalledWith( + '/rec/News-20260815-210000.ts' + ); + }); + + it('plays a managed available file', async () => { + mockRowDb(recordingRow()); + await expect( + getHandler('RECORDINGS_PLAY_FILE')( + null, + '/rec/News-20260815-210000.ts' + ) + ).resolves.toEqual({ success: true }); + expect(mockOpenPath).toHaveBeenCalledWith( + '/rec/News-20260815-210000.ts' + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/recordings.events.ts b/apps/electron-backend/src/app/events/database/recordings.events.ts new file mode 100644 index 000000000..da2c08990 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/recordings.events.ts @@ -0,0 +1,387 @@ +import type { + ElectronRecordingItem, + RecordingProgramSnapshot, +} from '@iptvnator/shared/interfaces'; +import { desc, eq } from 'drizzle-orm'; +import { ipcMain, shell } from 'electron'; +import { stat, unlink } from 'node:fs/promises'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { embeddedMpvNativeService } from '../../services/embedded-mpv-native.service'; +import { embeddedMpvRecordingTracker } from '../../services/embedded-mpv-recording-tracker'; +import { getDownloadFileAvailabilityWithTimeoutAsync } from './download-file-availability'; +import { broadcastRecordingsUpdate } from './recording-broadcast'; +import { + decodeRecordingPrograms, + sanitizeRecordingPrograms, +} from './recording-programs'; + +type RecordingRow = schema.Recording; + +const LIVE_SIZE_PROBE_TIMEOUT_MS = 1_000; + +/** + * Coalesced raw stat for an actively recorded file. Mirrors the availability + * probe's contract: caller deadlines never evict the in-flight operation, so + * a stat stalled on a slow network filesystem is not duplicated by every + * manager refresh; completed results are dropped so growth stays visible. + */ +const inFlightSizeProbes = new Map>(); + +function probeLiveFileSize(filePath: string): Promise { + const existing = inFlightSizeProbes.get(filePath); + if (existing) { + return existing; + } + const probe = stat(filePath).then( + (stats) => (stats.isFile() ? stats.size : null), + () => null + ); + inFlightSizeProbes.set(filePath, probe); + const clear = () => { + if (inFlightSizeProbes.get(filePath) === probe) { + inFlightSizeProbes.delete(filePath); + } + }; + void probe.then(clear, clear); + return probe; +} + +/** + * Bytes on disk for a recording still being written. The tracker only + * persists `file_size_bytes` at finalization, so the manager's active row + * would otherwise report nothing while mpv keeps growing the file. The stat + * is bounded: `RECORDINGS_GET_LIST` awaits every decorator, so one stat + * hanging on an unreachable filesystem must degrade to "no size" instead of + * wedging every manager refresh. + */ +async function liveFileSizeBytes(filePath: string): Promise { + let timeout: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), LIVE_SIZE_PROBE_TIMEOUT_MS); + }); + try { + return await Promise.race([probeLiveFileSize(filePath), timedOut]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +async function decorateRecordingItem( + row: RecordingRow +): Promise { + // Recordings reuse the download availability probe (bounded lstat with + // in-flight coalescing) through the variant that preserves 'unknown': + // a timed-out or errored probe is not proof of absence, and collapsing + // it to 'missing' would move a good recording on a slow mount to Needs + // attention and hide its Play/Reveal actions. 'interrupted' partials + // are playable files, so they are probed exactly like completed rows; + // 'recording' and 'failed' map to a non-completed status and come back + // 'not-applicable'. + const fileAvailability = await getDownloadFileAvailabilityWithTimeoutAsync({ + filePath: row.filePath, + status: + row.status === 'completed' || row.status === 'interrupted' + ? 'completed' + : 'failed', + }); + + const fileSizeBytes = + row.status === 'recording' + ? ((await liveFileSizeBytes(row.filePath)) ?? undefined) + : (row.fileSizeBytes ?? undefined); + + return { + id: row.id, + sessionId: row.sessionId ?? undefined, + status: row.status, + filePath: row.filePath, + fileSizeBytes, + channelName: row.channelName, + channelLogoUrl: row.channelLogoUrl ?? undefined, + playlistId: row.playlistId ?? undefined, + playlistName: row.playlistName ?? undefined, + sourceType: row.sourceType ?? undefined, + epgChannelId: row.epgChannelId ?? undefined, + programTitle: row.programTitle ?? undefined, + programDescription: row.programDescription ?? undefined, + programStart: row.programStart ?? undefined, + programStop: row.programStop ?? undefined, + programs: decodeRecordingPrograms(row.programsJson), + errorMessage: row.errorMessage ?? undefined, + startedAt: row.startedAt, + endedAt: row.endedAt ?? undefined, + createdAt: row.createdAt ?? undefined, + updatedAt: row.updatedAt ?? undefined, + fileAvailability, + }; +} + +/** + * Shell-touching IPCs may only operate on paths the main process itself + * reserved via `reserveRecordingTargetPath` — the renderer-supplied recording + * directory is a write-location preference, not a shell-access grant. + */ +async function isManagedRecordingFile(filePath: string): Promise { + if (!filePath) { + return false; + } + try { + const db = await getDatabase(); + const rows = await db + .select({ id: schema.recordings.id }) + .from(schema.recordings) + .where(eq(schema.recordings.filePath, filePath)) + .limit(1); + return rows.length > 0; + } catch (error) { + console.error('Error verifying managed recording path:', error); + return false; + } +} + +ipcMain.handle('RECORDINGS_GET_LIST', async (_event, playlistId?: string) => { + try { + const db = await getDatabase(); + const query = db.select().from(schema.recordings); + const rows = await (playlistId + ? query + .where(eq(schema.recordings.playlistId, playlistId)) + .orderBy(desc(schema.recordings.startedAt)) + : query.orderBy(desc(schema.recordings.startedAt))); + return Promise.all(rows.map((row) => decorateRecordingItem(row))); + } catch (error) { + console.error('[Recordings] Error getting recording list:', error); + throw error; + } +}); + +ipcMain.handle('RECORDINGS_GET', async (_event, recordingId: number) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.recordings) + .where(eq(schema.recordings.id, recordingId)) + .limit(1); + return result[0] ? await decorateRecordingItem(result[0]) : null; + } catch (error) { + console.error('[Recordings] Error getting recording:', error); + throw error; + } +}); + +ipcMain.handle('RECORDINGS_STOP', async (_event, recordingId: number) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.recordings) + .where(eq(schema.recordings.id, recordingId)) + .limit(1); + const row = result[0]; + if (!row || row.status !== 'recording' || !row.sessionId) { + return { error: 'Recording is not active', success: false }; + } + // Session ids restart per process, so under + // IPTVNATOR_ALLOW_MULTIPLE_INSTANCES another instance's row can name + // a session id that exists locally as an unrelated recording. Only + // the owning process may stop it. + if ( + row.ownerPid !== null && + row.ownerPid !== undefined && + row.ownerPid !== process.pid + ) { + return { + error: 'Recording belongs to another IPTVnator instance', + success: false, + }; + } + // Finalization (status, ended_at, file size, broadcast) happens in + // the recording tracker's stop hook, exactly like a stop from the + // player controls. + embeddedMpvNativeService.stopRecording(row.sessionId); + return { success: true }; + } catch (error) { + console.error('[Recordings] Error stopping recording:', error); + return { + error: error instanceof Error ? error.message : 'Stop failed', + success: false, + }; + } +}); + +ipcMain.handle('RECORDINGS_REMOVE', async (_event, recordingId: number) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.recordings) + .where(eq(schema.recordings.id, recordingId)) + .limit(1); + const row = result[0]; + if (!row) { + return { error: 'Recording not found', success: false }; + } + if (row.status === 'recording') { + return { + error: 'Stop the recording before removing it', + success: false, + }; + } + // Finished recordings keep their file on disk (same contract as + // completed downloads); only a failed row's leftover reservation is + // cleaned up best-effort — and only while no other row claims that + // path. A retry within the same timestamp second reuses the freed + // name, and unlinking then would take the newer recording's file + // (mpv would keep writing to an inode nobody can reach). + if (row.status === 'failed' && row.filePath) { + const otherOwners = await db + .select({ id: schema.recordings.id }) + .from(schema.recordings) + .where(eq(schema.recordings.filePath, row.filePath)) + .limit(2); + const pathIsExclusive = otherOwners.every( + (candidate) => candidate.id === row.id + ); + if (pathIsExclusive) { + // Bounded: a hung network unlink must not keep the Remove + // action busy — deleting the row is what matters, and the + // cleanup contract is best-effort anyway. + let unlinkDeadline: + | ReturnType + | undefined; + await Promise.race([ + unlink(row.filePath).catch(() => undefined), + new Promise((resolve) => { + unlinkDeadline = setTimeout( + resolve, + LIVE_SIZE_PROBE_TIMEOUT_MS + ); + }), + ]); + if (unlinkDeadline !== undefined) { + clearTimeout(unlinkDeadline); + } + } + } + await db + .delete(schema.recordings) + .where(eq(schema.recordings.id, recordingId)); + broadcastRecordingsUpdate(); + return { success: true }; + } catch (error) { + console.error('[Recordings] Error removing recording:', error); + return { + error: error instanceof Error ? error.message : 'Remove failed', + success: false, + }; + } +}); + +ipcMain.handle( + 'RECORDINGS_UPDATE_PROGRAMS', + async ( + _event, + targetPath: string, + programs: RecordingProgramSnapshot[] + ) => { + try { + const sanitized = sanitizeRecordingPrograms(programs); + if (!targetPath || sanitized === null) { + return { error: 'Invalid programs payload', success: false }; + } + // Only guarantees the row's INSERT has committed — a recording + // stopped within milliseconds of starting. Enrichment does not + // wait for finalization: the row is found in any status and + // `finalize()` never touches `programs_json`, so the two writes + // are order-independent and no deadline can drop the programs. + await embeddedMpvRecordingTracker.whenSettled(); + const db = await getDatabase(); + // `openSync('wx')` makes the reserved path exclusive while a + // recording owns it, so the newest row for that path is the + // recording that was just stopped — whatever status it currently + // carries. + const rows = await db + .select() + .from(schema.recordings) + .where(eq(schema.recordings.filePath, targetPath)) + .orderBy(desc(schema.recordings.id)) + .limit(1); + const row = rows[0]; + if (!row) { + return { error: 'Recording not found', success: false }; + } + const first = sanitized[0]; + await db + .update(schema.recordings) + .set({ + programsJson: JSON.stringify(sanitized), + // Backfill the headline program only when the start + // snapshot had none — the program airing at start stays + // authoritative for the title. + ...(row.programTitle || !first + ? {} + : { + programTitle: first.title, + programDescription: first.description, + programStart: first.start, + programStop: first.stop, + }), + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.recordings.id, row.id)); + broadcastRecordingsUpdate(); + return { success: true }; + } catch (error) { + console.error('[Recordings] Error updating programs:', error); + return { + error: error instanceof Error ? error.message : 'Update failed', + success: false, + }; + } + } +); + +/** + * Bounded pre-shell gate: only PROVEN absence refuses the action. The + * synchronous lstat variant would block the main process on a dead mount, + * and an inconclusive probe (timeout, permission error) must still let the + * shell try — the OS gives the honest answer either way. + */ +async function isRecordingFileMissing(filePath: string): Promise { + const availability = await getDownloadFileAvailabilityWithTimeoutAsync({ + filePath, + status: 'completed', + }); + return availability === 'missing'; +} + +ipcMain.handle('RECORDINGS_REVEAL_FILE', async (_event, filePath: string) => { + if ( + !(await isManagedRecordingFile(filePath)) || + (await isRecordingFileMissing(filePath)) + ) { + return { error: 'File not found', success: false }; + } + shell.showItemInFolder(filePath); + return { success: true }; +}); + +ipcMain.handle('RECORDINGS_PLAY_FILE', async (_event, filePath: string) => { + if ( + !(await isManagedRecordingFile(filePath)) || + (await isRecordingFileMissing(filePath)) + ) { + return { error: 'File not found', success: false }; + } + const error = await shell.openPath(filePath); + if (!error) { + return { success: true }; + } + return (await isRecordingFileMissing(filePath)) + ? { error: 'File not found', success: false } + : { error, success: false }; +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 19c01502a..65a5e6141 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -28,6 +28,7 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { toNativeViewBounds } from './embedded-mpv-bounds.util'; +import { embeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker'; import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; import { getFrameCopyRuntimeAvailability, @@ -605,9 +606,11 @@ export class EmbeddedMpvNativeService { options.directory?.trim() || this.getDefaultRecordingFolder(); mkdirSync(directory, { recursive: true }); + const fallbackChannelName = + options.title || session.title || 'IPTVnator recording'; const targetPath = this.reserveRecordingTargetPath( directory, - options.title || session.title || 'IPTVnator recording' + fallbackChannelName ); try { addon.startRecording(sessionId, targetPath); @@ -615,6 +618,15 @@ export class EmbeddedMpvNativeService { this.releaseReservedRecordingTargetPath(targetPath); throw error; } + // Register with the tracker before the refresh below broadcasts the + // first snapshot, so an immediately-active recording (frame-copy) is + // observed as such. + embeddedMpvRecordingTracker.onRecordingStarted({ + sessionId, + targetPath, + fallbackChannelName, + metadata: options.metadata, + }); return this.refreshSession(sessionId); } @@ -627,6 +639,7 @@ export class EmbeddedMpvNativeService { ); } addon.stopRecording(sessionId); + embeddedMpvRecordingTracker.onRecordingStopped(sessionId); return this.refreshSession(sessionId); } @@ -898,6 +911,12 @@ export class EmbeddedMpvNativeService { } private sendSessionUpdate(session: EmbeddedMpvSession): void { + // The tracker must see every snapshot — including the synthetic + // closed/error payloads — even when no window is left to notify: + // implicit stops (stream replacement, helper crash, dispose) are only + // observable here. + embeddedMpvRecordingTracker.observeSnapshot(session); + if (!App.mainWindow || App.mainWindow.isDestroyed()) { return; } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.spec.ts new file mode 100644 index 000000000..253aff74f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.spec.ts @@ -0,0 +1,579 @@ +import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces'; + +const mockGetDatabase = jest.fn(); +const mockBroadcast = jest.fn(); +const mockStatSync = jest.fn(); +const mockUnlinkSync = jest.fn(); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => mockGetDatabase(...args), +})); +jest.mock('../events/database/recording-broadcast', () => ({ + broadcastRecordingsUpdate: (...args: unknown[]) => mockBroadcast(...args), +})); +jest.mock('node:fs/promises', () => ({ + ...jest.requireActual( + 'node:fs/promises' + ), + stat: (...args: unknown[]) => mockStatSync(...args), + unlink: (...args: unknown[]) => mockUnlinkSync(...args), +})); + +import { EmbeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker'; + +interface DbHarness { + insertValues: jest.Mock; + updateSet: jest.Mock; + updateWhere: jest.Mock; +} + +function mockDb(): DbHarness { + const insertValues = jest + .fn((_row: Record) => + Promise.resolve({ lastInsertRowid: BigInt(7) }) + ) + .mockResolvedValue({ lastInsertRowid: BigInt(7) }); + const updateWhere = jest.fn().mockResolvedValue(undefined); + const updateSet = jest.fn((_patch: Record) => ({ + where: updateWhere, + })); + const db = { + insert: jest.fn(() => ({ values: insertValues })), + update: jest.fn(() => ({ set: updateSet })), + }; + mockGetDatabase.mockResolvedValue(db); + return { insertValues, updateSet, updateWhere }; +} + +function session(overrides: Partial): EmbeddedMpvSession { + return { + id: 'session-1', + title: 'Channel One', + streamUrl: 'http://stream', + status: 'playing', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + startedAt: '2026-08-15T21:00:00Z', + updatedAt: '2026-08-15T21:00:00Z', + ...overrides, + }; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const started = (tracker: EmbeddedMpvRecordingTracker) => + tracker.onRecordingStarted({ + sessionId: 'session-1', + targetPath: '/rec/News-20260815-210000.ts', + fallbackChannelName: 'Channel One', + metadata: { + channelName: 'Channel One', + channelLogoUrl: 'http://logo.png', + playlistId: 'playlist-a', + playlistName: 'My provider', + sourceType: 'm3u', + epgChannelId: 'channel.one', + currentProgram: { + title: 'Evening News', + start: '2026-08-15T21:00:00Z', + stop: '2026-08-15T21:45:00Z', + }, + }, + }); + +const inactiveSnapshot = (overrides: Partial = {}) => + session({ + recording: { + active: false, + targetPath: '/rec/News-20260815-210000.ts', + }, + ...overrides, + }); + +/** + * Pushes the inactive snapshot and drives the settle window that guards + * against macOS native-view's optimistic pre-reply state. + */ +async function settleStop( + tracker: EmbeddedMpvRecordingTracker, + overrides: Partial = {} +): Promise { + jest.useFakeTimers(); + try { + tracker.observeSnapshot(inactiveSnapshot(overrides)); + await jest.advanceTimersByTimeAsync(1_600); + } finally { + jest.useRealTimers(); + } + await flush(); +} + +const activeSnapshot = () => + session({ + recording: { + active: true, + targetPath: '/rec/News-20260815-210000.ts', + startedAt: '2026-08-15T21:00:01Z', + }, + }); + +describe('EmbeddedMpvRecordingTracker', () => { + let tracker: EmbeddedMpvRecordingTracker; + let db: DbHarness; + + beforeEach(() => { + jest.clearAllMocks(); + db = mockDb(); + mockStatSync.mockResolvedValue({ isFile: () => true, size: 1024 }); + mockUnlinkSync.mockResolvedValue(undefined); + tracker = new EmbeddedMpvRecordingTracker(); + }); + + it('inserts a row with the start metadata and broadcasts', async () => { + started(tracker); + await flush(); + + expect(db.insertValues).toHaveBeenCalledTimes(1); + expect(db.insertValues.mock.calls[0][0]).toEqual( + expect.objectContaining({ + status: 'recording', + sessionId: 'session-1', + filePath: '/rec/News-20260815-210000.ts', + channelName: 'Channel One', + channelLogoUrl: 'http://logo.png', + playlistId: 'playlist-a', + playlistName: 'My provider', + sourceType: 'm3u', + epgChannelId: 'channel.one', + programTitle: 'Evening News', + programStart: '2026-08-15T21:00:00Z', + programStop: '2026-08-15T21:45:00Z', + }) + ); + expect(mockBroadcast).toHaveBeenCalledTimes(1); + }); + + it('falls back to the session title when metadata is absent', async () => { + tracker.onRecordingStarted({ + sessionId: 'session-1', + targetPath: '/rec/x.ts', + fallbackChannelName: 'Fallback Channel', + }); + await flush(); + expect(db.insertValues.mock.calls[0][0].channelName).toBe( + 'Fallback Channel' + ); + }); + + it('finalizes an explicit stop only after mpv acknowledges it', async () => { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + await flush(); + + // addon.stopRecording() only dispatches: statting or unlinking here + // could hit a file mpv has not flushed yet. + expect(db.updateSet).not.toHaveBeenCalled(); + + await settleStop(tracker); + + expect(db.updateSet).toHaveBeenCalledTimes(1); + expect(db.updateSet.mock.calls[0][0]).toEqual( + expect.objectContaining({ + status: 'completed', + fileSizeBytes: 1024, + }) + ); + expect(db.updateSet.mock.calls[0][0].endedAt).toEqual( + expect.any(String) + ); + // Start + finalize + expect(mockBroadcast).toHaveBeenCalledTimes(2); + }); + + it('treats an observed active→inactive flip as the auto-stop', async () => { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + await settleStop(tracker); + expect(db.updateSet.mock.calls[0][0].status).toBe('completed'); + }); + + it('abandons finalization when a rejected stop revives the recording', async () => { + // macOS native-view clears recordingActive before dispatching the + // async property set and restores it if that request is rejected, so + // the first inactive snapshot is not an acknowledgement. + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + + jest.useFakeTimers(); + try { + tracker.observeSnapshot(inactiveSnapshot()); + await jest.advanceTimersByTimeAsync(500); + // mpv rejected the stop: it is recording again. + tracker.observeSnapshot(activeSnapshot()); + await jest.advanceTimersByTimeAsync(3_000); + } finally { + jest.useRealTimers(); + } + await flush(); + + expect(db.updateSet).not.toHaveBeenCalled(); + }); + + it('ignores inactive snapshots while an async start is still settling', async () => { + started(tracker); + tracker.observeSnapshot( + session({ + recording: { + active: false, + targetPath: '/rec/News-20260815-210000.ts', + }, + }) + ); + await flush(); + expect(db.updateSet).not.toHaveBeenCalled(); + }); + + it('marks a failed async start and unlinks the empty reservation', async () => { + mockStatSync.mockResolvedValue({ isFile: () => true, size: 0 }); + started(tracker); + tracker.observeSnapshot( + session({ + recording: { + active: false, + targetPath: '/rec/News-20260815-210000.ts', + error: 'property unavailable', + }, + }) + ); + await flush(); + + expect(db.updateSet.mock.calls[0][0]).toEqual( + expect.objectContaining({ + status: 'failed', + fileSizeBytes: null, + errorMessage: 'property unavailable', + }) + ); + expect(mockUnlinkSync).toHaveBeenCalledWith( + '/rec/News-20260815-210000.ts' + ); + }); + + /** + * Pushes an error/closed snapshot and drives the teardown flush window + * that lets the frame-copy helper exit and flush before the stat. + */ + async function settleTeardown( + push: () => void, + advanceMs = 2_600 + ): Promise { + jest.useFakeTimers(); + try { + push(); + await jest.advanceTimersByTimeAsync(advanceMs); + } finally { + jest.useRealTimers(); + } + await flush(); + } + + it('finalizes as interrupted when the session errors mid-recording', async () => { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + await settleTeardown(() => + tracker.observeSnapshot(session({ status: 'error' })) + ); + expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted'); + }); + + it('finalizes as interrupted on the synthetic closed dispose snapshot', async () => { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + await settleTeardown(() => + tracker.observeSnapshot( + session({ + status: 'closed', + recording: { + active: false, + targetPath: '/rec/News-20260815-210000.ts', + }, + }) + ) + ); + expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted'); + }); + + it('defers the teardown stat until the helper had time to flush', async () => { + // disposeSession() emits the synthetic snapshot immediately, but the + // frame-copy helper exits up to ~2 s later — statting right away + // would persist a truncated size or misread a short capture. + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + + jest.useFakeTimers(); + try { + tracker.observeSnapshot(session({ status: 'closed' })); + await jest.advanceTimersByTimeAsync(2_000); + // Row must stay 'recording' (recoverable) through the window. + expect(db.updateSet).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(600); + } finally { + jest.useRealTimers(); + } + await flush(); + expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted'); + }); + + it('lets an acknowledged stop finalize as completed despite a dispose', async () => { + // Stop acknowledged (inactive snapshot, settle pending) and then the + // player is closed: the recording ended cleanly, so the armed settle + // timer wins and the row must not be relabelled 'interrupted'. + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + + jest.useFakeTimers(); + try { + tracker.observeSnapshot(inactiveSnapshot()); + tracker.observeSnapshot(session({ status: 'closed' })); + await jest.advanceTimersByTimeAsync(3_000); + } finally { + jest.useRealTimers(); + } + await flush(); + + expect(db.updateSet).toHaveBeenCalledTimes(1); + expect(db.updateSet.mock.calls[0][0].status).toBe('completed'); + }); + + it('degrades an interrupted stop to failed when the file is gone', async () => { + mockStatSync.mockRejectedValue( + Object.assign(new Error('gone'), { code: 'ENOENT' }) + ); + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + await settleTeardown(() => + tracker.observeSnapshot(session({ status: 'closed' })) + ); + expect(db.updateSet.mock.calls[0][0].status).toBe('failed'); + }); + + it('keeps the requested status when the file cannot be judged', async () => { + // Only proven absence may fail the row; a permission/I-O error (or + // a stat stalled past its deadline) must not brand a likely-good + // recording on an unreachable mount as failed. + mockStatSync.mockRejectedValue( + Object.assign(new Error('denied'), { code: 'EACCES' }) + ); + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + await settleStop(tracker); + + expect(db.updateSet.mock.calls[0][0]).toEqual( + expect.objectContaining({ + status: 'completed', + fileSizeBytes: null, + }) + ); + expect(mockUnlinkSync).not.toHaveBeenCalled(); + }); + + it('is idempotent: only the first finalize writes', async () => { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + tracker.onRecordingStopped('session-1'); + await settleTeardown( + () => tracker.observeSnapshot(session({ status: 'closed' })), + 11_000 + ); + expect(db.updateSet).toHaveBeenCalledTimes(1); + }); + + it('serializes a stop that races the pending insert', async () => { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + await settleStop(tracker); + expect(db.insertValues).toHaveBeenCalledTimes(1); + expect(db.updateSet).toHaveBeenCalledTimes(1); + }); + + it('finalizes a stop mpv never acknowledges once the bound elapses', async () => { + jest.useFakeTimers(); + try { + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + jest.advanceTimersByTime(10_000); + } finally { + jest.useRealTimers(); + } + await flush(); + expect(db.updateSet.mock.calls[0][0].status).toBe('completed'); + }); + + it('keeps the file of a recording that went active even when it looks empty', async () => { + mockStatSync.mockResolvedValue({ isFile: () => true, size: 0 }); + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + await settleStop(tracker); + + expect(db.updateSet.mock.calls[0][0].status).toBe('failed'); + // Those bytes belong to mpv; only a never-started reservation is + // cleaned up. + expect(mockUnlinkSync).not.toHaveBeenCalled(); + }); + + it('keeps a settle timer from finalizing a restarted recording', async () => { + // Stop → immediate restart on the same session: the old entry's + // settle timer must finalize the OLD row, and the new recording must + // still finalize on its own stop — session-id-bound finalization + // used to end the new row and strand the old one in 'recording'. + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + + jest.useFakeTimers(); + try { + tracker.observeSnapshot(inactiveSnapshot()); + // Restart before the 1.5 s settle window expires. + tracker.onRecordingStarted({ + sessionId: 'session-1', + targetPath: '/rec/News-20260815-213000.ts', + fallbackChannelName: 'Channel One', + }); + await jest.advanceTimersByTimeAsync(1_600); + + // Only the old recording finalized. + expect(db.updateSet).toHaveBeenCalledTimes(1); + expect(db.updateSet.mock.calls[0][0].status).toBe('completed'); + + // The new recording is still tracked: its own stop finalizes it. + tracker.observeSnapshot( + session({ + recording: { + active: true, + targetPath: '/rec/News-20260815-213000.ts', + }, + }) + ); + tracker.observeSnapshot( + session({ + recording: { + active: false, + targetPath: '/rec/News-20260815-213000.ts', + }, + }) + ); + await jest.advanceTimersByTimeAsync(1_600); + } finally { + jest.useRealTimers(); + } + await flush(); + + expect(db.updateSet).toHaveBeenCalledTimes(2); + expect(db.insertValues).toHaveBeenCalledTimes(2); + }); + + it('finalizes a replaced recording even when no stop was ever observed', async () => { + // A new start on the same session replaces the map entry, so + // snapshots can never again reach the old entry — the replacement + // itself must arm the finalization. + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + + jest.useFakeTimers(); + try { + tracker.onRecordingStarted({ + sessionId: 'session-1', + targetPath: '/rec/News-20260815-213000.ts', + fallbackChannelName: 'Channel One', + }); + await jest.advanceTimersByTimeAsync(1_600); + } finally { + jest.useRealTimers(); + } + await flush(); + + expect(db.updateSet).toHaveBeenCalledTimes(1); + expect(db.updateSet.mock.calls[0][0].status).toBe('completed'); + }); + + it('keeps a finalizing row in the ledger until its update commits', async () => { + // finalize() removes the entry from the open map immediately, but + // the row stays persisted as 'recording' until the queued terminal + // update commits — startup recovery must still see it as live. + let releaseUpdate!: () => void; + db.updateWhere.mockReturnValue( + new Promise((resolve) => { + releaseUpdate = () => resolve(); + }) + ); + started(tracker); + tracker.observeSnapshot(activeSnapshot()); + tracker.onRecordingStopped('session-1'); + + jest.useFakeTimers(); + try { + tracker.observeSnapshot(inactiveSnapshot()); + await jest.advanceTimersByTimeAsync(1_600); + } finally { + jest.useRealTimers(); + } + + // The update is dispatched but not committed: still in the ledger. + expect(db.updateSet).toHaveBeenCalledTimes(1); + await expect(tracker.activeRowIds()).resolves.toEqual(new Set([7])); + + releaseUpdate(); + await flush(); + await expect(tracker.activeRowIds()).resolves.toEqual(new Set()); + }); + + it('records the owning process so recovery can skip live rows', async () => { + started(tracker); + await flush(); + expect(db.insertValues.mock.calls[0][0].ownerPid).toBe(process.pid); + }); + + it('whenSettled resolves once the queued row INSERT committed', async () => { + // Enrichment relies on this and nothing else: it no longer waits for + // finalization, so there is no deadline left to race. + started(tracker); + await tracker.whenSettled(); + expect(db.insertValues).toHaveBeenCalledTimes(1); + }); + + it('ignores snapshots for sessions without an open recording', async () => { + tracker.observeSnapshot(activeSnapshot()); + tracker.observeSnapshot(session({ status: 'closed' })); + await flush(); + expect(db.updateSet).not.toHaveBeenCalled(); + expect(mockBroadcast).not.toHaveBeenCalled(); + }); + + it('survives a failed insert without finalizing or broadcasting', async () => { + db.insertValues.mockRejectedValue(new Error('db down')); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + started(tracker); + tracker.onRecordingStopped('session-1'); + await flush(); + expect(db.updateSet).not.toHaveBeenCalled(); + expect(mockBroadcast).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts b/apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts new file mode 100644 index 000000000..685654846 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts @@ -0,0 +1,417 @@ +import { and, eq } from 'drizzle-orm'; +import { stat, unlink } from 'node:fs/promises'; +import { + EmbeddedMpvSession, + RecordingStartMetadata, +} from '@iptvnator/shared/interfaces'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { broadcastRecordingsUpdate } from '../events/database/recording-broadcast'; + +interface RecordingStartedEvent { + sessionId: string; + targetPath: string; + fallbackChannelName: string; + metadata?: RecordingStartMetadata; +} + +interface OpenRecordingEntry { + sessionId: string; + targetPath: string; + startedAt: string; + /** + * Whether any session snapshot reported this recording as active. A + * successful start may pass through `active: false` snapshots while the + * async mpv property set settles, so `active: false` alone is not a stop. + */ + sawActive: boolean; + /** Set once a stop was requested; the acknowledged snapshot finalizes. */ + stopRequested: boolean; + /** Fires if mpv never acknowledges the requested stop. */ + stopFallbackTimer?: ReturnType; + /** Armed by an inactive snapshot; finalizes once the stop looks settled. */ + settleTimer?: ReturnType; + /** + * Set by the first finalize. Timers hold the entry itself, so an entry + * replaced in the map (stop → immediate restart on the same session) can + * still be finalized late — this flag keeps that from happening twice. + */ + finalized: boolean; + rowId: Promise; +} + +type RecordingFinalStatus = 'completed' | 'interrupted' | 'failed'; + +/** + * mpv acknowledges a stop asynchronously (`mpv_set_property_async`, or a + * command written to the frame-copy helper), so the tracker waits for the + * snapshot that confirms it. This bound keeps a lost acknowledgement from + * leaving the row `recording` forever. + */ +const STOP_ACKNOWLEDGEMENT_TIMEOUT_MS = 10_000; + +/** + * How long an observed inactive recording must stay inactive before it is + * finalized. + * + * macOS native-view clears `recordingActive` *before* dispatching the async + * `stream-record` property set and restores it (with an error) if that + * request is rejected, so the first inactive snapshot is optimistic rather + * than an acknowledgement. Three 500 ms poll cycles are enough for that + * revert to arrive. The frame-copy helper sets the property synchronously and + * is unaffected; it simply finalizes a beat later. + */ +const STOP_SETTLE_WINDOW_MS = 1_500; + +/** + * How long a synthetic error/closed snapshot defers finalization. + * + * `disposeSession()` emits that snapshot immediately, while the frame-copy + * helper still has a 0.5 s quit grace and a 2 s SIGTERM grace before SIGKILL + * — statting the file earlier can record a truncated size or mistake a short + * capture for an empty failure. The row stays `recording` (and therefore + * repairable by startup recovery) until the window elapses, so an app quit + * that outruns the timer loses nothing. + */ +const TEARDOWN_FLUSH_WINDOW_MS = 2_500; + +/** + * Deadline for the finalization stat. The recording directory is + * user-selected and may sit on a network filesystem; an unbounded stat + * would stall the tracker's serialized queue (blocking new inserts and stop + * enrichment's `whenSettled`). A timed-out or error-shaped probe reports + * `unknown` — the recording is then finalized without branding a + * likely-good file as failed. + */ +const FINALIZE_STAT_TIMEOUT_MS = 3_000; + +type FinalizeFileProbe = + | { kind: 'size'; size: number } + | { kind: 'missing' } + | { kind: 'unknown' }; + +/** + * Persists the lifecycle of embedded-MPV live recordings into the + * `recordings` table. + * + * Explicit start/stop hooks are the only place where the start metadata and + * the reserved target path are visible deterministically, but they are + * requests, not outcomes: `addon.stopRecording()` merely dispatches, so + * finalization always waits for the snapshot that reports the recording + * inactive. That same observer covers the stop paths which never call + * `stopRecording()` at all (stream-replacement auto-stop, a frame-copy helper + * crash that leaves `active: true` behind, session error/close). Rows that + * survive a hard app kill in status 'recording' are repaired by + * `reconcileStaleRecordings()` at startup. + */ +export class EmbeddedMpvRecordingTracker { + private readonly open = new Map(); + /** + * Entries whose finalization is queued but whose terminal database + * update has not committed yet. `finalize()` removes an entry from + * `open` immediately, so without this ledger `activeRowIds()` would + * omit a row still persisted as `recording` and startup recovery could + * repair it mid-finalization. + */ + private readonly finalizing = new Set(); + /** Serializes DB mutations so a fast stop cannot outrun its insert. */ + private chain: Promise = Promise.resolve(); + + onRecordingStarted(event: RecordingStartedEvent): void { + // A session runs at most one recording, so a new start means any + // recording still open on this session has stopped. Once the map + // entry is replaced, snapshots can no longer reach the old entry — + // make sure one of its own timers finalizes it (after the settle + // window, so mpv gets to flush the old file first). + const replaced = this.open.get(event.sessionId); + if (replaced && !replaced.finalized) { + if (replaced.settleTimer !== undefined) { + clearTimeout(replaced.settleTimer); + } + replaced.settleTimer = setTimeout(() => { + this.finalize(replaced, 'completed'); + }, STOP_SETTLE_WINDOW_MS); + } + + const startedAt = new Date().toISOString(); + const metadata = event.metadata; + const rowId = this.enqueue(async () => { + const db = await getDatabase(); + const result = await db.insert(schema.recordings).values({ + sessionId: event.sessionId, + // Recovery uses this to tell a crashed run's leftovers from a + // row another live process still owns. + ownerPid: process.pid, + status: 'recording', + filePath: event.targetPath, + channelName: + metadata?.channelName?.trim() || event.fallbackChannelName, + channelLogoUrl: metadata?.channelLogoUrl, + playlistId: metadata?.playlistId, + playlistName: metadata?.playlistName, + sourceType: metadata?.sourceType, + epgChannelId: metadata?.epgChannelId, + programTitle: metadata?.currentProgram?.title, + programDescription: metadata?.currentProgram?.description, + programStart: metadata?.currentProgram?.start, + programStop: metadata?.currentProgram?.stop, + startedAt, + }); + return Number(result.lastInsertRowid); + }); + + this.open.set(event.sessionId, { + sessionId: event.sessionId, + targetPath: event.targetPath, + startedAt, + sawActive: false, + stopRequested: false, + finalized: false, + rowId, + }); + void rowId.then((id) => { + if (id !== null) { + broadcastRecordingsUpdate(); + } + }); + } + + /** + * Stop requested through the stop IPC/controls. The row is finalized by + * the acknowledged snapshot, not here: stopping is asynchronous, and + * statting (or unlinking) the file before mpv flushed it would report a + * short recording as failed and could delete bytes still being written. + */ + onRecordingStopped(sessionId: string): void { + const entry = this.open.get(sessionId); + if (!entry || entry.stopRequested) { + return; + } + entry.stopRequested = true; + entry.stopFallbackTimer = setTimeout(() => { + // mpv never confirmed; finalize anyway so the row cannot stay + // `recording` for the rest of the session. + this.finalize(entry, 'completed'); + }, STOP_ACKNOWLEDGEMENT_TIMEOUT_MS); + } + + /** + * Snapshot observer, wired into the service's session-update fan-out. It + * rides the existing 500 ms diffed poll — no timers of its own. + */ + observeSnapshot(session: EmbeddedMpvSession): void { + const entry = this.open.get(session.id); + if (!entry) { + return; + } + + if (session.status === 'error' || session.status === 'closed') { + // Teardown may still be flushing the file (the frame-copy helper + // exits up to ~2 s after this synthetic snapshot), so finalize + // after the flush window rather than statting now. An armed + // settle timer means the stop was already acknowledged before + // the session went down — leave it to finalize as 'completed'. + if (entry.settleTimer === undefined) { + entry.settleTimer = setTimeout(() => { + this.finalize(entry, 'interrupted'); + }, TEARDOWN_FLUSH_WINDOW_MS); + } + return; + } + + const recording = session.recording; + if (!recording) { + return; + } + + if (recording.active && recording.targetPath === entry.targetPath) { + entry.sawActive = true; + // A rejected stop puts the recording back: abandon the pending + // finalization instead of ending a row that is still recording. + if (entry.settleTimer !== undefined) { + clearTimeout(entry.settleTimer); + entry.settleTimer = undefined; + } + return; + } + + if (recording.active) { + return; + } + + if (entry.sawActive) { + // Inactive, but on native-view that state precedes mpv's reply. + // Finalize only once it survived the settle window. + if (entry.settleTimer === undefined) { + entry.settleTimer = setTimeout(() => { + this.finalize(entry, 'completed'); + }, STOP_SETTLE_WINDOW_MS); + } + } else if (recording.error) { + // The async start failed before the recording ever activated. + this.finalize(entry, 'failed', recording.error); + } + // else: start still settling — a successful async start passes + // through active:false snapshots before the property set applies. + } + + /** + * Resolves once every mutation enqueued so far has committed. + * + * Stop enrichment awaits this so the row's INSERT is guaranteed to exist + * before it is looked up (a recording stopped within milliseconds of + * starting). It deliberately involves no deadline: it waits for work, not + * for a clock, and the enrichment does not care whether the row has + * reached its terminal status yet. + */ + whenSettled(): Promise { + return this.chain; + } + + /** + * Row ids of the recordings this process is actively tracking. + * + * Startup recovery consults this because the renderer is interactive + * before the reconciliation pass runs: a recording started in that window + * carries `ownerPid === process.pid`, which the pid heuristic alone would + * misread as a recycled-pid leftover from a previous run. + */ + async activeRowIds(): Promise> { + // Open recordings AND rows whose finalization has not committed yet + // — both are still persisted as `recording` and must not be + // repaired by startup recovery. + const entries = [...this.open.values(), ...this.finalizing]; + const ids = await Promise.all(entries.map((entry) => entry.rowId)); + return new Set(ids.filter((id): id is number => id !== null)); + } + + private finalize( + entry: OpenRecordingEntry, + status: RecordingFinalStatus, + errorMessage?: string + ): void { + if (entry.finalized) { + return; + } + entry.finalized = true; + // Keep the row visible to activeRowIds() until the terminal update + // commits — it is still persisted as 'recording' meanwhile. + this.finalizing.add(entry); + // The map may already point at a newer recording on the same + // session (stop → immediate restart); only remove the mapping when + // it is still this entry's. + if (this.open.get(entry.sessionId) === entry) { + this.open.delete(entry.sessionId); + } + if (entry.stopFallbackTimer !== undefined) { + clearTimeout(entry.stopFallbackTimer); + } + if (entry.settleTimer !== undefined) { + clearTimeout(entry.settleTimer); + } + + void this.enqueue(async () => { + const rowId = await entry.rowId; + if (rowId === null) { + return null; + } + + const probe = await this.probeFinalFile(entry.targetPath); + let finalStatus: RecordingFinalStatus = status; + let fileSize: number | null = null; + if (probe.kind === 'size' && probe.size > 0) { + fileSize = probe.size; + } else if (probe.kind === 'unknown') { + // The stat timed out or errored (slow or unreachable + // filesystem): keep the requested status with an unknown + // size instead of branding a likely-good recording failed. + } else { + // Absent or empty — nothing usable reached the disk, so the + // honest terminal state is 'failed'. + finalStatus = 'failed'; + if ( + probe.kind === 'size' && + probe.size === 0 && + !entry.sawActive + ) { + // The empty file is the pre-reserved target of a + // recording that never started; a recording that did go + // active keeps its file even when it looks empty, since + // mpv owns those bytes. + this.safeUnlink(entry.targetPath); + } + } + + const db = await getDatabase(); + await db + .update(schema.recordings) + .set({ + status: finalStatus, + endedAt: new Date().toISOString(), + fileSizeBytes: fileSize, + ...(errorMessage ? { errorMessage } : {}), + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq(schema.recordings.id, rowId), + eq(schema.recordings.status, 'recording') + ) + ); + return rowId; + }).then((rowId) => { + this.finalizing.delete(entry); + if (rowId !== null) { + broadcastRecordingsUpdate(); + } + }); + } + + private enqueue(work: () => Promise): Promise { + const result = this.chain.then(work).catch((error) => { + console.error('[Recordings] tracker persistence failed:', error); + return null; + }); + this.chain = result.then(() => undefined); + return result; + } + + private async probeFinalFile(filePath: string): Promise { + let timeout: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timeout = setTimeout( + () => resolve({ kind: 'unknown' }), + FINALIZE_STAT_TIMEOUT_MS + ); + }); + const probe = stat(filePath).then( + (stats): FinalizeFileProbe => + stats.isFile() + ? { kind: 'size', size: stats.size } + : { kind: 'missing' }, + (error): FinalizeFileProbe => { + const code = (error as NodeJS.ErrnoException)?.code; + // Only proven absence may fail the row; every other error + // (permissions, I/O, unreachable mount) stays unknown. + return code === 'ENOENT' || code === 'ENOTDIR' + ? { kind: 'missing' } + : { kind: 'unknown' }; + } + ); + try { + return await Promise.race([probe, timedOut]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } + } + + private safeUnlink(filePath: string): void { + // Fire-and-forget: best-effort cleanup of the 0-byte reservation + // must not stall the serialized queue on a hung filesystem. + void unlink(filePath).catch(() => undefined); + } +} + +export const embeddedMpvRecordingTracker = new EmbeddedMpvRecordingTracker(); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 69b3d70b0..367ca55ad 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -9,6 +9,8 @@ import { resetStaleDownloads, setMainWindow as setDownloadsMainWindow, } from './app/events/database/downloads.events'; +import { setRecordingsMainWindow } from './app/events/database/recording-broadcast'; +import { reconcileStaleRecordings } from './app/events/database/recording-recovery'; import ElectronEvents from './app/events/electron.events'; import EmbeddedMpvEvents, { shutdownEmbeddedMpv, @@ -173,6 +175,7 @@ export default class Main { // rebuild the window while the process runs, and a stale reference // silently swallows every DOWNLOADS_UPDATE_EVENT. App.onMainWindowCreated(setDownloadsMainWindow); + App.onMainWindowCreated(setRecordingsMainWindow); // Load the renderer only after IPC handlers are registered. On slower // Linux CI hosts the renderer can otherwise invoke Electron bridge IPC @@ -196,6 +199,12 @@ export default class Main { trace('startup', 'reset-stale-downloads:done'); } + await reconcileStaleRecordings(); + + if (isStartupTraceEnabled()) { + trace('startup', 'reconcile-stale-recordings:done'); + } + if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:done'); } diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index a3626638a..691f79699 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -15,6 +15,10 @@ describe('app routes', () => { runtime: { isElectron: boolean }, router: { parseUrl: (url: string) => unknown } ) => unknown; + let resolveRecordingsCapabilityRoute: ( + runtime: { supportsRecordings: boolean }, + router: { parseUrl: (url: string) => unknown } + ) => unknown; beforeAll(async () => { jest.unstable_mockModule( @@ -55,6 +59,8 @@ describe('app routes', () => { workspaceChildren = workspaceRoute?.children ?? []; resolveElectronOnlyGlobalSearchRoute = appRoutes.resolveElectronOnlyGlobalSearchRoute; + resolveRecordingsCapabilityRoute = + appRoutes.resolveRecordingsCapabilityRoute; }); afterEach(() => { @@ -176,6 +182,33 @@ describe('app routes', () => { expect(result).toBe(redirectTree); }); + it('guards the focused recording detail behind the recordings capability', async () => { + const recordingRoute = workspaceChildren.find( + (route) => route.path === 'downloads/recording/:recordingId' + ); + + expect(recordingRoute?.canActivate).toHaveLength(1); + }); + + it('redirects the recording detail to the manager without the capability', async () => { + const redirectTree = { url: '/workspace/downloads' }; + const parseUrl = jest.fn().mockReturnValue(redirectTree); + + const result = resolveRecordingsCapabilityRoute( + { supportsRecordings: false }, + { parseUrl } + ); + + expect(parseUrl).toHaveBeenCalledWith('/workspace/downloads'); + expect(result).toBe(redirectTree); + expect( + resolveRecordingsCapabilityRoute( + { supportsRecordings: true }, + { parseUrl } + ) + ).toBe(true); + }); + it('uses a dynamic redirect for the default /workspace child route', async () => { const workspaceDefaultRoute = workspaceChildren.find( (route) => route.path === '' diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 565bfd3eb..c02ee01da 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -33,6 +33,28 @@ const electronOnlyGlobalSearchGuard = () => { ); }; +/** + * The focused recording detail depends on `RecordingsService`, whose list + * never becomes authoritative without the recordings capability — the PWA + * would render a permanently blank workspace instead of the not-found + * redirect. Send it to the manager, which owns its own unavailable state. + */ +export function resolveRecordingsCapabilityRoute( + runtime: Pick, + router: Pick +) { + return runtime.supportsRecordings + ? true + : router.parseUrl('/workspace/downloads'); +} + +const recordingsCapabilityGuard = () => { + return resolveRecordingsCapabilityRoute( + inject(RuntimeCapabilitiesService), + inject(Router) + ); +}; + export const routes: Routes = [ { path: '', @@ -112,6 +134,14 @@ export const routes: Routes = [ (c) => c.GlobalSearchResultsComponent ), }, + { + path: 'downloads/recording/:recordingId', + canActivate: [recordingsCapabilityGuard], + loadComponent: () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.RecordingDetailComponent + ), + }, { path: 'downloads/:downloadId', loadComponent: () => diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index f05901cc3..96dd79bc0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1196,7 +1196,9 @@ "FAILED": "فشل", "CANCELED": "ملغى", "PAUSED": "متوقف مؤقتًا", - "FILE_MISSING": "الملف مفقود" + "FILE_MISSING": "الملف مفقود", + "RECORDING": "جارٍ التسجيل", + "INTERRUPTED": "انقطع" }, "ACTIVE_COUNT": "{{count}} نشط", "TRACKED_DOWNLOADS": "التنزيلات المتتبعة", @@ -1211,7 +1213,22 @@ "ALL": "الكل", "MOVIES": "الأفلام", "SERIES": "المسلسلات", - "IN_PROGRESS": "قيد التقدم" + "IN_PROGRESS": "قيد التقدم", + "RECORDINGS": "التسجيلات" + }, + "RECORDING_NOW": "جارٍ التسجيل الآن", + "RECORDINGS": "التسجيلات", + "RECORDING_BADGE": "جارٍ التسجيل", + "STOP_RECORDING": "إيقاف التسجيل", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "إزالة التسجيل من مدير التنزيلات؟", + "MESSAGE": "يؤدي هذا إلى إزالة الإدخال من IPTVnator. ويبقى الملف المسجّل على القرص." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "البرامج المسجّلة", + "RECORDED_FROM": "تم التسجيل من {{playlist}}", + "MISSING_BODY": "لم يعد ملف التسجيل موجودًا على القرص. لا يمكن إعادة تسجيل البث المباشر، لكن يمكنك إزالة الإدخال من مدير التنزيلات.", + "FAILED_BODY": "فشل هذا التسجيل ولم ينتج عنه أي ملف قابل للتشغيل." }, "REMOVE_FROM_MANAGER": "إزالة من مدير التنزيلات", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 5bf907456..c19d29267 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1196,7 +1196,9 @@ "FAILED": "فشل", "CANCELED": "تلغى", "PAUSED": "موقوف مؤقتاً", - "FILE_MISSING": "الملف ناقص" + "FILE_MISSING": "الملف ناقص", + "RECORDING": "جاري التسجيل", + "INTERRUPTED": "تقطع" }, "ACTIVE_COUNT": "{{count}} نشيط", "TRACKED_DOWNLOADS": "التحميلات المتتبعة", @@ -1211,7 +1213,22 @@ "ALL": "الكل", "MOVIES": "الأفلام", "SERIES": "السلاسل", - "IN_PROGRESS": "كيتم التحميل" + "IN_PROGRESS": "كيتم التحميل", + "RECORDINGS": "التسجيلات" + }, + "RECORDING_NOW": "كيتسجل دابا", + "RECORDINGS": "التسجيلات", + "RECORDING_BADGE": "تسجيل", + "STOP_RECORDING": "وقّف التسجيل", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "نحيدو التسجيل من مدير التحميلات؟", + "MESSAGE": "هاد العملية كتحيد هاد العنصر من IPTVnator. الملف المسجل كيبقى على القرص." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "البرامج المسجلة", + "RECORDED_FROM": "تسجل من {{playlist}}", + "MISSING_BODY": "ملف التسجيل ما بقاش على القرص. البث المباشر ما يمكنش يتعاود تسجيلو، ولكن تقدر تحيد هاد العنصر من مدير التحميلات.", + "FAILED_BODY": "هاد التسجيل فشل وما عطى حتى ملف يقدر يتشغل." }, "REMOVE_FROM_MANAGER": "حيد من مسير التحميلات", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index f115fa435..a16a2fb97 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1196,7 +1196,9 @@ "FAILED": "Няўдала", "CANCELED": "Скасавана", "PAUSED": "На паўзе", - "FILE_MISSING": "Файл адсутнічае" + "FILE_MISSING": "Файл адсутнічае", + "RECORDING": "Запісваецца", + "INTERRUPTED": "Перарвана" }, "ACTIVE_COUNT": "{{count}} актыўныя", "TRACKED_DOWNLOADS": "Адсочваныя спампоўкі", @@ -1211,7 +1213,22 @@ "ALL": "Усе", "MOVIES": "Фільмы", "SERIES": "Серыялы", - "IN_PROGRESS": "Выконваецца" + "IN_PROGRESS": "Выконваецца", + "RECORDINGS": "Запісы" + }, + "RECORDING_NOW": "Зараз запісваецца", + "RECORDINGS": "Запісы", + "RECORDING_BADGE": "Ідзе запіс", + "STOP_RECORDING": "Спыніць запіс", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Выдаліць запіс з менеджара?", + "MESSAGE": "Гэта выдаліць запіс з IPTVnator. Запісаны файл застанецца на дыску." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Запісаныя перадачы", + "RECORDED_FROM": "Запісана з {{playlist}}", + "MISSING_BODY": "Файл запісу больш недаступны на дыску. Прамую трансляцыю нельга запісаць паўторна, але вы можаце выдаліць запіс з менеджара.", + "FAILED_BODY": "Гэты запіс не ўдаўся, таму файла для прайгравання няма." }, "REMOVE_FROM_MANAGER": "Выдаліць з менеджара спамповак", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 94aba1023..85faf5100 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1196,7 +1196,9 @@ "FAILED": "Fehlgeschlagen", "CANCELED": "Abgebrochen", "PAUSED": "Pausiert", - "FILE_MISSING": "Datei fehlt" + "FILE_MISSING": "Datei fehlt", + "RECORDING": "Wird aufgenommen", + "INTERRUPTED": "Unterbrochen" }, "ACTIVE_COUNT": "{{count}} aktiv", "TRACKED_DOWNLOADS": "Überwachte Downloads", @@ -1211,7 +1213,22 @@ "ALL": "Alle", "MOVIES": "Filme", "SERIES": "Serien", - "IN_PROGRESS": "In Bearbeitung" + "IN_PROGRESS": "In Bearbeitung", + "RECORDINGS": "Aufnahmen" + }, + "RECORDING_NOW": "Aufnahme läuft", + "RECORDINGS": "Aufnahmen", + "RECORDING_BADGE": "Aufnahme", + "STOP_RECORDING": "Aufnahme stoppen", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Aufnahme aus dem Manager entfernen?", + "MESSAGE": "Dadurch wird der Eintrag aus IPTVnator entfernt. Die aufgenommene Datei bleibt auf der Festplatte erhalten." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Aufgenommene Sendungen", + "RECORDED_FROM": "Aufgenommen aus {{playlist}}", + "MISSING_BODY": "Die Aufnahmedatei ist nicht mehr auf der Festplatte vorhanden. Eine Live-Übertragung kann nicht erneut aufgenommen werden, aber Sie können den Eintrag aus dem Manager entfernen.", + "FAILED_BODY": "Diese Aufnahme ist fehlgeschlagen und hat keine abspielbare Datei erzeugt." }, "REMOVE_FROM_MANAGER": "Aus dem Download-Manager entfernen", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 5fc9a209e..55c8f33f3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1196,7 +1196,9 @@ "FAILED": "Απέτυχε", "CANCELED": "Ακυρώθηκε", "PAUSED": "Σε παύση", - "FILE_MISSING": "Λείπει το αρχείο" + "FILE_MISSING": "Λείπει το αρχείο", + "RECORDING": "Εγγραφή", + "INTERRUPTED": "Διακόπηκε" }, "ACTIVE_COUNT": "{{count}} ενεργά", "TRACKED_DOWNLOADS": "Παρακολουθούμενες λήψεις", @@ -1211,7 +1213,22 @@ "ALL": "Όλα", "MOVIES": "Ταινίες", "SERIES": "Σειρές", - "IN_PROGRESS": "Σε εξέλιξη" + "IN_PROGRESS": "Σε εξέλιξη", + "RECORDINGS": "Εγγραφές" + }, + "RECORDING_NOW": "Εγγραφή σε εξέλιξη", + "RECORDINGS": "Εγγραφές", + "RECORDING_BADGE": "Εγγραφή", + "STOP_RECORDING": "Διακοπή εγγραφής", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Αφαίρεση εγγραφής από τη διαχείριση;", + "MESSAGE": "Αυτό αφαιρεί την καταχώριση από το IPTVnator. Το αρχείο εγγραφής παραμένει στον δίσκο." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Εγγεγραμμένα προγράμματα", + "RECORDED_FROM": "Εγγράφηκε από {{playlist}}", + "MISSING_BODY": "Το αρχείο εγγραφής δεν βρίσκεται πλέον στον δίσκο. Μια ζωντανή μετάδοση δεν μπορεί να εγγραφεί ξανά, αλλά μπορείτε να αφαιρέσετε την καταχώριση από τη διαχείριση.", + "FAILED_BODY": "Αυτή η εγγραφή απέτυχε και δεν δημιούργησε αρχείο που να μπορεί να αναπαραχθεί." }, "REMOVE_FROM_MANAGER": "Αφαίρεση από τον διαχειριστή λήψεων", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ab0c8f2e4..4caeb987a 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1196,7 +1196,9 @@ "FAILED": "Failed", "CANCELED": "Canceled", "PAUSED": "Paused", - "FILE_MISSING": "File missing" + "FILE_MISSING": "File missing", + "RECORDING": "Recording", + "INTERRUPTED": "Interrupted" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1211,7 +1213,22 @@ "ALL": "All", "MOVIES": "Movies", "SERIES": "Series", - "IN_PROGRESS": "In progress" + "IN_PROGRESS": "In progress", + "RECORDINGS": "Recordings" + }, + "RECORDING_NOW": "Recording now", + "RECORDINGS": "Recordings", + "RECORDING_BADGE": "Recording", + "STOP_RECORDING": "Stop recording", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Remove recording from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The recorded file remains on disk." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Recorded programs", + "RECORDED_FROM": "Recorded from {{playlist}}", + "MISSING_BODY": "The recording file is no longer on disk. A live broadcast cannot be re-recorded, but you can remove the entry from the manager.", + "FAILED_BODY": "This recording failed and produced no playable file." }, "REMOVE_FROM_MANAGER": "Remove from manager", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index d711a5001..0124fc1e1 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1196,7 +1196,9 @@ "FAILED": "Fallido", "CANCELED": "Cancelado", "PAUSED": "En pausa", - "FILE_MISSING": "Falta el archivo" + "FILE_MISSING": "Falta el archivo", + "RECORDING": "Grabando", + "INTERRUPTED": "Interrumpido" }, "ACTIVE_COUNT": "{{count}} activas", "TRACKED_DOWNLOADS": "Descargas registradas", @@ -1211,7 +1213,22 @@ "ALL": "Todas", "MOVIES": "Películas", "SERIES": "Series de TV", - "IN_PROGRESS": "En curso" + "IN_PROGRESS": "En curso", + "RECORDINGS": "Grabaciones" + }, + "RECORDING_NOW": "Grabando ahora", + "RECORDINGS": "Grabaciones", + "RECORDING_BADGE": "Grabando", + "STOP_RECORDING": "Detener grabación", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "¿Quitar la grabación del gestor?", + "MESSAGE": "Esto elimina la entrada de IPTVnator. El archivo grabado permanece en el disco." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Programas grabados", + "RECORDED_FROM": "Grabado desde {{playlist}}", + "MISSING_BODY": "El archivo de la grabación ya no está en el disco. Una emisión en vivo no se puede volver a grabar, pero puedes quitar la entrada del gestor.", + "FAILED_BODY": "Esta grabación ha fallado y no ha generado ningún archivo reproducible." }, "REMOVE_FROM_MANAGER": "Quitar del administrador de descargas", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b25aa2bbd..92db1dffa 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1196,7 +1196,9 @@ "FAILED": "Échec", "CANCELED": "Annulé", "PAUSED": "En pause", - "FILE_MISSING": "Fichier manquant" + "FILE_MISSING": "Fichier manquant", + "RECORDING": "Enregistrement", + "INTERRUPTED": "Interrompu" }, "ACTIVE_COUNT": "{{count}} actifs", "TRACKED_DOWNLOADS": "Téléchargements suivis", @@ -1211,7 +1213,22 @@ "ALL": "Tous", "MOVIES": "Films", "SERIES": "Séries", - "IN_PROGRESS": "En cours" + "IN_PROGRESS": "En cours", + "RECORDINGS": "Enregistrements" + }, + "RECORDING_NOW": "Enregistrement en cours", + "RECORDINGS": "Enregistrements", + "RECORDING_BADGE": "Enregistrement", + "STOP_RECORDING": "Arrêter l'enregistrement", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Retirer l'enregistrement du gestionnaire ?", + "MESSAGE": "Cela retire l'entrée d'IPTVnator. Le fichier enregistré reste sur le disque." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Programmes enregistrés", + "RECORDED_FROM": "Enregistré depuis {{playlist}}", + "MISSING_BODY": "Le fichier d'enregistrement n'est plus sur le disque. Une diffusion en direct ne peut pas être réenregistrée, mais vous pouvez retirer l'entrée du gestionnaire.", + "FAILED_BODY": "Cet enregistrement a échoué et n'a produit aucun fichier lisible." }, "REMOVE_FROM_MANAGER": "Retirer du gestionnaire de téléchargements", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0ff554239..33094adae 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1196,7 +1196,9 @@ "FAILED": "Sikertelen", "CANCELED": "Megszakítva", "PAUSED": "Paused", - "FILE_MISSING": "Hiányzik a fájl" + "FILE_MISSING": "Hiányzik a fájl", + "RECORDING": "Felvétel folyamatban", + "INTERRUPTED": "Megszakadt" }, "ACTIVE_COUNT": "{{count}} folyamatban", "TRACKED_DOWNLOADS": "Letöltési feladatok", @@ -1211,7 +1213,22 @@ "ALL": "Összes", "MOVIES": "Filmek", "SERIES": "Sorozatok", - "IN_PROGRESS": "Folyamatban" + "IN_PROGRESS": "Folyamatban", + "RECORDINGS": "Felvételek" + }, + "RECORDING_NOW": "Folyamatban lévő felvételek", + "RECORDINGS": "Felvételek", + "RECORDING_BADGE": "Felvétel", + "STOP_RECORDING": "Felvétel leállítása", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Eltávolítja a felvételt a kezelőből?", + "MESSAGE": "A művelet eltávolítja a bejegyzést az IPTVnator alkalmazásból. A felvett fájl a lemezen marad." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Felvett műsorok", + "RECORDED_FROM": "Felvétel forrása: {{playlist}}", + "MISSING_BODY": "A felvett fájl már nem érhető el a lemezen. Az élő adás nem vehető fel újra, de a bejegyzést eltávolíthatja a kezelőből.", + "FAILED_BODY": "Ez a felvétel meghiúsult, és nem jött létre lejátszható fájl." }, "REMOVE_FROM_MANAGER": "Eltávolítás a letöltéskezelőből", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0619dc614..8b6b930c3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1196,7 +1196,9 @@ "FAILED": "Non riuscito", "CANCELED": "Annullato", "PAUSED": "In pausa", - "FILE_MISSING": "File mancante" + "FILE_MISSING": "File mancante", + "RECORDING": "In registrazione", + "INTERRUPTED": "Interrotto" }, "ACTIVE_COUNT": "{{count}} attivi", "TRACKED_DOWNLOADS": "Download tracciati", @@ -1211,7 +1213,22 @@ "ALL": "Tutti", "MOVIES": "Film", "SERIES": "Serie", - "IN_PROGRESS": "In corso" + "IN_PROGRESS": "In corso", + "RECORDINGS": "Registrazioni" + }, + "RECORDING_NOW": "Registrazione in corso", + "RECORDINGS": "Registrazioni", + "RECORDING_BADGE": "In registrazione", + "STOP_RECORDING": "Interrompi registrazione", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Rimuovere la registrazione dal gestore?", + "MESSAGE": "L'elemento viene rimosso da IPTVnator. Il file registrato rimane su disco." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Programmi registrati", + "RECORDED_FROM": "Registrato da {{playlist}}", + "MISSING_BODY": "Il file della registrazione non è più disponibile su disco. Una trasmissione in diretta non può essere registrata di nuovo, ma è possibile rimuovere l'elemento dal gestore.", + "FAILED_BODY": "Questa registrazione non è riuscita e non ha prodotto alcun file riproducibile." }, "REMOVE_FROM_MANAGER": "Rimuovi dal gestore download", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1c349bc67..2484f389f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1196,7 +1196,9 @@ "FAILED": "失敗", "CANCELED": "キャンセル済み", "PAUSED": "一時停止中", - "FILE_MISSING": "ファイルが見つかりません" + "FILE_MISSING": "ファイルが見つかりません", + "RECORDING": "録画中", + "INTERRUPTED": "中断" }, "ACTIVE_COUNT": "{{count}} 件が進行中", "TRACKED_DOWNLOADS": "管理中のダウンロード", @@ -1211,7 +1213,22 @@ "ALL": "すべて", "MOVIES": "映画", "SERIES": "シリーズ", - "IN_PROGRESS": "進行中" + "IN_PROGRESS": "進行中", + "RECORDINGS": "録画" + }, + "RECORDING_NOW": "現在録画中", + "RECORDINGS": "録画", + "RECORDING_BADGE": "録画", + "STOP_RECORDING": "録画を停止", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "録画をマネージャーから削除しますか?", + "MESSAGE": "IPTVnatorから項目を削除します。録画ファイルはディスクに残ります。" + }, + "RECORDING_DETAIL": { + "PROGRAMS": "録画された番組", + "RECORDED_FROM": "{{playlist}}から録画", + "MISSING_BODY": "録画ファイルはディスク上で見つかりませんでした。ライブ放送は再録画できませんが、項目をマネージャーから削除できます。", + "FAILED_BODY": "この録画は失敗し、再生可能なファイルは作成されませんでした。" }, "REMOVE_FROM_MANAGER": "マネージャーから削除", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 7a2ef66ea..3b9e32f43 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1196,7 +1196,9 @@ "FAILED": "실패", "CANCELED": "취소됨", "PAUSED": "일시정지됨", - "FILE_MISSING": "파일이 없습니다" + "FILE_MISSING": "파일이 없습니다", + "RECORDING": "녹화 중", + "INTERRUPTED": "중단됨" }, "ACTIVE_COUNT": "{{count}}개 진행 중", "TRACKED_DOWNLOADS": "관리 중인 다운로드", @@ -1211,7 +1213,22 @@ "ALL": "전체", "MOVIES": "영화", "SERIES": "시리즈", - "IN_PROGRESS": "진행 중" + "IN_PROGRESS": "진행 중", + "RECORDINGS": "녹화" + }, + "RECORDING_NOW": "지금 녹화 중", + "RECORDINGS": "녹화", + "RECORDING_BADGE": "녹화 중", + "STOP_RECORDING": "녹화 중지", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "관리 목록에서 녹화를 제거하시겠습니까?", + "MESSAGE": "IPTVnator에서 항목을 제거합니다. 녹화된 파일은 디스크에 그대로 남아 있습니다." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "녹화된 프로그램", + "RECORDED_FROM": "{{playlist}}에서 녹화됨", + "MISSING_BODY": "녹화 파일이 더 이상 디스크에 없습니다. 라이브 방송은 다시 녹화할 수 없지만, 관리 목록에서 항목을 제거할 수 있습니다.", + "FAILED_BODY": "이 녹화는 실패하여 재생 가능한 파일이 생성되지 않았습니다." }, "REMOVE_FROM_MANAGER": "다운로드 관리자에서 제거", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 1e9e71774..c1af29454 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1196,7 +1196,9 @@ "FAILED": "Mislukt", "CANCELED": "Geannuleerd", "PAUSED": "Gepauzeerd", - "FILE_MISSING": "Bestand ontbreekt" + "FILE_MISSING": "Bestand ontbreekt", + "RECORDING": "Opnemen", + "INTERRUPTED": "Onderbroken" }, "ACTIVE_COUNT": "{{count}} actief", "TRACKED_DOWNLOADS": "Bijgehouden downloads", @@ -1211,7 +1213,22 @@ "ALL": "Alles", "MOVIES": "Films", "SERIES": "Reeksen", - "IN_PROGRESS": "Bezig" + "IN_PROGRESS": "Bezig", + "RECORDINGS": "Opnames" + }, + "RECORDING_NOW": "Nu aan het opnemen", + "RECORDINGS": "Opnames", + "RECORDING_BADGE": "Opname", + "STOP_RECORDING": "Opname stoppen", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Opname uit downloadbeheer verwijderen?", + "MESSAGE": "Hiermee wordt het item uit IPTVnator verwijderd. Het opgenomen bestand blijft op de schijf staan." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Opgenomen programma's", + "RECORDED_FROM": "Opgenomen van {{playlist}}", + "MISSING_BODY": "Het opnamebestand staat niet meer op de schijf. Een live-uitzending kan niet opnieuw worden opgenomen, maar je kunt het item uit downloadbeheer verwijderen.", + "FAILED_BODY": "Deze opname is mislukt en heeft geen afspeelbaar bestand opgeleverd." }, "REMOVE_FROM_MANAGER": "Uit de downloadbeheerder verwijderen", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 51f0a92db..35b445c87 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1196,7 +1196,9 @@ "FAILED": "Niepowodzenie", "CANCELED": "Anulowane", "PAUSED": "Wstrzymane", - "FILE_MISSING": "Brak pliku" + "FILE_MISSING": "Brak pliku", + "RECORDING": "Nagrywanie", + "INTERRUPTED": "Przerwane" }, "ACTIVE_COUNT": "{{count}} aktywnych", "TRACKED_DOWNLOADS": "Śledzone pobrania", @@ -1211,7 +1213,22 @@ "ALL": "Wszystkie", "MOVIES": "Filmy", "SERIES": "Seriale", - "IN_PROGRESS": "W toku" + "IN_PROGRESS": "W toku", + "RECORDINGS": "Nagrania" + }, + "RECORDING_NOW": "Trwa nagrywanie", + "RECORDINGS": "Nagrania", + "RECORDING_BADGE": "Nagrywanie", + "STOP_RECORDING": "Zatrzymaj nagrywanie", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Usunąć nagranie z menedżera?", + "MESSAGE": "Spowoduje to usunięcie wpisu z IPTVnator. Nagrany plik pozostanie na dysku." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Nagrane programy", + "RECORDED_FROM": "Nagrano z {{playlist}}", + "MISSING_BODY": "Plik nagrania nie znajduje się już na dysku. Transmisji na żywo nie można nagrać ponownie, ale wpis można usunąć z menedżera.", + "FAILED_BODY": "To nagrywanie nie powiodło się i nie powstał plik możliwy do odtworzenia." }, "REMOVE_FROM_MANAGER": "Usuń z menedżera pobierania", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index bb07e4918..bea2bbc82 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1196,7 +1196,9 @@ "FAILED": "Falhou", "CANCELED": "Cancelado", "PAUSED": "Pausado", - "FILE_MISSING": "Arquivo ausente" + "FILE_MISSING": "Arquivo ausente", + "RECORDING": "Gravando", + "INTERRUPTED": "Interrompida" }, "ACTIVE_COUNT": "{{count}} ativos", "TRACKED_DOWNLOADS": "Downloads acompanhados", @@ -1211,7 +1213,22 @@ "ALL": "Todos", "MOVIES": "Filmes", "SERIES": "Séries", - "IN_PROGRESS": "Em andamento" + "IN_PROGRESS": "Em andamento", + "RECORDINGS": "Gravações" + }, + "RECORDING_NOW": "Gravando agora", + "RECORDINGS": "Gravações", + "RECORDING_BADGE": "Gravação", + "STOP_RECORDING": "Parar gravação", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Remover gravação do gerenciador?", + "MESSAGE": "Isso remove a entrada do IPTVnator. O arquivo gravado permanece no disco." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Programas gravados", + "RECORDED_FROM": "Gravado a partir de {{playlist}}", + "MISSING_BODY": "O arquivo da gravação não está mais no disco. Uma transmissão ao vivo não pode ser gravada novamente, mas você pode remover a entrada do gerenciador.", + "FAILED_BODY": "Esta gravação falhou e não gerou nenhum arquivo reproduzível." }, "REMOVE_FROM_MANAGER": "Remover do gerenciador", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 93c0e284d..c8d559642 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1196,7 +1196,9 @@ "FAILED": "Ошибка", "CANCELED": "Отменено", "PAUSED": "На паузе", - "FILE_MISSING": "Файл отсутствует" + "FILE_MISSING": "Файл отсутствует", + "RECORDING": "Идёт запись", + "INTERRUPTED": "Прервано" }, "ACTIVE_COUNT": "{{count}} активно", "TRACKED_DOWNLOADS": "Отслеживаемые загрузки", @@ -1211,7 +1213,22 @@ "ALL": "Все", "MOVIES": "Фильмы", "SERIES": "Сериалы", - "IN_PROGRESS": "В процессе" + "IN_PROGRESS": "В процессе", + "RECORDINGS": "Записи" + }, + "RECORDING_NOW": "Записываются сейчас", + "RECORDINGS": "Записи", + "RECORDING_BADGE": "Запись", + "STOP_RECORDING": "Остановить запись", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Убрать запись из менеджера?", + "MESSAGE": "Запись исчезнет из IPTVnator, но записанный файл останется на диске." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Записанные передачи", + "RECORDED_FROM": "Записано из {{playlist}}", + "MISSING_BODY": "Файл записи больше недоступен на диске. Прямой эфир нельзя записать заново, но запись можно убрать из менеджера.", + "FAILED_BODY": "Эта запись завершилась ошибкой, и файл для воспроизведения не был создан." }, "REMOVE_FROM_MANAGER": "Удалить из менеджера загрузок", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ebc9975c4..39f6cd0b4 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1196,7 +1196,9 @@ "FAILED": "Başarısız", "CANCELED": "İptal Edildi", "PAUSED": "Duraklatıldı", - "FILE_MISSING": "Dosya eksik" + "FILE_MISSING": "Dosya eksik", + "RECORDING": "Kaydediliyor", + "INTERRUPTED": "Kesildi" }, "ACTIVE_COUNT": "{{count}} etkin", "TRACKED_DOWNLOADS": "İzlenen indirmeler", @@ -1211,7 +1213,22 @@ "ALL": "Tümü", "MOVIES": "Filmler", "SERIES": "Diziler", - "IN_PROGRESS": "Devam ediyor" + "IN_PROGRESS": "Devam ediyor", + "RECORDINGS": "Kayıtlar" + }, + "RECORDING_NOW": "Şu anda kaydediliyor", + "RECORDINGS": "Kayıtlar", + "RECORDING_BADGE": "Kaydediliyor", + "STOP_RECORDING": "Kaydı durdur", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "Kayıt yöneticiden kaldırılsın mı?", + "MESSAGE": "Bu işlem, kaydı IPTVnator'dan kaldırır. Kaydedilen dosya diskte kalır." + }, + "RECORDING_DETAIL": { + "PROGRAMS": "Kaydedilen programlar", + "RECORDED_FROM": "{{playlist}} kaynağından kaydedildi", + "MISSING_BODY": "Kayıt dosyası artık diskte mevcut değil. Canlı yayın yeniden kaydedilemez, ancak kaydı yöneticiden kaldırabilirsiniz.", + "FAILED_BODY": "Bu kayıt başarısız oldu ve oynatılabilir bir dosya oluşmadı." }, "REMOVE_FROM_MANAGER": "İndirme yöneticisinden kaldır", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ff0d877ab..a57414d44 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1196,7 +1196,9 @@ "FAILED": "失败", "CANCELED": "已取消", "PAUSED": "已暂停", - "FILE_MISSING": "文件缺失" + "FILE_MISSING": "文件缺失", + "RECORDING": "录制中", + "INTERRUPTED": "已中断" }, "ACTIVE_COUNT": "{{count}} 个进行中", "TRACKED_DOWNLOADS": "下载任务", @@ -1211,7 +1213,22 @@ "ALL": "全部", "MOVIES": "电影", "SERIES": "剧集", - "IN_PROGRESS": "进行中" + "IN_PROGRESS": "进行中", + "RECORDINGS": "录制" + }, + "RECORDING_NOW": "正在录制", + "RECORDINGS": "录制内容", + "RECORDING_BADGE": "录制中", + "STOP_RECORDING": "停止录制", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "从管理器中移除录制?", + "MESSAGE": "此操作将从 IPTVnator 中移除该条目。录制文件仍保留在磁盘上。" + }, + "RECORDING_DETAIL": { + "PROGRAMS": "已录制的节目", + "RECORDED_FROM": "录制自 {{playlist}}", + "MISSING_BODY": "录制文件已不在磁盘上。直播无法重新录制,但您可以从管理器中移除该条目。", + "FAILED_BODY": "此次录制失败,未生成可播放的文件。" }, "REMOVE_FROM_MANAGER": "从管理器中移除", "REMOVE_COMPLETED_DIALOG": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index b4ad1eef7..2b6c312e2 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1196,7 +1196,9 @@ "FAILED": "失敗", "CANCELED": "已取消", "PAUSED": "已暫停", - "FILE_MISSING": "檔案遺失" + "FILE_MISSING": "檔案遺失", + "RECORDING": "錄製中", + "INTERRUPTED": "已中斷" }, "ACTIVE_COUNT": "{{count}} 個進行中", "TRACKED_DOWNLOADS": "追蹤的下載", @@ -1211,7 +1213,22 @@ "ALL": "全部", "MOVIES": "電影", "SERIES": "影集", - "IN_PROGRESS": "進行中" + "IN_PROGRESS": "進行中", + "RECORDINGS": "錄製檔" + }, + "RECORDING_NOW": "正在錄製", + "RECORDINGS": "錄製檔", + "RECORDING_BADGE": "錄製中", + "STOP_RECORDING": "停止錄製", + "REMOVE_RECORDING_DIALOG": { + "TITLE": "從管理員中移除錄製檔?", + "MESSAGE": "這會從 IPTVnator 移除此項目。錄製的檔案仍會保留在磁碟上。" + }, + "RECORDING_DETAIL": { + "PROGRAMS": "已錄製的節目", + "RECORDED_FROM": "錄製自 {{playlist}}", + "MISSING_BODY": "錄製檔已不在磁碟上。直播節目無法重新錄製,但您可以從管理員中移除此項目。", + "FAILED_BODY": "此次錄製失敗,未產生可播放的檔案。" }, "REMOVE_FROM_MANAGER": "從管理器移除", "REMOVE_COMPLETED_DIALOG": { diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 89949aeec..112bd9bcb 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -282,7 +282,148 @@ variants, contextual buttons, and theme-aware styling. ignores search/category filtering while honoring route scope, so hiding a card never makes its disk footprint appear to vanish. +## Live-TV recordings + +Recordings made with the embedded MPV player live beside downloads, not inside +them: a recording has no source URL to re-fetch, no byte totals, and no +retry/resume semantics, so it gets its own `recordings` SQLite table (no +unique index — re-recording a channel is normal; `playlist_id` carries no FK +so recordings survive source deletion, with `playlist_name` stored as a +display snapshot via `playlistDisplayLabel`). + +- **Lifecycle tracking** is owned by `EmbeddedMpvRecordingTracker` + (`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`): + explicit start/stop hooks in `EmbeddedMpvNativeService` plus a + session-snapshot observer. The stop hook is a *request*, not an outcome — + `addon.stopRecording()` only dispatches (async mpv property set, or a + command written to the frame-copy helper), so finalization always waits for + the snapshot reporting the recording inactive; statting or unlinking + earlier would report a short recording as failed and could delete bytes mpv + is still flushing. macOS native-view clears `recordingActive` *before* + dispatching the async property set and restores it if that request is + rejected, so an inactive snapshot must additionally survive a 1.5 s settle + window (three poll cycles) before it counts as an acknowledgement; a revived + recording cancels the pending finalization. A 10 s bound finalizes anyway if + the acknowledgement never arrives. None of this timing is load-bearing for + stop enrichment — that path does not wait for finalization at all. The same observer covers the stop paths that never call + `stopRecording()` at all (stream-replacement auto-stop, frame-copy helper + crash, session error/close). A synthetic error/closed snapshot arrives + while teardown may still be flushing (the frame-copy helper exits up to + ~2 s after `disposeSession()`), so it defers finalization behind a 2.5 s + flush window instead of statting immediately — the row stays `recording` + (startup-repairable) through the window, and an already-armed stop settle + timer keeps its `completed` verdict. Statuses: `recording` → `completed` (acknowledged + stop, `fs.stat` size) / `interrupted` (implicit stop with a playable partial + — MPEG-TS is streamable) / `failed` (start error or absent/empty file). Only + a recording that never went active has its empty pre-reserved file unlinked; + once mpv owned the file, the bytes are left alone. Rows carry `owner_pid`, + so startup repair (`recording-recovery.ts`, `reconcileStaleRecordings`) + resolves what a hard kill left in `recording` while skipping rows another + live instance still owns (`IPTVNATOR_ALLOW_MULTIPLE_INSTANCES`) and rows + the tracker itself is still tracking (`activeRowIds()`) — the renderer is + interactive before this pass runs, so a recording started during bootstrap + carries this process's own pid and only the tracker's ledger proves it + live. Bare pid liveness is not ownership: the holder must also look like + an IPTVnator/Electron process (`ps`/`tasklist` name, best-effort) and must + not provably have started after the recording did (`ps -o etime=` / + PowerShell `StartTime`) — a pid frees only when its previous owner dies, + so a recycled pid's holder is always younger than the recording, even + when it landed on another Electron app; unreadable evidence stays + conservative and keeps the skip. Repair changes broadcast one + `RECORDINGS_UPDATE_EVENT` because the renderer may already hold the + pre-repair list. Both repair and tracker finalization stat through a + bounded async probe (3 s deadline; only `ENOENT`/`ENOTDIR` proves + absence): repair leaves an unjudgeable row in `recording` for a later + startup, finalization keeps the requested status with an unknown size — + a recording directory on a dead network mount must neither block the + main thread/queue nor brand a likely-good file `failed`. Repair probes + its whole batch concurrently, so `main.ts` awaits roughly one deadline, + not one per row. List decoration likewise preserves an inconclusive + probe: `ElectronRecordingItem.fileAvailability` adds `'unknown'`, and + consumers gate on `=== 'missing'` so only proven absence reaches Needs + attention or hides file actions. The focused + recording detail route is guarded by the `supportsRecordings` capability + (redirect to the manager) since `RecordingsService` never becomes + authoritative in the PWA. Tracker finalization is bound to the exact open entry, never the + reusable session id: a stop → immediate restart on the same session leaves + the old entry to its own settle timer, and replacing the map entry arms + that timer if no stop was ever observed, so neither row can be finalized + by the other's lifecycle. +- **Metadata is captured at recording start** (EPG is time-sensitive and + Xtream/Stalker EPG never reaches SQLite): each live host — M3U player, + Xtream live layout, Stalker ITV layout, unified live tab — assembles a + `RecordingStartMetadata` (channel name/logo, playlist id + display-label + snapshot, source type, EPG key, current program) that flows + `WebPlayerViewComponent → EmbeddedMpvPlayerComponent → + EmbeddedMpvControlsAdapter → EmbeddedMpvRecordingStartOptions.metadata`. + `EmbeddedMpvPlayerComponent` watches the session snapshot for the + active→inactive recording edge and emits `recordingStopped` — one owner for + every trigger, including a Stop clicked in the download manager, which + talks to the main process directly and never reaches the player's own + toggle. The event carries the EPG key captured while the recording was + active, because a channel switch auto-stops the recording and the host's own + state already describes the new channel by the time the stop is handled; + each host enriches only when that key still matches its current channel. + The EPG key is not unique for M3U items (a `tvgId`, or the display-name + fallback, can be shared by several list entries), so hosts with M3U + selections additionally set `RecordingStartMetadata.sourceItemKey` (the + unified tab's `item.uid`, the M3U player's `channel.id`) — captured and + carried through the stop event the same way, and compared before + enriching, so switching between two same-keyed items cannot attach the + second item's schedule to the first item's recording. The host answers with **stop enrichment**: it filters its in-memory + program list to the programs overlapping `[startedAt, endedAt]` + (`filterRecordingProgramsOverlap` in `@iptvnator/shared/interfaces`) and + sends them through `RECORDINGS_UPDATE_PROGRAMS`, keyed by the unique + target path — that is how a recording spanning a program boundary lists + every covered show. The handler is deliberately independent of finalization: it + looks the row up in **any** status (the newest for that path — `openSync + ('wx')` keeps a reserved path exclusive while its recording owns it) and + `finalize()` never touches `programs_json`, so the two writes commit in + either order. The only wait is the tracker's queue drain, which guarantees + the row's INSERT exists — no deadline, and therefore no way for a one-shot + enrichment to be dropped by a clock. A recording stopped while no player is mounted on that + channel keeps its start snapshot. +- **IPC surface** (`recordings.events.ts`): `RECORDINGS_GET_LIST/GET/STOP/ + REMOVE/UPDATE_PROGRAMS/REVEAL_FILE/PLAY_FILE` plus the dedicated + `RECORDINGS_UPDATE_EVENT` bare ping (not shared with downloads, so + recording transitions do not force availability-probed download refetches). + Active rows are decorated with a live `fs.stat` size — `file_size_bytes` is + only persisted at finalization, so the manager's growing size comes from + there. Recording totals also feed the manager-wide All chip and the header's + active badge, so a page listing only recordings never reads "All 0". + Reveal/play are gated by `isManagedRecordingFile` — the path must exist in + the recordings table, mirroring `isManagedDownloadFile`, so the + renderer-supplied recording directory stays a write-location preference + rather than a shell-access grant. `RECORDINGS_STOP` resolves the row's + `session_id` and stops through `EmbeddedMpvNativeService`, so the manager + can stop a recording without knowing about MPV sessions — but only for rows + this process owns: session ids restart per process, so dispatching a + foreign row's id would stop an unrelated local recording. Remove keeps + finished files on disk (same contract as downloads) and cleans up a failed + row's leftover reservation only while no other row claims that path — a + retry within the same timestamp second reuses the freed name. Renderer gate: a separate + `supportsRecordings` capability allowlist — deliberately NOT folded into + `supportsDownloads`, which would strip older builds of the whole manager. +- **UI**: `RecordingsService` mirrors `DownloadsService` (one global list, + coalesced refreshes via `DownloadListLoadState`, refetch on ping). The + manager adds a `recording` filter chip; `recording-manager.viewmodel.ts` + partitions rows into a "Recording now" queue section (pulsing REC chip with + elapsed time and live file size — never a percentage, the length is + unknown; the size comes from a bounded, in-flight-coalesced `stat` with a + 1 s deadline so a recording directory on a dead network filesystem + degrades to "no size" instead of wedging every list load), a recordings-only Needs attention list (Remove only: a broadcast + cannot be re-recorded), and a "Recordings" library section of 16:9 + channel-logo cards (`recording-queue.component.*`, + `recording-library.component.*`). Card titles use the captured program + title, falling back to channel + start time. The focused detail + (`recording-detail/`, route `/workspace/downloads/recording/:recordingId`, + context panel and route search hidden via `workspace-shell-route.utils.ts`) + shows the recorded time range, covered programs when a recording spans ≥2 + shows, file path, and Play/Reveal/Stop/Remove; a missing file degrades to + Back + Remove. + Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work -might cover recordings, queue reordering, bulk pause/cancel actions, disk-free -space telemetry, or playback analytics. +might cover queue reordering, bulk pause/cancel actions, disk-free space +telemetry, playback analytics, or frame-copy screenshot posters for +recordings. diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index a6a533376..4e0e259fc 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -532,7 +532,7 @@ replacement, engine handoff, or component destruction cancels pending ownership, timers, and stale feedback. Native-view retains its existing recording UI and timer path. -The default recording folder is `app.getPath('downloads')`, matching the desktop download manager's fallback. Users can override it in Settings through `Settings.recordingFolder`; an empty setting means system Downloads. Recordings are intentionally not inserted into the Downloads database or queue in v1 because MPV writes from the active playback session while the download manager owns independent backend download jobs. +The default recording folder is `app.getPath('downloads')`, matching the desktop download manager's fallback. Users can override it in Settings through `Settings.recordingFolder`; an empty setting means system Downloads. Recordings never enter the Downloads queue (MPV writes from the active playback session while the download manager owns independent backend download jobs), but their lifecycle IS tracked: `EmbeddedMpvRecordingTracker` (`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`) persists each recording into the dedicated `recordings` SQLite table — start hook and explicit-stop hook from `EmbeddedMpvNativeService`, plus a session-snapshot observer that catches the three implicit stop paths (stream-replacement auto-stop, frame-copy helper crash leaving `active: true` behind, session error/close). Rows left in `recording` by a hard app kill are repaired at startup into playable `interrupted` partials or `failed`. The download manager surfaces these rows; see `docs/architecture/download-manager.md` ("Live-TV recordings"). mpv's own caveats apply: the output container is inferred from the target extension, and seeking or switching streams while recording can produce broken output. IPTVnator limits the UI to live streams and stops recording on playback replacement to avoid the most obvious corruption path, but the feature should still be treated as an experimental embedded MPV capability. diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 35ae9e3f8..5f03f2152 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -103,9 +103,11 @@ : (playerSettings.player ?? null) " [volume]="volume()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index eb75601d8..ddb7808ec 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -42,6 +42,8 @@ import { Channel, EpgProgram, ExternalPlayerSession, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, Settings, VideoPlayer, @@ -122,7 +124,9 @@ class StubWebPlayerViewComponent { readonly playback = input(null); readonly playerOverride = input(null); readonly volume = input(1); + readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 04ccb2aa3..d0c97367d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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(() => { + 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 diff --git a/libs/portal/downloads/feature/src/index.ts b/libs/portal/downloads/feature/src/index.ts index f0a82109d..aa6741e07 100644 --- a/libs/portal/downloads/feature/src/index.ts +++ b/libs/portal/downloads/feature/src/index.ts @@ -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'; diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts index f324798c7..bd8c70a55 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -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( [ diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 3c14397ec..8309b6f83 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -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; } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index b9eff4737..6cc699e87 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -5,14 +5,14 @@

{{ 'DOWNLOADS.TITLE' | translate }}

- @if (model().activeCount > 0) { + @if (activeCount() > 0) { {{ 'DOWNLOADS.ACTIVE_COUNT' - | translate: { count: model().activeCount } + | translate: { count: activeCount() } }} } @@ -227,6 +227,13 @@

{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}

} @else { + + } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 0df2fd033..3e3fe37a8 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -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(): Deferred { return { promise, resolve }; } +function recording( + id: number, + overrides: Partial = {} +): 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 = {} @@ -141,11 +161,39 @@ describe('DownloadsComponent', () => { }; let snackBar: { open: jest.Mock }; let matDialog: { open: jest.Mock }; + let recordings: ReturnType>; + let recordingsService: { + readonly recordings: typeof recordings; + readonly isAvailable: ReturnType>; + readonly hasLoadedRecordings: ReturnType>; + readonly hasAuthoritativeRecordingList: ReturnType< + typeof signal + >; + readonly isLoadingRecordings: ReturnType>; + loadRecordings: jest.Mock; + stopRecording: jest.Mock; + removeRecording: jest.Mock; + playFile: jest.Mock; + revealFile: jest.Mock; + }; const success = async () => ({ success: true }); beforeEach(async () => { downloads = signal([]); + recordings = signal([]); + 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([ diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 6f44bb6fd..ea972f544 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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 { + 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; } } diff --git a/libs/portal/downloads/feature/src/lib/recording-actions.ts b/libs/portal/downloads/feature/src/lib/recording-actions.ts new file mode 100644 index 000000000..60f45d33d --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-actions.ts @@ -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; +} diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html new file mode 100644 index 000000000..ff7eb6725 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html @@ -0,0 +1,191 @@ +@if (item(); as recording) { + + @if (isMissing() || isFailed()) { +
+ + +
+ } @else { + + + + {{ 'DOWNLOADS.RECORDING_BADGE' | translate }} + + {{ recording.channelName }} + @if (durationLabel()) { + {{ durationLabel() }} + } + @if (sizeLabel()) { + {{ sizeLabel() }} + } + MPEG-TS + @if (recording.status === 'interrupted') { + + {{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }} + + } + + + +
+ + {{ recording.startedAt | date: 'longDate' }} + · {{ recording.startedAt | date: 'HH:mm' }} + @if (recording.endedAt) { + – {{ recording.endedAt | date: 'HH:mm' }} + } + +
+ @if (recording.playlistName; as playlistName) { +
+ + {{ + 'DOWNLOADS.RECORDING_DETAIL.RECORDED_FROM' + | translate: { playlist: playlistName } + }} + +
+ } +
+ + {{ recording.filePath }} + +
+
+ + + @if (isActive()) { + + } @else { + + } + + + + + + + } + + @if (coveredPrograms().length > 0) { +
+

+ {{ 'DOWNLOADS.RECORDING_DETAIL.PROGRAMS' | translate }} + +

+
    + @for (program of coveredPrograms(); track program.start) { +
  1. + +
    +
    + {{ program.title }} +
    + @if (program.description) { +
    + {{ program.description }} +
    + } +
    +
  2. + } +
+
+ } +
+} diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss new file mode 100644 index 000000000..e2a5dcccf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss @@ -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; +} diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts new file mode 100644 index 000000000..fc98f503e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts @@ -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); + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.html b/libs/portal/downloads/feature/src/lib/recording-library.component.html new file mode 100644 index 000000000..dde04e70f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.html @@ -0,0 +1,162 @@ +@if (items().length > 0) { +
+
+

+ {{ 'DOWNLOADS.RECORDINGS' | translate }} + +

+
+ +
+ @for (row of items(); track row.item.id) { +
+
+ + + @if (durationLabel(row)) { + + } @else if (row.interrupted) { + + } + + + + + + + + +
+ +
+ + + {{ row.channelName }} + · {{ row.item.startedAt | date: 'd MMM' }} + @if (row.item.fileSizeBytes) { + · {{ formatBytes(row.item.fileSizeBytes) }} + } + +
+
+ } +
+
+} diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.scss b/libs/portal/downloads/feature/src/lib/recording-library.component.scss new file mode 100644 index 000000000..cbf174edf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.scss @@ -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; + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/recording-library.component.spec.ts new file mode 100644 index 000000000..d48c8ab7f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.spec.ts @@ -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 { + 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 { + return { + item, + programTitle: item.programTitle?.trim() ?? '', + channelName: item.channelName, + attentionReason: null, + durationSeconds: null, + interrupted: false, + ...overrides, + }; +} + +describe('RecordingLibraryComponent', () => { + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + function render( + items: readonly RecordingRowViewModel[], + pendingIds: ReadonlySet = 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 { + const more = host.querySelector( + '.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( + `[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( + '.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( + '.recording-library__artwork-button' + ); + expect(artwork?.getAttribute('aria-label')).toBe('Open Late movie'); + artwork?.click(); + host.querySelector( + '.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'); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.ts b/libs/portal/downloads/feature/src/lib/recording-library.component.ts new file mode 100644 index 000000000..1c2004e10 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.ts @@ -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 pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + 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); + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/recording-manager-actions.service.ts new file mode 100644 index 000000000..ee5007f59 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-manager-actions.service.ts @@ -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>(new Set()); + + async run(action: RecordingItemAction): Promise { + 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 { + 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, + onFailure: (error?: string) => void | Promise = (error) => + this.showActionError(error), + failureResult: (error?: string) => RecordingActionResult = () => + 'failed' + ): Promise { + 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; + }); + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.spec.ts new file mode 100644 index 000000000..eefa8c39f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.spec.ts @@ -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 { + 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(''); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts new file mode 100644 index 000000000..4304f24d9 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts @@ -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, + }; +} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.html b/libs/portal/downloads/feature/src/lib/recording-queue.component.html new file mode 100644 index 000000000..d5e613fc8 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.html @@ -0,0 +1,216 @@ +@for (section of sections(); track section.testId) { +
+

+ + {{ section.headingKey | translate }} + + +

+ +
+ @for (row of section.items; track row.item.id) { +
+ + +
+
+ @if (row.item.status === 'recording') { + + + REC {{ elapsedLabel(row.item) }} + + } + +
+ + + + @if (row.item.status === 'recording') { + + } +
+ +
+ @if (row.item.status === 'recording') { + + } @else { + + } + + + + + @if (row.attentionReason === null) { + + } + @if (row.item.status !== 'recording') { + + } + +
+
+ } +
+
+} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.scss b/libs/portal/downloads/feature/src/lib/recording-queue.component.scss new file mode 100644 index 000000000..243344ebf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.scss @@ -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; + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts new file mode 100644 index 000000000..92e04f3fe --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts @@ -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 { + 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 { + return { + item, + programTitle: item.programTitle?.trim() ?? '', + channelName: item.channelName, + attentionReason: null, + durationSeconds: null, + interrupted: false, + ...overrides, + }; +} + +describe('RecordingQueueComponent', () => { + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + function render( + activeItems: readonly RecordingRowViewModel[] = [], + attentionItems: readonly RecordingRowViewModel[] = [], + pendingIds: ReadonlySet = 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( + `[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( + '.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( + '.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( + '.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( + '[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('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(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.ts b/libs/portal/downloads/feature/src/lib/recording-queue.component.ts new file mode 100644 index 000000000..41a2ca5cb --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.ts @@ -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 attentionItems = + input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + private readonly elapsedTick = signal(Date.now()); + + readonly sections = computed(() => { + 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); + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 94e3de217..8800ce7f8 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -64,9 +64,11 @@ [title]="activeDetail()?.playback?.title ?? ''" [playback]="inlinePlayback()" [playerOverride]="player()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index f43570e82..ccb0e3e7b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -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(); - readonly maxWidth = input(); - readonly defaultWidth = input(); - readonly storageKey = input(''); -} - -@Component({ - selector: 'app-global-favorites-list', - template: '', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubGlobalFavoritesListComponent { - readonly channels = input.required(); - readonly mode = input<'favorites' | 'recent'>('favorites'); - readonly showEpg = input(true); - readonly favoriteUids = input>(new Set()); - readonly epgMap = input>(new Map()); - readonly progressTick = input(0); - readonly activeUid = input(null); - readonly searchTermInput = input(''); - readonly draggable = input(true); - readonly sortMode = input( - DEFAULT_FAVORITES_CHANNEL_SORT_MODE - ); - - readonly channelSelected = output(); - readonly channelsReordered = output(); - readonly favoriteToggled = output(); - readonly removeRequested = output(); -} - -// 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: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubEpgTimelineComponent { - readonly programs = input([]); - readonly channelName = input(''); - readonly channelLogo = input(''); - readonly archivePlaybackAvailable = input(false); - readonly archiveDays = input(0); - readonly activeProgram = input(null); - readonly isLivePlayback = input(true); - readonly selectedDate = input(null); - readonly collapsed = input(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly summary = input(null); - readonly summaryLabelKey = input(''); - readonly selectedDateChange = output(); - readonly collapsedChange = output(); - readonly programActivated = output(); - readonly returnToLive = output(); -} - -@Component({ - selector: 'app-audio-player', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubAudioPlayerComponent { - readonly icon = input(''); - readonly url = input.required(); - readonly channelName = input(''); -} - -@Component({ - selector: 'app-web-player-view', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubWebPlayerViewComponent { - readonly playbackSessionKey = input.required(); - readonly streamUrl = input.required(); - readonly title = input(''); - readonly playback = input(null); - readonly playerOverride = input(null); - readonly externalFallbackRequested = output(); -} +import { + StubAudioPlayerComponent, + StubEpgTimelineComponent, + StubGlobalFavoritesListComponent, + StubResizableDirective, + StubWebPlayerViewComponent, +} from './unified-live-tab.spec-stubs'; describe('UnifiedLiveTabComponent', () => { let fixture: ComponentFixture; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 2c34a5ef1..b6a61f0a9 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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(() => { + 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 diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts new file mode 100644 index 000000000..7765a60a8 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -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(); + readonly maxWidth = input(); + readonly defaultWidth = input(); + readonly storageKey = input(''); +} + +@Component({ + selector: 'app-global-favorites-list', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubGlobalFavoritesListComponent { + readonly channels = input.required(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly showEpg = input(true); + readonly favoriteUids = input>(new Set()); + readonly epgMap = input>(new Map()); + readonly progressTick = input(0); + readonly activeUid = input(null); + readonly searchTermInput = input(''); + readonly draggable = input(true); + readonly sortMode = input( + DEFAULT_FAVORITES_CHANNEL_SORT_MODE + ); + + readonly channelSelected = output(); + readonly channelsReordered = output(); + readonly favoriteToggled = output(); + readonly removeRequested = output(); +} + +// 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: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(true); + readonly selectedDate = input(null); + readonly collapsed = input(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly summary = input(null); + readonly summaryLabelKey = input(''); + readonly selectedDateChange = output(); + readonly collapsedChange = output(); + readonly programActivated = output(); + readonly returnToLive = output(); +} + +@Component({ + selector: 'app-audio-player', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubAudioPlayerComponent { + readonly icon = input(''); + readonly url = input.required(); + readonly channelName = input(''); +} + +@Component({ + selector: 'app-web-player-view', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); + readonly streamUrl = input.required(); + readonly title = input(''); + readonly playback = input(null); + readonly playerOverride = input(null); + readonly recordingMetadata = input(null); + readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 857b581af..9dfbfb0cb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -190,9 +190,11 @@ [streamUrl]="streamUrl()" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 92ace94cc..8574eb544 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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(null); - readonly selected = input(false); - readonly showEpg = input(true); - readonly isRadio = input(false); - readonly epgProgram = input(null); - readonly progressPercentage = input(0); - readonly showFavoriteButton = input(false); - readonly showProgramInfoButton = input(false); - readonly showDetailsContextMenu = input(false); - readonly isFavorite = input(false); - readonly clicked = output(); - readonly activated = output(); - readonly favoriteToggled = output(); - readonly contextMenuRequested = output(); -} - -@Component({ - selector: 'app-web-player-view', - standalone: true, - template: '', -}) -class StubWebPlayerViewComponent { - readonly playbackSessionKey = input.required(); - readonly streamUrl = input(''); - readonly title = input(''); - readonly playback = input(null); - readonly externalFallbackRequested = output(); -} - -@Component({ - selector: 'app-audio-player', - standalone: true, - template: '', -}) -class StubAudioPlayerComponent { - readonly url = input.required(); - 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: ` -
{{ summary()?.title }}
- `, -}) -class StubEpgTimelineComponent { - readonly programs = input([]); - readonly channelName = input(''); - readonly channelLogo = input(''); - readonly sourceLabel = input(''); - readonly archivePlaybackAvailable = input(false); - readonly archiveDays = input(0); - readonly activeProgram = input(null); - readonly isLivePlayback = input(false); - readonly loading = input(false); - readonly emptyReason = input('none'); - readonly selectedDate = input(null); - readonly collapsed = input(false); - readonly summary = input<{ title?: string } | null>(null); - readonly summaryLabelKey = input(''); - readonly selectedDateChange = output(); - readonly programActivated = output(); - readonly returnToLive = output(); - readonly openEpgSettings = output(); - readonly retry = output(); - readonly collapsedChange = output(); -} - -@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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e86e71a0a..8d6c6ac8c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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 | 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(() => { + 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( 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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts new file mode 100644 index 000000000..d0e44cf2e --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts @@ -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(null); + readonly selected = input(false); + readonly showEpg = input(true); + readonly isRadio = input(false); + readonly epgProgram = input(null); + readonly progressPercentage = input(0); + readonly showFavoriteButton = input(false); + readonly showProgramInfoButton = input(false); + readonly showDetailsContextMenu = input(false); + readonly isFavorite = input(false); + readonly clicked = output(); + readonly activated = output(); + readonly favoriteToggled = output(); + readonly contextMenuRequested = output(); +} + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '', +}) +export class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); + readonly streamUrl = input(''); + readonly title = input(''); + readonly playback = input(null); + readonly recordingMetadata = input(null); + readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); +} + +@Component({ + selector: 'app-audio-player', + standalone: true, + template: '', +}) +export class StubAudioPlayerComponent { + readonly url = input.required(); + 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: ` +
{{ summary()?.title }}
+ `, +}) +export class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly sourceLabel = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(false); + readonly loading = input(false); + readonly emptyReason = input('none'); + readonly selectedDate = input(null); + readonly collapsed = input(false); + readonly summary = input<{ title?: string } | null>(null); + readonly summaryLabelKey = input(''); + readonly selectedDateChange = output(); + readonly programActivated = output(); + readonly returnToLive = output(); + readonly openEpgSettings = output(); + readonly retry = output(); + readonly collapsedChange = output(); +} + +@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 {} diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 529b327e1..abf727b57 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -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(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) => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index cb0446e31..a59066935 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -100,9 +100,11 @@ [streamUrl]="streamUrl" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index be66cfac8..86806819e 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -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(null); + readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ca971872c..d1740fdd2 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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(() => this.epgItems().map((program) => this.toControlledEpgProgram(program)) ); + private readonly recordingsService = inject(RecordingsService); + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly recordingMetadata = computed(() => { + 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(null); readonly controlledArchiveDays = computed(() => diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index b0e108bae..8126c9c86 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 904ca9890..2e40f869e 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -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() { diff --git a/libs/services/src/lib/recordings.models.ts b/libs/services/src/lib/recordings.models.ts new file mode 100644 index 000000000..bb83061b3 --- /dev/null +++ b/libs/services/src/lib/recordings.models.ts @@ -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'; diff --git a/libs/services/src/lib/recordings.service.spec.ts b/libs/services/src/lib/recordings.service.spec.ts new file mode 100644 index 000000000..e85bea5d5 --- /dev/null +++ b/libs/services/src/lib/recordings.service.spec.ts @@ -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; + isAvailable: () => boolean; + isLoadingRecordings: Signal; + hasLoadedRecordings: Signal; + hasAuthoritativeRecordingList: Signal; + 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 { + return { + id, + status, + filePath: `/recordings/${id}.ts`, + channelName: `Channel ${id}`, + startedAt: '2026-08-15T12:00:00Z', + fileAvailability: 'available', + ...overrides, + }; + } + + function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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((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(); + const second = createDeferred(); + 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(); + } + }); +}); diff --git a/libs/services/src/lib/recordings.service.ts b/libs/services/src/lib/recordings.service.ts new file mode 100644 index 000000000..74f4a9a5b --- /dev/null +++ b/libs/services/src/lib/recordings.service.ts @@ -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([]); + 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 { + 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 { + 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 { + return this.runAction('stop', () => + window.electron.recordingsStop?.(recordingId) + ); + } + + async removeRecording( + recordingId: number + ): Promise { + 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 { + return this.runAction('update programs', () => + window.electron.recordingsUpdatePrograms?.(targetPath, programs) + ); + } + + async revealFile(filePath: string): Promise { + return this.runAction('reveal', () => + window.electron.recordingsRevealFile?.(filePath) + ); + } + + async playFile(filePath: string): Promise { + return this.runAction('play', () => + window.electron.recordingsPlayFile?.(filePath) + ); + } + + private async runAction( + label: string, + action: () => Promise | undefined, + refreshAfter = false + ): Promise { + 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, + }; + } + } +} diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index b6e659fbf..a53a3e210 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -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(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 41da43352..0299e466b 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -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', diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 006bf26af..01fc74b92 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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, diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index ec69a15fa..93771f49d 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -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:' → full TMDB details payload (JSON) diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 2a076185c..8c0c81074 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index d2c91a01e..be6e34dee 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; 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; + recordingsGet?: ( + recordingId: number + ) => Promise; + recordingsStop?: (recordingId: number) => Promise; + recordingsRemove?: ( + recordingId: number + ) => Promise; + recordingsUpdatePrograms?: ( + targetPath: string, + programs: RecordingProgramSnapshot[] + ) => Promise; + recordingsRevealFile?: ( + filePath: string + ) => Promise; + recordingsPlayFile?: ( + filePath: string + ) => Promise; + onRecordingsUpdate?: (callback: () => void) => () => void; } diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 74fd73112..0c2b4c2fe 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -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 { diff --git a/libs/shared/interfaces/src/lib/recording-metadata.interface.ts b/libs/shared/interfaces/src/lib/recording-metadata.interface.ts new file mode 100644 index 000000000..943186d2f --- /dev/null +++ b/libs/shared/interfaces/src/lib/recording-metadata.interface.ts @@ -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]; diff --git a/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts b/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts new file mode 100644 index 000000000..47b1546d8 --- /dev/null +++ b/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts @@ -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' }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts b/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts new file mode 100644 index 000000000..f6912800e --- /dev/null +++ b/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts @@ -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); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts index c79a09064..84861131e 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts @@ -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( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts index 06692fc43..2239ebc0f 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts @@ -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 { 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; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts index 8f3f57782..19cada4dd 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts @@ -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( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts index da14adcbc..4f04723d6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts @@ -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( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts index e71a15118..05d18ab83 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts @@ -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) => ({ diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index 742d19e77..7dd4ad750 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -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; readonly seriesNavigation: Signal; readonly recordingFolder: Signal; + /** Channel/EPG snapshot the live host captured for the recording tracker. */ + readonly recordingMetadata?: Signal; } interface MappedPlayerStatus { @@ -349,6 +353,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { playback: context.playback(), playbackIdentity, session, + metadata: context.recordingMetadata?.() ?? null, }); } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts index dd0b54223..bb68b0b24 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts @@ -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 = []; + 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 = []; + 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); + }); }); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index d22b261cc..fa450f17c 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -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(); readonly showControls = input(true); readonly recordingFolder = input(''); + readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); @@ -87,6 +90,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + /** Clean recording stop — the host answers with EPG stop enrichment. */ + readonly recordingStopped = output(); 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( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index f760bec97..ecb54e379 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -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 => - this.commands.startRecording(directory, title); + this.commands.startRecording(directory, title, metadata); readonly stopRecording = (): Promise< EmbeddedMpvSession['recording'] | null > => this.commands.stopRecording(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index bb1cf277d..bf67c6a99 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -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)" /> } } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index f42224023..9bb8f2684 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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(); readonly mediaTitle = input(null); readonly recordingFolder = input(''); + readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly recordingStopped = output(); } describe('WebPlayerViewComponent', () => { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index f0be45979..9dffa5654 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(null); readonly mediaTitle = input(null); readonly alternativeSources = input([]); + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly recordingMetadata = input(null); readonly timeUpdate = output<{ currentTime: number; @@ -122,6 +126,8 @@ export class WebPlayerViewComponent implements OnDestroy { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + /** Re-emitted embedded-MPV clean recording stop (stop enrichment). */ + readonly recordingStopped = output(); readonly showCaptions = computed( () => this.settingsStore.showCaptions?.() ?? false diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index bc58931ba..1783cb8af 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -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(); readonly mediaTitle = input(null); readonly recordingFolder = input(''); + readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly recordingStopped = output(); } diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts index 6f36c3f9d..53b05e4a0 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts @@ -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( diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts index 68239ca2a..c5232ae43 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts @@ -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);