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

@@ -119,6 +119,37 @@ const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
// No unique index: re-recording the same channel is a normal workflow, and
// playlist_id carries no FK so recordings survive source deletion.
const RECORDINGS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
owner_pid INTEGER,
status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')),
file_path TEXT NOT NULL,
file_size_bytes INTEGER,
channel_name TEXT NOT NULL,
channel_logo_url TEXT,
playlist_id TEXT,
playlist_name TEXT,
source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')),
epg_channel_id TEXT,
program_title TEXT,
program_description TEXT,
program_start TEXT,
program_stop TEXT,
programs_json TEXT,
error_message TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`;
const RECORDINGS_INDEX_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status)`,
`CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path)`,
`CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id)`,
];
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
@@ -344,6 +375,9 @@ const CREATE_TABLE_STATEMENTS = [
// Downloads table
DOWNLOADS_TABLE_SQL,
...DOWNLOADS_INDEX_STATEMENTS,
// Live-TV recordings table
RECORDINGS_TABLE_SQL,
...RECORDINGS_INDEX_STATEMENTS,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
+55
View File
@@ -371,6 +371,61 @@ export const downloads = sqliteTable(
export type Download = typeof downloads.$inferSelect;
export type NewDownload = typeof downloads.$inferInsert;
// Live-TV recordings table. Rows are created by the embedded-MPV recording
// tracker, not by the download queue: a recording has no source URL to
// re-fetch, no byte totals, and no retry/resume semantics, so it lives beside
// `downloads` instead of inside it. playlist_id has no FK on purpose —
// recordings outlive the deletion of their source playlist.
export const recordings = sqliteTable(
'recordings',
{
id: integer('id').primaryKey({ autoIncrement: true }),
// Transient embedded-MPV session id, correlation/debugging only.
sessionId: text('session_id'),
// PID of the process that opened the recording. Startup recovery uses
// it to leave rows owned by another live instance alone
// (IPTVNATOR_ALLOW_MULTIPLE_INSTANCES).
ownerPid: integer('owner_pid'),
status: text('status', {
enum: ['recording', 'completed', 'interrupted', 'failed'],
})
.notNull()
.default('recording'),
filePath: text('file_path').notNull(),
fileSizeBytes: integer('file_size_bytes'),
// Metadata snapshot captured at recording start (EPG is
// time-sensitive; it cannot be reconstructed after the fact).
channelName: text('channel_name').notNull(),
channelLogoUrl: text('channel_logo_url'),
playlistId: text('playlist_id'),
playlistName: text('playlist_name'),
sourceType: text('source_type', {
enum: ['m3u', 'xtream', 'stalker'],
}),
epgChannelId: text('epg_channel_id'),
programTitle: text('program_title'),
programDescription: text('program_description'),
programStart: text('program_start'),
programStop: text('program_stop'),
// Stop-time enrichment: JSON array of program snapshots overlapping
// [started_at, ended_at] (a recording can span program boundaries).
programsJson: text('programs_json'),
errorMessage: text('error_message'),
startedAt: text('started_at').notNull(),
endedAt: text('ended_at'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
statusIdx: index('recordings_status_idx').on(table.status),
filePathIdx: index('recordings_file_path_idx').on(table.filePath),
playlistIdx: index('recordings_playlist_idx').on(table.playlistId),
})
);
export type Recording = typeof recordings.$inferSelect;
export type NewRecording = typeof recordings.$inferInsert;
// TMDB metadata cache table.
// Two row kinds share the table, discriminated by the lookup_key prefix:
// - 'id:<tmdbId>' → full TMDB details payload (JSON)
+1 -1
View File
@@ -6,7 +6,7 @@
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"ngx-indexed-db": "^21.0.0",
"ngx-indexed-db": "^22.0.0",
"tslib": "^2.3.0"
}
}
+2
View File
@@ -41,6 +41,8 @@ export * from './lib/portal-playback.interface';
export * from './lib/provider-import-detection.util';
export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
export * from './lib/recording-program-overlap.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-auth-failure.util';
@@ -16,6 +16,11 @@ import {
GlobalSearchResultSource,
} from './global-search-result.interface';
import { M3uFavoriteChannel } from './m3u-favorite-channel.interface';
import {
RecordingProgramSnapshot,
RecordingSourceType,
RecordingStatus,
} from './recording-metadata.interface';
import { PlaybackPositionData } from './playback-position.interface';
import { AutoUpdatePlaylistsResult } from './playlist-auto-update.interface';
import {
@@ -601,6 +606,41 @@ export interface ElectronDownloadItem {
updatedAt?: string;
}
/**
* Renderer-facing live-TV recording row. `fileAvailability` is derived at
* read time (never persisted) and `programs` is the decoded `programs_json`
* column. Recordings reuse the download availability vocabulary so the
* manager UI can share its missing-file affordances — extended by
* `'unknown'` for an inconclusive probe (timeout, permission or I/O error):
* only proven absence may move a recording to Needs attention or hide its
* file actions, so consumers gate on `=== 'missing'`, never on
* `!== 'available'`.
*/
export interface ElectronRecordingItem {
id: number;
sessionId?: string;
status: RecordingStatus;
filePath: string;
fileSizeBytes?: number;
channelName: string;
channelLogoUrl?: string;
playlistId?: string;
playlistName?: string;
sourceType?: RecordingSourceType;
epgChannelId?: string;
programTitle?: string;
programDescription?: string;
programStart?: string;
programStop?: string;
programs?: RecordingProgramSnapshot[];
errorMessage?: string;
startedAt: string;
endedAt?: string;
createdAt?: string;
updatedAt?: string;
fileAvailability: ElectronDownloadFileAvailability | 'unknown';
}
export interface ElectronBridgeApi {
onPortalDebugEvent?: (
callback: (data: PortalDebugEvent) => void
@@ -1201,4 +1241,28 @@ export interface ElectronBridgeApi {
playlistId?: string
) => Promise<ElectronBridgeResult>;
onDownloadsUpdate: (callback: () => void) => () => void;
// Live-TV recordings surface. Optional: older Electron builds have no
// recordings bridge, and `supportsRecordings` (not `supportsDownloads`)
// gates every consumer.
recordingsGetList?: (
playlistId?: string
) => Promise<ElectronRecordingItem[]>;
recordingsGet?: (
recordingId: number
) => Promise<ElectronRecordingItem | null>;
recordingsStop?: (recordingId: number) => Promise<ElectronBridgeErrorResult>;
recordingsRemove?: (
recordingId: number
) => Promise<ElectronBridgeErrorResult>;
recordingsUpdatePrograms?: (
targetPath: string,
programs: RecordingProgramSnapshot[]
) => Promise<ElectronBridgeErrorResult>;
recordingsRevealFile?: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
recordingsPlayFile?: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
onRecordingsUpdate?: (callback: () => void) => () => void;
}
@@ -1,4 +1,5 @@
import type { PlayerSubtitleStyle } from './subtitle-style.util';
import type { RecordingStartMetadata } from './recording-metadata.interface';
export type EmbeddedMpvSessionStatus =
| 'idle'
@@ -99,6 +100,12 @@ export interface EmbeddedMpvRecordingState {
export interface EmbeddedMpvRecordingStartOptions {
directory?: string;
title?: string;
/**
* Channel/EPG snapshot captured by the live host at recording start; the
* main-process recording tracker persists it. See
* recording-metadata.interface.ts for why capture must happen up front.
*/
metadata?: RecordingStartMetadata;
}
export interface EmbeddedMpvSession {
@@ -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);
}