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(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).then === 'function' ) { return (result as Promise) .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 ): Promise => 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 ): Promise => 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 => ipcRenderer.invoke('EMBEDDED_MPV_SUPPORT'), prepareEmbeddedMpv: (): Promise => ipcRenderer.invoke('EMBEDDED_MPV_PREPARE'), createEmbeddedMpvSession: ( bounds: EmbeddedMpvBounds, title?: string, initialVolume?: number ): Promise => ipcRenderer.invoke( 'EMBEDDED_MPV_CREATE_SESSION', bounds, title, initialVolume ), loadEmbeddedMpvPlayback: ( sessionId: string, playback: ResolvedPortalPlayback ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_LOAD_PLAYBACK', sessionId, playback), setEmbeddedMpvBounds: ( sessionId: string, bounds: EmbeddedMpvBounds ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_BOUNDS', sessionId, bounds), setEmbeddedMpvPaused: ( sessionId: string, paused: boolean ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_PAUSED', sessionId, paused), seekEmbeddedMpv: ( sessionId: string, seconds: number ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds), seekEmbeddedMpvBy: ( sessionId: string, deltaSeconds: number ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SEEK_BY', sessionId, deltaSeconds), setEmbeddedMpvVolume: ( sessionId: string, volume: number ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_VOLUME', sessionId, volume), setEmbeddedMpvAudioTrack: ( sessionId: string, trackId: number ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_AUDIO_TRACK', sessionId, trackId), setEmbeddedMpvSubtitleTrack: ( sessionId: string, trackId: number ): Promise => ipcRenderer.invoke( 'EMBEDDED_MPV_SET_SUBTITLE_TRACK', sessionId, trackId ), addEmbeddedMpvSubtitle: ( sessionId: string, filePath: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_ADD_SUBTITLE', sessionId, filePath), setEmbeddedMpvSubtitleDelay: ( sessionId: string, seconds: number ): Promise => ipcRenderer.invoke( 'EMBEDDED_MPV_SET_SUBTITLE_DELAY', sessionId, seconds ), setEmbeddedMpvSubtitleStyle: ( sessionId: string, style: EmbeddedMpvSubtitleStyle ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_SUBTITLE_STYLE', sessionId, style), selectEmbeddedMpvSubtitleFile: (): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'), setEmbeddedMpvSpeed: ( sessionId: string, speed: number ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_SPEED', sessionId, speed), setEmbeddedMpvAspect: ( sessionId: string, aspect: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect), startEmbeddedMpvRecording: ( sessionId: string, options: EmbeddedMpvRecordingStartOptions ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_START_RECORDING', sessionId, options), stopEmbeddedMpvRecording: ( sessionId: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_STOP_RECORDING', sessionId), getEmbeddedMpvDefaultRecordingFolder: (): Promise => ipcRenderer.invoke('EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER'), selectEmbeddedMpvRecordingFolder: (): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SELECT_RECORDING_FOLDER'), disposeEmbeddedMpvSession: ( sessionId: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), attachEmbeddedMpvFrameView: (sessionId: string): Promise => 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) => 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; 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; 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 | 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));