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

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

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

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"ngx-indexed-db": "^21.0.0",
"ngx-indexed-db": "^22.0.0",
"tslib": "^2.3.0"
}
}
+2
View File
@@ -41,6 +41,8 @@ export * from './lib/portal-playback.interface';
export * from './lib/provider-import-detection.util';
export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
export * from './lib/recording-program-overlap.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-auth-failure.util';
@@ -16,6 +16,11 @@ import {
GlobalSearchResultSource,
} from './global-search-result.interface';
import { M3uFavoriteChannel } from './m3u-favorite-channel.interface';
import {
RecordingProgramSnapshot,
RecordingSourceType,
RecordingStatus,
} from './recording-metadata.interface';
import { PlaybackPositionData } from './playback-position.interface';
import { AutoUpdatePlaylistsResult } from './playlist-auto-update.interface';
import {
@@ -601,6 +606,41 @@ export interface ElectronDownloadItem {
updatedAt?: string;
}
/**
* Renderer-facing live-TV recording row. `fileAvailability` is derived at
* read time (never persisted) and `programs` is the decoded `programs_json`
* column. Recordings reuse the download availability vocabulary so the
* manager UI can share its missing-file affordances — extended by
* `'unknown'` for an inconclusive probe (timeout, permission or I/O error):
* only proven absence may move a recording to Needs attention or hide its
* file actions, so consumers gate on `=== 'missing'`, never on
* `!== 'available'`.
*/
export interface ElectronRecordingItem {
id: number;
sessionId?: string;
status: RecordingStatus;
filePath: string;
fileSizeBytes?: number;
channelName: string;
channelLogoUrl?: string;
playlistId?: string;
playlistName?: string;
sourceType?: RecordingSourceType;
epgChannelId?: string;
programTitle?: string;
programDescription?: string;
programStart?: string;
programStop?: string;
programs?: RecordingProgramSnapshot[];
errorMessage?: string;
startedAt: string;
endedAt?: string;
createdAt?: string;
updatedAt?: string;
fileAvailability: ElectronDownloadFileAvailability | 'unknown';
}
export interface ElectronBridgeApi {
onPortalDebugEvent?: (
callback: (data: PortalDebugEvent) => void
@@ -1201,4 +1241,28 @@ export interface ElectronBridgeApi {
playlistId?: string
) => Promise<ElectronBridgeResult>;
onDownloadsUpdate: (callback: () => void) => () => void;
// Live-TV recordings surface. Optional: older Electron builds have no
// recordings bridge, and `supportsRecordings` (not `supportsDownloads`)
// gates every consumer.
recordingsGetList?: (
playlistId?: string
) => Promise<ElectronRecordingItem[]>;
recordingsGet?: (
recordingId: number
) => Promise<ElectronRecordingItem | null>;
recordingsStop?: (recordingId: number) => Promise<ElectronBridgeErrorResult>;
recordingsRemove?: (
recordingId: number
) => Promise<ElectronBridgeErrorResult>;
recordingsUpdatePrograms?: (
targetPath: string,
programs: RecordingProgramSnapshot[]
) => Promise<ElectronBridgeErrorResult>;
recordingsRevealFile?: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
recordingsPlayFile?: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
onRecordingsUpdate?: (callback: () => void) => () => void;
}
@@ -1,4 +1,5 @@
import type { PlayerSubtitleStyle } from './subtitle-style.util';
import type { RecordingStartMetadata } from './recording-metadata.interface';
export type EmbeddedMpvSessionStatus =
| 'idle'
@@ -99,6 +100,12 @@ export interface EmbeddedMpvRecordingState {
export interface EmbeddedMpvRecordingStartOptions {
directory?: string;
title?: string;
/**
* Channel/EPG snapshot captured by the live host at recording start; the
* main-process recording tracker persists it. See
* recording-metadata.interface.ts for why capture must happen up front.
*/
metadata?: RecordingStartMetadata;
}
export interface EmbeddedMpvSession {
@@ -0,0 +1,82 @@
/**
* Metadata captured for a live-TV recording.
*
* EPG is time-sensitive: what is airing on a channel cannot be reconstructed
* after the fact (Xtream/Stalker EPG never reaches SQLite), so the renderer
* snapshots everything it knows at recording START and hands it down with the
* start IPC. When the recording stops cleanly, the renderer additionally
* filters its in-memory program list to the programs overlapping the recorded
* interval — that is how a recording spanning a program boundary lists every
* covered program. An implicit stop (session dispose, crash) keeps the start
* snapshot as-is.
*/
export interface RecordingProgramSnapshot {
title: string;
description?: string;
/** ISO datetime string, same convention as `epg_programs.start`. */
start: string;
/** ISO datetime string, same convention as `epg_programs.stop`. */
stop: string;
}
export type RecordingSourceType = 'm3u' | 'xtream' | 'stalker';
export interface RecordingStartMetadata {
channelName: string;
channelLogoUrl?: string;
playlistId?: string;
/** Display label snapshot — recordings outlive playlist deletion. */
playlistName?: string;
sourceType?: RecordingSourceType;
/** Resolved EPG lookup/mapping key, when the host knows one. */
epgChannelId?: string;
/**
* Host-scoped identity of the exact recorded selection. The EPG key is
* not unique for M3U items (`tvgId`, or the display-name fallback, can
* be shared by several list entries), so stop enrichment additionally
* compares this key — otherwise switching between two same-keyed items
* while recording would attach the second item's schedule to the first
* item's recording. Opaque to everything below the host.
*/
sourceItemKey?: string;
/** The program airing when the recording started, if EPG knew it. */
currentProgram?: RecordingProgramSnapshot;
}
/**
* Emitted by the player layer when a recording stopped cleanly. The host
* component answers it with the programs overlapping [startedAt, endedAt]
* from its in-memory EPG state (stop enrichment). Implicit stops (session
* dispose, crash) never emit — the start snapshot stands.
*/
export interface RecordingStoppedEvent {
targetPath: string;
/** Recording start (ISO); null when the snapshot never carried it. */
startedAt: string | null;
/** Observation time of the stop (ISO). */
endedAt: string;
/**
* EPG key of the channel that was being recorded, captured when the
* recording went active. Switching channels auto-stops the recording, and
* by the time the host handles the stop its own state already describes
* the NEW channel — the host compares this key before enriching, so a
* recording is never given another channel's schedule.
*/
epgChannelId?: string | null;
/**
* `RecordingStartMetadata.sourceItemKey` captured the same way. The EPG
* key alone cannot tell two same-keyed M3U items apart, so hosts that
* set a source-item key also compare it before enriching.
*/
sourceItemKey?: string | null;
}
export const RECORDING_STATUSES = [
'recording',
'completed',
'interrupted',
'failed',
] as const;
export type RecordingStatus = (typeof RECORDING_STATUSES)[number];
@@ -0,0 +1,115 @@
import {
filterRecordingProgramsOverlap,
toRecordingProgramSnapshot,
} from './recording-program-overlap.util';
const program = (title: string, start: string, stop: string) => ({
title,
start,
stop,
});
describe('filterRecordingProgramsOverlap', () => {
const programs = [
program('Late show', '2026-08-15T22:10:00Z', '2026-08-15T23:00:00Z'),
program('News', '2026-08-15T21:00:00Z', '2026-08-15T21:45:00Z'),
program('Weather', '2026-08-15T21:45:00Z', '2026-08-15T22:10:00Z'),
program('Morning', '2026-08-15T08:00:00Z', '2026-08-15T09:00:00Z'),
];
it('fails closed without a recording start', () => {
expect(
filterRecordingProgramsOverlap(
programs,
null,
'2026-08-15T22:00:00Z'
)
).toEqual([]);
});
it('fails closed on unparseable or inverted windows', () => {
expect(
filterRecordingProgramsOverlap(
programs,
'garbage',
'2026-08-15T22:00:00Z'
)
).toEqual([]);
expect(
filterRecordingProgramsOverlap(
programs,
'2026-08-15T22:00:00Z',
'2026-08-15T21:00:00Z'
)
).toEqual([]);
});
it('keeps every overlapping program sorted by start (boundary case)', () => {
const result = filterRecordingProgramsOverlap(
programs,
'2026-08-15T21:30:00Z',
'2026-08-15T21:58:00Z'
);
expect(result.map(({ title }) => title)).toEqual(['News', 'Weather']);
});
it('excludes programs that only touch the window edges', () => {
// Weather starts exactly at window end; News stops exactly at start.
const result = filterRecordingProgramsOverlap(
programs,
'2026-08-15T21:45:00Z',
'2026-08-15T21:45:00Z'
);
expect(result).toEqual([]);
});
it('drops programs with unparseable times', () => {
const result = filterRecordingProgramsOverlap(
[program('Broken', 'nope', 'also nope'), programs[1]],
'2026-08-15T21:10:00Z',
'2026-08-15T21:20:00Z'
);
expect(result.map(({ title }) => title)).toEqual(['News']);
});
});
describe('toRecordingProgramSnapshot', () => {
it('prefers description over desc and trims it', () => {
expect(
toRecordingProgramSnapshot({
title: 'News',
description: ' Daily ',
desc: 'legacy',
start: 'a',
stop: 'b',
})
).toEqual({ title: 'News', description: 'Daily', start: 'a', stop: 'b' });
});
it('falls back to the XMLTV desc field', () => {
expect(
toRecordingProgramSnapshot({
title: 'News',
desc: 'From XMLTV',
start: 'a',
stop: 'b',
})
).toEqual({
title: 'News',
description: 'From XMLTV',
start: 'a',
stop: 'b',
});
});
it('omits description entirely when both sources are empty', () => {
expect(
toRecordingProgramSnapshot({
title: 'News',
desc: null,
start: 'a',
stop: 'b',
})
).toEqual({ title: 'News', start: 'a', stop: 'b' });
});
});
@@ -0,0 +1,63 @@
import type { RecordingProgramSnapshot } from './recording-metadata.interface';
/**
* Normalizes the hosts' program shapes (XMLTV `EpgProgram.desc`, Xtream
* `EpgItem.description`) into the recording snapshot form.
*/
export function toRecordingProgramSnapshot(program: {
title: string;
desc?: string | null;
description?: string | null;
start: string;
stop: string;
}): RecordingProgramSnapshot {
const description = (program.description ?? program.desc ?? '').trim();
return {
title: program.title,
...(description ? { description } : {}),
start: program.start,
stop: program.stop,
};
}
/**
* Filters normalized program snapshots to the ones overlapping the recorded
* interval, sorted by start. Used by the live hosts' stop-enrichment
* handlers, so a recording spanning a program boundary reports every covered
* program. Fails closed: an unknown recording start yields an empty list
* (the start snapshot then stands).
*/
export function filterRecordingProgramsOverlap(
programs: readonly RecordingProgramSnapshot[],
startedAt: string | null,
endedAt: string
): RecordingProgramSnapshot[] {
if (!startedAt) {
return [];
}
const windowStart = Date.parse(startedAt);
const windowEnd = Date.parse(endedAt);
if (
!Number.isFinite(windowStart) ||
!Number.isFinite(windowEnd) ||
windowEnd <= windowStart
) {
return [];
}
return programs
.map((program) => ({
program,
start: Date.parse(program.start),
stop: Date.parse(program.stop),
}))
.filter(
({ start, stop }) =>
Number.isFinite(start) &&
Number.isFinite(stop) &&
start < windowEnd &&
stop > windowStart
)
.sort((a, b) => a.start - b.start)
.map(({ program }) => program);
}