From 1badb9a057adbb739e9337ae796698f4299b0ee2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:52:22 +0200 Subject: [PATCH] refactor(electron): share typed preload bridge contract (#1018) * refactor(electron): share typed preload bridge contract * refactor(electron): tighten bridge review fixes * refactor(electron): tighten playlist bridge returns * refactor(electron): use bridge epg progress type * refactor(epg): alias progress bridge types --- CLAUDE.md | 1 + .../src/app/api/main.preload.spec-data.ts | 80 +- .../src/app/api/main.preload.spec.ts | 35 +- .../src/app/api/main.preload.ts | 167 ++-- apps/web/src/app/services/electron.service.ts | 3 +- apps/web/src/typings.d.ts | 539 +----------- docs/architecture/download-manager.md | 2 +- docs/architecture/electron-security.md | 17 + global.d.ts | 516 +----------- .../src/lib/epg-runtime-bridge.service.ts | 136 ++- .../src/lib/runtime-capabilities.service.ts | 14 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 793 ++++++++++++++++++ .../multi-epg-container.component.ts | 32 +- 14 files changed, 1080 insertions(+), 1256 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/electron-api.interface.ts diff --git a/CLAUDE.md b/CLAUDE.md index 10c11a606..33ea68e1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -543,6 +543,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts` - Exposes `window.electron` API via `contextBridge` - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) + - The canonical TypeScript contract is `ElectronBridgeApi` in `libs/shared/interfaces/src/lib/electron-api.interface.ts`; `global.d.ts`, `apps/web/src/typings.d.ts`, and `main.preload.ts` must reference this shared type instead of maintaining separate method lists. - **Event handlers**: `apps/electron-backend/src/app/events/` - `database.events.ts` - Database CRUD operations - `playlist.events.ts` - Playlist import/update diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index c05a36804..d382aa3e6 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -1,5 +1,18 @@ +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; + +type ElectronBridgeMethodName = Extract< + { + [K in keyof ElectronBridgeApi]: ElectronBridgeApi[K] extends ( + ...args: never[] + ) => unknown + ? K + : never; + }[keyof ElectronBridgeApi], + string +>; + type PreloadInvokeCase = { - method: string; + method: ElectronBridgeMethodName; args: unknown[]; channel: string; forwardedArgs: unknown[]; @@ -7,6 +20,8 @@ type PreloadInvokeCase = { const playlistId = 'playlist-1'; export const operationId = 'operation-1'; +const epgUrls = ['https://example.com/guide.xml']; +const channelIds = ['channel-1', 'channel-2']; const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' }; const playlists = [playlist]; const playlistUpdates = { name: 'Updated playlist' }; @@ -319,3 +334,66 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ forwardedArgs: [playlistId, 42, 'vod'], }, ]; + +export const epgPreloadCases: PreloadInvokeCase[] = [ + { + method: 'fetchEpg', + args: [epgUrls], + channel: 'FETCH_EPG', + forwardedArgs: [{ url: epgUrls }], + }, + { + method: 'getChannelPrograms', + args: ['channel-1'], + channel: 'GET_CHANNEL_PROGRAMS', + forwardedArgs: [{ channelId: 'channel-1' }], + }, + { + method: 'getCurrentProgramsBatch', + args: [channelIds], + channel: 'GET_CURRENT_PROGRAMS_BATCH', + forwardedArgs: [{ channelIds }], + }, + { + method: 'getEpgChannelMetadata', + args: [channelIds], + channel: 'EPG_GET_CHANNEL_METADATA', + forwardedArgs: [{ channelIds }], + }, + { + method: 'getEpgChannels', + args: [], + channel: 'EPG_GET_CHANNELS', + forwardedArgs: [], + }, + { + method: 'getEpgChannelsByRange', + args: [10, 20], + channel: 'EPG_GET_CHANNELS_BY_RANGE', + forwardedArgs: [{ skip: 10, limit: 20 }], + }, + { + method: 'forceFetchEpg', + args: ['https://example.com/guide.xml'], + channel: 'EPG_FORCE_FETCH', + forwardedArgs: ['https://example.com/guide.xml'], + }, + { + method: 'clearEpgData', + args: [], + channel: 'EPG_CLEAR_ALL', + forwardedArgs: [], + }, + { + method: 'checkEpgFreshness', + args: [epgUrls, 24], + channel: 'EPG_CHECK_FRESHNESS', + forwardedArgs: [{ urls: epgUrls, maxAgeHours: 24 }], + }, + { + method: 'searchEpgPrograms', + args: ['news', 50], + channel: 'EPG_DB_SEARCH_PROGRAMS', + forwardedArgs: ['news', 50], + }, +]; diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index d83fc3cc8..06fcca47e 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -2,9 +2,15 @@ import { DB_WORKER_OPERATIONS, DbWorkerOperation, } from '../workers/database-worker.types'; -import { dbPreloadCases, operationId } from './main.preload.spec-data'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { + dbPreloadCases, + epgPreloadCases, + operationId, +} from './main.preload.spec-data'; -type ExposedElectronApi = Record unknown>; +type ExposedElectronApi = ElectronBridgeApi & + Record unknown>; type MockIpcRenderer = { invoke: jest.Mock; @@ -25,6 +31,15 @@ function getExposedApi(): ExposedElectronApi { return mockExposedApi; } +function callExposedApiMethod( + api: ExposedElectronApi, + method: string, + args: unknown[] +): unknown { + const callableApi = api as Record unknown>; + return callableApi[method](...args); +} + describe('main preload DB IPC contract', () => { beforeEach(async () => { jest.resetModules(); @@ -75,7 +90,21 @@ describe('main preload DB IPC contract', () => { async ({ method, args, channel, forwardedArgs }) => { const api = getExposedApi(); - await api[method](...args); + await callExposedApiMethod(api, method, args); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + channel, + ...forwardedArgs + ); + } + ); + + it.each(epgPreloadCases)( + '$method invokes $channel with the expected arguments', + async ({ method, args, channel, forwardedArgs }) => { + const api = getExposedApi(); + + await callExposedApiMethod(api, method, args); expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( channel, diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index f8efdaf56..1ea1d604f 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -4,10 +4,25 @@ import type { EmbeddedMpvRecordingStartOptions, EmbeddedMpvSession, EmbeddedMpvSupport, + ElectronBridgeApi, + ElectronBridgeDbOperationEvent, + ElectronBridgeDownloadStartPayload, + ElectronBridgeEpgProgress, + ElectronBridgePlaybackPositionInput, + ElectronBridgePlaylistInput, + ElectronBridgePlaylistUpsertInput, + ElectronBridgeRemoteControlCommand, + ElectronBridgeRemoteControlStatus, + ElectronBridgeXtreamContentStream, ExternalPlayerSession, + PlaybackPositionData, + PlayerContentInfo, + Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, + PortalDebugEvent, ResolvedPortalPlayback, + Settings, XtreamCategory, } from '@iptvnator/shared/interfaces'; import { @@ -23,33 +38,11 @@ const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE'; const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT'; const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT'; -type PortalDebugEvent = { - requestId: string; - provider: 'xtream' | 'stalker'; - operation: string; - transport: 'electron-main' | 'electron-renderer' | 'pwa-http'; - startedAt: string; - durationMs: number; - status: 'success' | 'error'; - request: unknown; - response?: unknown; - error?: unknown; -}; - -type DbOperationEvent = { - operationId?: string; - operation: string; - playlistId?: string; - status: 'started' | 'progress' | 'completed' | 'cancelled' | 'error'; - phase?: string; - current?: number; - total?: number; - increment?: number; - error?: string; -}; - const dbSaveContentProgressListeners = new Set< - (event: Electron.IpcRendererEvent, data: DbOperationEvent) => void + ( + event: Electron.IpcRendererEvent, + data: ElectronBridgeDbOperationEvent + ) => void >(); const shouldTraceRendererApi = isRendererApiTraceEnabled(); @@ -69,7 +62,7 @@ function emitRendererTrace(payload: { ipcRenderer.send(DEBUG_TRACE_EVENT_CHANNEL, payload); } -function wrapElectronApi>(api: T): T { +function wrapElectronApi(api: T): T { if (!shouldTraceRendererApi) { return api; } @@ -163,32 +156,29 @@ function wrapElectronApi>(api: T): T { ) as T; } -const electronApi = { +const electronApi: ElectronBridgeApi = { // Remote control channel change listener onChannelChange: ( callback: (data: { direction: 'up' | 'down' }) => void ) => { - const handler = (_event: Electron.IpcRendererEvent, data: any) => - callback(data); + 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: { - type: - | 'channel-select-number' - | 'volume-up' - | 'volume-down' - | 'volume-toggle-mute'; - number?: number; - }) => void + callback: (data: ElectronBridgeRemoteControlCommand) => void ) => { - const handler = (_event: Electron.IpcRendererEvent, data: any) => - callback(data); + const handler = ( + _event: Electron.IpcRendererEvent, + data: ElectronBridgeRemoteControlCommand + ) => callback(data); ipcRenderer.on('REMOTE_CONTROL_COMMAND', handler); return () => ipcRenderer.off('REMOTE_CONTROL_COMMAND', handler); }, - updateRemoteControlStatus: (status: any) => { + updateRemoteControlStatus: (status: ElectronBridgeRemoteControlStatus) => { ipcRenderer.send('REMOTE_CONTROL_STATUS_UPDATE', status); }, // Player error listener @@ -202,26 +192,25 @@ const electronApi = { ipcRenderer.on('player-error', (_event, data) => callback(data)); }, onPortalDebugEvent: (callback: (data: PortalDebugEvent) => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: any) => - callback(data as PortalDebugEvent); + 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: { - url: string; - status: 'loading' | 'complete' | 'error'; - stats?: { totalChannels: number; totalPrograms: number }; - error?: string; - }) => void - ) => { + onEpgProgress: (callback: (data: ElectronBridgeEpgProgress) => void) => { ipcRenderer.on('EPG_PROGRESS_UPDATE', (_event, data) => callback(data)); }, // Playback position update listener - returns unsubscribe function - onPlaybackPositionUpdate: (callback: (data: any) => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: any) => - callback(data); + 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); }, @@ -245,17 +234,23 @@ const electronApi = { ipcRenderer.on(EMBEDDED_MPV_SESSION_UPDATE, handler); return () => ipcRenderer.off(EMBEDDED_MPV_SESSION_UPDATE, handler); }, - onDbOperationEvent: (callback: (data: DbOperationEvent) => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: any) => - callback(data as DbOperationEvent); + 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: any) => - callback(data as PlaylistRefreshEvent); + const handler = ( + _event: Electron.IpcRendererEvent, + data: PlaylistRefreshEvent + ) => callback(data); ipcRenderer.on(PLAYLIST_REFRESH_EVENT, handler); return () => ipcRenderer.off(PLAYLIST_REFRESH_EVENT, handler); }, @@ -263,7 +258,7 @@ const electronApi = { onDbSaveContentProgress: (callback: (count: number) => void) => { const handler = ( _event: Electron.IpcRendererEvent, - data: DbOperationEvent + data: ElectronBridgeDbOperationEvent ) => { if ( data.operation !== 'save-content' || @@ -311,7 +306,7 @@ const electronApi = { userAgent: string | undefined, referer?: string, origin?: string, - contentInfo?: any, + contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record ): Promise => @@ -334,7 +329,7 @@ const electronApi = { userAgent: string | undefined, referer?: string, origin?: string, - contentInfo?: any, + contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record ): Promise => @@ -456,7 +451,7 @@ const electronApi = { ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath), setVlcPlayerPath: (vlcPlayerPath: string) => ipcRenderer.invoke('SET_VLC_PLAYER_PATH', vlcPlayerPath), - updateSettings: (settings: any) => + updateSettings: (settings: Partial) => ipcRenderer.invoke('SETTINGS_UPDATE', settings), getAiSettings: () => ipcRenderer.invoke('GET_AI_SETTINGS'), stalkerRequest: (payload: { @@ -483,19 +478,21 @@ const electronApi = { cancelPlaylistRefresh: (operationId: string) => ipcRenderer.invoke('PLAYLIST:CANCEL_REFRESH', operationId), // Database operations - dbCreatePlaylist: (playlist: any) => + dbCreatePlaylist: (playlist: ElectronBridgePlaylistUpsertInput) => ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), - dbUpsertAppPlaylist: (playlist: any) => + dbUpsertAppPlaylist: (playlist: Playlist) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), - dbUpsertAppPlaylists: (playlists: any[]) => + dbUpsertAppPlaylists: (playlists: Playlist[]) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists), dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'), dbGetAppPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId), - dbUpdatePlaylist: (playlistId: string, updates: any) => - ipcRenderer.invoke('DB_UPDATE_PLAYLIST', playlistId, updates), + 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) => @@ -544,7 +541,7 @@ const electronApi = { ipcRenderer.invoke('DB_GET_CONTENT', playlistId, type), dbSaveContent: ( playlistId: string, - streams: any[], + streams: ElectronBridgeXtreamContentStream[], type: string, operationId?: string ) => @@ -671,8 +668,10 @@ const electronApi = { dbSetAppState: (key: string, value: string) => ipcRenderer.invoke('DB_SET_APP_STATE', key, value), // Playback Positions - dbSavePlaybackPosition: (playlistId: string, data: any) => - ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data), + dbSavePlaybackPosition: ( + playlistId: string, + data: ElectronBridgePlaybackPositionInput + ) => ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data), dbGetPlaybackPosition: ( playlistId: string, contentXtreamId: number, @@ -716,30 +715,8 @@ const electronApi = { ), getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'), // Downloads - downloadsStart: (data: { - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - title: string; - url: string; - posterUrl?: string; - downloadFolder: string; - headers?: { userAgent?: string; referer?: string; origin?: string }; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - // Playlist info for auto-creation if needed - playlistName?: string; - playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; - serverUrl?: string; - portalUrl?: string; - macAddress?: string; - }) => ipcRenderer.invoke('DOWNLOADS_START', data), + downloadsStart: (data: ElectronBridgeDownloadStartPayload) => + ipcRenderer.invoke('DOWNLOADS_START', data), downloadsCancel: (downloadId: number) => ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId), downloadsRetry: (downloadId: number, downloadFolder: string) => diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index c9d3bf053..9b2bf8069 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -8,6 +8,7 @@ import { AUTO_UPDATE_PLAYLISTS, createDevLogger, ERROR, + PlayerContentInfo, Playlist, PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE, @@ -30,7 +31,7 @@ interface PlayerLaunchPayload { readonly title?: string; readonly url: string; readonly ['user-agent']?: string; - readonly contentInfo?: unknown; + readonly contentInfo?: PlayerContentInfo; } interface ErrorStatus { diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 4d5fc21b8..07e55d870 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -4,547 +4,12 @@ interface NodeModule { id: string; } -import { - EmbeddedMpvBounds, - EmbeddedMpvRecordingStartOptions, - EmbeddedMpvSession, - EmbeddedMpvSupport, - EpgChannel, - EpgChannelMetadata, - EpgProgram, - ExternalPlayerSession, - PlaybackPositionData, - Playlist, - PlaylistRefreshEvent, - PlaylistRefreshPayload, - PortalDebugEvent, - ResolvedPortalPlayback, - Settings, - XtreamBackupFavoriteItem, - XtreamBackupHiddenCategory, - XtreamBackupRecentlyViewedItem, - XtreamCategory, -} from '@iptvnator/shared/interfaces'; -import { - DbOperationEvent, - GlobalFavoriteItem, - GlobalRecentlyAddedItem, - GlobalRecentItem, - GlobalSearchResult, - XCategoryFromDb, - XtreamContent, -} from '@iptvnator/services'; - -type JsonObject = Record; -type JsonArray = unknown[]; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; declare global { interface Window { - electron: { - onPortalDebugEvent?: ( - callback: (data: PortalDebugEvent) => void - ) => () => void; - onPlaylistRefreshEvent?: ( - callback: (data: PlaylistRefreshEvent) => void - ) => () => void; - getAppVersion: () => Promise; - platform: string; - fetchPlaylistByUrl: ( - url: string, - title?: string - ) => Promise; - updatePlaylistFromFilePath: ( - filePath: string, - title: string - ) => Promise; - openPlaylistFromFile: () => Promise; - getPathForFile: (file: File) => string; - saveFileDialog: ( - defaultPath: string, - filters?: { name: string; extensions: string[] }[] - ) => Promise; - writeFile: ( - filePath: string, - content: string - ) => Promise<{ success: boolean }>; - setUserAgent: ( - userAgent?: string | null, - referer?: string | null, - scopeUrl?: string | null - ) => Promise; - openInMpv: ( - url: string, - title: string, - thumbnail: string, - userAgent: string | undefined, - referer?: string, - origin?: string, - contentInfo?: unknown, - startTime?: number, - headers?: Record - ) => Promise; - openInVlc: ( - url: string, - title: string, - thumbnail: string, - userAgent: string | undefined, - referer?: string, - origin?: string, - contentInfo?: unknown, - startTime?: number, - headers?: Record - ) => Promise; - autoUpdatePlaylists: (playlists: Playlist[]) => Promise; - fetchEpg: (urls: string[]) => Promise<{ - success: boolean; - message?: string; - skipped?: string[]; - }>; - getChannelPrograms: (channelId: string) => Promise; - getCurrentProgramsBatch: ( - channelIds: string[] - ) => Promise>; - getEpgChannelMetadata: ( - channelIds: string[] - ) => Promise>; - getEpgChannels: () => Promise; - getEpgChannelsByRange: ( - skip: number, - limit: number - ) => Promise; - forceFetchEpg: ( - url: string - ) => Promise<{ success: boolean; message?: string }>; - clearEpgData: () => Promise<{ success: boolean }>; - checkEpgFreshness: ( - urls: string[], - maxAgeHours?: number - ) => Promise<{ staleUrls: string[]; freshUrls: string[] }>; - searchEpgPrograms: ( - searchTerm: string, - limit?: number - ) => Promise; - updateSettings: (settings: Partial) => Promise; - getAiSettings: () => Promise<{ - aiProvider: string; - aiModelName: string; - aiApiKey: string; - }>; - setMpvPlayerPath: (mpvPlayerPath: string) => Promise; - setVlcPlayerPath: (vlcPlayerPath: string) => Promise; - stalkerRequest: (payload: { - url: string; - macAddress: string; - params: Record; - token?: string; - serialNumber?: string; - requestId?: string; - }) => Promise; - xtreamRequest: (payload: { - url: string; - params: Record; - requestId?: string; - sessionId?: string; - suppressErrorLog?: boolean; - }) => Promise<{ payload: unknown; action: string }>; - xtreamCancelSession: ( - sessionId: string - ) => Promise<{ success: boolean; cancelled: number }>; - xtreamProbeUrl: ( - url: string, - method?: 'GET' | 'HEAD' - ) => Promise<{ status: number; url: string }>; - refreshPlaylist: ( - payload: PlaylistRefreshPayload - ) => Promise; - cancelPlaylistRefresh: ( - operationId: string - ) => Promise<{ success: boolean }>; - // Database operations - dbCreatePlaylist: ( - playlist: Playlist - ) => Promise<{ success: boolean }>; - dbGetPlaylist: (playlistId: string) => Promise; - dbUpsertAppPlaylist: ( - playlist: Playlist - ) => Promise<{ success: boolean }>; - dbUpsertAppPlaylists: ( - playlists: Playlist[] - ) => Promise<{ success: boolean; count: number }>; - dbGetAppPlaylists: () => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; - dbUpdatePlaylist: ( - playlistId: string, - updates: Partial - ) => Promise<{ success: boolean }>; - dbDeletePlaylist: ( - playlistId: string, - operationId?: string - ) => Promise<{ success: boolean }>; - dbDeleteXtreamContent: ( - playlistId: string, - operationId?: string - ) => Promise<{ - success: boolean; - favorites: XtreamBackupFavoriteItem[]; - recentlyViewed: XtreamBackupRecentlyViewedItem[]; - hiddenCategories: XtreamBackupHiddenCategory[]; - }>; - dbRestoreXtreamUserData: ( - playlistId: string, - favorites: XtreamBackupFavoriteItem[], - recentlyViewed: XtreamBackupRecentlyViewedItem[], - operationId?: string - ) => Promise<{ success: boolean }>; - dbHasCategories: ( - playlistId: string, - type: string - ) => Promise; - dbGetCategories: ( - playlistId: string, - type: string - ) => Promise; - dbSaveCategories: ( - playlistId: string, - categories: XtreamCategory[], - type: string, - hiddenCategoryXtreamIds?: number[] - ) => Promise<{ success: boolean }>; - dbGetAllCategories: ( - playlistId: string, - type: string - ) => Promise; - dbUpdateCategoryVisibility: ( - categoryIds: number[], - hidden: boolean - ) => Promise<{ success: boolean }>; - dbHasContent: ( - playlistId: string, - type: string - ) => Promise; - dbGetContent: ( - playlistId: string, - type: string - ) => Promise; - dbSaveContent: ( - playlistId: string, - streams: JsonArray, - type: string, - operationId?: string - ) => Promise<{ success: boolean; count: number }>; - dbClearXtreamImportCache: ( - playlistId: string, - type: 'live' | 'movie' | 'series' - ) => Promise<{ success: boolean }>; - dbSearchContent: ( - playlistId: string, - searchTerm: string, - types: string[], - excludeHidden?: boolean - ) => Promise; - dbGlobalSearch: ( - searchTerm: string, - types: string[], - excludeHidden?: boolean - ) => Promise; - dbGetGlobalRecentlyAdded: ( - kind: 'all' | 'vod' | 'series', - limit?: number - ) => Promise; - dbGetRecentlyViewed: () => Promise; - dbClearRecentlyViewed: () => Promise<{ success: boolean }>; - // Favorites - dbAddFavorite: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise<{ success: boolean }>; - dbRemoveFavorite: ( - contentId: number, - playlistId: string - ) => Promise<{ success: boolean }>; - dbIsFavorite: ( - contentId: number, - playlistId: string - ) => Promise; - dbGetFavorites: (playlistId: string) => Promise; - dbGetGlobalFavorites: () => Promise; - dbGetAllGlobalFavorites: () => Promise; - dbReorderGlobalFavorites: ( - updates: { content_id: number; position: number }[] - ) => Promise<{ success: boolean }>; - // Recently viewed (playlist-specific) - dbGetRecentItems: (playlistId: string) => Promise; - dbAddRecentItem: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise<{ success: boolean }>; - dbClearPlaylistRecentItems: ( - playlistId: string - ) => Promise<{ success: boolean }>; - dbRemoveRecentItem: ( - contentId: number, - playlistId: string - ) => Promise<{ success: boolean }>; - dbRemoveRecentItemsBatch: ( - items: { contentId: number; playlistId: string }[] - ) => Promise<{ success: boolean; count: number }>; - dbGetContentByXtreamId: ( - xtreamId: number, - playlistId: string, - contentType?: 'live' | 'movie' | 'series' - ) => Promise; - dbSetContentBackdropIfMissing: ( - contentId: number, - backdropUrl?: string - ) => Promise<{ success: boolean }>; - dbGetAppState: (key: string) => Promise; - dbSetAppState: ( - key: string, - value: string - ) => Promise<{ success: boolean }>; - // Remote control - onChannelChange?: ( - callback: (data: { direction: 'up' | 'down' }) => void - ) => () => void; - onRemoteControlCommand?: ( - callback: (data: { - type: - | 'channel-select-number' - | 'volume-up' - | 'volume-down' - | 'volume-toggle-mute'; - number?: number; - }) => void - ) => () => void; - updateRemoteControlStatus?: (status: { - portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; - isLiveView: boolean; - channelName?: string; - channelNumber?: number; - epgTitle?: string; - epgStart?: string; - epgEnd?: string; - supportsVolume?: boolean; - volume?: number; - muted?: boolean; - }) => void; - // Player error notifications - onPlayerError?: ( - callback: (data: { - player: string; - error: string; - originalError: string; - }) => void - ) => void; - onEmbeddedMpvSessionUpdate?: ( - callback: (data: EmbeddedMpvSession) => void - ) => () => void; - onExternalPlayerSessionUpdate?: ( - callback: (data: ExternalPlayerSession) => void - ) => () => void; - getEmbeddedMpvSupport: () => Promise; - prepareEmbeddedMpv?: () => Promise; - createEmbeddedMpvSession: ( - bounds: EmbeddedMpvBounds, - title?: string, - initialVolume?: number - ) => Promise; - loadEmbeddedMpvPlayback: ( - sessionId: string, - playback: ResolvedPortalPlayback - ) => Promise; - setEmbeddedMpvBounds: ( - sessionId: string, - bounds: EmbeddedMpvBounds - ) => Promise; - setEmbeddedMpvPaused: ( - sessionId: string, - paused: boolean - ) => Promise; - seekEmbeddedMpv: ( - sessionId: string, - seconds: number - ) => Promise; - setEmbeddedMpvVolume: ( - sessionId: string, - volume: number - ) => Promise; - setEmbeddedMpvAudioTrack: ( - sessionId: string, - trackId: number - ) => Promise; - setEmbeddedMpvSubtitleTrack?: ( - sessionId: string, - trackId: number - ) => Promise; - setEmbeddedMpvSpeed?: ( - sessionId: string, - speed: number - ) => Promise; - setEmbeddedMpvAspect?: ( - sessionId: string, - aspect: string - ) => Promise; - startEmbeddedMpvRecording?: ( - sessionId: string, - options: EmbeddedMpvRecordingStartOptions - ) => Promise; - stopEmbeddedMpvRecording?: ( - sessionId: string - ) => Promise; - getEmbeddedMpvDefaultRecordingFolder?: () => Promise; - selectEmbeddedMpvRecordingFolder?: () => Promise; - disposeEmbeddedMpvSession: ( - sessionId: string - ) => Promise; - getLocalIpAddresses: () => Promise; - // EPG progress listener - onEpgProgress?: ( - callback: (data: { - url: string; - status: 'loading' | 'complete' | 'error'; - stats?: { totalChannels: number; totalPrograms: number }; - error?: string; - }) => void - ) => void; - // DB save content progress listener - onDbSaveContentProgress: ( - callback: (count: number) => void - ) => void; - removeDbSaveContentProgress: () => void; - onDbOperationEvent?: ( - callback: (data: DbOperationEvent) => void - ) => () => void; - dbDeleteAllPlaylists: ( - operationId?: string - ) => Promise<{ success: boolean }>; - dbCancelOperation: ( - operationId: string - ) => Promise<{ success: boolean }>; - // Playback positions - dbSavePlaybackPosition: ( - playlistId: string, - data: { - contentXtreamId: number; - contentType: 'vod' | 'episode'; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - position: number; - duration: number; - title: string; - posterUrl?: string; - } - ) => Promise<{ success: boolean }>; - dbGetPlaybackPosition: ( - playlistId: string, - contentXtreamId: number, - contentType: 'vod' | 'episode' - ) => Promise; - dbGetSeriesPlaybackPositions: ( - playlistId: string, - seriesXtreamId: number - ) => Promise; - dbGetRecentPlaybackPositions: ( - playlistId: string, - limit?: number - ) => Promise; - dbGetAllPlaybackPositions: ( - playlistId: string - ) => Promise; - dbClearAllPlaybackPositions: ( - playlistId: string - ) => Promise<{ success: boolean }>; - dbClearPlaybackPosition: ( - playlistId: string, - contentXtreamId: number, - contentType: 'vod' | 'episode' - ) => Promise<{ success: boolean }>; - onPlaybackPositionUpdate: ( - callback: (data: PlaybackPositionData) => void - ) => () => void; - closeExternalPlayerSession: ( - sessionId: string - ) => Promise; - // Downloads - downloadsStart: (data: { - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - title: string; - url: string; - posterUrl?: string; - downloadFolder: string; - headers?: { - userAgent?: string; - referer?: string; - origin?: string; - }; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - // Playlist info for auto-creation if needed - playlistName?: string; - playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; - serverUrl?: string; - portalUrl?: string; - macAddress?: string; - }) => Promise<{ success: boolean; id?: number; error?: string }>; - downloadsCancel: ( - downloadId: number - ) => Promise<{ success: boolean; error?: string }>; - downloadsRetry: ( - downloadId: number, - downloadFolder: string - ) => Promise<{ success: boolean; error?: string }>; - downloadsRemove: ( - downloadId: number - ) => Promise<{ success: boolean; error?: string }>; - downloadsGetList: (playlistId?: string) => Promise; - downloadsGet: (downloadId: number) => Promise; - downloadsGetDefaultFolder: () => Promise; - downloadsSelectFolder: () => Promise; - downloadsRevealFile: ( - filePath: string - ) => Promise<{ success: boolean; error?: string }>; - downloadsPlayFile: ( - filePath: string - ) => Promise<{ success: boolean; error?: string }>; - downloadsClearCompleted: ( - playlistId?: string - ) => Promise<{ success: boolean }>; - onDownloadsUpdate: (callback: () => void) => () => void; - }; + electron: ElectronBridgeApi; process: NodeJS.Process; require: NodeRequire; } - - /** Download item from the database */ - interface DownloadItem { - id: number; - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - title: string; - url: string; - fileName?: string; - filePath?: string; - posterUrl?: string; - status: 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled'; - bytesDownloaded?: number; - totalBytes?: number; - errorMessage?: string; - createdAt?: string; - updatedAt?: string; - } } diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 20dd78b94..6abcc7d7f 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -24,7 +24,7 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Global API surface - **Preload + types** - `apps/electron-backend/src/app/api/main.preload.ts` wires every download IPC command plus the `onDownloadsUpdate` listener to `window.electron`. `global.d.ts` now mirrors those methods, adds playback-position helpers, and exposes `onPlaybackPositionUpdate` / `removePlaybackPositionListener` so Angular can type-check the new APIs. This keeps the renderer typing in sync with the backend implementation. + `apps/electron-backend/src/app/api/main.preload.ts` wires every download IPC command plus the `onDownloadsUpdate` listener to `window.electron`. The shared `ElectronBridgeApi` contract in `libs/shared/interfaces/src/lib/electron-api.interface.ts` owns the download and playback-position method types; `global.d.ts` and `apps/web/src/typings.d.ts` reference that contract instead of redeclaring the bridge. ## Routing and navigation diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 696240a54..9c8b1f1de 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -18,6 +18,23 @@ Do not re-enable direct Node.js access from Angular code. New desktop-only APIs should be added to the preload bridge and backed by an `ipcMain.handle(...)` owner in the Electron backend. +## Preload API Type Contract + +The canonical renderer bridge type is +`libs/shared/interfaces/src/lib/electron-api.interface.ts`. + +Keep these surfaces in sync when adding or changing a preload method: + +1. `ElectronBridgeApi` in `@iptvnator/shared/interfaces` +2. `apps/electron-backend/src/app/api/main.preload.ts` +3. the owning `ipcMain.handle(...)` event module +4. renderer capability checks or runtime bridge services that consume the method + +`global.d.ts` and `apps/web/src/typings.d.ts` should reference +`ElectronBridgeApi` instead of redeclaring `window.electron` method lists. +`main.preload.ts` is typed as `ElectronBridgeApi`, so missing or extra preload +methods fail typecheck instead of silently drifting from renderer typings. + ## Navigation And External URLs The main window owns three navigation gates: diff --git a/global.d.ts b/global.d.ts index 5be1ab229..5850f4639 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,21 +1,9 @@ import 'jest-extended'; -import { EmbeddedMpvBounds } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; -import { EmbeddedMpvRecordingStartOptions } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; -import { EmbeddedMpvSession } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; -import { EmbeddedMpvSupport } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; -import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface'; -import { PlaybackPositionData } from './libs/shared/interfaces/src/lib/playback-position.interface'; -import { - XtreamBackupFavoriteItem, - XtreamBackupHiddenCategory, - XtreamBackupRecentlyViewedItem, -} from './libs/shared/interfaces/src/lib/playlist-backup.interface'; -import { - PlaylistRefreshEvent, - PlaylistRefreshPayload, -} from './libs/shared/interfaces/src/lib/playlist-refresh.interface'; -import { Playlist } from './libs/shared/interfaces/src/lib/playlist.interface'; -import { ResolvedPortalPlayback } from './libs/shared/interfaces/src/lib/portal-playback.interface'; +import type { + ElectronBridgeApi, + ElectronBridgeDbOperationEvent, + ElectronDownloadItem, +} from './libs/shared/interfaces/src/lib/electron-api.interface'; declare module 'video.js' { export interface VideoJsPlayer { @@ -24,507 +12,19 @@ declare module 'video.js' { } declare global { - interface ElectronDbOperationEvent { - operationId?: string; - operation: string; - playlistId?: string; - status: 'started' | 'progress' | 'completed' | 'cancelled' | 'error'; - phase?: string; - current?: number; - total?: number; - increment?: number; - error?: string; - } + type ElectronDbOperationEvent = ElectronBridgeDbOperationEvent; interface Window { __IPTVNATOR_CONFIG__?: { BACKEND_URL?: string; }; - electron: { - onPlaylistRefreshEvent?: ( - callback: (data: PlaylistRefreshEvent) => void - ) => () => void; - getAppVersion: () => Promise; - platform: string; - fetchPlaylistByUrl: ( - url: string, - title?: string - ) => Promise; - updatePlaylistFromFilePath: ( - filePath: string, - title: string - ) => Promise; - openPlaylistFromFile: () => Promise; - saveFileDialog: ( - defaultPath: string, - filters?: { name: string; extensions: string[] }[] - ) => Promise; - writeFile: ( - filePath: string, - content: string - ) => Promise<{ success: boolean }>; - setUserAgent: ( - userAgent?: string | null, - referer?: string | null, - scopeUrl?: string | null - ) => Promise; - openInMpv: ( - url: string, - title: string, - thumbnail: string, - userAgent: string | undefined, - referer?: string, - origin?: string, - contentInfo?: any, - startTime?: number, - headers?: Record - ) => Promise; - openInVlc: ( - url: string, - title: string, - thumbnail: string, - userAgent: string | undefined, - referer?: string, - origin?: string, - contentInfo?: any, - startTime?: number, - headers?: Record - ) => Promise; - autoUpdatePlaylists: (playlists: Playlist[]) => Promise; - fetchEpg: ( - urls: string[] - ) => Promise<{ success: boolean; message?: string }>; - getChannelPrograms: (channelId: string) => Promise; - getCurrentProgramsBatch: ( - channelIds: string[] - ) => Promise>; - getEpgChannels: () => Promise; - getEpgChannelsByRange: ( - skip: number, - limit: number - ) => Promise; - forceFetchEpg: ( - url: string - ) => Promise<{ success: boolean; message?: string }>; - clearEpgData: () => Promise<{ success: boolean }>; - checkEpgFreshness: ( - urls: string[], - maxAgeHours?: number - ) => Promise<{ staleUrls: string[]; freshUrls: string[] }>; - searchEpgPrograms: ( - searchTerm: string, - limit?: number - ) => Promise; - updateSettings: (settings: any) => Promise; - getAiSettings: () => Promise<{ - aiProvider: string; - aiModelName: string; - aiApiKey: string; - }>; - setMpvPlayerPath: (mpvPlayerPath: string) => Promise; - setVlcPlayerPath: (vlcPlayerPath: string) => Promise; - onExternalPlayerSessionUpdate?: ( - callback: (data: ExternalPlayerSession) => void - ) => () => void; - onEmbeddedMpvSessionUpdate?: ( - callback: (data: EmbeddedMpvSession) => void - ) => () => void; - closeExternalPlayerSession: ( - sessionId: string - ) => Promise; - getEmbeddedMpvSupport: () => Promise; - prepareEmbeddedMpv?: () => Promise; - createEmbeddedMpvSession: ( - bounds: EmbeddedMpvBounds, - title?: string, - initialVolume?: number - ) => Promise; - loadEmbeddedMpvPlayback: ( - sessionId: string, - playback: ResolvedPortalPlayback - ) => Promise; - setEmbeddedMpvBounds: ( - sessionId: string, - bounds: EmbeddedMpvBounds - ) => Promise; - setEmbeddedMpvPaused: ( - sessionId: string, - paused: boolean - ) => Promise; - seekEmbeddedMpv: ( - sessionId: string, - seconds: number - ) => Promise; - setEmbeddedMpvVolume: ( - sessionId: string, - volume: number - ) => Promise; - setEmbeddedMpvAudioTrack: ( - sessionId: string, - trackId: number - ) => Promise; - setEmbeddedMpvSubtitleTrack?: ( - sessionId: string, - trackId: number - ) => Promise; - setEmbeddedMpvSpeed?: ( - sessionId: string, - speed: number - ) => Promise; - setEmbeddedMpvAspect?: ( - sessionId: string, - aspect: string - ) => Promise; - startEmbeddedMpvRecording?: ( - sessionId: string, - options: EmbeddedMpvRecordingStartOptions - ) => Promise; - stopEmbeddedMpvRecording?: ( - sessionId: string - ) => Promise; - getEmbeddedMpvDefaultRecordingFolder?: () => Promise; - selectEmbeddedMpvRecordingFolder?: () => Promise; - disposeEmbeddedMpvSession: ( - sessionId: string - ) => Promise; - stalkerRequest: (payload: { - url: string; - macAddress: string; - params: Record; - token?: string; - serialNumber?: string; - requestId?: string; - }) => Promise; - xtreamRequest: (payload: { - url: string; - params: Record; - requestId?: string; - sessionId?: string; - suppressErrorLog?: boolean; - }) => Promise<{ payload: any; action: string }>; - xtreamCancelSession: ( - sessionId: string - ) => Promise<{ success: boolean; cancelled: number }>; - refreshPlaylist: ( - payload: PlaylistRefreshPayload - ) => Promise; - cancelPlaylistRefresh: ( - operationId: string - ) => Promise<{ success: boolean }>; - // Database operations - dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; - dbGetPlaylist: (playlistId: string) => Promise; - dbUpsertAppPlaylist: ( - playlist: any - ) => Promise<{ success: boolean }>; - dbUpsertAppPlaylists: ( - playlists: any[] - ) => Promise<{ success: boolean; count: number }>; - dbGetAppPlaylists: () => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; - dbUpdatePlaylist: ( - playlistId: string, - updates: any - ) => Promise<{ success: boolean }>; - dbDeletePlaylist: ( - playlistId: string, - operationId?: string - ) => Promise<{ success: boolean }>; - dbDeleteXtreamContent: ( - playlistId: string, - operationId?: string - ) => Promise<{ - success: boolean; - favorites: XtreamBackupFavoriteItem[]; - recentlyViewed: XtreamBackupRecentlyViewedItem[]; - hiddenCategories: XtreamBackupHiddenCategory[]; - }>; - dbRestoreXtreamUserData: ( - playlistId: string, - favorites: XtreamBackupFavoriteItem[], - recentlyViewed: XtreamBackupRecentlyViewedItem[], - operationId?: string - ) => Promise<{ success: boolean }>; - dbHasCategories: ( - playlistId: string, - type: string - ) => Promise; - dbGetCategories: ( - playlistId: string, - type: string - ) => Promise; - dbSaveCategories: ( - playlistId: string, - categories: any[], - type: string, - hiddenCategoryXtreamIds?: number[] - ) => Promise<{ success: boolean }>; - dbGetAllCategories: ( - playlistId: string, - type: string - ) => Promise; - dbUpdateCategoryVisibility: ( - categoryIds: number[], - hidden: boolean - ) => Promise<{ success: boolean }>; - dbHasContent: ( - playlistId: string, - type: string - ) => Promise; - dbGetContent: (playlistId: string, type: string) => Promise; - dbSaveContent: ( - playlistId: string, - streams: any[], - type: string, - operationId?: string - ) => Promise<{ success: boolean; count: number }>; - dbClearXtreamImportCache: ( - playlistId: string, - type: 'live' | 'movie' | 'series' - ) => Promise<{ success: boolean }>; - dbSearchContent: ( - playlistId: string, - searchTerm: string, - types: string[], - excludeHidden?: boolean - ) => Promise; - dbGlobalSearch: ( - searchTerm: string, - types: string[], - excludeHidden?: boolean - ) => Promise; - dbGetGlobalRecentlyAdded: ( - kind: 'all' | 'vod' | 'series', - limit?: number, - playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url' - ) => Promise; - dbGetRecentlyViewed: () => Promise; - dbClearRecentlyViewed: () => Promise<{ success: boolean }>; - // Favorites - dbAddFavorite: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise<{ success: boolean }>; - dbRemoveFavorite: ( - contentId: number, - playlistId: string - ) => Promise<{ success: boolean }>; - dbIsFavorite: ( - contentId: number, - playlistId: string - ) => Promise; - dbGetFavorites: (playlistId: string) => Promise; - dbGetGlobalFavorites: () => Promise; - dbGetAllGlobalFavorites: () => Promise; - dbReorderGlobalFavorites: ( - updates: { content_id: number; position: number }[] - ) => Promise<{ success: boolean }>; - // Recently viewed (playlist-specific) - dbGetRecentItems: (playlistId: string) => Promise; - dbAddRecentItem: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise<{ success: boolean }>; - dbClearPlaylistRecentItems: ( - playlistId: string - ) => Promise<{ success: boolean }>; - dbRemoveRecentItem: ( - contentId: number, - playlistId: string - ) => Promise<{ success: boolean }>; - dbRemoveRecentItemsBatch: ( - items: { contentId: number; playlistId: string }[] - ) => Promise<{ success: boolean; count: number }>; - dbGetContentByXtreamId: ( - xtreamId: number, - playlistId: string, - contentType?: 'live' | 'movie' | 'series' - ) => Promise; - dbSetContentBackdropIfMissing: ( - contentId: number, - backdropUrl?: string - ) => Promise<{ success: boolean }>; - dbGetAppState: (key: string) => Promise; - dbSetAppState: ( - key: string, - value: string - ) => Promise<{ success: boolean }>; - // Remote control - onChannelChange?: ( - callback: (data: { direction: 'up' | 'down' }) => void - ) => () => void; - onRemoteControlCommand?: ( - callback: (data: { - type: - | 'channel-select-number' - | 'volume-up' - | 'volume-down' - | 'volume-toggle-mute'; - number?: number; - }) => void - ) => () => void; - updateRemoteControlStatus?: (status: { - portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; - isLiveView: boolean; - channelName?: string; - channelNumber?: number; - epgTitle?: string; - epgStart?: string; - epgEnd?: string; - supportsVolume?: boolean; - volume?: number; - muted?: boolean; - }) => void; - // Player error notifications - onPlayerError?: ( - callback: (data: { - player: string; - error: string; - originalError: string; - }) => void - ) => void; - getLocalIpAddresses: () => Promise; - // EPG progress listener - onEpgProgress?: ( - callback: (data: { - url: string; - status: 'loading' | 'complete' | 'error'; - stats?: { totalChannels: number; totalPrograms: number }; - error?: string; - }) => void - ) => void; - // DB save content progress listener - onDbSaveContentProgress: ( - callback: (count: number) => void - ) => void; - removeDbSaveContentProgress: () => void; - onDbOperationEvent?: ( - callback: (data: ElectronDbOperationEvent) => void - ) => () => void; - dbDeleteAllPlaylists: ( - operationId?: string - ) => Promise<{ success: boolean }>; - dbCancelOperation: ( - operationId: string - ) => Promise<{ success: boolean }>; - // Playback positions - dbSavePlaybackPosition: ( - playlistId: string, - data: any - ) => Promise<{ success: boolean }>; - dbGetPlaybackPosition: ( - playlistId: string, - contentXtreamId: number, - contentType: 'vod' | 'episode' - ) => Promise; - dbGetSeriesPlaybackPositions: ( - playlistId: string, - seriesXtreamId: number - ) => Promise; - dbGetRecentPlaybackPositions: ( - playlistId: string, - limit?: number - ) => Promise; - dbGetAllPlaybackPositions: ( - playlistId: string - ) => Promise; - dbClearAllPlaybackPositions: ( - playlistId: string - ) => Promise<{ success: boolean }>; - dbClearPlaybackPosition: ( - playlistId: string, - contentXtreamId: number, - contentType: 'vod' | 'episode' - ) => Promise<{ success: boolean }>; - onPlaybackPositionUpdate: ( - callback: (data: any) => void - ) => () => void; - // Downloads - downloadsStart: (data: { - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - title: string; - url: string; - posterUrl?: string; - downloadFolder: string; - headers?: { - userAgent?: string; - referer?: string; - origin?: string; - }; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - // Playlist info for auto-creation if needed - playlistName?: string; - playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; - serverUrl?: string; - portalUrl?: string; - macAddress?: string; - }) => Promise<{ success: boolean; id?: number; error?: string }>; - downloadsCancel: ( - downloadId: number - ) => Promise<{ success: boolean; error?: string }>; - downloadsRetry: ( - downloadId: number, - downloadFolder: string - ) => Promise<{ success: boolean; error?: string }>; - downloadsRemove: ( - downloadId: number - ) => Promise<{ success: boolean; error?: string }>; - downloadsGetList: (playlistId?: string) => Promise; - downloadsGet: (downloadId: number) => Promise; - downloadsGetDefaultFolder: () => Promise; - downloadsSelectFolder: () => Promise; - downloadsRevealFile: ( - filePath: string - ) => Promise<{ success: boolean; error?: string }>; - downloadsPlayFile: ( - filePath: string - ) => Promise<{ success: boolean; error?: string }>; - downloadsClearCompleted: ( - playlistId?: string - ) => Promise<{ success: boolean }>; - onDownloadsUpdate: (callback: () => void) => () => void; - }; + electron: ElectronBridgeApi; process: NodeJS.Process; require: NodeRequire; } /** Download item from the database */ - interface DownloadItem { - id: number; - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - title: string; - url: string; - fileName?: string; - filePath?: string; - posterUrl?: string; - status: 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled'; - bytesDownloaded?: number; - totalBytes?: number; - errorMessage?: string; - createdAt?: string; - updatedAt?: string; - } + type DownloadItem = ElectronDownloadItem; } // SystemJS module definition diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 3390c448d..339f209e2 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -1,81 +1,41 @@ import { inject, Injectable } from '@angular/core'; import { - EpgChannel, + ElectronBridgeApi, + ElectronBridgeEpgFetchResult, + ElectronBridgeEpgProgress, + ElectronBridgeEpgProgressStats, + ElectronBridgeEpgProgressStatus, + ElectronBridgeEpgChannelWithPrograms, + ElectronBridgeEpgFreshnessResult, + ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, + ElectronBridgeResult, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -export const EPG_IMPORT_STATUS = { - Complete: 'complete', - Error: 'error', - Loading: 'loading', - Queued: 'queued', -} as const; +export const EPG_IMPORT_STATUS = ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES; +export type EpgImportStatus = ElectronBridgeEpgProgressStatus; +export type EpgImportStats = ElectronBridgeEpgProgressStats; +export type EpgImportProgress = ElectronBridgeEpgProgress; +export type EpgFetchResult = ElectronBridgeEpgFetchResult; +export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult; +export type EpgClearResult = ElectronBridgeResult; -export type EpgImportStatus = - (typeof EPG_IMPORT_STATUS)[keyof typeof EPG_IMPORT_STATUS]; - -export interface EpgImportStats { - totalChannels: number; - totalPrograms: number; -} - -export interface EpgImportProgress { - url: string; - status: EpgImportStatus; - stats?: EpgImportStats; - error?: string; - queuePosition?: number; -} - -export interface EpgFetchResult { - success: boolean; - message?: string; - skipped?: string[]; -} - -export interface EpgFreshnessResult { - staleUrls: string[]; - freshUrls: string[]; -} - -export interface EpgClearResult { - success: boolean; -} - -type EpgElectronBridge = Partial< - { - checkEpgFreshness: ( - urls: string[], - maxAgeHours?: number - ) => Promise; - clearEpgData: () => Promise; - fetchEpg: (urls: string[]) => Promise; - forceFetchEpg: (url: string) => Promise; - getChannelPrograms: (channelId: string) => Promise; - getCurrentProgramsBatch: ( - channelIds: string[] - ) => Promise>; - getEpgChannelMetadata: ( - channelIds: string[] - ) => Promise>; - getEpgChannelsByRange: ( - skip: number, - limit: number - ) => Promise; - onEpgProgress: (callback: (data: EpgImportProgress) => void) => void; - searchEpgPrograms: ( - searchTerm: string, - limit?: number - ) => Promise; - } +type EpgElectronBridge = Pick< + Partial, + | 'checkEpgFreshness' + | 'clearEpgData' + | 'fetchEpg' + | 'forceFetchEpg' + | 'getChannelPrograms' + | 'getCurrentProgramsBatch' + | 'getEpgChannelMetadata' + | 'getEpgChannelsByRange' + | 'onEpgProgress' + | 'searchEpgPrograms' >; -type EpgRuntimeWindow = Window & { - electron?: EpgElectronBridge; -}; - @Injectable({ providedIn: 'root' }) export class EpgRuntimeBridgeService { private readonly runtime = inject(RuntimeCapabilitiesService); @@ -145,8 +105,10 @@ export class EpgRuntimeBridgeService { return Promise.resolve(null); } - return this.bridge?.getChannelPrograms?.(channelId) ?? - Promise.resolve(null); + return ( + this.bridge?.getChannelPrograms?.(channelId) ?? + Promise.resolve(null) + ); } getCurrentProgramsBatch( @@ -156,8 +118,10 @@ export class EpgRuntimeBridgeService { return Promise.resolve(null); } - return this.bridge?.getCurrentProgramsBatch?.(channelIds) ?? - Promise.resolve(null); + return ( + this.bridge?.getCurrentProgramsBatch?.(channelIds) ?? + Promise.resolve(null) + ); } getChannelMetadata( @@ -167,8 +131,10 @@ export class EpgRuntimeBridgeService { return Promise.resolve(null); } - return this.bridge?.getEpgChannelMetadata?.(channelIds) ?? - Promise.resolve(null); + return ( + this.bridge?.getEpgChannelMetadata?.(channelIds) ?? + Promise.resolve(null) + ); } checkFreshness( @@ -179,20 +145,24 @@ export class EpgRuntimeBridgeService { return Promise.resolve(null); } - return this.bridge?.checkEpgFreshness?.(urls, maxAgeHours) ?? - Promise.resolve(null); + return ( + this.bridge?.checkEpgFreshness?.(urls, maxAgeHours) ?? + Promise.resolve(null) + ); } getChannelsByRange( skip: number, limit: number - ): Promise { + ): Promise { if (!this.supportsChannelBrowser) { return Promise.resolve(null); } - return this.bridge?.getEpgChannelsByRange?.(skip, limit) ?? - Promise.resolve(null); + return ( + this.bridge?.getEpgChannelsByRange?.(skip, limit) ?? + Promise.resolve(null) + ); } searchPrograms( @@ -203,8 +173,10 @@ export class EpgRuntimeBridgeService { return Promise.resolve(null); } - return this.bridge?.searchEpgPrograms?.(searchTerm, limit) ?? - Promise.resolve(null); + return ( + this.bridge?.searchEpgPrograms?.(searchTerm, limit) ?? + Promise.resolve(null) + ); } onProgress(callback: (data: EpgImportProgress) => void): void { @@ -220,6 +192,6 @@ export class EpgRuntimeBridgeService { return undefined; } - return (window as EpgRuntimeWindow).electron; + return window.electron; } } diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index f3df8bc25..d2f262938 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -1,12 +1,9 @@ import { Injectable } from '@angular/core'; +import { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; export type RuntimeEnvironment = 'electron' | 'pwa'; -type RuntimeElectronBridge = Record; - -type RuntimeWindow = Window & { - electron?: RuntimeElectronBridge; -}; +type RuntimeElectronBridge = Partial; const playbackPositionStorageMethods = [ 'dbSavePlaybackPosition', @@ -242,7 +239,10 @@ export class RuntimeCapabilitiesService { } private hasElectronMethod(methodName: string): boolean { - return typeof this.electronBridge?.[methodName] === 'function'; + const bridge = this.electronBridge as + | Record + | undefined; + return typeof bridge?.[methodName] === 'function'; } private get electronBridge(): RuntimeElectronBridge | undefined { @@ -250,6 +250,6 @@ export class RuntimeCapabilitiesService { return undefined; } - return (window as RuntimeWindow).electron; + return window.electron; } } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 747be3c26..038682309 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/channel.interface'; export * from './lib/channel.model'; export * from './lib/dev-logger.util'; export * from './lib/embedded-mpv-session.interface'; +export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; export * from './lib/epg-channel.model'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts new file mode 100644 index 000000000..afabf5477 --- /dev/null +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -0,0 +1,793 @@ +import { + EmbeddedMpvBounds, + EmbeddedMpvRecordingStartOptions, + EmbeddedMpvSession, + EmbeddedMpvSupport, +} from './embedded-mpv-session.interface'; +import { EpgChannelMetadata } from './epg-channel-metadata.model'; +import { EpgProgram } from './epg-program.model'; +import { ExternalPlayerSession } from './external-player-session.interface'; +import { PlaybackPositionData } from './playback-position.interface'; +import { + XtreamBackupFavoriteItem, + XtreamBackupHiddenCategory, + XtreamBackupRecentlyViewedItem, +} from './playlist-backup.interface'; +import { + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from './playlist-refresh.interface'; +import { Playlist } from './playlist.interface'; +import { + PlayerContentInfo, + ResolvedPortalPlayback, +} from './portal-playback.interface'; +import { PortalDebugEvent } from './portal-debug.interface'; +import { Settings } from './settings.interface'; +import { XtreamCategory } from './xtream-category.interface'; + +export const ELECTRON_BRIDGE_CONTENT_TYPES = { + Episode: 'episode', + Live: 'live', + Movie: 'movie', + Series: 'series', + Vod: 'vod', +} as const; + +export type ElectronBridgeContentType = + (typeof ELECTRON_BRIDGE_CONTENT_TYPES)[keyof typeof ELECTRON_BRIDGE_CONTENT_TYPES]; + +export type ElectronBridgePortalContentType = + | typeof ELECTRON_BRIDGE_CONTENT_TYPES.Live + | typeof ELECTRON_BRIDGE_CONTENT_TYPES.Movie + | typeof ELECTRON_BRIDGE_CONTENT_TYPES.Series; + +export type ElectronBridgePlaybackContentType = + | typeof ELECTRON_BRIDGE_CONTENT_TYPES.Vod + | typeof ELECTRON_BRIDGE_CONTENT_TYPES.Episode; + +export const ELECTRON_BRIDGE_PLAYLIST_TYPES = { + M3uFile: 'm3u-file', + M3uText: 'm3u-text', + M3uUrl: 'm3u-url', + Stalker: 'stalker', + Xtream: 'xtream', +} as const; + +export type ElectronBridgePlaylistType = + (typeof ELECTRON_BRIDGE_PLAYLIST_TYPES)[keyof typeof ELECTRON_BRIDGE_PLAYLIST_TYPES]; + +export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = { + Complete: 'complete', + Error: 'error', + Loading: 'loading', + Queued: 'queued', +} as const; + +export type ElectronBridgeEpgProgressStatus = + (typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES)[keyof typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES]; + +export const ELECTRON_BRIDGE_DB_OPERATION_STATUSES = { + Cancelled: 'cancelled', + Completed: 'completed', + Error: 'error', + Progress: 'progress', + Started: 'started', +} as const; + +export type ElectronBridgeDbOperationStatus = + (typeof ELECTRON_BRIDGE_DB_OPERATION_STATUSES)[keyof typeof ELECTRON_BRIDGE_DB_OPERATION_STATUSES]; + +export const ELECTRON_BRIDGE_REMOTE_CONTROL_COMMAND_TYPES = { + ChannelSelectNumber: 'channel-select-number', + VolumeDown: 'volume-down', + VolumeToggleMute: 'volume-toggle-mute', + VolumeUp: 'volume-up', +} as const; + +export type ElectronBridgeRemoteControlCommandType = + (typeof ELECTRON_BRIDGE_REMOTE_CONTROL_COMMAND_TYPES)[keyof typeof ELECTRON_BRIDGE_REMOTE_CONTROL_COMMAND_TYPES]; + +export const ELECTRON_BRIDGE_REMOTE_PORTALS = { + M3u: 'm3u', + Stalker: 'stalker', + Unknown: 'unknown', + Xtream: 'xtream', +} as const; + +export type ElectronBridgeRemotePortal = + (typeof ELECTRON_BRIDGE_REMOTE_PORTALS)[keyof typeof ELECTRON_BRIDGE_REMOTE_PORTALS]; + +export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = { + Canceled: 'canceled', + Completed: 'completed', + Downloading: 'downloading', + Failed: 'failed', + Queued: 'queued', +} as const; + +export type ElectronBridgeDownloadStatus = + (typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES]; + +export const ELECTRON_BRIDGE_GLOBAL_RECENTLY_ADDED_KINDS = { + All: 'all', + Series: 'series', + Vod: 'vod', +} as const; + +export type ElectronBridgeGlobalRecentlyAddedKind = + (typeof ELECTRON_BRIDGE_GLOBAL_RECENTLY_ADDED_KINDS)[keyof typeof ELECTRON_BRIDGE_GLOBAL_RECENTLY_ADDED_KINDS]; + +export interface ElectronBridgeResult { + success: boolean; +} + +export interface ElectronBridgeErrorResult extends ElectronBridgeResult { + error?: string; +} + +export interface ElectronBridgeCountResult extends ElectronBridgeResult { + count: number; +} + +export interface ElectronBridgeDialogFilter { + name: string; + extensions: string[]; +} + +export interface ElectronBridgeAiSettings { + aiProvider: string; + aiModelName: string; + aiApiKey: string; +} + +export interface ElectronBridgeStalkerRequestPayload { + url: string; + macAddress: string; + params: Record; + token?: string; + serialNumber?: string; + requestId?: string; +} + +export interface ElectronBridgeXtreamRequestPayload { + url: string; + params: Record; + requestId?: string; + sessionId?: string; + suppressErrorLog?: boolean; +} + +export interface ElectronBridgeXtreamResponse { + payload: unknown; + action: string; +} + +export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult { + cancelled: number; +} + +export interface ElectronBridgeXtreamProbeResult { + status: number; + url: string; +} + +export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult { + message?: string; + skipped?: string[]; +} + +export interface ElectronBridgeEpgFreshnessResult { + staleUrls: string[]; + freshUrls: string[]; +} + +export interface ElectronBridgeEpgProgressStats { + totalChannels: number; + totalPrograms: number; +} + +export interface ElectronBridgeEpgProgress { + url: string; + status: ElectronBridgeEpgProgressStatus; + stats?: ElectronBridgeEpgProgressStats; + error?: string; + queuePosition?: number; +} + +export interface ElectronBridgeEpgChannelSummary { + id: string; + displayName: string; +} + +export interface ElectronBridgeEpgChannelListResult { + channels: ElectronBridgeEpgChannelSummary[]; + /** Always empty for this endpoint; retained for wire-format compatibility. */ + programs: []; +} + +export interface ElectronBridgeEpgChannelWithPrograms extends ElectronBridgeEpgChannelSummary { + iconUrl: string | null; + programs: EpgProgram[]; +} + +export interface ElectronBridgeDbOperationEvent { + operationId?: string; + operation: string; + playlistId?: string; + status: ElectronBridgeDbOperationStatus; + phase?: string; + current?: number; + total?: number; + increment?: number; + error?: string; +} + +export interface ElectronBridgePlaylistInput { + id?: string; + _id?: string; + name?: string; + title?: string; + serverUrl?: string; + username?: string; + password?: string; + macAddress?: string; + url?: string; + type?: ElectronBridgePlaylistType | string; + lastUpdated?: string; +} + +export interface ElectronBridgePlaylistRow { + id: string; + name: string; + serverUrl: string; + username: string; + password: string; + type: string; + macAddress?: string; + url?: string; + lastUpdated?: string; +} + +export type ElectronBridgePlaylistUpsertInput = + | Playlist + | ElectronBridgePlaylistInput; + +export interface ElectronBridgeCategoryRow { + id: number; + name: string; + playlist_id: string; + type: 'movies' | 'live' | 'series'; + xtream_id: number; + hidden: boolean; +} + +export interface ElectronBridgeXtreamContent { + id: number; + category_id: number; + title: string; + rating: string; + added: string; + poster_url: string; + backdrop_url?: string | null; + epg_channel_id?: string | null; + tv_archive?: number | null; + tv_archive_duration?: number | null; + direct_source?: string | null; + xtream_id: number; + type: string; + added_at?: string; + viewed_at?: string; + position?: number | null; +} + +export type ElectronBridgeXtreamContentStream = + | { + category_id: string | number; + rating?: string | number; + rating_imdb?: string; + last_modified?: string; + added?: string; + stream_icon?: string; + poster?: string; + cover?: string; + name?: string; + title?: string; + epg_channel_id?: string; + tv_archive?: string | number; + tv_archive_duration?: string | number; + direct_source?: string; + series_id?: string | number; + stream_id?: string | number; + } + | Record; + +export interface ElectronBridgeGlobalSearchResult extends ElectronBridgeXtreamContent { + playlist_id: string; + playlist_name: string; +} + +export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamContent { + playlist_id: string; + playlist_name: string; + viewed_at: string; +} + +export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamContent { + playlist_id: string; + playlist_name: string; + added_at: string; +} + +export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtreamContent { + playlist_id: string; + playlist_name: string; + added_at: string; +} + +export interface ElectronBridgeFavoriteReorderUpdate { + content_id: number; + position: number; +} + +export interface ElectronBridgeRecentItemsBatchItem { + contentId: number; + playlistId: string; +} + +export interface ElectronBridgeRemoteControlCommand { + type: ElectronBridgeRemoteControlCommandType; + number?: number; +} + +export interface ElectronBridgeRemoteControlStatus { + portal: ElectronBridgeRemotePortal; + isLiveView: boolean; + channelName?: string; + channelNumber?: number; + epgTitle?: string; + epgStart?: string; + epgEnd?: string; + supportsVolume?: boolean; + volume?: number; + muted?: boolean; +} + +export interface ElectronBridgePlayerError { + player: string; + error: string; + originalError: string; +} + +export interface ElectronBridgePlaybackPositionInput extends Omit< + PlaybackPositionData, + 'playlistId' | 'updatedAt' +> { + playlistType?: ElectronBridgePlaylistType; +} + +export interface ElectronBridgeDownloadHeaders { + userAgent?: string; + referer?: string; + origin?: string; +} + +export interface ElectronBridgeDownloadStartPayload { + playlistId: string; + xtreamId: number; + contentType: ElectronBridgePlaybackContentType; + title: string; + url: string; + posterUrl?: string; + downloadFolder: string; + headers?: ElectronBridgeDownloadHeaders; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + playlistName?: string; + playlistType?: ElectronBridgePlaylistType; + serverUrl?: string; + portalUrl?: string; + macAddress?: string; +} + +export interface ElectronBridgeDownloadStartResult extends ElectronBridgeErrorResult { + id?: number; +} + +export interface ElectronDownloadItem { + id: number; + playlistId: string; + xtreamId: number; + contentType: ElectronBridgePlaybackContentType; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + title: string; + url: string; + fileName?: string; + filePath?: string; + posterUrl?: string; + status: ElectronBridgeDownloadStatus; + bytesDownloaded?: number; + totalBytes?: number; + errorMessage?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ElectronBridgeApi { + onPortalDebugEvent?: ( + callback: (data: PortalDebugEvent) => void + ) => () => void; + onPlaylistRefreshEvent?: ( + callback: (data: PlaylistRefreshEvent) => void + ) => () => void; + getAppVersion: () => Promise; + platform: string; + fetchPlaylistByUrl: (url: string, title?: string) => Promise; + updatePlaylistFromFilePath: ( + filePath: string, + title: string + ) => Promise; + openPlaylistFromFile: () => Promise; + getPathForFile: (file: File) => string; + saveFileDialog: ( + defaultPath: string, + filters?: ElectronBridgeDialogFilter[] + ) => Promise; + writeFile: ( + filePath: string, + content: string + ) => Promise; + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => Promise; + openInMpv: ( + url: string, + title: string, + thumbnail: string, + userAgent: string | undefined, + referer?: string, + origin?: string, + contentInfo?: PlayerContentInfo, + startTime?: number, + headers?: Record + ) => Promise; + openInVlc: ( + url: string, + title: string, + thumbnail: string, + userAgent: string | undefined, + referer?: string, + origin?: string, + contentInfo?: PlayerContentInfo, + startTime?: number, + headers?: Record + ) => Promise; + autoUpdatePlaylists: (playlists: Playlist[]) => Promise; + fetchEpg: (urls: string[]) => Promise; + getChannelPrograms: (channelId: string) => Promise; + getCurrentProgramsBatch: ( + channelIds: string[] + ) => Promise>; + getEpgChannelMetadata: ( + channelIds: string[] + ) => Promise>; + getEpgChannels: () => Promise; + getEpgChannelsByRange: ( + skip: number, + limit: number + ) => Promise; + forceFetchEpg: (url: string) => Promise; + clearEpgData: () => Promise; + checkEpgFreshness: ( + urls: string[], + maxAgeHours?: number + ) => Promise; + searchEpgPrograms: ( + searchTerm: string, + limit?: number + ) => Promise; + updateSettings: (settings: Partial) => Promise; + getAiSettings: () => Promise; + setMpvPlayerPath: (mpvPlayerPath: string) => Promise; + setVlcPlayerPath: (vlcPlayerPath: string) => Promise; + stalkerRequest: ( + payload: ElectronBridgeStalkerRequestPayload + ) => Promise>; + xtreamRequest: ( + payload: ElectronBridgeXtreamRequestPayload + ) => Promise; + xtreamCancelSession: ( + sessionId: string + ) => Promise; + xtreamProbeUrl: ( + url: string, + method?: 'GET' | 'HEAD' + ) => Promise; + refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise; + cancelPlaylistRefresh: ( + operationId: string + ) => Promise; + dbCreatePlaylist: ( + playlist: ElectronBridgePlaylistUpsertInput + ) => Promise; + dbGetPlaylist: ( + playlistId: string + ) => Promise; + dbUpsertAppPlaylist: (playlist: Playlist) => Promise; + dbUpsertAppPlaylists: ( + playlists: Playlist[] + ) => Promise; + dbGetAppPlaylists: () => Promise; + dbGetAppPlaylist: (playlistId: string) => Promise; + dbUpdatePlaylist: ( + playlistId: string, + updates: Partial | ElectronBridgePlaylistInput + ) => Promise; + dbDeletePlaylist: ( + playlistId: string, + operationId?: string + ) => Promise; + dbDeleteXtreamContent: ( + playlistId: string, + operationId?: string + ) => Promise<{ + success: boolean; + favorites: XtreamBackupFavoriteItem[]; + recentlyViewed: XtreamBackupRecentlyViewedItem[]; + hiddenCategories: XtreamBackupHiddenCategory[]; + }>; + dbRestoreXtreamUserData: ( + playlistId: string, + favorites: XtreamBackupFavoriteItem[], + recentlyViewed: XtreamBackupRecentlyViewedItem[], + operationId?: string + ) => Promise; + dbHasCategories: (playlistId: string, type: string) => Promise; + dbGetCategories: ( + playlistId: string, + type: string + ) => Promise; + dbSaveCategories: ( + playlistId: string, + categories: XtreamCategory[], + type: string, + hiddenCategoryXtreamIds?: number[] + ) => Promise; + dbGetAllCategories: ( + playlistId: string, + type: string + ) => Promise; + dbUpdateCategoryVisibility: ( + categoryIds: number[], + hidden: boolean + ) => Promise; + dbHasContent: (playlistId: string, type: string) => Promise; + dbGetContent: ( + playlistId: string, + type: string + ) => Promise; + dbSaveContent: ( + playlistId: string, + streams: ElectronBridgeXtreamContentStream[], + type: string, + operationId?: string + ) => Promise; + dbClearXtreamImportCache: ( + playlistId: string, + type: ElectronBridgePortalContentType + ) => Promise; + dbSearchContent: ( + playlistId: string, + searchTerm: string, + types: string[], + excludeHidden?: boolean + ) => Promise; + dbGlobalSearch: ( + searchTerm: string, + types: string[], + excludeHidden?: boolean + ) => Promise; + dbGetGlobalRecentlyAdded: ( + kind: ElectronBridgeGlobalRecentlyAddedKind, + limit?: number, + playlistType?: ElectronBridgePlaylistType + ) => Promise; + dbGetRecentlyViewed: () => Promise; + dbClearRecentlyViewed: () => Promise; + dbAddFavorite: ( + contentId: number, + playlistId: string, + backdropUrl?: string + ) => Promise; + dbRemoveFavorite: ( + contentId: number, + playlistId: string + ) => Promise; + dbIsFavorite: (contentId: number, playlistId: string) => Promise; + dbGetFavorites: ( + playlistId: string + ) => Promise; + dbGetGlobalFavorites: () => Promise; + dbGetAllGlobalFavorites: () => Promise; + dbReorderGlobalFavorites: ( + updates: ElectronBridgeFavoriteReorderUpdate[] + ) => Promise; + dbGetRecentItems: ( + playlistId: string + ) => Promise; + dbAddRecentItem: ( + contentId: number, + playlistId: string, + backdropUrl?: string + ) => Promise; + dbClearPlaylistRecentItems: ( + playlistId: string + ) => Promise; + dbRemoveRecentItem: ( + contentId: number, + playlistId: string + ) => Promise; + dbRemoveRecentItemsBatch: ( + items: ElectronBridgeRecentItemsBatchItem[] + ) => Promise; + dbGetContentByXtreamId: ( + xtreamId: number, + playlistId: string, + contentType?: ElectronBridgePortalContentType + ) => Promise; + dbSetContentBackdropIfMissing: ( + contentId: number, + backdropUrl?: string + ) => Promise; + dbGetAppState: (key: string) => Promise; + dbSetAppState: ( + key: string, + value: string + ) => Promise; + onChannelChange?: ( + callback: (data: { direction: 'up' | 'down' }) => void + ) => () => void; + onRemoteControlCommand?: ( + callback: (data: ElectronBridgeRemoteControlCommand) => void + ) => () => void; + updateRemoteControlStatus?: ( + status: ElectronBridgeRemoteControlStatus + ) => void; + onPlayerError?: ( + callback: (data: ElectronBridgePlayerError) => void + ) => void; + getLocalIpAddresses: () => Promise; + onEpgProgress?: ( + callback: (data: ElectronBridgeEpgProgress) => void + ) => void; + onDbSaveContentProgress: (callback: (count: number) => void) => void; + removeDbSaveContentProgress: () => void; + onDbOperationEvent?: ( + callback: (data: ElectronBridgeDbOperationEvent) => void + ) => () => void; + dbDeleteAllPlaylists: ( + operationId?: string + ) => Promise; + dbCancelOperation: (operationId: string) => Promise; + dbSavePlaybackPosition: ( + playlistId: string, + data: ElectronBridgePlaybackPositionInput + ) => Promise; + dbGetPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: ElectronBridgePlaybackContentType + ) => Promise; + dbGetSeriesPlaybackPositions: ( + playlistId: string, + seriesXtreamId: number + ) => Promise; + dbGetRecentPlaybackPositions: ( + playlistId: string, + limit?: number + ) => Promise; + dbGetAllPlaybackPositions: ( + playlistId: string + ) => Promise; + dbClearAllPlaybackPositions: ( + playlistId: string + ) => Promise; + dbClearPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: ElectronBridgePlaybackContentType + ) => Promise; + onPlaybackPositionUpdate: ( + callback: (data: PlaybackPositionData) => void + ) => () => void; + onExternalPlayerSessionUpdate?: ( + callback: (data: ExternalPlayerSession) => void + ) => () => void; + onEmbeddedMpvSessionUpdate?: ( + callback: (data: EmbeddedMpvSession) => void + ) => () => void; + closeExternalPlayerSession: ( + sessionId: string + ) => Promise; + getEmbeddedMpvSupport: () => Promise; + prepareEmbeddedMpv?: () => Promise; + createEmbeddedMpvSession: ( + bounds: EmbeddedMpvBounds, + title?: string, + initialVolume?: number + ) => Promise; + loadEmbeddedMpvPlayback: ( + sessionId: string, + playback: ResolvedPortalPlayback + ) => Promise; + setEmbeddedMpvBounds: ( + sessionId: string, + bounds: EmbeddedMpvBounds + ) => Promise; + setEmbeddedMpvPaused: ( + sessionId: string, + paused: boolean + ) => Promise; + seekEmbeddedMpv: ( + sessionId: string, + seconds: number + ) => Promise; + setEmbeddedMpvVolume: ( + sessionId: string, + volume: number + ) => Promise; + setEmbeddedMpvAudioTrack: ( + sessionId: string, + trackId: number + ) => Promise; + setEmbeddedMpvSubtitleTrack?: ( + sessionId: string, + trackId: number + ) => Promise; + setEmbeddedMpvSpeed?: ( + sessionId: string, + speed: number + ) => Promise; + setEmbeddedMpvAspect?: ( + sessionId: string, + aspect: string + ) => Promise; + startEmbeddedMpvRecording?: ( + sessionId: string, + options: EmbeddedMpvRecordingStartOptions + ) => Promise; + stopEmbeddedMpvRecording?: ( + sessionId: string + ) => Promise; + getEmbeddedMpvDefaultRecordingFolder?: () => Promise; + selectEmbeddedMpvRecordingFolder?: () => Promise; + disposeEmbeddedMpvSession: ( + sessionId: string + ) => Promise; + downloadsStart: ( + data: ElectronBridgeDownloadStartPayload + ) => Promise; + downloadsCancel: (downloadId: number) => Promise; + downloadsRetry: ( + downloadId: number, + downloadFolder: string + ) => Promise; + downloadsRemove: (downloadId: number) => Promise; + downloadsGetList: (playlistId?: string) => Promise; + downloadsGet: (downloadId: number) => Promise; + downloadsGetDefaultFolder: () => Promise; + downloadsSelectFolder: () => Promise; + downloadsRevealFile: ( + filePath: string + ) => Promise; + downloadsPlayFile: (filePath: string) => Promise; + downloadsClearCompleted: ( + playlistId?: string + ) => Promise; + onDownloadsUpdate: (callback: () => void) => () => void; +} diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index ba7c3498b..5c363c18f 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -25,17 +25,15 @@ import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; import { Observable, Subscription, startWith } from 'rxjs'; import { Channel, + ElectronBridgeEpgChannelWithPrograms, EpgChannel, - EpgChannelWithPrograms, EpgProgram, } from '@iptvnator/shared/interfaces'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component'; import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; -interface MultiEpgChannel extends EpgChannelWithPrograms { - iconUrl?: string | null; -} +type MultiEpgChannel = ElectronBridgeEpgChannelWithPrograms; interface EnrichedProgram extends EpgProgram { startDate: Date; @@ -63,13 +61,7 @@ export function isSelectedEpgDayToday( } @Component({ - imports: [ - DatePipe, - MatButtonModule, - MatIcon, - MatTooltip, - TranslatePipe, - ], + imports: [DatePipe, MatButtonModule, MatIcon, MatTooltip, TranslatePipe], selector: 'app-multi-epg-container', templateUrl: './multi-epg-container.component.html', styleUrls: ['./multi-epg-container.component.scss'], @@ -288,10 +280,7 @@ export class MultiEpgContainerComponent if (response && Array.isArray(response)) { // Append new data to existing data - this.originalEpgData.update((data) => [ - ...data, - ...(response as MultiEpgChannel[]), - ]); + this.originalEpgData.update((data) => [...data, ...response]); // Update isLastPage based on the number of channels received this.isLastPage.set(response.length < this.visibleChannels); @@ -369,7 +358,7 @@ export class MultiEpgContainerComponent /** * Get display name from EpgChannel */ - getChannelName(channel: EpgChannel): string { + getChannelName(channel: EpgChannel | MultiEpgChannel): string { if (typeof channel.displayName === 'string') { return channel.displayName; } @@ -389,7 +378,11 @@ export class MultiEpgContainerComponent if ('iconUrl' in channel && channel.iconUrl) { return channel.iconUrl; } - if (channel.icon && channel.icon.length > 0) { + if ( + 'icon' in channel && + Array.isArray(channel.icon) && + channel.icon.length > 0 + ) { return channel.icon[0].src; } return ''; @@ -472,10 +465,7 @@ export class MultiEpgContainerComponent this.searchDebounceTimer = setTimeout(async () => { this.isSearchingPrograms.set(true); try { - const results = await this.epgBridge.searchPrograms( - query, - 20 - ); + const results = await this.epgBridge.searchPrograms(query, 20); this.programSearchResults.set( (results as ProgramSearchResult[]) || [] );