Files
iptvnator/apps/electron-backend/src/app/api/main.preload.ts
T
4grayandClaude Opus 5.5 8a33516c31 perf(services): keep only the shared startup inventory read (J1)
Drop the startup deferral gate, its kill switch and the deferred loads:
they lowered renderer.ipcCallsToFirstCard but moved neither
spawnToFirstCardMs nor load->card beyond drift, and they grew
renderer.initialBytes. Keep the shared in-flight inventory read, inline
it in PlaylistsService with short property names, and copy the joiner's
result with structuredClone (the Electron renderer has it; the services
test setup polyfills it for jsdom).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:59:35 +02:00

1203 lines
46 KiB
TypeScript

import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
import {
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
APP_UPDATE_GET_RELEASE_NOTES,
APP_UPDATE_GET_STATUS,
APP_UPDATE_INSTALL,
APP_UPDATE_STATUS_CHANGED,
ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST,
ANNOUNCE_PLAYLIST_OPEN_LISTENER,
OPEN_FILE,
WINDOW_GET_ZOOM_LEVEL,
WINDOW_ZOOM_LEVEL_APPLIED,
} from '@iptvnator/shared/interfaces/ipc-commands';
import {
attachEmbeddedMpvFrameView,
detachEmbeddedMpvFrameView,
} from './embedded-mpv-frame-pump';
import {
createPreloadPerformanceCapture,
toPreloadPerformanceTargetMethod,
} from './preload-performance-capture';
import {
adjustFrameZoomLevel,
applyPersistedZoomLevel,
} from './preload-zoom-level';
import {
createXtreamPreloadPerformanceCapture,
isXtreamPreloadPerformanceCaptureEnabled,
toXtreamPreloadPerformanceTargetMethod,
} from './xtream-preload-performance-capture';
import type {
ContentMetadataPatch,
DownloadMetadataSnapshot,
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSession,
EmbeddedMpvSupport,
ElectronBridgeApi,
ElectronBridgeAppUpdateReleaseNotesRequest,
ElectronBridgeAppUpdateStatus,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
ElectronBridgeCurrentProgramsOptions,
ElectronBridgeEpgGuideWindow,
ElectronBridgeEpgLookupOptions,
ElectronBridgeEpgProgress,
ElectronBridgePlaybackPositionInput,
ElectronBridgePlaylistInput,
ElectronBridgePlaylistOpenRequest,
ElectronBridgePlaylistUpsertInput,
ElectronBridgeRemoteControlCommand,
ElectronBridgeRemoteControlStatus,
ElectronBridgeTrustOptions,
ElectronBridgePlaylistFetchOptions,
ElectronBridgeWindowState,
ElectronBridgeXtreamContentStream,
ExternalPlayerSession,
GlobalSearchPaginationOptions,
GlobalSearchResultSource,
PlaybackPositionData,
PlayerContentInfo,
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
PortalDebugEvent,
RecordingProgramSnapshot,
ResolvedPortalPlayback,
Settings,
TmdbCacheEntry,
TmdbCacheMediaType,
StreamProbeHeaders,
VodSourcePin,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
import {
DEBUG_TRACE_EVENT_CHANNEL,
isPerformanceCaptureEnabled,
isRendererApiTraceEnabled,
roundTraceDuration,
summarizeForTrace,
} from '../services/debug-trace';
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
const WINDOW_CLOSE = 'WINDOW:CLOSE';
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
const WINDOW_SET_CLOSE_GUARD = 'WINDOW:SET_CLOSE_GUARD';
const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE';
const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE';
const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED';
const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE';
const dbSaveContentProgressListeners = new Set<
(
event: Electron.IpcRendererEvent,
data: ElectronBridgeDbOperationEvent
) => void
>();
const shouldTraceRendererApi = isRendererApiTraceEnabled();
const shouldCapturePerformance = isPerformanceCaptureEnabled();
const shouldCaptureXtreamPerformance =
isXtreamPreloadPerformanceCaptureEnabled();
const preloadPerformanceCapture = createPreloadPerformanceCapture(
shouldCapturePerformance,
(channel, marker) => ipcRenderer.send(channel, marker)
);
const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance
? createXtreamPreloadPerformanceCapture(true, (channel, marker) =>
ipcRenderer.send(channel, marker)
)
: null;
function emitRendererTrace(payload: {
method: string;
phase: 'start' | 'success' | 'error';
args?: unknown;
durationMs?: number;
error?: unknown;
result?: unknown;
}): void {
if (!shouldTraceRendererApi) {
return;
}
ipcRenderer.send(DEBUG_TRACE_EVENT_CHANNEL, payload);
}
function wrapElectronApi<T extends object>(api: T): T {
if (!shouldTraceRendererApi && !shouldCapturePerformance) {
return api;
}
return Object.fromEntries(
Object.entries(api).map(([name, value]) => {
const isPreloadPerformanceTarget =
toPreloadPerformanceTargetMethod(name) !== null;
const isXtreamPreloadPerformanceTarget =
shouldCaptureXtreamPerformance &&
toXtreamPreloadPerformanceTargetMethod(name) !== null;
if (
typeof value !== 'function' ||
name.startsWith('on') ||
name.startsWith('remove') ||
(!shouldTraceRendererApi &&
!isPreloadPerformanceTarget &&
!isXtreamPreloadPerformanceTarget)
) {
return [name, value];
}
const original = value as (...args: unknown[]) => unknown;
return [
name,
(...args: unknown[]) => {
const startedAt = shouldTraceRendererApi
? (globalThis.performance?.now?.() ?? Date.now())
: 0;
if (shouldTraceRendererApi) {
emitRendererTrace({
args: summarizeForTrace(args),
method: name,
phase: 'start',
});
}
const performanceCall = preloadPerformanceCapture.start(
name,
args
);
const xtreamPerformanceCall =
xtreamPreloadPerformanceCapture?.start(name, args) ??
null;
try {
const result = original(...args);
if (
result &&
typeof (result as PromiseLike<unknown>).then ===
'function'
) {
return (result as Promise<unknown>)
.then((resolvedValue) => {
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(
resolvedValue
),
});
}
preloadPerformanceCapture.success(
performanceCall,
resolvedValue
);
xtreamPreloadPerformanceCapture?.success(
xtreamPerformanceCall
);
return resolvedValue;
})
.catch((error: unknown) => {
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
}
preloadPerformanceCapture.error(
performanceCall
);
xtreamPreloadPerformanceCapture?.error(
xtreamPerformanceCall
);
throw error;
});
}
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(result),
});
}
preloadPerformanceCapture.success(
performanceCall,
result
);
xtreamPreloadPerformanceCapture?.success(
xtreamPerformanceCall
);
return result;
} catch (error) {
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
}
preloadPerformanceCapture.error(performanceCall);
xtreamPreloadPerformanceCapture?.error(
xtreamPerformanceCall
);
throw error;
}
},
];
})
) as T;
}
const frameZoomPorts = {
getZoomLevel: () => webFrame.getZoomLevel(),
setZoomLevel: (level: number) => webFrame.setZoomLevel(level),
};
const electronApi: ElectronBridgeApi = {
// Remote control channel change listener
onChannelChange: (
callback: (data: { direction: 'up' | 'down' }) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: { direction: 'up' | 'down' }
) => callback(data);
ipcRenderer.on('CHANNEL_CHANGE', handler);
return () => ipcRenderer.off('CHANNEL_CHANGE', handler);
},
onRemoteControlCommand: (
callback: (data: ElectronBridgeRemoteControlCommand) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: ElectronBridgeRemoteControlCommand
) => callback(data);
ipcRenderer.on('REMOTE_CONTROL_COMMAND', handler);
return () => ipcRenderer.off('REMOTE_CONTROL_COMMAND', handler);
},
updateRemoteControlStatus: (status: ElectronBridgeRemoteControlStatus) => {
ipcRenderer.send('REMOTE_CONTROL_STATUS_UPDATE', status);
},
// Player error listener
onPlayerError: (
callback: (data: {
player: string;
error: string;
originalError: string;
}) => void
) => {
ipcRenderer.on('player-error', (_event, data) => callback(data));
},
onPortalDebugEvent: (callback: (data: PortalDebugEvent) => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: PortalDebugEvent
) => callback(data);
ipcRenderer.on(PORTAL_DEBUG_EVENT, handler);
return () => ipcRenderer.off(PORTAL_DEBUG_EVENT, handler);
},
// EPG progress listener
onEpgProgress: (callback: (data: ElectronBridgeEpgProgress) => void) => {
ipcRenderer.on('EPG_PROGRESS_UPDATE', (_event, data) => callback(data));
},
// Playback position update listener - returns unsubscribe function
onPlaybackPositionUpdate: (
callback: (data: PlaybackPositionData) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: PlaybackPositionData
) => callback(data);
ipcRenderer.on('playback-position-update', handler);
return () => ipcRenderer.off('playback-position-update', handler);
},
onExternalPlayerSessionUpdate: (
callback: (data: ExternalPlayerSession) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: ExternalPlayerSession
) => callback(data);
ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
},
onEmbeddedMpvSessionUpdate: (
callback: (data: EmbeddedMpvSession) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: EmbeddedMpvSession
) => callback(data);
ipcRenderer.on(EMBEDDED_MPV_SESSION_UPDATE, handler);
return () => ipcRenderer.off(EMBEDDED_MPV_SESSION_UPDATE, handler);
},
onDbOperationEvent: (
callback: (data: ElectronBridgeDbOperationEvent) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: ElectronBridgeDbOperationEvent
) => callback(data);
ipcRenderer.on(DB_OPERATION_EVENT, handler);
return () => ipcRenderer.off(DB_OPERATION_EVENT, handler);
},
onPlaylistRefreshEvent: (
callback: (data: PlaylistRefreshEvent) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: PlaylistRefreshEvent
) => callback(data);
ipcRenderer.on(PLAYLIST_REFRESH_EVENT, handler);
return () => ipcRenderer.off(PLAYLIST_REFRESH_EVENT, handler);
},
// DB save content progress listener
onDbSaveContentProgress: (callback: (count: number) => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: ElectronBridgeDbOperationEvent
) => {
if (
data.operation !== 'save-content' ||
data.status !== 'progress'
) {
return;
}
callback(data.increment ?? data.current ?? 0);
};
dbSaveContentProgressListeners.add(handler);
ipcRenderer.on(DB_OPERATION_EVENT, handler);
},
// Remove DB save content progress listener
removeDbSaveContentProgress: () => {
dbSaveContentProgressListeners.forEach((handler) => {
ipcRenderer.off(DB_OPERATION_EVENT, handler);
});
dbSaveContentProgressListeners.clear();
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
getAppUpdateStatus: () => ipcRenderer.invoke(APP_UPDATE_GET_STATUS),
checkForAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_CHECK),
downloadAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_DOWNLOAD),
installAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_INSTALL),
getAppUpdateReleaseNotes: (
request?: ElectronBridgeAppUpdateReleaseNotesRequest
) => ipcRenderer.invoke(APP_UPDATE_GET_RELEASE_NOTES, request),
onAppUpdateStatusChange: (
callback: (status: ElectronBridgeAppUpdateStatus) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
status: ElectronBridgeAppUpdateStatus
) => callback(status);
ipcRenderer.on(APP_UPDATE_STATUS_CHANGED, handler);
return () => ipcRenderer.off(APP_UPDATE_STATUS_CHANGED, handler);
},
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN),
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
// Zoom shortcuts: same frame-bound webFrame route as the restore below;
// synchronous, no IPC — persistence reads the level back in main.
adjustZoomLevel: (action) => adjustFrameZoomLevel(frameZoomPorts, action),
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
onWindowStateChange: (
callback: (state: ElectronBridgeWindowState) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
state: ElectronBridgeWindowState
) => callback(state);
ipcRenderer.on(WINDOW_STATE_CHANGED, handler);
return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler);
},
setWindowCloseGuard: (active: boolean) =>
ipcRenderer.invoke(WINDOW_SET_CLOSE_GUARD, active),
confirmWindowClose: () => ipcRenderer.invoke(WINDOW_CONFIRM_CLOSE),
cancelWindowClose: (requestId?: number) =>
ipcRenderer.invoke(WINDOW_CANCEL_CLOSE, requestId),
onWindowCloseRequested: (callback: (requestId: number) => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
requestId: number
) => callback(requestId);
ipcRenderer.on(WINDOW_CLOSE_REQUESTED, handler);
return () => ipcRenderer.off(WINDOW_CLOSE_REQUESTED, handler);
},
setPlaybackKeepAwake: (active: boolean) =>
ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true),
setParentalLockState: (active: boolean) =>
ipcRenderer.invoke(PARENTAL_LOCK_SET_STATE, active === true),
fetchPlaylistByUrl: (
url: string,
title?: string,
options?: ElectronBridgePlaylistFetchOptions
) => ipcRenderer.invoke('fetch-playlist-by-url', url, title, options),
updatePlaylistFromFilePath: (filePath: string, title: string) =>
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'),
onPlaylistOpenRequest: (
callback: (request: ElectronBridgePlaylistOpenRequest) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
request: ElectronBridgePlaylistOpenRequest
) => callback(request);
ipcRenderer.on(OPEN_FILE, handler);
return () => ipcRenderer.off(OPEN_FILE, handler);
},
announcePlaylistOpenListener: () =>
ipcRenderer.invoke(ANNOUNCE_PLAYLIST_OPEN_LISTENER),
acknowledgePlaylistOpenRequest: (requestId: string) =>
ipcRenderer.invoke(ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, requestId),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null,
credentials?: {
authorization?: string | null;
cookie?: string | null;
} | null
) =>
ipcRenderer.invoke(
'set-user-agent',
userAgent,
referer,
scopeUrl,
credentials
),
openInMpv: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
ipcRenderer.invoke(
'OPEN_MPV_PLAYER',
url,
title,
thumbnail,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers
),
openInVlc: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
ipcRenderer.invoke(
'OPEN_VLC_PLAYER',
url,
title,
thumbnail,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers
),
closeExternalPlayerSession: (sessionId: string) =>
ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId),
getEmbeddedMpvSupport: (): Promise<EmbeddedMpvSupport> =>
ipcRenderer.invoke('EMBEDDED_MPV_SUPPORT'),
prepareEmbeddedMpv: (): Promise<EmbeddedMpvSupport> =>
ipcRenderer.invoke('EMBEDDED_MPV_PREPARE'),
createEmbeddedMpvSession: (
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
): Promise<EmbeddedMpvSession> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_CREATE_SESSION',
bounds,
title,
initialVolume
),
loadEmbeddedMpvPlayback: (
sessionId: string,
playback: ResolvedPortalPlayback
): Promise<void> =>
ipcRenderer.invoke('EMBEDDED_MPV_LOAD_PLAYBACK', sessionId, playback),
setEmbeddedMpvBounds: (
sessionId: string,
bounds: EmbeddedMpvBounds
): Promise<void> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_BOUNDS', sessionId, bounds),
setEmbeddedMpvPaused: (
sessionId: string,
paused: boolean
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_PAUSED', sessionId, paused),
seekEmbeddedMpv: (
sessionId: string,
seconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds),
seekEmbeddedMpvBy: (
sessionId: string,
deltaSeconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SEEK_BY', sessionId, deltaSeconds),
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_VOLUME', sessionId, volume),
setEmbeddedMpvAudioTrack: (
sessionId: string,
trackId: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_AUDIO_TRACK', sessionId, trackId),
setEmbeddedMpvSubtitleTrack: (
sessionId: string,
trackId: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_TRACK',
sessionId,
trackId
),
addEmbeddedMpvSubtitle: (
sessionId: string,
filePath: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_ADD_SUBTITLE', sessionId, filePath),
setEmbeddedMpvSubtitleDelay: (
sessionId: string,
seconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_DELAY',
sessionId,
seconds
),
setEmbeddedMpvSubtitleStyle: (
sessionId: string,
style: EmbeddedMpvSubtitleStyle
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_SUBTITLE_STYLE', sessionId, style),
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
setEmbeddedMpvSpeed: (
sessionId: string,
speed: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_SPEED', sessionId, speed),
setEmbeddedMpvAspect: (
sessionId: string,
aspect: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect),
startEmbeddedMpvRecording: (
sessionId: string,
options: EmbeddedMpvRecordingStartOptions
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_START_RECORDING', sessionId, options),
stopEmbeddedMpvRecording: (
sessionId: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_STOP_RECORDING', sessionId),
getEmbeddedMpvDefaultRecordingFolder: (): Promise<string> =>
ipcRenderer.invoke('EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER'),
selectEmbeddedMpvRecordingFolder: (): Promise<string | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_RECORDING_FOLDER'),
disposeEmbeddedMpvSession: (
sessionId: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId),
attachEmbeddedMpvFrameView: (sessionId: string): Promise<boolean> =>
attachEmbeddedMpvFrameView(sessionId),
detachEmbeddedMpvFrameView: (): void => detachEmbeddedMpvFrameView(),
autoUpdatePlaylists: (
playlists: Playlist[],
options?: ElectronBridgeTrustOptions
) => ipcRenderer.invoke('AUTO_UPDATE', playlists, options),
fetchEpg: (urls: string[], options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('FETCH_EPG', { url: urls, options }),
getChannelPrograms: (
channelId: string,
options?: ElectronBridgeEpgLookupOptions
) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId, options }),
getCurrentProgramsBatch: (
channelIds: string[],
options?: ElectronBridgeCurrentProgramsOptions
) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', {
channelIds,
options,
}),
getEpgChannelMetadata: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds, options }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
getEpgProgramsForChannels: (window: ElectronBridgeEpgGuideWindow) =>
ipcRenderer.invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', window),
getEpgProgramCoverage: (window: ElectronBridgeEpgGuideWindow) =>
ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window),
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
openEpgFileDialog: () => ipcRenderer.invoke('EPG_OPEN_FILE_DIALOG'),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
reconcileEpgSources: (urls: string[]) =>
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
clearEpgDataForSource: (sourceUrl: string) =>
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
getEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }),
getEpgMappingsBatch: (channelKeys: string[]) =>
ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }),
setEpgMapping: (
channelKey: string,
epgChannelId: string,
playlistId?: string
) =>
ipcRenderer.invoke('EPG_MAPPING_SET', {
channelKey,
epgChannelId,
playlistId,
}),
deleteEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }),
searchEpgChannels: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_CHANNEL_SEARCH', { searchTerm, limit }),
setMpvPlayerPath: (mpvPlayerPath: string) =>
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
setVlcPlayerPath: (vlcPlayerPath: string) =>
ipcRenderer.invoke('SET_VLC_PLAYER_PATH', vlcPlayerPath),
updateSettings: (settings: Partial<Settings>) =>
ipcRenderer.invoke('SETTINGS_UPDATE', settings),
getAiSettings: () => ipcRenderer.invoke('GET_AI_SETTINGS'),
cancelSourceProbe: (id: string) =>
ipcRenderer.invoke('SOURCE_HEALTH_CANCEL', id),
probeM3uSource: (payload: unknown) =>
ipcRenderer.invoke('M3U_SOURCE_PROBE', payload),
stalkerRequest: (payload: {
probe?: SourceProbeContext;
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
skipConnectionGuard?: boolean;
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
resetHostConnectivityGuard: (url: string) =>
ipcRenderer.invoke('CONNECTIVITY_GUARD_RESET', { url }),
xtreamRequest: (payload: {
probe?: SourceProbeContext;
connectionTest?: boolean;
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
xtreamCancelSession: (sessionId: string) =>
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD',
headers?: StreamProbeHeaders
) => ipcRenderer.invoke('STREAM_PROBE_URL', { url, method, ...headers }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
ipcRenderer.invoke('PLAYLIST:CANCEL_REFRESH', operationId),
// Database operations
dbCreatePlaylist: (playlist: ElectronBridgePlaylistUpsertInput) =>
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
dbGetPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
dbSetPlaylistServerTimezone: (
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
) =>
ipcRenderer.invoke(
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
playlistId,
connection,
serverTimezone
),
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
dbMigrateAppPlaylists: (playlists: Playlist[]) =>
ipcRenderer.invoke('DB_MIGRATE_APP_PLAYLISTS', playlists),
dbRecoverLegacyPlaylists: () =>
ipcRenderer.invoke('DB_RECOVER_LEGACY_PLAYLISTS'),
dbUpsertAppPlaylists: (playlists: Playlist[]) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists),
dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'),
dbGetAppPlaylistMetas: () =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'),
dbGetAppPlaylist: (playlistId: string, _operationId?: string) =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId),
dbGetAppPlaylistFavoriteChannels: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId),
dbUpdatePlaylist: (
playlistId: string,
updates: Partial<Playlist> | ElectronBridgePlaylistInput
) => ipcRenderer.invoke('DB_UPDATE_PLAYLIST', playlistId, updates),
dbDeletePlaylist: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId),
dbDeleteXtreamContent: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId, operationId),
dbRestoreXtreamUserData: (
playlistId: string,
favorites: unknown[],
recentlyViewed: unknown[],
operationId?: string
) =>
ipcRenderer.invoke(
'DB_RESTORE_XTREAM_USER_DATA',
playlistId,
favorites,
recentlyViewed,
operationId
),
dbHasCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_HAS_CATEGORIES', playlistId, type),
dbGetCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type),
dbSaveCategories: (
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
) =>
ipcRenderer.invoke(
'DB_SAVE_CATEGORIES',
playlistId,
categories,
type,
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
),
dbGetAllCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) =>
ipcRenderer.invoke(
'DB_UPDATE_CATEGORY_VISIBILITY',
categoryIds,
hidden
),
dbSetCategoryLocks: (
playlistId: string,
type: string,
lockedXtreamIds: number[]
) =>
ipcRenderer.invoke(
'DB_SET_CATEGORY_LOCKS',
playlistId,
type,
lockedXtreamIds
),
dbHasContent: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
dbGetContent: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_CONTENT', playlistId, type),
dbSaveContent: (
playlistId: string,
streams: ElectronBridgeXtreamContentStream[],
type: string,
operationId?: string
) =>
ipcRenderer.invoke(
'DB_SAVE_CONTENT',
playlistId,
streams,
type,
operationId
),
dbClearXtreamImportCache: (
playlistId: string,
type: 'live' | 'movie' | 'series'
) => ipcRenderer.invoke('DB_CLEAR_XTREAM_IMPORT_CACHE', playlistId, type),
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden?: boolean
) =>
ipcRenderer.invoke(
'DB_SEARCH_CONTENT',
playlistId,
searchTerm,
types,
excludeHidden
),
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean,
sources?: GlobalSearchResultSource[],
options?: GlobalSearchPaginationOptions
) => {
if (sources?.length || options) {
return ipcRenderer.invoke(
'DB_GLOBAL_SEARCH',
searchTerm,
types,
excludeHidden,
sources,
options
);
}
return ipcRenderer.invoke(
'DB_GLOBAL_SEARCH',
searchTerm,
types,
excludeHidden
);
},
dbGetGlobalRecentlyAdded: (
kind: 'all' | 'vod' | 'series',
limit?: number,
playlistType?:
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'
) =>
ipcRenderer.invoke(
'DB_GET_GLOBAL_RECENTLY_ADDED',
kind,
limit,
playlistType
),
dbGetRecentlyViewed: () => ipcRenderer.invoke('DB_GET_RECENTLY_VIEWED'),
dbClearRecentlyViewed: () => ipcRenderer.invoke('DB_CLEAR_RECENTLY_VIEWED'),
// Favorites
dbAddFavorite: (
contentId: number,
playlistId: string,
backdropUrl?: string
) =>
ipcRenderer.invoke(
'DB_ADD_FAVORITE',
contentId,
playlistId,
backdropUrl
),
dbRemoveFavorite: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId),
dbIsFavorite: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_IS_FAVORITE', contentId, playlistId),
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
dbGetAllGlobalFavorites: () =>
ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
dbReorderGlobalFavorites: (
updates: { content_id: number; playlist_id: string; position: number }[]
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_RECENT_ITEMS', playlistId),
dbAddRecentItem: (
contentId: number,
playlistId: string,
backdropUrl?: string
) =>
ipcRenderer.invoke(
'DB_ADD_RECENT_ITEM',
contentId,
playlistId,
backdropUrl
),
dbClearPlaylistRecentItems: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId),
dbRemoveRecentItem: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
dbRemoveRecentItemsBatch: (
items: { contentId: number; playlistId: string }[]
) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEMS_BATCH', items),
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) =>
ipcRenderer.invoke(
'DB_GET_CONTENT_BY_XTREAM_ID',
xtreamId,
playlistId,
contentType
),
dbSetContentMetadataIfMissing: (
contentId: number,
patch?: ContentMetadataPatch
) =>
ipcRenderer.invoke(
'DB_SET_CONTENT_METADATA_IF_MISSING',
contentId,
patch
),
dbDeleteAllPlaylists: (operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS', operationId),
dbCancelOperation: (operationId: string) =>
ipcRenderer.invoke('DB_CANCEL_OPERATION', operationId),
dbGetAppState: (key: string) => ipcRenderer.invoke('DB_GET_APP_STATE', key),
dbSetAppState: (key: string, value: string) =>
ipcRenderer.invoke('DB_SET_APP_STATE', key, value),
// TMDB metadata cache
dbGetTmdbMetadata: (
mediaType: TmdbCacheMediaType,
lookupKey: string,
language: string
) =>
ipcRenderer.invoke(
'DB_GET_TMDB_METADATA',
mediaType,
lookupKey,
language
),
dbSetTmdbMetadata: (entry: TmdbCacheEntry) =>
ipcRenderer.invoke('DB_SET_TMDB_METADATA', entry),
dbGetTmdbCacheStats: () => ipcRenderer.invoke('DB_GET_TMDB_CACHE_STATS'),
dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'),
dbMatchTitles: (titles: string[]) =>
ipcRenderer.invoke('DB_MATCH_TITLES', titles),
// VOD multi-source
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => ipcRenderer.invoke('DB_FIND_TITLE_SOURCES', request),
dbGetVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys),
dbListVodSourcePins: (playlistId: string) =>
ipcRenderer.invoke('DB_LIST_VOD_SOURCE_PINS', playlistId),
dbClearVodSourcePinsForPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', playlistId),
dbSetVodSourcePin: (
pin: VodSourcePin,
retireKeys?: string[],
aliasKeys?: string[]
) =>
ipcRenderer.invoke(
'DB_SET_VOD_SOURCE_PIN',
pin,
retireKeys ?? [],
aliasKeys ?? []
),
dbReplaceVodSourcePins: (playlistId: string, pins: VodSourcePin[]) =>
ipcRenderer.invoke('DB_REPLACE_VOD_SOURCE_PINS', playlistId, pins),
dbClearVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys),
// Playback Positions
dbSavePlaybackPosition: (
playlistId: string,
data: ElectronBridgePlaybackPositionInput
) => ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data),
dbGetPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
ipcRenderer.invoke(
'DB_GET_PLAYBACK_POSITION',
playlistId,
contentXtreamId,
contentType
),
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) =>
ipcRenderer.invoke(
'DB_GET_SERIES_PLAYBACK_POSITIONS',
playlistId,
seriesXtreamId
),
dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) =>
ipcRenderer.invoke(
'DB_GET_RECENT_PLAYBACK_POSITIONS',
playlistId,
limit
),
dbGetAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
ipcRenderer.invoke(
'DB_CLEAR_PLAYBACK_POSITION',
playlistId,
contentXtreamId,
contentType
),
dbSavePlaybackPositionsBatch: (
playlistId: string,
items: ElectronBridgePlaybackPositionInput[]
) =>
ipcRenderer.invoke(
'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
playlistId,
items
),
dbClearPlaybackPositionsBatch: (
playlistId: string,
items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[]
) =>
ipcRenderer.invoke(
'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
playlistId,
items
),
getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'),
// Downloads
downloadsStart: (data: ElectronBridgeDownloadStartPayload) =>
ipcRenderer.invoke('DOWNLOADS_START', data),
downloadsCancel: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId),
downloadsPause: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_PAUSE', downloadId),
downloadsResume: (downloadId: number, downloadFolder: string) =>
ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder),
downloadsRetry: (downloadId: number, downloadFolder: string) =>
ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder),
downloadsRedownloadMissing: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_REDOWNLOAD_MISSING', downloadId),
downloadsRemove: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_REMOVE', downloadId),
downloadsGetList: (playlistId?: string) =>
ipcRenderer.invoke('DOWNLOADS_GET_LIST', playlistId),
downloadsGet: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_GET', downloadId),
downloadsUpdateMetadata: (
downloadId: number,
metadataSnapshot: DownloadMetadataSnapshot
) =>
ipcRenderer.invoke(
'DOWNLOADS_UPDATE_METADATA',
downloadId,
metadataSnapshot
),
downloadsGetDefaultFolder: () =>
ipcRenderer.invoke('DOWNLOADS_GET_DEFAULT_FOLDER'),
downloadsSelectFolder: () => ipcRenderer.invoke('DOWNLOADS_SELECT_FOLDER'),
downloadsRevealFile: (filePath: string) =>
ipcRenderer.invoke('DOWNLOADS_REVEAL_FILE', filePath),
downloadsPlayFile: (filePath: string) =>
ipcRenderer.invoke('DOWNLOADS_PLAY_FILE', filePath),
downloadsClearCompleted: (playlistId?: string) =>
ipcRenderer.invoke('DOWNLOADS_CLEAR_COMPLETED', playlistId),
onDownloadsUpdate: (callback: () => void) => {
const handler = () => callback();
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);
},
};
// Restore the app zoom level (issue #1109). Must be webFrame (temporary,
// frame-bound zoom), not a main-process setZoomLevel: under file:// Chromium
// keys zoom by full URL, and the app's pushState routing would reset it on
// the next resize. Applied at DOMContentLoaded, never earlier — see
// preload-zoom-level.ts for the Linux/Windows ready-to-show trap.
applyPersistedZoomLevel({
requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL),
...frameZoomPorts,
whenDocumentParsed: (apply) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', apply, { once: true });
} else {
apply();
}
},
notifyApplied: () => ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED),
});
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));