mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -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
@@ -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