mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user