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
This commit is contained in:
4gray authored and GitHub committed 2026-06-01 09:52:22 +02:00
1 parent 4847eb5241
commit 1badb9a057
14 files changed
+1080 -1256

No files matched your search

+1
View File
@@ -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
@@ -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],
},
];
@@ -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<string, (...args: unknown[]) => unknown>;
type ExposedElectronApi = ElectronBridgeApi &
Record<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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,
@@ -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<T extends Record<string, unknown>>(api: T): T {
function wrapElectronApi<T extends object>(api: T): T {
if (!shouldTraceRendererApi) {
return api;
}
@@ -163,32 +156,29 @@ function wrapElectronApi<T extends Record<string, unknown>>(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<string, string>
): Promise<ExternalPlayerSession> =>
@@ -334,7 +329,7 @@ const electronApi = {
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
@@ -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<Settings>) =>
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<Playlist> | ElectronBridgePlaylistInput
) => ipcRenderer.invoke('DB_UPDATE_PLAYLIST', playlistId, updates),
dbDeletePlaylist: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId),
dbDeleteXtreamContent: (playlistId: string, operationId?: string) =>
@@ -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) =>
@@ -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 {
+2 -537
View File
@@ -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<string, unknown>;
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<string>;
platform: string;
fetchPlaylistByUrl: (
url: string,
title?: string
) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
) => Promise<Playlist>;
openPlaylistFromFile: () => Promise<Playlist | null>;
getPathForFile: (file: File) => string;
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
) => Promise<string | null>;
writeFile: (
filePath: string,
content: string
) => Promise<{ success: boolean }>;
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => Promise<boolean>;
openInMpv: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: unknown,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
openInVlc: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: unknown,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (urls: string[]) => Promise<{
success: boolean;
message?: string;
skipped?: string[];
}>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannels: () => Promise<EpgChannel[]>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<EpgChannel[]>;
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<EpgProgram[]>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
aiApiKey: string;
}>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
}) => Promise<JsonObject>;
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
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<Playlist>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<{ success: boolean }>;
// Database operations
dbCreatePlaylist: (
playlist: Playlist
) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbUpsertAppPlaylist: (
playlist: Playlist
) => Promise<{ success: boolean }>;
dbUpsertAppPlaylists: (
playlists: Playlist[]
) => Promise<{ success: boolean; count: number }>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbUpdatePlaylist: (
playlistId: string,
updates: Partial<Playlist>
) => 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<boolean>;
dbGetCategories: (
playlistId: string,
type: string
) => Promise<XCategoryFromDb[]>;
dbSaveCategories: (
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<{ success: boolean }>;
dbGetAllCategories: (
playlistId: string,
type: string
) => Promise<XCategoryFromDb[]>;
dbUpdateCategoryVisibility: (
categoryIds: number[],
hidden: boolean
) => Promise<{ success: boolean }>;
dbHasContent: (
playlistId: string,
type: string
) => Promise<boolean>;
dbGetContent: (
playlistId: string,
type: string
) => Promise<XtreamContent[]>;
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<XtreamContent[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean
) => Promise<GlobalSearchResult[]>;
dbGetGlobalRecentlyAdded: (
kind: 'all' | 'vod' | 'series',
limit?: number
) => Promise<GlobalRecentlyAddedItem[]>;
dbGetRecentlyViewed: () => Promise<GlobalRecentItem[]>;
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<boolean>;
dbGetFavorites: (playlistId: string) => Promise<XtreamContent[]>;
dbGetGlobalFavorites: () => Promise<GlobalFavoriteItem[]>;
dbGetAllGlobalFavorites: () => Promise<GlobalFavoriteItem[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<XtreamContent[]>;
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<XtreamContent | null>;
dbSetContentBackdropIfMissing: (
contentId: number,
backdropUrl?: string
) => Promise<{ success: boolean }>;
dbGetAppState: (key: string) => Promise<string | null>;
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<EmbeddedMpvSupport>;
prepareEmbeddedMpv?: () => Promise<EmbeddedMpvSupport>;
createEmbeddedMpvSession: (
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
) => Promise<EmbeddedMpvSession>;
loadEmbeddedMpvPlayback: (
sessionId: string,
playback: ResolvedPortalPlayback
) => Promise<void>;
setEmbeddedMpvBounds: (
sessionId: string,
bounds: EmbeddedMpvBounds
) => Promise<void>;
setEmbeddedMpvPaused: (
sessionId: string,
paused: boolean
) => Promise<EmbeddedMpvSession | null>;
seekEmbeddedMpv: (
sessionId: string,
seconds: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAudioTrack: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleTrack?: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSpeed?: (
sessionId: string,
speed: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAspect?: (
sessionId: string,
aspect: string
) => Promise<EmbeddedMpvSession | null>;
startEmbeddedMpvRecording?: (
sessionId: string,
options: EmbeddedMpvRecordingStartOptions
) => Promise<EmbeddedMpvSession | null>;
stopEmbeddedMpvRecording?: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
getEmbeddedMpvDefaultRecordingFolder?: () => Promise<string>;
selectEmbeddedMpvRecordingFolder?: () => Promise<string | null>;
disposeEmbeddedMpvSession: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
getLocalIpAddresses: () => Promise<string[]>;
// 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<PlaybackPositionData | null>;
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) => Promise<PlaybackPositionData[]>;
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<PlaybackPositionData[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
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<ExternalPlayerSession | null>;
// 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<DownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<DownloadItem | null>;
downloadsGetDefaultFolder: () => Promise<string>;
downloadsSelectFolder: () => Promise<string | null>;
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;
}
}
+1 -1
View File
@@ -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
+17
View File
@@ -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:
Vendored
+8 -508
View File
@@ -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<string>;
platform: string;
fetchPlaylistByUrl: (
url: string,
title?: string
) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
) => Promise<Playlist>;
openPlaylistFromFile: () => Promise<Playlist>;
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
) => Promise<string | null>;
writeFile: (
filePath: string,
content: string
) => Promise<{ success: boolean }>;
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => Promise<boolean>;
openInMpv: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
openInVlc: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (
urls: string[]
) => Promise<{ success: boolean; message?: string }>;
getChannelPrograms: (channelId: string) => Promise<any>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, any>>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<any>;
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<any[]>;
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
aiApiKey: string;
}>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
onExternalPlayerSessionUpdate?: (
callback: (data: ExternalPlayerSession) => void
) => () => void;
onEmbeddedMpvSessionUpdate?: (
callback: (data: EmbeddedMpvSession) => void
) => () => void;
closeExternalPlayerSession: (
sessionId: string
) => Promise<ExternalPlayerSession | null>;
getEmbeddedMpvSupport: () => Promise<EmbeddedMpvSupport>;
prepareEmbeddedMpv?: () => Promise<EmbeddedMpvSupport>;
createEmbeddedMpvSession: (
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
) => Promise<EmbeddedMpvSession>;
loadEmbeddedMpvPlayback: (
sessionId: string,
playback: ResolvedPortalPlayback
) => Promise<void>;
setEmbeddedMpvBounds: (
sessionId: string,
bounds: EmbeddedMpvBounds
) => Promise<void>;
setEmbeddedMpvPaused: (
sessionId: string,
paused: boolean
) => Promise<EmbeddedMpvSession | null>;
seekEmbeddedMpv: (
sessionId: string,
seconds: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAudioTrack: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleTrack?: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSpeed?: (
sessionId: string,
speed: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAspect?: (
sessionId: string,
aspect: string
) => Promise<EmbeddedMpvSession | null>;
startEmbeddedMpvRecording?: (
sessionId: string,
options: EmbeddedMpvRecordingStartOptions
) => Promise<EmbeddedMpvSession | null>;
stopEmbeddedMpvRecording?: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
getEmbeddedMpvDefaultRecordingFolder?: () => Promise<string>;
selectEmbeddedMpvRecordingFolder?: () => Promise<string | null>;
disposeEmbeddedMpvSession: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => Promise<{ payload: any; action: string }>;
xtreamCancelSession: (
sessionId: string
) => Promise<{ success: boolean; cancelled: number }>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<{ success: boolean }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbUpsertAppPlaylist: (
playlist: any
) => Promise<{ success: boolean }>;
dbUpsertAppPlaylists: (
playlists: any[]
) => Promise<{ success: boolean; count: number }>;
dbGetAppPlaylists: () => Promise<any[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<any | null>;
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<boolean>;
dbGetCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
dbSaveCategories: (
playlistId: string,
categories: any[],
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<{ success: boolean }>;
dbGetAllCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
dbUpdateCategoryVisibility: (
categoryIds: number[],
hidden: boolean
) => Promise<{ success: boolean }>;
dbHasContent: (
playlistId: string,
type: string
) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
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<any[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean
) => Promise<any[]>;
dbGetGlobalRecentlyAdded: (
kind: 'all' | 'vod' | 'series',
limit?: number,
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url'
) => Promise<any[]>;
dbGetRecentlyViewed: () => Promise<any[]>;
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<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetGlobalFavorites: () => Promise<any[]>;
dbGetAllGlobalFavorites: () => Promise<any[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
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<any | null>;
dbSetContentBackdropIfMissing: (
contentId: number,
backdropUrl?: string
) => Promise<{ success: boolean }>;
dbGetAppState: (key: string) => Promise<string | null>;
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<string[]>;
// 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<any | null>;
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) => Promise<any[]>;
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<PlaybackPositionData[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
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<DownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<DownloadItem | null>;
downloadsGetDefaultFolder: () => Promise<string>;
downloadsSelectFolder: () => Promise<string | null>;
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
@@ -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<EpgFreshnessResult>;
clearEpgData: () => Promise<EpgClearResult>;
fetchEpg: (urls: string[]) => Promise<EpgFetchResult>;
forceFetchEpg: (url: string) => Promise<EpgFetchResult>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<EpgChannel[]>;
onEpgProgress: (callback: (data: EpgImportProgress) => void) => void;
searchEpgPrograms: (
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
}
type EpgElectronBridge = Pick<
Partial<ElectronBridgeApi>,
| '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<EpgChannel[] | null> {
): Promise<ElectronBridgeEpgChannelWithPrograms[] | null> {
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;
}
}
@@ -1,12 +1,9 @@
import { Injectable } from '@angular/core';
import { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
export type RuntimeEnvironment = 'electron' | 'pwa';
type RuntimeElectronBridge = Record<string, unknown>;
type RuntimeWindow = Window & {
electron?: RuntimeElectronBridge;
};
type RuntimeElectronBridge = Partial<ElectronBridgeApi>;
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<string, unknown>
| 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;
}
}
+1
View File
@@ -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';
@@ -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<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
}
export interface ElectronBridgeXtreamRequestPayload {
url: string;
params: Record<string, string>;
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<string, unknown>;
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<string>;
platform: string;
fetchPlaylistByUrl: (url: string, title?: string) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
) => Promise<Playlist>;
openPlaylistFromFile: () => Promise<Playlist | null>;
getPathForFile: (file: File) => string;
saveFileDialog: (
defaultPath: string,
filters?: ElectronBridgeDialogFilter[]
) => Promise<string | null>;
writeFile: (
filePath: string,
content: string
) => Promise<ElectronBridgeResult>;
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => Promise<boolean>;
openInMpv: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
openInVlc: (
url: string,
title: string,
thumbnail: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (urls: string[]) => Promise<ElectronBridgeEpgFetchResult>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannels: () => Promise<ElectronBridgeEpgChannelListResult>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<ElectronBridgeEpgChannelWithPrograms[]>;
forceFetchEpg: (url: string) => Promise<ElectronBridgeEpgFetchResult>;
clearEpgData: () => Promise<ElectronBridgeResult>;
checkEpgFreshness: (
urls: string[],
maxAgeHours?: number
) => Promise<ElectronBridgeEpgFreshnessResult>;
searchEpgPrograms: (
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<ElectronBridgeAiSettings>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
stalkerRequest: (
payload: ElectronBridgeStalkerRequestPayload
) => Promise<Record<string, unknown>>;
xtreamRequest: (
payload: ElectronBridgeXtreamRequestPayload
) => Promise<ElectronBridgeXtreamResponse>;
xtreamCancelSession: (
sessionId: string
) => Promise<ElectronBridgeXtreamCancelResult>;
xtreamProbeUrl: (
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise<Playlist>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<ElectronBridgeResult>;
dbCreatePlaylist: (
playlist: ElectronBridgePlaylistUpsertInput
) => Promise<ElectronBridgeResult>;
dbGetPlaylist: (
playlistId: string
) => Promise<ElectronBridgePlaylistRow | null>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylists: (
playlists: Playlist[]
) => Promise<ElectronBridgeCountResult>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbUpdatePlaylist: (
playlistId: string,
updates: Partial<Playlist> | ElectronBridgePlaylistInput
) => Promise<ElectronBridgeResult>;
dbDeletePlaylist: (
playlistId: string,
operationId?: string
) => Promise<ElectronBridgeResult>;
dbDeleteXtreamContent: (
playlistId: string,
operationId?: string
) => Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}>;
dbRestoreXtreamUserData: (
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[],
operationId?: string
) => Promise<ElectronBridgeResult>;
dbHasCategories: (playlistId: string, type: string) => Promise<boolean>;
dbGetCategories: (
playlistId: string,
type: string
) => Promise<ElectronBridgeCategoryRow[]>;
dbSaveCategories: (
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<ElectronBridgeResult>;
dbGetAllCategories: (
playlistId: string,
type: string
) => Promise<ElectronBridgeCategoryRow[]>;
dbUpdateCategoryVisibility: (
categoryIds: number[],
hidden: boolean
) => Promise<ElectronBridgeResult>;
dbHasContent: (playlistId: string, type: string) => Promise<boolean>;
dbGetContent: (
playlistId: string,
type: string
) => Promise<ElectronBridgeXtreamContent[]>;
dbSaveContent: (
playlistId: string,
streams: ElectronBridgeXtreamContentStream[],
type: string,
operationId?: string
) => Promise<ElectronBridgeCountResult>;
dbClearXtreamImportCache: (
playlistId: string,
type: ElectronBridgePortalContentType
) => Promise<ElectronBridgeResult>;
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden?: boolean
) => Promise<ElectronBridgeXtreamContent[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean
) => Promise<ElectronBridgeGlobalSearchResult[]>;
dbGetGlobalRecentlyAdded: (
kind: ElectronBridgeGlobalRecentlyAddedKind,
limit?: number,
playlistType?: ElectronBridgePlaylistType
) => Promise<ElectronBridgeGlobalRecentlyAddedItem[]>;
dbGetRecentlyViewed: () => Promise<ElectronBridgeGlobalRecentItem[]>;
dbClearRecentlyViewed: () => Promise<ElectronBridgeResult>;
dbAddFavorite: (
contentId: number,
playlistId: string,
backdropUrl?: string
) => Promise<ElectronBridgeResult>;
dbRemoveFavorite: (
contentId: number,
playlistId: string
) => Promise<ElectronBridgeResult>;
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
dbGetFavorites: (
playlistId: string
) => Promise<ElectronBridgeXtreamContent[]>;
dbGetGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
dbGetAllGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
dbReorderGlobalFavorites: (
updates: ElectronBridgeFavoriteReorderUpdate[]
) => Promise<ElectronBridgeResult>;
dbGetRecentItems: (
playlistId: string
) => Promise<ElectronBridgeXtreamContent[]>;
dbAddRecentItem: (
contentId: number,
playlistId: string,
backdropUrl?: string
) => Promise<ElectronBridgeResult>;
dbClearPlaylistRecentItems: (
playlistId: string
) => Promise<ElectronBridgeResult>;
dbRemoveRecentItem: (
contentId: number,
playlistId: string
) => Promise<ElectronBridgeResult>;
dbRemoveRecentItemsBatch: (
items: ElectronBridgeRecentItemsBatchItem[]
) => Promise<ElectronBridgeCountResult>;
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string,
contentType?: ElectronBridgePortalContentType
) => Promise<ElectronBridgeXtreamContent | null>;
dbSetContentBackdropIfMissing: (
contentId: number,
backdropUrl?: string
) => Promise<ElectronBridgeResult>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
key: string,
value: string
) => Promise<ElectronBridgeResult>;
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<string[]>;
onEpgProgress?: (
callback: (data: ElectronBridgeEpgProgress) => void
) => void;
onDbSaveContentProgress: (callback: (count: number) => void) => void;
removeDbSaveContentProgress: () => void;
onDbOperationEvent?: (
callback: (data: ElectronBridgeDbOperationEvent) => void
) => () => void;
dbDeleteAllPlaylists: (
operationId?: string
) => Promise<ElectronBridgeResult>;
dbCancelOperation: (operationId: string) => Promise<ElectronBridgeResult>;
dbSavePlaybackPosition: (
playlistId: string,
data: ElectronBridgePlaybackPositionInput
) => Promise<ElectronBridgeResult>;
dbGetPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: ElectronBridgePlaybackContentType
) => Promise<PlaybackPositionData | null>;
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) => Promise<PlaybackPositionData[]>;
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<PlaybackPositionData[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
dbClearAllPlaybackPositions: (
playlistId: string
) => Promise<ElectronBridgeResult>;
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: ElectronBridgePlaybackContentType
) => Promise<ElectronBridgeResult>;
onPlaybackPositionUpdate: (
callback: (data: PlaybackPositionData) => void
) => () => void;
onExternalPlayerSessionUpdate?: (
callback: (data: ExternalPlayerSession) => void
) => () => void;
onEmbeddedMpvSessionUpdate?: (
callback: (data: EmbeddedMpvSession) => void
) => () => void;
closeExternalPlayerSession: (
sessionId: string
) => Promise<ExternalPlayerSession | null>;
getEmbeddedMpvSupport: () => Promise<EmbeddedMpvSupport>;
prepareEmbeddedMpv?: () => Promise<EmbeddedMpvSupport>;
createEmbeddedMpvSession: (
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
) => Promise<EmbeddedMpvSession>;
loadEmbeddedMpvPlayback: (
sessionId: string,
playback: ResolvedPortalPlayback
) => Promise<void>;
setEmbeddedMpvBounds: (
sessionId: string,
bounds: EmbeddedMpvBounds
) => Promise<void>;
setEmbeddedMpvPaused: (
sessionId: string,
paused: boolean
) => Promise<EmbeddedMpvSession | null>;
seekEmbeddedMpv: (
sessionId: string,
seconds: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAudioTrack: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleTrack?: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSpeed?: (
sessionId: string,
speed: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAspect?: (
sessionId: string,
aspect: string
) => Promise<EmbeddedMpvSession | null>;
startEmbeddedMpvRecording?: (
sessionId: string,
options: EmbeddedMpvRecordingStartOptions
) => Promise<EmbeddedMpvSession | null>;
stopEmbeddedMpvRecording?: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
getEmbeddedMpvDefaultRecordingFolder?: () => Promise<string>;
selectEmbeddedMpvRecordingFolder?: () => Promise<string | null>;
disposeEmbeddedMpvSession: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
downloadsStart: (
data: ElectronBridgeDownloadStartPayload
) => Promise<ElectronBridgeDownloadStartResult>;
downloadsCancel: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsRetry: (
downloadId: number,
downloadFolder: string
) => Promise<ElectronBridgeErrorResult>;
downloadsRemove: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsGetList: (playlistId?: string) => Promise<ElectronDownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<ElectronDownloadItem | null>;
downloadsGetDefaultFolder: () => Promise<string>;
downloadsSelectFolder: () => Promise<string | null>;
downloadsRevealFile: (
filePath: string
) => Promise<ElectronBridgeErrorResult>;
downloadsPlayFile: (filePath: string) => Promise<ElectronBridgeErrorResult>;
downloadsClearCompleted: (
playlistId?: string
) => Promise<ElectronBridgeResult>;
onDownloadsUpdate: (callback: () => void) => () => void;
}
@@ -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[]) || []
);