Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
const esbuild = require('esbuild');
const path = require('path');
// Node.js built-in modules that should be externalized
const nodeBuiltins = [
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster',
'console', 'constants', 'crypto', 'dgram', 'dns', 'domain',
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module',
'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
'querystring', 'readline', 'repl', 'stream', 'string_decoder',
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util',
'v8', 'vm', 'worker_threads', 'zlib'
];
// Native modules that must be externalized (loaded at runtime)
const nativeModules = [
'better-sqlite3',
];
const isProduction = process.env.NODE_ENV === 'production';
async function buildWorker() {
try {
const workers = [
{
label: 'EPG parser worker',
entry: path.join(
__dirname,
'src/app/workers/epg-parser.worker.ts'
),
outfile: path.join(
__dirname,
'../../dist/apps/electron-backend/workers/epg-parser.worker.js'
),
},
{
label: 'database worker',
entry: path.join(
__dirname,
'src/app/workers/database.worker.ts'
),
outfile: path.join(
__dirname,
'../../dist/apps/electron-backend/workers/database.worker.js'
),
},
{
label: 'playlist refresh worker',
entry: path.join(
__dirname,
'src/app/workers/playlist-refresh.worker.ts'
),
outfile: path.join(
__dirname,
'../../dist/apps/electron-backend/workers/playlist-refresh.worker.js'
),
},
];
for (const worker of workers) {
console.log(
`Building ${worker.label} with esbuild (${isProduction ? 'production' : 'development'})...`
);
await esbuild.build({
entryPoints: [worker.entry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: worker.outfile,
external: [
...nodeBuiltins.map((m) => `node:${m}`),
...nodeBuiltins,
...nativeModules,
],
sourcemap: !isProduction,
minify: isProduction,
alias: {
'shared-interfaces': path.join(
__dirname,
'../../libs/shared/interfaces/src/index.ts'
),
'm3u-utils': path.join(
__dirname,
'../../libs/shared/m3u-utils/src/index.ts'
),
'database': path.join(
__dirname,
'../../libs/shared/database/src/index.ts'
),
'database-schema': path.join(
__dirname,
'../../libs/shared/database/src/lib/schema.ts'
),
'database-path-utils': path.join(
__dirname,
'../../libs/shared/database/src/lib/path-utils.ts'
),
},
});
}
console.log('✅ Workers built successfully!');
} catch (error) {
console.error('❌ Worker build failed:', error);
process.exit(1);
}
}
buildWorker();
+3
View File
@@ -0,0 +1,3 @@
import baseConfig from '../../eslint.config.mjs';
export default [...baseConfig];
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'electron-backend',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/electron-backend',
};
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.fourgray.iptvnator</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>IPTVnator</name>
<developer_name>4gray</developer_name>
<update_contact>fourgray@proton.me</update_contact>
<summary>Open-source IPTV player for M3U, Xtream Codes, and Stalker portals</summary>
<launchable type="desktop-id">com.fourgray.iptvnator.desktop</launchable>
<description>
<p>IPTVnator is a cross-platform IPTV player for user-supplied playlists and portal connections.</p>
<p>It supports M3U and M3U8 playlists, Xtream Codes API, and Stalker portals with EPG, catch-up, favorites, and external player integration.</p>
<p>The app does not include channels, playlists, or media catalogs. Any IPTV sources, logos, metadata, and programme information come from services the user configures.</p>
</description>
<categories>
<category>AudioVideo</category>
<category>Player</category>
<category>Video</category>
</categories>
<keywords>
<keyword>IPTV</keyword>
<keyword>M3U</keyword>
<keyword>M3U8</keyword>
<keyword>EPG</keyword>
<keyword>Xtream</keyword>
<keyword>Stalker</keyword>
</keywords>
<url type="homepage">https://4gray.github.io/iptvnator/</url>
<url type="bugtracker">https://github.com/4gray/iptvnator/issues</url>
<url type="help">https://4gray.github.io/iptvnator/blog/</url>
<url type="donation">https://github.com/sponsors/4gray</url>
<content_rating type="oars-1.0"/>
<releases>
<release version="0.20.0" type="development" date="2026-02-21"/>
<release version="0.19.0" type="stable" date="2026-02-11"/>
</releases>
<screenshots>
<screenshot type="default">
<caption>Browse channels and watch live IPTV streams</caption>
<image type="source">https://4gray.github.io/iptvnator/appstream/player.png</image>
</screenshot>
<screenshot>
<caption>Manage playlists and switch between sources</caption>
<image type="source">https://4gray.github.io/iptvnator/appstream/playlists.png</image>
</screenshot>
<screenshot>
<caption>View programme schedules in the integrated EPG</caption>
<image type="source">https://4gray.github.io/iptvnator/appstream/epg.png</image>
</screenshot>
<screenshot>
<caption>Adjust playback, interface, and provider settings</caption>
<image type="source">https://4gray.github.io/iptvnator/appstream/settings.png</image>
</screenshot>
</screenshots>
<provides>
<binary>iptvnator</binary>
</provides>
</component>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.kiracloud.kiratv</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>KiraTV</name>
<developer_name>Kira Cloud</developer_name>
<update_contact>jnavajascordobes@gmail.com</update_contact>
<summary>Cliente de escritorio IPTV para Kira Cloud (Xtream Codes)</summary>
<launchable type="desktop-id">com.kiracloud.kiratv.desktop</launchable>
<description>
<p>KiraTV es un reproductor IPTV para Windows y Linux, orientado a la familia y preconfigurado para Kira Cloud.</p>
<p>Incluye soporte para Xtream Codes (TV en directo, VOD y series), EPG y favoritos.</p>
<p>La aplicación no incluye canales ni contenido: el usuario inicia sesión con las credenciales de su proveedor.</p>
</description>
<categories>
<category>AudioVideo</category>
<category>Player</category>
<category>Video</category>
</categories>
<keywords>
<keyword>IPTV</keyword>
<keyword>Xtream</keyword>
<keyword>KiraTV</keyword>
</keywords>
<url type="homepage">https://play.kiracloud.es</url>
<content_rating type="oars-1.0"/>
<releases>
<release version="0.20.0" type="stable" date="2026-05-03"/>
</releases>
<provides>
<binary>kiratv</binary>
</provides>
</component>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+128
View File
@@ -0,0 +1,128 @@
{
"name": "electron-backend",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/electron-backend/src",
"projectType": "application",
"targets": {
"build-worker": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"],
"options": {
"command": "node apps/electron-backend/build-worker.js"
}
},
"build": {
"dependsOn": [
"electron-backend:build-worker",
{
"projects": ["web"],
"target": "build",
"params": "forward"
},
{
"projects": ["remote-control-web"],
"target": "build",
"params": "forward"
}
],
"executor": "nx-electron:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts",
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"assets": ["apps/electron-backend/src/assets"]
},
"configurations": {
"production": {
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/electron-backend/src/environments/environment.ts",
"with": "apps/electron-backend/src/environments/environment.prod.ts"
}
]
},
"kiratv": {
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/electron-backend/src/environments/environment.ts",
"with": "apps/electron-backend/src/environments/environment.kiratv.ts"
}
]
}
}
},
"build-e2e": {
"dependsOn": [
"electron-backend:build-worker",
{
"projects": ["web"],
"target": "build-e2e"
},
{
"projects": ["remote-control-web"],
"target": "build-e2e"
}
],
"executor": "nx-electron:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts",
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"assets": ["apps/electron-backend/src/assets"]
}
},
"serve": {
"executor": "nx:run-commands",
"options": {
"command": "nx run electron-backend:serve-electron"
}
},
"serve-electron": {
"executor": "nx-electron:execute",
"options": {
"buildTarget": "electron-backend:build",
"waitUntilTargets": ["web:serve"],
"remoteDebuggingPort": 9222
}
},
"package": {
"dependsOn": ["electron-backend:build"],
"executor": "nx-electron:package",
"options": {
"name": "electron-backend",
"frontendProject": "web",
"sourcePath": "dist/apps",
"outputPath": "dist/packages",
"prepackageOnly": true
}
},
"make": {
"dependsOn": ["electron-backend:build"],
"executor": "nx-electron:make",
"options": {
"name": "electron-backend",
"frontendProject": "web",
"sourcePath": "dist/apps",
"outputPath": "dist/executables"
}
},
"lint": {
"command": "eslint apps/electron-backend/**/*.ts"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/electron-backend/jest.config.ts"
}
}
}
}
@@ -0,0 +1,666 @@
import { contextBridge, ipcRenderer } from 'electron';
import type {
ExternalPlayerSession,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
XtreamCategory,
} from 'shared-interfaces';
import {
DEBUG_TRACE_EVENT_CHANNEL,
isRendererApiTraceEnabled,
roundTraceDuration,
summarizeForTrace,
} from '../services/debug-trace';
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_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
>();
const shouldTraceRendererApi = isRendererApiTraceEnabled();
function emitRendererTrace(payload: {
method: string;
phase: 'start' | 'success' | 'error';
args?: unknown;
durationMs?: number;
error?: unknown;
result?: unknown;
}): void {
if (!shouldTraceRendererApi) {
return;
}
ipcRenderer.send(DEBUG_TRACE_EVENT_CHANNEL, payload);
}
function wrapElectronApi<T extends Record<string, unknown>>(api: T): T {
if (!shouldTraceRendererApi) {
return api;
}
return Object.fromEntries(
Object.entries(api).map(([name, value]) => {
if (
typeof value !== 'function' ||
name.startsWith('on') ||
name.startsWith('remove')
) {
return [name, value];
}
const original = value as (...args: unknown[]) => unknown;
return [
name,
(...args: unknown[]) => {
const startedAt =
globalThis.performance?.now?.() ?? Date.now();
emitRendererTrace({
args: summarizeForTrace(args),
method: name,
phase: 'start',
});
try {
const result = original(...args);
if (
result &&
typeof (result as PromiseLike<unknown>).then ===
'function'
) {
return (result as Promise<unknown>)
.then((resolvedValue) => {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(
resolvedValue
),
});
return resolvedValue;
})
.catch((error: unknown) => {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
throw error;
});
}
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(result),
});
return result;
} catch (error) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
throw error;
}
},
];
})
) as T;
}
const electronApi = {
// Remote control channel change listener
onChannelChange: (
callback: (data: { direction: 'up' | 'down' }) => void
) => {
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
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
) => {
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
callback(data);
ipcRenderer.on('REMOTE_CONTROL_COMMAND', handler);
return () => ipcRenderer.off('REMOTE_CONTROL_COMMAND', handler);
},
updateRemoteControlStatus: (status: any) => {
ipcRenderer.send('REMOTE_CONTROL_STATUS_UPDATE', status);
},
// Player error listener
onPlayerError: (
callback: (data: {
player: string;
error: string;
originalError: string;
}) => void
) => {
ipcRenderer.on('player-error', (_event, data) => callback(data));
},
onPortalDebugEvent: (callback: (data: PortalDebugEvent) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
callback(data as PortalDebugEvent);
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
) => {
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);
ipcRenderer.on('playback-position-update', handler);
return () => ipcRenderer.off('playback-position-update', handler);
},
onExternalPlayerSessionUpdate: (
callback: (data: ExternalPlayerSession) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: ExternalPlayerSession
) => callback(data);
ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
},
onDbOperationEvent: (callback: (data: DbOperationEvent) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
callback(data as DbOperationEvent);
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);
ipcRenderer.on(PLAYLIST_REFRESH_EVENT, handler);
return () => ipcRenderer.off(PLAYLIST_REFRESH_EVENT, handler);
},
// DB save content progress listener
onDbSaveContentProgress: (callback: (count: number) => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: DbOperationEvent
) => {
if (
data.operation !== 'save-content' ||
data.status !== 'progress'
) {
return;
}
callback(data.increment ?? data.current ?? 0);
};
dbSaveContentProgressListeners.add(handler);
ipcRenderer.on(DB_OPERATION_EVENT, handler);
},
// Remove DB save content progress listener
removeDbSaveContentProgress: () => {
dbSaveContentProgressListeners.forEach((handler) => {
ipcRenderer.off(DB_OPERATION_EVENT, handler);
});
dbSaveContentProgressListeners.clear();
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
fetchPlaylistByUrl: (url: string, title?: string) =>
ipcRenderer.invoke('fetch-playlist-by-url', url, title),
updatePlaylistFromFilePath: (filePath: string, title: string) =>
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'),
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
setUserAgent: (userAgent: string, referer?: string) =>
ipcRenderer.invoke('set-user-agent', userAgent, referer),
openInMpv: (
url: string,
title: string,
thumbnail: string,
userAgent: string,
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
ipcRenderer.invoke(
'OPEN_MPV_PLAYER',
url,
title,
thumbnail,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers
),
openInVlc: (
url: string,
title: string,
thumbnail: string,
userAgent: string,
referer?: string,
origin?: string,
contentInfo?: any,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
ipcRenderer.invoke(
'OPEN_VLC_PLAYER',
url,
title,
thumbnail,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers
),
closeExternalPlayerSession: (sessionId: string) =>
ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId),
autoUpdatePlaylists: (playlists) =>
ipcRenderer.invoke('AUTO_UPDATE', playlists),
fetchEpg: (urls: string[]) =>
ipcRenderer.invoke('FETCH_EPG', { url: urls }),
getChannelPrograms: (channelId: string) =>
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
getEpgChannelMetadata: (channelIds: string[]) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
getEpgChannelsByRange: (skip: number, limit: number) =>
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
forceFetchEpg: (url: string) => ipcRenderer.invoke('EPG_FORCE_FETCH', url),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
setMpvPlayerPath: (mpvPlayerPath: string) =>
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
setVlcPlayerPath: (vlcPlayerPath: string) =>
ipcRenderer.invoke('SET_VLC_PLAYER_PATH', vlcPlayerPath),
updateSettings: (settings: any) =>
ipcRenderer.invoke('SETTINGS_UPDATE', settings),
getAiSettings: () => ipcRenderer.invoke('GET_AI_SETTINGS'),
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
xtreamCancelSession: (sessionId: string) =>
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
ipcRenderer.invoke('PLAYLIST:CANCEL_REFRESH', operationId),
// Database operations
dbCreatePlaylist: (playlist: any) =>
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
dbGetPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
dbUpsertAppPlaylist: (playlist: any) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
dbUpsertAppPlaylists: (playlists: any[]) =>
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),
dbDeletePlaylist: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId),
dbDeleteXtreamContent: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId, operationId),
dbRestoreXtreamUserData: (
playlistId: string,
favorites: unknown[],
recentlyViewed: unknown[],
operationId?: string
) =>
ipcRenderer.invoke(
'DB_RESTORE_XTREAM_USER_DATA',
playlistId,
favorites,
recentlyViewed,
operationId
),
dbHasCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_HAS_CATEGORIES', playlistId, type),
dbGetCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type),
dbSaveCategories: (
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
) =>
ipcRenderer.invoke(
'DB_SAVE_CATEGORIES',
playlistId,
categories,
type,
hiddenCategoryXtreamIds
),
dbGetAllCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) =>
ipcRenderer.invoke(
'DB_UPDATE_CATEGORY_VISIBILITY',
categoryIds,
hidden
),
dbHasContent: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
dbGetContent: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_CONTENT', playlistId, type),
dbSaveContent: (
playlistId: string,
streams: any[],
type: string,
operationId?: string
) =>
ipcRenderer.invoke(
'DB_SAVE_CONTENT',
playlistId,
streams,
type,
operationId
),
dbClearXtreamImportCache: (
playlistId: string,
type: 'live' | 'movie' | 'series'
) => ipcRenderer.invoke('DB_CLEAR_XTREAM_IMPORT_CACHE', playlistId, type),
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden?: boolean
) =>
ipcRenderer.invoke(
'DB_SEARCH_CONTENT',
playlistId,
searchTerm,
types,
excludeHidden
),
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean
) =>
ipcRenderer.invoke(
'DB_GLOBAL_SEARCH',
searchTerm,
types,
excludeHidden
),
dbGetGlobalRecentlyAdded: (
kind: 'all' | 'vod' | 'series',
limit?: number,
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url'
) =>
ipcRenderer.invoke(
'DB_GET_GLOBAL_RECENTLY_ADDED',
kind,
limit,
playlistType
),
dbGetRecentlyViewed: () => ipcRenderer.invoke('DB_GET_RECENTLY_VIEWED'),
dbClearRecentlyViewed: () => ipcRenderer.invoke('DB_CLEAR_RECENTLY_VIEWED'),
// Favorites
dbAddFavorite: (
contentId: number,
playlistId: string,
backdropUrl?: string
) =>
ipcRenderer.invoke(
'DB_ADD_FAVORITE',
contentId,
playlistId,
backdropUrl
),
dbRemoveFavorite: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId),
dbIsFavorite: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_IS_FAVORITE', contentId, playlistId),
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
dbGetAllGlobalFavorites: () =>
ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_RECENT_ITEMS', playlistId),
dbAddRecentItem: (
contentId: number,
playlistId: string,
backdropUrl?: string
) =>
ipcRenderer.invoke(
'DB_ADD_RECENT_ITEM',
contentId,
playlistId,
backdropUrl
),
dbClearPlaylistRecentItems: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId),
dbRemoveRecentItem: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) =>
ipcRenderer.invoke(
'DB_GET_CONTENT_BY_XTREAM_ID',
xtreamId,
playlistId,
contentType
),
dbSetContentBackdropIfMissing: (
contentId: number,
backdropUrl?: string
) =>
ipcRenderer.invoke(
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
contentId,
backdropUrl
),
dbDeleteAllPlaylists: (operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS', operationId),
dbCancelOperation: (operationId: string) =>
ipcRenderer.invoke('DB_CANCEL_OPERATION', operationId),
dbGetAppState: (key: string) => ipcRenderer.invoke('DB_GET_APP_STATE', key),
dbSetAppState: (key: string, value: string) =>
ipcRenderer.invoke('DB_SET_APP_STATE', key, value),
// Playback Positions
dbSavePlaybackPosition: (playlistId: string, data: any) =>
ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data),
dbGetPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
ipcRenderer.invoke(
'DB_GET_PLAYBACK_POSITION',
playlistId,
contentXtreamId,
contentType
),
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) =>
ipcRenderer.invoke(
'DB_GET_SERIES_PLAYBACK_POSITIONS',
playlistId,
seriesXtreamId
),
dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) =>
ipcRenderer.invoke(
'DB_GET_RECENT_PLAYBACK_POSITIONS',
playlistId,
limit
),
dbGetAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
ipcRenderer.invoke(
'DB_CLEAR_PLAYBACK_POSITION',
playlistId,
contentXtreamId,
contentType
),
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),
downloadsCancel: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId),
downloadsRetry: (downloadId: number, downloadFolder: string) =>
ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder),
downloadsRemove: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_REMOVE', downloadId),
downloadsGetList: (playlistId?: string) =>
ipcRenderer.invoke('DOWNLOADS_GET_LIST', playlistId),
downloadsGet: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_GET', downloadId),
downloadsGetDefaultFolder: () =>
ipcRenderer.invoke('DOWNLOADS_GET_DEFAULT_FOLDER'),
downloadsSelectFolder: () => ipcRenderer.invoke('DOWNLOADS_SELECT_FOLDER'),
downloadsRevealFile: (filePath: string) =>
ipcRenderer.invoke('DOWNLOADS_REVEAL_FILE', filePath),
downloadsPlayFile: (filePath: string) =>
ipcRenderer.invoke('DOWNLOADS_PLAY_FILE', filePath),
downloadsClearCompleted: (playlistId?: string) =>
ipcRenderer.invoke('DOWNLOADS_CLEAR_COMPLETED', playlistId),
onDownloadsUpdate: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler);
return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler);
},
};
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));
+286
View File
@@ -0,0 +1,286 @@
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
import { join } from 'path';
import { rendererAppName, rendererAppPort } from './constants';
import {
isRendererConsoleTraceEnabled,
isWindowTraceEnabled,
trace,
} from './services/debug-trace';
import { store, WINDOW_BOUNDS } from './services/store.service';
import { environment } from '../environments/environment';
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
if (!isWindowTraceEnabled()) {
return;
}
const webContents = mainWindow.webContents;
trace('window', 'created', {
id: mainWindow.id,
});
mainWindow.on('unresponsive', () => {
trace('window', 'unresponsive', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
mainWindow.on('responsive', () => {
trace('window', 'responsive', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on('did-start-loading', () => {
trace('window', 'did-start-loading', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on('dom-ready', () => {
trace('window', 'dom-ready', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on('did-finish-load', () => {
trace('window', 'did-finish-load', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL) => {
trace('window', 'did-fail-load', {
errorCode,
errorDescription,
id: mainWindow.id,
validatedURL,
});
}
);
webContents.on('did-navigate', (_event, url) => {
trace('window', 'did-navigate', {
id: mainWindow.id,
url,
});
});
webContents.on('render-process-gone', (_event, details) => {
trace('window', 'render-process-gone', {
details,
id: mainWindow.id,
url: webContents.getURL(),
});
});
if (isRendererConsoleTraceEnabled()) {
webContents.on(
'console-message',
(_event, level, message, line, sourceId) => {
trace('renderer-console', 'message', {
level,
line,
message,
sourceId,
});
}
);
}
}
export default class App {
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
static mainWindow: Electron.BrowserWindow;
static application: Electron.App;
static BrowserWindow;
private static shouldOpenDevTools() {
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
}
public static isDevelopmentMode() {
// First check ELECTRON_IS_DEV environment variable (used by E2E tests)
// This allows E2E tests to run in production mode without packaging
if ('ELECTRON_IS_DEV' in process.env) {
return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
}
// Fall back to Electron's built-in app.isPackaged
// This is the most reliable way to detect if the app is packaged
return !app.isPackaged;
}
private static onWindowAllClosed() {
if (process.platform !== 'darwin') {
App.application.quit();
}
}
private static onClose() {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
App.mainWindow = null;
}
private static onRedirect(event: any, url: string) {
if (url !== App.mainWindow.webContents.getURL()) {
// this is a normal external redirect, open it in a new browser window
event.preventDefault();
shell.openExternal(url);
}
}
private static onReady() {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
if (rendererAppName) {
App.initMainWindow();
App.loadMainWindow();
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (App.mainWindow === null) {
App.onReady();
}
}
private static initMainWindow() {
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
const width = Math.min(1280, workAreaSize.width || 1280);
const height = Math.min(720, workAreaSize.height || 720);
const savedWindowBounds = store.get(WINDOW_BOUNDS);
// Create the browser window.
App.mainWindow = new BrowserWindow({
title: environment.windowTitle,
width: width,
height: height,
show: false,
webPreferences: {
contextIsolation: true,
backgroundThrottling: false,
preload: join(__dirname, 'main.preload.js'),
},
...savedWindowBounds,
minHeight: 600,
minWidth: 900,
...(process.platform === 'darwin'
? {
titleBarStyle: 'hidden',
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 20 },
}
: {}),
});
App.mainWindow.setMenu(null);
attachWindowTrace(App.mainWindow);
if (!savedWindowBounds) {
App.mainWindow.center();
}
// if main window is ready to show, close the splash window and show the main window
App.mainWindow.once('ready-to-show', () => {
App.mainWindow.show();
});
// Route target="_blank" / window.open() to the OS default browser
App.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//.test(url)) {
shell.openExternal(url);
}
return { action: 'deny' };
});
// Emitted when the window is closed.
App.mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
App.mainWindow = null;
});
App.mainWindow.on('close', () => {
if (App.mainWindow) {
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
}
});
// Enable context menu for input fields only
App.mainWindow.webContents.on('context-menu', (event, params) => {
const { isEditable, editFlags } = params;
// Check if this is an editable field (input, textarea, contenteditable)
// editFlags.canPaste is a good indicator of an input field
if (isEditable && editFlags.canPaste) {
const menu = Menu.buildFromTemplate([
{
label: 'Cut',
role: 'cut',
enabled: editFlags.canCut,
},
{
label: 'Copy',
role: 'copy',
enabled: editFlags.canCopy,
},
{
label: 'Paste',
role: 'paste',
enabled: editFlags.canPaste,
},
{
type: 'separator',
},
{
label: 'Select All',
role: 'selectAll',
enabled: editFlags.canSelectAll,
},
]);
menu.popup();
}
});
}
private static loadMainWindow() {
// load the index.html of the app.
if (App.isDevelopmentMode()) {
App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`);
if (App.shouldOpenDevTools()) {
App.mainWindow.webContents.openDevTools();
}
} else {
App.mainWindow.loadFile(
join(__dirname, '..', rendererAppName, 'index.html')
);
}
}
static main(app: Electron.App, browserWindow: typeof BrowserWindow) {
// we pass the Electron.App object and the
// Electron.BrowserWindow into this function
// so this class has no dependencies. This
// makes the code easier to write tests for
App.BrowserWindow = browserWindow;
App.application = app;
App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed.
App.application.on('ready', App.onReady); // App is ready to load data
App.application.on('activate', App.onActivate); // App is activated
App.application.on('before-quit', () => {
if (App.mainWindow)
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
});
}
}
@@ -0,0 +1,4 @@
export const rendererAppPort = 4200;
export const rendererAppName = 'web'; // options.name.split('-')[0] + '-web'
export const electronAppName = 'kiratv';
export const updateServerUrl = 'https://deployment-server-url.com'; // TODO: insert your update server url here
@@ -0,0 +1,14 @@
/**
* Database connection re-export from shared library
* This file maintains backwards compatibility for existing imports
*/
export {
getDatabase,
initDatabase,
getDatabasePath,
closeDatabase,
type DatabaseInstance,
type DatabaseOptions,
} from 'database';
@@ -0,0 +1,4 @@
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import * as schema from 'database-schema';
export type AppDatabase = BetterSQLite3Database<typeof schema>;
@@ -0,0 +1,98 @@
import type { AppDatabase } from '../database.types';
import { saveCategories } from './category.operations';
function createDbMock(existingCount = 0) {
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const onConflictDoNothing = jest.fn().mockResolvedValue(undefined);
const values = jest.fn().mockReturnValue({ onConflictDoNothing });
const insert = jest.fn().mockReturnValue({ values });
return {
db: {
select,
insert,
} as unknown as AppDatabase,
insert,
values,
onConflictDoNothing,
select,
from,
where,
};
}
describe('category.operations', () => {
it('restores hidden categories when Xtream API category IDs are strings', async () => {
const { db, values, insert } = createDbMock();
await saveCategories(
db,
'playlist-1',
[
{ category_name: 'News', category_id: '101' },
{ category_name: 'Sports', category_id: '102' },
],
'live',
[102]
);
expect(insert).toHaveBeenCalled();
expect(values).toHaveBeenCalledWith([
{
playlistId: 'playlist-1',
name: 'News',
type: 'live',
xtreamId: 101,
hidden: false,
},
{
playlistId: 'playlist-1',
name: 'Sports',
type: 'live',
xtreamId: 102,
hidden: true,
},
]);
});
it('skips categories whose Xtream IDs are not numeric', async () => {
const { db, values } = createDbMock();
await saveCategories(
db,
'playlist-1',
[
{ category_name: 'Valid', category_id: '201' },
{ category_name: 'Broken', category_id: 'not-a-number' },
],
'movies',
[201]
);
expect(values).toHaveBeenCalledWith([
{
playlistId: 'playlist-1',
name: 'Valid',
type: 'movies',
xtreamId: 201,
hidden: true,
},
]);
});
it('does not insert categories when all Xtream IDs are invalid', async () => {
const { db, insert } = createDbMock();
await saveCategories(
db,
'playlist-1',
[{ category_name: 'Broken', category_id: 'not-a-number' }],
'series',
[301]
);
expect(insert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,146 @@
import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
type XtreamCategoryInput = {
category_name: string;
category_id: string | number;
};
function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null {
const xtreamId = Number.parseInt(String(rawCategoryId), 10);
return Number.isNaN(xtreamId) ? null : xtreamId;
}
export async function hasCategories(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series'
): Promise<boolean> {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type)
)
);
return result[0].count > 0;
}
export async function getCategories(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series'
) {
return db
.select()
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type),
eq(schema.categories.hidden, false)
)
)
.orderBy(sql`name COLLATE NOCASE`);
}
export async function saveCategories(
db: AppDatabase,
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<{ success: boolean }> {
if (!categories || categories.length === 0) {
return { success: true };
}
const existingCategories = await db
.select({ count: sql<number>`count(*)` })
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type)
)
);
if ((existingCategories[0]?.count ?? 0) > 0) {
return { success: true };
}
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const values = categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
if (values.length === 0) {
return { success: true };
}
await db
.insert(schema.categories)
.values(values)
.onConflictDoNothing({
target: [
schema.categories.playlistId,
schema.categories.type,
schema.categories.xtreamId,
],
});
return { success: true };
}
export async function getAllCategories(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series'
) {
return db
.select()
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type)
)
)
.orderBy(sql`name COLLATE NOCASE`);
}
export async function updateCategoryVisibility(
db: AppDatabase,
categoryIds: number[],
hidden: boolean
): Promise<{ success: boolean }> {
if (categoryIds.length === 0) {
return { success: true };
}
await db
.update(schema.categories)
.set({ hidden })
.where(inArray(schema.categories.id, categoryIds));
return { success: true };
}
@@ -0,0 +1,93 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings,
values,
}));
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
eq: (left: unknown, right: unknown) => eqMock(left, right),
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
sqlMock(strings, ...values),
}));
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import { setContentBackdropIfMissing } from './content-backdrop.operations';
function createDbMock() {
const where = jest.fn().mockResolvedValue(undefined);
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
return {
db: {
update,
} as unknown as AppDatabase,
set,
update,
where,
};
}
describe('content-backdrop.operations', () => {
beforeEach(() => {
andMock.mockClear();
eqMock.mockClear();
sqlMock.mockClear();
});
it('populates the content backdrop when a non-empty url is provided', async () => {
const { db, set, update, where } = createDbMock();
await expect(
setContentBackdropIfMissing(
db,
42,
' https://example.com/backdrop.jpg '
)
).resolves.toEqual({ success: true });
expect(update).toHaveBeenCalledWith(schema.content);
expect(set).toHaveBeenCalledWith({
backdropUrl: 'https://example.com/backdrop.jpg',
});
expect(eqMock).toHaveBeenCalledWith(schema.content.id, 42);
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
});
it('skips blank urls without touching content or recency tables', async () => {
const { db, update } = createDbMock();
await expect(
setContentBackdropIfMissing(db, 42, ' ')
).resolves.toEqual({ success: true });
expect(update).not.toHaveBeenCalled();
});
it('never updates recently viewed timestamps for backdrop-only backfill', async () => {
const { db, update } = createDbMock();
await setContentBackdropIfMissing(
db,
42,
'https://example.com/backdrop.jpg'
);
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(schema.content);
expect(update).not.toHaveBeenCalledWith(
schema.recentlyViewed as unknown as never
);
});
});
@@ -0,0 +1,33 @@
import { and, eq, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
export async function persistContentBackdropIfMissing(
db: AppDatabase,
contentId: number,
backdropUrl?: string
): Promise<void> {
const normalizedBackdropUrl = backdropUrl?.trim();
if (!normalizedBackdropUrl) {
return;
}
await db
.update(schema.content)
.set({ backdropUrl: normalizedBackdropUrl })
.where(
and(
eq(schema.content.id, contentId),
sql`(${schema.content.backdropUrl} IS NULL OR ${schema.content.backdropUrl} = '')`
)
);
}
export async function setContentBackdropIfMissing(
db: AppDatabase,
contentId: number,
backdropUrl?: string
): Promise<{ success: boolean }> {
await persistContentBackdropIfMissing(db, contentId, backdropUrl);
return { success: true };
}
@@ -0,0 +1,94 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
asc: jest.fn(),
desc: jest.fn(),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: jest.fn(),
or: jest.fn(),
sql: jest.fn(),
}));
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import { getContentByXtreamId } from './content.operations';
function createDbMock(result: unknown[] = []) {
const limit = jest.fn().mockResolvedValue(result);
const where = jest.fn().mockReturnValue({ limit });
const innerJoin = jest.fn().mockReturnValue({ where });
const from = jest.fn().mockReturnValue({ innerJoin });
const select = jest.fn().mockReturnValue({ from });
return {
db: {
select,
} as unknown as AppDatabase,
innerJoin,
limit,
select,
where,
};
}
describe('content.operations', () => {
beforeEach(() => {
andMock.mockClear();
eqMock.mockClear();
});
it('adds the content type filter when resolving by xtream ID', async () => {
const { db, where } = createDbMock([
{
title: 'Krypton',
type: 'series',
xtream_id: 290,
},
]);
const result = await getContentByXtreamId(
db,
290,
'playlist-1',
'series'
);
expect(eqMock).toHaveBeenCalledWith(schema.content.xtreamId, 290);
expect(eqMock).toHaveBeenCalledWith(
schema.categories.playlistId,
'playlist-1'
);
expect(eqMock).toHaveBeenCalledWith(schema.content.type, 'series');
expect(where.mock.calls[0][0].conditions).toHaveLength(3);
expect(result).toEqual(
expect.objectContaining({
title: 'Krypton',
type: 'series',
xtream_id: 290,
})
);
});
it('keeps the legacy lookup path when no content type is provided', async () => {
const { db, where } = createDbMock();
await getContentByXtreamId(db, 290, 'playlist-1');
expect(eqMock).toHaveBeenCalledWith(schema.content.xtreamId, 290);
expect(eqMock).toHaveBeenCalledWith(
schema.categories.playlistId,
'playlist-1'
);
expect(eqMock).not.toHaveBeenCalledWith(schema.content.type, 'series');
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
});
});
@@ -0,0 +1,501 @@
import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
function escapeLikePattern(term: string): string {
return term.replace(/[%_\\]/g, '\\$&');
}
function buildLikePatterns(term: string): string[] {
const variants = new Set<string>();
const titleCase =
term.length > 0
? term.charAt(0).toLocaleUpperCase() +
term.slice(1).toLocaleLowerCase()
: term;
variants.add(term);
variants.add(term.toLocaleLowerCase());
variants.add(term.toLocaleUpperCase());
variants.add(titleCase);
return [...variants].map((value) => `%${escapeLikePattern(value)}%`);
}
export type GlobalRecentlyAddedKind = 'all' | 'vod' | 'series';
function getRecentlyAddedContentTypes(
kind: GlobalRecentlyAddedKind
): Array<'movie' | 'series'> {
if (kind === 'vod') {
return ['movie'];
}
if (kind === 'series') {
return ['series'];
}
return ['movie', 'series'];
}
function selectContentFields() {
return {
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
epg_channel_id: schema.content.epgChannelId,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
direct_source: schema.content.directSource,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
};
}
export async function hasContent(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.content.type, type)
)
);
return result[0].count > 0;
}
export async function getContent(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
) {
const baseQuery = db
.select(selectContentFields())
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.content.type, type)
)
);
return type === 'live'
? baseQuery.orderBy(asc(schema.content.id))
: baseQuery.orderBy(desc(schema.content.added));
}
export type RecentlyAddedPlaylistType =
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
export async function getGlobalRecentlyAdded(
db: AppDatabase,
kind: GlobalRecentlyAddedKind = 'all',
limit = 200,
playlistType?: RecentlyAddedPlaylistType
) {
const contentTypes = getRecentlyAddedContentTypes(kind);
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 200)
: 200;
const addedOrder = sql<number>`CAST(${schema.content.added} AS INTEGER)`;
const whereConditions = [
inArray(schema.content.type, contentTypes),
eq(schema.categories.hidden, false),
sql`${schema.content.added} <> ''`,
];
if (playlistType) {
whereConditions.push(eq(schema.playlists.type, playlistType));
}
return db
.select({
...selectContentFields(),
added_at: schema.content.added,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.where(and(...whereConditions))
.orderBy(desc(addedOrder))
.limit(normalizedLimit);
}
type XtreamContentValue = {
categoryId: number;
title: string;
rating: string;
added: string;
posterUrl: string;
epgChannelId?: string | null;
tvArchive?: number | null;
tvArchiveDuration?: number | null;
directSource?: string | null;
xtreamId: number;
type: 'live' | 'movie' | 'series';
};
type XtreamContentSource = Record<string, unknown> & {
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;
};
function toXtreamContentValue(
stream: Record<string, unknown>,
type: 'live' | 'movie' | 'series',
categoryMap: Map<number, number>
): XtreamContentValue | null {
const source = stream as XtreamContentSource;
const streamCategoryId =
type === 'series'
? parseInt(String(source.category_id || '0'))
: parseInt(String(source.category_id));
const categoryId = categoryMap.get(streamCategoryId);
if (!categoryId) {
return null;
}
const title =
type === 'series'
? source.title ||
source.name ||
`Unknown Series ${source.series_id ?? ''}`.trim()
: source.name ||
source.title ||
`Unknown Stream ${source.stream_id ?? ''}`.trim();
return {
categoryId,
title,
rating: String(source.rating || source.rating_imdb || ''),
added:
type === 'series'
? String(source.last_modified || '')
: String(source.added || ''),
posterUrl: String(
source.stream_icon || source.poster || source.cover || ''
),
epgChannelId:
type === 'live'
? String(source.epg_channel_id ?? '').trim() || null
: null,
tvArchive:
type === 'live'
? Number.parseInt(String(source.tv_archive ?? '0'), 10) || 0
: null,
tvArchiveDuration:
type === 'live'
? Number.parseInt(
String(source.tv_archive_duration ?? '0'),
10
) || 0
: null,
directSource:
type === 'live'
? String(source.direct_source ?? '').trim() || null
: null,
xtreamId:
type === 'series'
? parseInt(String(source.series_id || '0'))
: parseInt(String(source.stream_id || '0')),
type,
};
}
export async function saveContent(
db: AppDatabase,
playlistId: string,
streams: Array<Record<string, unknown>>,
type: 'live' | 'movie' | 'series',
control?: OperationControl
): Promise<{ success: boolean; count: number }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
const existingContent = await db
.select({ count: sql<number>`count(*)` })
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, dbType),
eq(schema.content.type, type)
)
);
if ((existingContent[0]?.count ?? 0) > 0) {
return { success: true, count: existingContent[0].count };
}
const categories = await db
.select({
id: schema.categories.id,
xtreamId: schema.categories.xtreamId,
})
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, dbType)
)
);
const categoryMap = new Map(categories.map((category) => [
category.xtreamId,
category.id,
]));
const values = streams
.map((stream) => toXtreamContentValue(stream, type, categoryMap))
.filter((value): value is XtreamContentValue => value !== null);
const total = values.length;
const chunkSize = 100;
let totalInserted = 0;
for (let index = 0; index < values.length; index += chunkSize) {
await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db
.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
});
totalInserted += chunk.length;
await reportOperationProgress(control, {
phase: 'saving-content',
current: totalInserted,
total,
increment: chunk.length,
});
}
return { success: true, count: totalInserted };
}
export async function clearXtreamImportCache(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<{ success: boolean }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
const categoryRows = await db
.select({ id: schema.categories.id })
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, dbType)
)
);
const categoryIds = categoryRows.map((category) => category.id);
if (categoryIds.length === 0) {
return { success: true };
}
const contentRows = await db
.select({ id: schema.content.id })
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
for (const chunk of chunkValues(
contentRows.map((row) => row.id),
100
)) {
await db.delete(schema.content).where(inArray(schema.content.id, chunk));
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk));
}
return { success: true };
}
export async function getContentByXtreamId(
db: AppDatabase,
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) {
const conditions = [
eq(schema.content.xtreamId, xtreamId),
eq(schema.categories.playlistId, playlistId),
];
if (contentType) {
conditions.push(eq(schema.content.type, contentType));
}
const result = await db
.select(selectContentFields())
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(and(...conditions))
.limit(1);
return result[0] || null;
}
export async function searchContent(
db: AppDatabase,
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden = false
) {
if (!types || types.length === 0) {
return [];
}
const searchTermLower = searchTerm.toLocaleLowerCase();
const likePatterns = buildLikePatterns(searchTerm);
const likeConditions = likePatterns.map(
(pattern) => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
);
const conditions = [
eq(schema.categories.playlistId, playlistId),
inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>),
or(...likeConditions),
];
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const candidates = await db
.select(selectContentFields())
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(and(...conditions))
.limit(200);
return candidates
.filter((item) =>
item.title?.toLocaleLowerCase().includes(searchTermLower)
)
.slice(0, 50);
}
export async function globalSearch(
db: AppDatabase,
searchTerm: string,
types: string[],
excludeHidden = false
) {
if (!types || types.length === 0) {
return [];
}
const searchTermLower = searchTerm.toLocaleLowerCase();
const likePatterns = buildLikePatterns(searchTerm);
const likeConditions = likePatterns.map(
(pattern) => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
);
const conditions = [
inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>),
or(...likeConditions),
];
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const candidates = await db
.select({
...selectContentFields(),
playlist_id: schema.categories.playlistId,
playlist_name: schema.playlists.name,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.where(and(...conditions))
.orderBy(schema.content.title)
.limit(200);
return candidates
.filter((item) =>
item.title?.toLocaleLowerCase().includes(searchTermLower)
)
.slice(0, 50);
}
@@ -0,0 +1,191 @@
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import {
chunkValues,
checkpointOperation,
type OperationControl,
reportOperationProgress,
} from './operation-control';
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
const DEFAULT_BATCH_SIZE = 100;
export async function addFavorite(
db: AppDatabase,
contentId: number,
playlistId: string,
options?: { backdropUrl?: string }
): Promise<{ success: boolean }> {
await db.insert(schema.favorites).values({
contentId,
playlistId,
});
await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl);
return { success: true };
}
export async function removeFavorite(
db: AppDatabase,
contentId: number,
playlistId: string
): Promise<{ success: boolean }> {
await db
.delete(schema.favorites)
.where(
and(
eq(schema.favorites.contentId, contentId),
eq(schema.favorites.playlistId, playlistId)
)
);
return { success: true };
}
export async function isFavorite(
db: AppDatabase,
contentId: number,
playlistId: string
): Promise<boolean> {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(schema.favorites)
.where(
and(
eq(schema.favorites.contentId, contentId),
eq(schema.favorites.playlistId, playlistId)
)
);
return result[0].count > 0;
}
export async function getFavorites(db: AppDatabase, playlistId: string) {
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.where(eq(schema.favorites.playlistId, playlistId))
.orderBy(
asc(schema.favorites.position),
desc(schema.favorites.addedAt)
);
}
export async function getGlobalFavorites(db: AppDatabase) {
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.where(eq(schema.content.type, 'live'))
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt))
.limit(300);
}
export async function getAllGlobalFavorites(db: AppDatabase) {
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
backdrop_url: schema.content.backdropUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt))
.limit(500);
}
export async function reorderGlobalFavorites(
db: AppDatabase,
updates: { content_id: number; position: number }[],
control?: OperationControl
): Promise<{ success: boolean }> {
if (!Array.isArray(updates) || updates.length === 0) {
return { success: true };
}
let current = 0;
const total = updates.length;
for (const chunk of chunkValues(updates, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
for (const { content_id, position } of chunk) {
await db
.update(schema.favorites)
.set({ position })
.where(eq(schema.favorites.contentId, content_id));
}
current += chunk.length;
await reportOperationProgress(control, {
phase: 'reordering-global-favorites',
current,
total,
increment: chunk.length,
});
}
return { success: true };
}
@@ -0,0 +1,40 @@
export interface OperationProgressUpdate {
phase: string;
current?: number;
total?: number;
increment?: number;
}
export interface OperationControl {
checkpoint?: () => void | Promise<void>;
onProgress?: (
progress: OperationProgressUpdate
) => void | Promise<void>;
}
export async function checkpointOperation(
control?: OperationControl
): Promise<void> {
await control?.checkpoint?.();
}
export async function reportOperationProgress(
control: OperationControl | undefined,
progress: OperationProgressUpdate
): Promise<void> {
await control?.onProgress?.(progress);
await checkpointOperation(control);
}
export function chunkValues<T>(
values: T[],
chunkSize: number
): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += chunkSize) {
chunks.push(values.slice(index, index + chunkSize));
}
return chunks;
}
@@ -0,0 +1,173 @@
import { and, desc, eq, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
type PlaylistType = 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
type PlaybackPositionPayload = {
contentXtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
positionSeconds: number;
durationSeconds?: number;
playlistType?: PlaylistType;
};
async function ensurePlaylistExists(
db: AppDatabase,
playlistId: string,
playlistType: PlaylistType = 'stalker'
): Promise<void> {
const existing = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
if (existing.length === 0) {
await db.insert(schema.playlists).values({
id: playlistId,
name: 'Imported Playlist',
type: playlistType,
});
}
}
export async function savePlaybackPosition(
db: AppDatabase,
playlistId: string,
data: PlaybackPositionPayload
): Promise<{ success: boolean }> {
await ensurePlaylistExists(db, playlistId, data.playlistType);
const values = {
playlistId,
contentXtreamId: data.contentXtreamId,
contentType: data.contentType,
seriesXtreamId: data.seriesXtreamId,
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
positionSeconds: data.positionSeconds,
durationSeconds: data.durationSeconds,
updatedAt: sql`CURRENT_TIMESTAMP`,
};
const existing = await db
.select()
.from(schema.playbackPositions)
.where(
and(
eq(schema.playbackPositions.playlistId, playlistId),
eq(
schema.playbackPositions.contentXtreamId,
data.contentXtreamId
),
eq(schema.playbackPositions.contentType, data.contentType)
)
)
.limit(1);
if (existing.length > 0) {
await db
.update(schema.playbackPositions)
.set(values)
.where(eq(schema.playbackPositions.id, existing[0].id));
} else {
await db.insert(schema.playbackPositions).values(values);
}
return { success: true };
}
export async function getPlaybackPosition(
db: AppDatabase,
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) {
const result = await db
.select()
.from(schema.playbackPositions)
.where(
and(
eq(schema.playbackPositions.playlistId, playlistId),
eq(schema.playbackPositions.contentXtreamId, contentXtreamId),
eq(schema.playbackPositions.contentType, contentType)
)
)
.limit(1);
return result[0] || null;
}
export async function getSeriesPlaybackPositions(
db: AppDatabase,
playlistId: string,
seriesXtreamId: number
) {
return db
.select()
.from(schema.playbackPositions)
.where(
and(
eq(schema.playbackPositions.playlistId, playlistId),
eq(schema.playbackPositions.seriesXtreamId, seriesXtreamId),
eq(schema.playbackPositions.contentType, 'episode')
)
);
}
export async function getRecentPlaybackPositions(
db: AppDatabase,
playlistId: string,
limit = 20
) {
return db
.select()
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId))
.orderBy(desc(schema.playbackPositions.updatedAt))
.limit(limit);
}
export async function getAllPlaybackPositions(
db: AppDatabase,
playlistId: string
) {
return db
.select()
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId));
}
export async function clearAllPlaybackPositions(
db: AppDatabase,
playlistId: string
): Promise<{ success: boolean }> {
await db
.delete(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId));
return { success: true };
}
export async function clearPlaybackPosition(
db: AppDatabase,
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<{ success: boolean }> {
await db
.delete(schema.playbackPositions)
.where(
and(
eq(schema.playbackPositions.playlistId, playlistId),
eq(schema.playbackPositions.contentXtreamId, contentXtreamId),
eq(schema.playbackPositions.contentType, contentType)
)
);
return { success: true };
}
@@ -0,0 +1,36 @@
import { parseAppPlaylist } from './playlist.operations';
describe('playlist.operations', () => {
it('hydrates updateDate from lastUpdated when payload is stale', async () => {
const parsed = parseAppPlaylist({
id: 'playlist-1',
name: 'Refresh Xtream Source',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
dateCreated: '2026-04-03T08:00:00.000Z',
lastUpdated: '2026-04-03T11:15:00.000Z',
type: 'xtream',
autoRefresh: false,
count: 0,
importDate: '2026-04-03T08:00:00.000Z',
payload: JSON.stringify({
_id: 'playlist-1',
title: 'Refresh Xtream Source',
count: 0,
importDate: '2026-04-03T08:00:00.000Z',
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
}),
} as any);
expect(parsed).toEqual(
expect.objectContaining({
_id: 'playlist-1',
updateDate: new Date('2026-04-03T11:15:00.000Z').getTime(),
})
);
});
});
@@ -0,0 +1,504 @@
import { eq, inArray } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
const PLAYLIST_TYPES = {
XTREAM: 'xtream',
STALKER: 'stalker',
M3U_FILE: 'm3u-file',
M3U_TEXT: 'm3u-text',
M3U_URL: 'm3u-url',
} as const;
const DEFAULT_BATCH_SIZE = 100;
type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
const PLAYLIST_TYPE_VALUES = new Set<PlaylistType>(
Object.values(PLAYLIST_TYPES)
);
function getStringValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function getNumericValue(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('Failed to parse JSON value from DB:', error);
return fallback;
}
}
function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
const explicitType = getStringValue(playlist.type);
if (
explicitType &&
PLAYLIST_TYPE_VALUES.has(explicitType as PlaylistType)
) {
return explicitType as PlaylistType;
}
if (getStringValue(playlist.serverUrl)) {
return PLAYLIST_TYPES.XTREAM;
}
if (getStringValue(playlist.macAddress)) {
return PLAYLIST_TYPES.STALKER;
}
if (getStringValue(playlist.filePath)) {
return PLAYLIST_TYPES.M3U_FILE;
}
if (getStringValue(playlist.url)) {
return PLAYLIST_TYPES.M3U_URL;
}
return PLAYLIST_TYPES.M3U_TEXT;
}
function buildPlaylistRow(
playlist: Record<string, unknown>
): schema.NewPlaylist | null {
const id = getStringValue(playlist._id) ?? getStringValue(playlist.id);
if (!id) {
return null;
}
const type = inferPlaylistType(playlist);
const portalUrl = getStringValue(playlist.portalUrl);
const url = getStringValue(playlist.url);
const nowIso = new Date().toISOString();
const updateDate = getNumericValue(playlist.updateDate);
return {
id,
name:
getStringValue(playlist.title) ??
getStringValue(playlist.name) ??
id,
serverUrl: getStringValue(playlist.serverUrl),
username: getStringValue(playlist.username),
password: getStringValue(playlist.password),
lastUpdated:
typeof updateDate === 'number'
? new Date(updateDate).toISOString()
: getStringValue(playlist.lastUpdated),
type,
userAgent: getStringValue(playlist.userAgent),
origin: getStringValue(playlist.origin),
referrer: getStringValue(playlist.referrer),
filePath: getStringValue(playlist.filePath),
autoRefresh: Boolean(playlist.autoRefresh),
macAddress: getStringValue(playlist.macAddress),
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
portalUrl,
count: getNumericValue(playlist.count),
importDate: getStringValue(playlist.importDate),
updateDate,
position: getNumericValue(playlist.position),
favorites:
playlist.favorites !== undefined
? JSON.stringify(playlist.favorites)
: undefined,
recentlyViewed:
playlist.recentlyViewed !== undefined
? JSON.stringify(playlist.recentlyViewed)
: undefined,
payload: JSON.stringify(playlist),
lastUsage: getStringValue(playlist.lastUsage) ?? nowIso,
};
}
export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
);
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const importDate =
row.importDate ?? row.dateCreated ?? new Date().toISOString();
const portalUrl =
row.portalUrl ??
(row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
return {
...base,
_id: row.id,
title:
getStringValue(base.title) ??
getStringValue(base.name) ??
row.name,
count: row.count ?? getNumericValue(base.count) ?? 0,
importDate: getStringValue(base.importDate) ?? importDate,
lastUsage:
row.lastUsage ??
getStringValue(base.lastUsage) ??
getStringValue(base.importDate) ??
importDate,
favorites,
recentlyViewed,
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? row.url ?? getStringValue(base.url)
: getStringValue(base.url),
filePath: row.filePath ?? getStringValue(base.filePath),
userAgent: row.userAgent ?? getStringValue(base.userAgent),
referrer: row.referrer ?? getStringValue(base.referrer),
origin: row.origin ?? getStringValue(base.origin),
updateDate,
position: row.position ?? getNumericValue(base.position),
serverUrl: row.serverUrl ?? getStringValue(base.serverUrl),
username: row.username ?? getStringValue(base.username),
password: row.password ?? getStringValue(base.password),
macAddress: row.macAddress ?? getStringValue(base.macAddress),
portalUrl: portalUrl ?? getStringValue(base.portalUrl),
};
}
export async function createPlaylist(
db: AppDatabase,
playlist: {
id: string;
name: string;
serverUrl?: string;
username?: string;
password?: string;
macAddress?: string;
url?: string;
type: string;
}
): Promise<{ success: boolean }> {
await db.insert(schema.playlists).values({
id: playlist.id,
name: playlist.name,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
macAddress: playlist.macAddress,
url: playlist.url,
portalUrl:
playlist.type === PLAYLIST_TYPES.STALKER ? playlist.url : undefined,
type: playlist.type as PlaylistType,
});
return { success: true };
}
export async function upsertAppPlaylist(
db: AppDatabase,
playlist: Record<string, unknown>
): Promise<{ success: boolean }> {
const row = buildPlaylistRow(playlist);
if (!row) {
throw new Error('Playlist ID is required for upsert');
}
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
return { success: true };
}
export async function upsertAppPlaylists(
db: AppDatabase,
playlists: Record<string, unknown>[]
): Promise<{ success: boolean; count: number }> {
if (!Array.isArray(playlists) || playlists.length === 0) {
return { success: true, count: 0 };
}
let count = 0;
for (const playlist of playlists) {
const row = buildPlaylistRow(playlist);
if (!row) {
continue;
}
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
count += 1;
}
return { success: true, count };
}
export async function getAppPlaylists(db: AppDatabase) {
const rows = await db.select().from(schema.playlists);
return rows.map((row) => parseAppPlaylist(row));
}
export async function getAppPlaylist(db: AppDatabase, playlistId: string) {
const rows = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
return rows[0] ? parseAppPlaylist(rows[0]) : null;
}
export async function getPlaylist(db: AppDatabase, playlistId: string) {
const result = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
return result[0] || null;
}
export async function updatePlaylist(
db: AppDatabase,
playlistId: string,
updates: {
name?: string;
username?: string;
password?: string;
serverUrl?: string;
lastUpdated?: string;
}
): Promise<{ success: boolean }> {
await db
.update(schema.playlists)
.set(updates)
.where(eq(schema.playlists.id, playlistId));
return { success: true };
}
export async function deletePlaylist(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{ success: boolean }> {
const [favoriteRows, recentlyViewedRows, playbackPositionRows, downloadRows] =
await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const categoryRows = await db
.select({ id: schema.categories.id })
.from(schema.categories)
.where(eq(schema.categories.playlistId, playlistId));
const categoryIds = categoryRows.map((category) => category.id);
const contentRows =
categoryIds.length > 0
? await db
.select({ id: schema.content.id })
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds))
: [];
for (const [phase, ids, column, table] of [
[
'deleting-favorites',
favoriteRows.map((row) => row.id),
schema.favorites.id,
schema.favorites,
],
[
'deleting-recently-viewed',
recentlyViewedRows.map((row) => row.id),
schema.recentlyViewed.id,
schema.recentlyViewed,
],
[
'deleting-playback-positions',
playbackPositionRows.map((row) => row.id),
schema.playbackPositions.id,
schema.playbackPositions,
],
[
'deleting-downloads',
downloadRows.map((row) => row.id),
schema.downloads.id,
schema.downloads,
],
[
'deleting-content',
contentRows.map((row) => row.id),
schema.content.id,
schema.content,
],
[
'deleting-categories',
categoryIds,
schema.categories.id,
schema.categories,
],
] as const) {
let current = 0;
const total = ids.length;
for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.delete(table).where(inArray(column, chunk));
current += chunk.length;
await reportOperationProgress(control, {
phase,
current,
total,
increment: chunk.length,
});
}
}
await checkpointOperation(control);
await db.delete(schema.playlists).where(eq(schema.playlists.id, playlistId));
await reportOperationProgress(control, {
phase: 'deleting-playlist',
current: 1,
total: 1,
increment: 1,
});
return { success: true };
}
export async function getAppState(db: AppDatabase, key: string) {
const rows = await db
.select()
.from(schema.appState)
.where(eq(schema.appState.key, key))
.limit(1);
return rows[0]?.value ?? null;
}
export async function setAppState(
db: AppDatabase,
key: string,
value: string
): Promise<{ success: boolean }> {
const updatedAt = new Date().toISOString();
await db
.insert(schema.appState)
.values({
key,
value,
updatedAt,
})
.onConflictDoUpdate({
target: schema.appState.key,
set: { value, updatedAt },
});
return { success: true };
}
export async function deleteAllPlaylists(
db: AppDatabase,
control?: OperationControl
): Promise<{ success: boolean }> {
const deleteStages = [
{
phase: 'deleting-favorites',
execute: () => db.delete(schema.favorites),
},
{
phase: 'deleting-recently-viewed',
execute: () => db.delete(schema.recentlyViewed),
},
{
phase: 'deleting-playback-positions',
execute: () => db.delete(schema.playbackPositions),
},
{
phase: 'deleting-downloads',
execute: () => db.delete(schema.downloads),
},
{
phase: 'deleting-content',
execute: () => db.delete(schema.content),
},
{
phase: 'deleting-categories',
execute: () => db.delete(schema.categories),
},
{
phase: 'deleting-playlists',
execute: () => db.delete(schema.playlists),
},
] as const;
let current = 0;
const total = deleteStages.length;
for (const stage of deleteStages) {
await checkpointOperation(control);
await stage.execute();
current += 1;
await reportOperationProgress(control, {
phase: stage.phase,
current,
total,
increment: 1,
});
}
return { success: true };
}
@@ -0,0 +1,152 @@
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
export async function getRecentlyViewed(db: AppDatabase) {
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
backdrop_url: schema.content.backdropUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.categories.playlistId,
playlist_name: schema.playlists.name,
viewed_at: schema.recentlyViewed.viewedAt,
})
.from(schema.recentlyViewed)
.innerJoin(
schema.content,
eq(schema.recentlyViewed.contentId, schema.content.id)
)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.orderBy(desc(schema.recentlyViewed.viewedAt))
.limit(100);
}
export async function clearRecentlyViewed(
db: AppDatabase
): Promise<{ success: boolean }> {
await db.delete(schema.recentlyViewed);
return { success: true };
}
export async function getRecentItems(
db: AppDatabase,
playlistId: string
) {
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
backdrop_url: schema.content.backdropUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
viewed_at: schema.recentlyViewed.viewedAt,
})
.from(schema.recentlyViewed)
.innerJoin(
schema.content,
eq(schema.recentlyViewed.contentId, schema.content.id)
)
.where(eq(schema.recentlyViewed.playlistId, playlistId))
.orderBy(desc(schema.recentlyViewed.viewedAt))
.limit(100);
}
export async function addRecentItem(
db: AppDatabase,
contentId: number,
playlistId: string,
options?: { backdropUrl?: string }
): Promise<{ success: boolean }> {
const existing = await db
.select()
.from(schema.recentlyViewed)
.where(
and(
eq(schema.recentlyViewed.contentId, contentId),
eq(schema.recentlyViewed.playlistId, playlistId)
)
)
.limit(1);
if (existing.length > 0) {
await db
.update(schema.recentlyViewed)
.set({ viewedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(schema.recentlyViewed.contentId, contentId),
eq(schema.recentlyViewed.playlistId, playlistId)
)
);
} else {
await db.insert(schema.recentlyViewed).values({
contentId,
playlistId,
});
}
await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl);
return { success: true };
}
export async function clearPlaylistRecentItems(
db: AppDatabase,
playlistId: string
): Promise<{ success: boolean }> {
const contentIds = await db
.select({ id: schema.content.id })
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(eq(schema.categories.playlistId, playlistId));
if (contentIds.length > 0) {
await db.delete(schema.recentlyViewed).where(
inArray(
schema.recentlyViewed.contentId,
contentIds.map((content) => content.id)
)
);
}
return { success: true };
}
export async function removeRecentItem(
db: AppDatabase,
contentId: number,
playlistId: string
): Promise<{ success: boolean }> {
await db
.delete(schema.recentlyViewed)
.where(
and(
eq(schema.recentlyViewed.contentId, contentId),
eq(schema.recentlyViewed.playlistId, playlistId)
)
);
return { success: true };
}
@@ -0,0 +1,319 @@
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from 'database-schema';
import type {
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
} from 'shared-interfaces';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
const DEFAULT_BATCH_SIZE = 100;
type ContentIdentity = {
id: number;
xtreamId: number;
contentType: XtreamBackupFavoriteItem['contentType'];
};
function toContentIdentityKey(
contentType: XtreamBackupFavoriteItem['contentType'],
xtreamId: number
): string {
return `${contentType}:${xtreamId}`;
}
export async function deleteXtreamContent(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
const categories = await db
.select({
id: schema.categories.id,
xtreamId: schema.categories.xtreamId,
type: schema.categories.type,
hidden: schema.categories.hidden,
})
.from(schema.categories)
.where(eq(schema.categories.playlistId, playlistId));
const categoryIds = categories.map((category) => category.id);
const hiddenCategories = categories
.filter((category) => category.hidden)
.map((category) => ({
xtreamId: category.xtreamId,
categoryType: category.type,
}));
let favorites: XtreamBackupFavoriteItem[] = [];
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
if (categoryIds.length > 0) {
const favoritedContent = await db
.select({
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
addedAt: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.where(
and(
eq(schema.favorites.playlistId, playlistId),
inArray(schema.content.categoryId, categoryIds)
)
);
favorites = favoritedContent.map((favorite) => ({
xtreamId: favorite.xtreamId,
contentType: favorite.contentType,
addedAt: favorite.addedAt ?? undefined,
position: favorite.position,
}));
const recentlyViewedContent = await db
.select({
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
viewedAt: schema.recentlyViewed.viewedAt,
})
.from(schema.recentlyViewed)
.innerJoin(
schema.content,
eq(schema.recentlyViewed.contentId, schema.content.id)
)
.where(
and(
eq(schema.recentlyViewed.playlistId, playlistId),
inArray(schema.content.categoryId, categoryIds)
)
);
recentlyViewed = recentlyViewedContent.map((item) => ({
xtreamId: item.xtreamId,
contentType: item.contentType,
viewedAt: item.viewedAt || new Date().toISOString(),
}));
const contentRows = await db
.select({ id: schema.content.id })
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
let deletedContent = 0;
const totalContent = contentRows.length;
for (const chunk of chunkValues(
contentRows.map((content) => content.id),
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db
.delete(schema.content)
.where(inArray(schema.content.id, chunk));
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
current: deletedContent,
total: totalContent,
increment: chunk.length,
});
}
}
let deletedCategories = 0;
const totalCategories = categoryIds.length;
for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk));
deletedCategories += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-categories',
current: deletedCategories,
total: totalCategories,
increment: chunk.length,
});
}
return {
success: true,
favorites,
recentlyViewed,
hiddenCategories,
};
}
async function getContentIdentityMap(
db: AppDatabase,
playlistId: string,
identities: Array<{
contentType: XtreamBackupFavoriteItem['contentType'];
xtreamId: number;
}>
): Promise<Map<string, number>> {
const xtreamIds = Array.from(
new Set(identities.map((item) => item.xtreamId))
);
if (xtreamIds.length === 0) {
return new Map();
}
const content = await db
.select({
id: schema.content.id,
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
inArray(schema.content.xtreamId, xtreamIds)
)
);
return new Map(
content.map((item: ContentIdentity) => [
toContentIdentityKey(item.contentType, item.xtreamId),
item.id,
])
);
}
export async function restoreXtreamUserData(
db: AppDatabase,
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[],
control?: OperationControl
): Promise<{ success: boolean }> {
await checkpointOperation(control);
await db
.delete(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId));
await checkpointOperation(control);
await db
.delete(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId));
const contentByIdentity = await getContentIdentityMap(db, playlistId, [
...favorites.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
})),
...recentlyViewed.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
})),
]);
const favoriteValues = favorites
.map((item, index) => {
const contentId = contentByIdentity.get(
toContentIdentityKey(item.contentType, item.xtreamId)
);
if (!contentId) {
return null;
}
return {
contentId,
playlistId,
addedAt: item.addedAt ?? new Date().toISOString(),
position: item.position ?? index,
};
})
.filter(
(
value
): value is {
contentId: number;
playlistId: string;
addedAt: string;
position: number | null;
} => value !== null
);
let restoredFavorites = 0;
const totalFavorites = favoriteValues.length;
for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.favorites).values(chunk);
restoredFavorites += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-favorites',
current: restoredFavorites,
total: totalFavorites,
increment: chunk.length,
});
}
const recentlyViewedValues = recentlyViewed
.map((item) => {
const contentId = contentByIdentity.get(
toContentIdentityKey(item.contentType, item.xtreamId)
);
if (!contentId) {
return null;
}
return {
contentId,
playlistId,
viewedAt: item.viewedAt,
};
})
.filter(
(
value
): value is {
contentId: number;
playlistId: string;
viewedAt: string;
} => value !== null
);
let restoredRecentlyViewed = 0;
const totalRecentlyViewed = recentlyViewedValues.length;
for (const chunk of chunkValues(recentlyViewedValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.recentlyViewed).values(chunk);
restoredRecentlyViewed += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-recently-viewed',
current: restoredRecentlyViewed,
total: totalRecentlyViewed,
increment: chunk.length,
});
}
return { success: true };
}
@@ -0,0 +1,39 @@
/**
* Database schema re-export from shared library
* This file maintains backwards compatibility for existing imports
*/
export {
// Tables
playlists,
categories,
content,
recentlyViewed,
favorites,
epgChannels,
epgPrograms,
playbackPositions,
downloads,
appState,
// Types
type Playlist,
type NewPlaylist,
type AppState,
type NewAppState,
type Category,
type NewCategory,
type Content,
type NewContent,
type RecentlyViewed,
type NewRecentlyViewed,
type Favorite,
type NewFavorite,
type EpgChannel,
type NewEpgChannel,
type EpgProgramDb,
type NewEpgProgramDb,
type PlaybackPosition,
type NewPlaybackPosition,
type Download,
type NewDownload,
} from 'database';
@@ -0,0 +1,22 @@
/**
* Database IPC event handlers for Electron
* Provides database operations to the renderer process
*/
import { ipcMain } from 'electron';
import './database/category.events';
import './database/content.events';
import './database/downloads.events';
import './database/epg-db.events';
import './database/favorites.events';
import './database/playback-position.events';
import './database/playlist.events';
import './database/recently-viewed.events';
import './database/xtream.events';
export default class DatabaseEvents {
static bootstrapDatabaseEvents(): Electron.IpcMain {
return ipcMain;
}
}
@@ -0,0 +1,56 @@
/**
* Category IPC event handlers
* Operations for managing categories within playlists
*/
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest(
'DB_HAS_CATEGORIES',
(playlistId: string, type: 'live' | 'movies' | 'series') => ({
playlistId,
type,
})
);
handleWorkerRequest(
'DB_GET_CATEGORIES',
(playlistId: string, type: 'live' | 'movies' | 'series') => ({
playlistId,
type,
})
);
handleWorkerRequest(
'DB_SAVE_CATEGORIES',
(
playlistId: string,
categories: Array<{
category_name: string;
category_id: string | number;
}>,
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
) => ({
playlistId,
categories,
type,
hiddenCategoryXtreamIds,
})
);
handleWorkerRequest(
'DB_UPDATE_CATEGORY_VISIBILITY',
(categoryIds: number[], hidden: boolean) => ({
categoryIds,
hidden,
})
);
handleWorkerRequest(
'DB_GET_ALL_CATEGORIES',
(playlistId: string, type: 'live' | 'movies' | 'series') => ({
playlistId,
type,
})
);
@@ -0,0 +1,124 @@
/**
* Content IPC event handlers
* Operations for managing and searching content (streams, movies, series)
*/
import { ipcMain } from 'electron';
import {
handleWorkerRequest,
requestWorkerWithEvents,
} from './worker-events.utils';
handleWorkerRequest(
'DB_HAS_CONTENT',
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
playlistId,
type,
})
);
handleWorkerRequest(
'DB_GET_CONTENT',
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
playlistId,
type,
})
);
handleWorkerRequest(
'DB_GET_GLOBAL_RECENTLY_ADDED',
(
kind: 'all' | 'vod' | 'series' = 'all',
limit = 200,
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url'
) => ({
kind,
limit,
playlistType,
})
);
ipcMain.handle(
'DB_SAVE_CONTENT',
async (
event,
playlistId: string,
streams: Array<Record<string, unknown>>,
type: 'live' | 'movie' | 'series',
operationId?: string
) => {
try {
return await requestWorkerWithEvents(
event,
'DB_SAVE_CONTENT',
{
playlistId,
streams,
type,
operationId,
}
);
} catch (error) {
console.error('Error handling DB_SAVE_CONTENT:', error);
throw error;
}
}
);
handleWorkerRequest(
'DB_CLEAR_XTREAM_IMPORT_CACHE',
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
playlistId,
type,
})
);
handleWorkerRequest(
'DB_GET_CONTENT_BY_XTREAM_ID',
(
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) => ({
xtreamId,
playlistId,
contentType,
})
);
handleWorkerRequest(
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
(contentId: number, backdropUrl?: string) => ({
contentId,
backdropUrl,
})
);
handleWorkerRequest(
'DB_SEARCH_CONTENT',
(
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden = false
) => ({
playlistId,
searchTerm,
types,
excludeHidden,
})
);
handleWorkerRequest(
'DB_GLOBAL_SEARCH',
(searchTerm: string, types: string[], excludeHidden = false) => ({
searchTerm,
types,
excludeHidden,
})
);
@@ -0,0 +1,649 @@
/**
* Downloads IPC event handlers
* Uses electron-dl for download management with queue control and progress tracking
*/
import { and, eq, inArray, sql } from 'drizzle-orm';
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { download, File as ElectronDlFile } from 'electron-dl';
import { existsSync, unlinkSync } from 'fs';
import { basename, extname, join } from 'path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled';
interface DownloadTask {
id: number;
url: string;
fileName: string;
directory: string;
headers?: Record<string, string>;
downloadItem?: ElectronDlFile;
}
// Download queue management
const downloadQueue: DownloadTask[] = [];
let activeDownload: DownloadTask | null = null;
let mainWindow: BrowserWindow | null = null;
/**
* Set the main window reference for sending updates
*/
export function setMainWindow(win: BrowserWindow) {
mainWindow = win;
}
/**
* Broadcast download updates to renderer
*/
function broadcastUpdate() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
}
}
/**
* Get file extension from URL
*/
function getExtensionFromUrl(url: string): string {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const ext = extname(pathname);
return ext || '.mp4';
} catch {
return '.mp4';
}
}
/**
* Sanitize filename for filesystem
*/
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
/**
* Process the download queue - starts next download if none active
*/
async function processQueue() {
if (activeDownload || downloadQueue.length === 0) {
return;
}
const task = downloadQueue.shift();
if (!task) return;
activeDownload = task;
await startDownload(task);
}
/**
* Start a download using electron-dl
*/
async function startDownload(task: DownloadTask) {
const db = await getDatabase();
// Update status to downloading
await db
.update(schema.downloads)
.set({
status: 'downloading',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastUpdate();
if (!mainWindow || mainWindow.isDestroyed()) {
console.error('[Downloads] No main window available');
await db
.update(schema.downloads)
.set({
status: 'failed',
errorMessage: 'No window available for download',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
return;
}
let lastProgressUpdate = 0;
const PROGRESS_THROTTLE_MS = 500;
try {
const downloadOptions: Parameters<typeof download>[2] = {
directory: task.directory,
filename: task.fileName,
overwrite: true,
onStarted: (item) => {
console.log(`[Downloads] Started: ${task.fileName}`);
task.downloadItem = item as unknown as ElectronDlFile;
},
onProgress: async (progress) => {
const now = Date.now();
if (now - lastProgressUpdate < PROGRESS_THROTTLE_MS) {
return;
}
lastProgressUpdate = now;
await db
.update(schema.downloads)
.set({
bytesDownloaded: progress.transferredBytes,
totalBytes: progress.totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastUpdate();
},
onCompleted: async (file) => {
console.log(`[Downloads] Completed: ${task.fileName}`);
await db
.update(schema.downloads)
.set({
status: 'completed',
filePath: file.path,
fileName: file.filename,
bytesDownloaded: file.fileSize,
totalBytes: file.fileSize,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
},
onCancel: async () => {
console.log(`[Downloads] Canceled: ${task.fileName}`);
// Delete partial file if it exists
const partialPath = join(task.directory, task.fileName);
if (existsSync(partialPath)) {
try {
unlinkSync(partialPath);
} catch (e) {
console.error('[Downloads] Failed to delete partial file:', e);
}
}
await db
.update(schema.downloads)
.set({
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
},
};
// Add headers if provided (user-agent, referer, origin)
if (task.headers) {
(downloadOptions as any).headers = task.headers;
}
await download(mainWindow, task.url, downloadOptions);
} catch (error) {
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
// Delete partial file if it exists
const partialPath = join(task.directory, task.fileName);
if (existsSync(partialPath)) {
try {
unlinkSync(partialPath);
} catch (e) {
console.error('[Downloads] Failed to delete partial file:', e);
}
}
await db
.update(schema.downloads)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : String(error),
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
}
}
/**
* Enqueue a new download
*/
ipcMain.handle(
'DOWNLOADS_START',
async (
_event,
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;
}
) => {
try {
console.log('[Downloads] Enqueue download:', data.title);
const db = await getDatabase();
// Ensure playlist exists in database (required for foreign key constraint)
if (data.playlistId) {
const existingPlaylist = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, data.playlistId))
.limit(1);
if (existingPlaylist.length === 0) {
// Create playlist entry for downloads to work
console.log('[Downloads] Creating playlist entry for:', data.playlistId);
await db.insert(schema.playlists).values({
id: data.playlistId,
name: data.playlistName || 'Unknown Playlist',
type: data.playlistType || 'stalker',
serverUrl: data.serverUrl,
macAddress: data.macAddress,
url: data.portalUrl,
});
}
} else {
throw new Error('playlistId is required for downloads');
}
// Check if already exists
const existing = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, data.playlistId),
eq(schema.downloads.xtreamId, data.xtreamId),
eq(schema.downloads.contentType, data.contentType)
)
)
.limit(1);
if (existing.length > 0) {
const item = existing[0];
// If completed or failed/canceled, allow retry by updating
if (['completed', 'failed', 'canceled'].includes(item.status)) {
await db
.update(schema.downloads)
.set({
status: 'queued',
url: data.url,
bytesDownloaded: 0,
totalBytes: null,
errorMessage: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, item.id));
const ext = getExtensionFromUrl(data.url);
const fileName = sanitizeFilename(data.title) + ext;
downloadQueue.push({
id: item.id,
url: data.url,
fileName,
directory: data.downloadFolder,
headers: data.headers
? {
'User-Agent': data.headers.userAgent || '',
Referer: data.headers.referer || '',
Origin: data.headers.origin || '',
}
: undefined,
});
broadcastUpdate();
processQueue();
return { success: true, id: item.id };
}
// Already queued or downloading
return { success: false, error: 'Download already in progress', id: item.id };
}
// Create new download entry
const ext = getExtensionFromUrl(data.url);
const fileName = sanitizeFilename(data.title) + ext;
const result = await db.insert(schema.downloads).values({
playlistId: data.playlistId,
xtreamId: data.xtreamId,
contentType: data.contentType,
title: data.title,
url: data.url,
posterUrl: data.posterUrl,
fileName,
status: 'queued',
seriesXtreamId: data.seriesXtreamId,
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
});
const insertedId = Number(result.lastInsertRowid);
downloadQueue.push({
id: insertedId,
url: data.url,
fileName,
directory: data.downloadFolder,
headers: data.headers
? {
'User-Agent': data.headers.userAgent || '',
Referer: data.headers.referer || '',
Origin: data.headers.origin || '',
}
: undefined,
});
broadcastUpdate();
processQueue();
return { success: true, id: insertedId };
} catch (error) {
console.error('[Downloads] Error enqueuing download:', error);
throw error;
}
}
);
/**
* Cancel a download
*/
ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Cancel download:', downloadId);
const db = await getDatabase();
// Check if it's the active download
if (activeDownload && activeDownload.id === downloadId) {
if (activeDownload.downloadItem) {
(activeDownload.downloadItem as any).cancel?.();
}
return { success: true };
}
// Remove from queue
const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId);
if (queueIndex !== -1) {
downloadQueue.splice(queueIndex, 1);
await db
.update(schema.downloads)
.set({
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
broadcastUpdate();
return { success: true };
}
return { success: false, error: 'Download not found in queue' };
} catch (error) {
console.error('[Downloads] Error canceling download:', error);
throw error;
}
});
/**
* Retry a failed/canceled download
*/
ipcMain.handle(
'DOWNLOADS_RETRY',
async (_event, downloadId: number, downloadFolder: string) => {
try {
console.log('[Downloads] Retry download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { success: false, error: 'Download not found' };
}
const item = existing[0];
if (!['failed', 'canceled'].includes(item.status)) {
return { success: false, error: 'Can only retry failed or canceled downloads' };
}
await db
.update(schema.downloads)
.set({
status: 'queued',
bytesDownloaded: 0,
totalBytes: null,
errorMessage: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
const ext = getExtensionFromUrl(item.url);
const fileName = sanitizeFilename(item.title) + ext;
downloadQueue.push({
id: item.id,
url: item.url,
fileName,
directory: downloadFolder,
});
broadcastUpdate();
processQueue();
return { success: true };
} catch (error) {
console.error('[Downloads] Error retrying download:', error);
throw error;
}
}
);
/**
* Remove a download from the list
*/
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Remove download:', downloadId);
const db = await getDatabase();
// Cancel if active
if (activeDownload && activeDownload.id === downloadId) {
if (activeDownload.downloadItem) {
(activeDownload.downloadItem as any).cancel?.();
}
}
// Remove from queue
const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId);
if (queueIndex !== -1) {
downloadQueue.splice(queueIndex, 1);
}
// Delete from database
await db.delete(schema.downloads).where(eq(schema.downloads.id, downloadId));
broadcastUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error removing download:', error);
throw error;
}
});
/**
* Get all downloads for a playlist
*/
ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
if (playlistId) {
const result = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId))
.orderBy(schema.downloads.createdAt);
return result;
}
const result = await db
.select()
.from(schema.downloads)
.orderBy(schema.downloads.createdAt);
return result;
} catch (error) {
console.error('[Downloads] Error getting download list:', error);
throw error;
}
});
/**
* Get download by ID
*/
ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
try {
const db = await getDatabase();
const result = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
return result[0] || null;
} catch (error) {
console.error('[Downloads] Error getting download:', error);
throw error;
}
});
/**
* Get default download folder
*/
ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => {
return app.getPath('downloads');
});
/**
* Select download folder via dialog
*/
ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: 'Select Download Folder',
defaultPath: app.getPath('downloads'),
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
});
/**
* Reveal file in system file manager
*/
ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
if (existsSync(filePath)) {
shell.showItemInFolder(filePath);
return { success: true };
}
return { success: false, error: 'File not found' };
});
/**
* Play downloaded file
*/
ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
if (existsSync(filePath)) {
await shell.openPath(filePath);
return { success: true };
}
return { success: false, error: 'File not found' };
});
/**
* Clear all completed downloads
*/
ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
if (playlistId) {
await db
.delete(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, playlistId),
inArray(schema.downloads.status, ['completed', 'failed', 'canceled'])
)
);
} else {
await db
.delete(schema.downloads)
.where(
inArray(schema.downloads.status, ['completed', 'failed', 'canceled'])
);
}
broadcastUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
});
/**
* Reset stale downloads on startup (downloading -> failed)
*/
export async function resetStaleDownloads() {
try {
const db = await getDatabase();
await db
.update(schema.downloads)
.set({
status: 'failed',
errorMessage: 'Download interrupted by application restart',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
inArray(schema.downloads.status, ['queued', 'downloading'])
);
console.log('[Downloads] Reset stale downloads');
} catch (error) {
console.error('[Downloads] Error resetting stale downloads:', error);
}
}
@@ -0,0 +1,362 @@
/**
* EPG Database IPC event handlers
* Operations for storing and querying EPG data in SQLite with FTS5 support
*/
import { and, eq, gte, lte, sql } from 'drizzle-orm';
import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
const loggerLabel = '[EPG DB]';
/**
* Save EPG channels in bulk (upsert)
*/
ipcMain.handle(
'EPG_DB_SAVE_CHANNELS',
async (
_event,
sourceUrl: string,
channels: Array<{
id: string;
displayName: string;
iconUrl?: string;
url?: string;
}>
) => {
try {
const db = await getDatabase();
// Delete existing channels from this source first
await db
.delete(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, sourceUrl));
if (channels.length === 0) return { success: true, count: 0 };
// Insert in chunks
const CHUNK_SIZE = 500;
let totalInserted = 0;
for (let i = 0; i < channels.length; i += CHUNK_SIZE) {
const chunk = channels.slice(i, i + CHUNK_SIZE);
const values = chunk.map((ch) => ({
id: ch.id,
displayName: ch.displayName,
iconUrl: ch.iconUrl || null,
url: ch.url || null,
sourceUrl,
}));
await db.insert(schema.epgChannels).values(values);
totalInserted += chunk.length;
}
console.log(
loggerLabel,
`Saved ${totalInserted} channels from ${sourceUrl}`
);
return { success: true, count: totalInserted };
} catch (error) {
console.error(loggerLabel, 'Error saving EPG channels:', error);
throw error;
}
}
);
/**
* Save EPG programs in bulk
*/
ipcMain.handle(
'EPG_DB_SAVE_PROGRAMS',
async (
_event,
programs: Array<{
channelId: string;
start: string;
stop: string;
title: string;
description?: string;
category?: string;
iconUrl?: string;
rating?: string;
episodeNum?: string;
}>
) => {
try {
const db = await getDatabase();
if (programs.length === 0) return { success: true, count: 0 };
// Insert in chunks for better performance
const CHUNK_SIZE = 500;
let totalInserted = 0;
for (let i = 0; i < programs.length; i += CHUNK_SIZE) {
const chunk = programs.slice(i, i + CHUNK_SIZE);
const values = chunk.map((prog) => ({
channelId: prog.channelId,
start: prog.start,
stop: prog.stop,
title: prog.title,
description: prog.description || null,
category: prog.category || null,
iconUrl: prog.iconUrl || null,
rating: prog.rating || null,
episodeNum: prog.episodeNum || null,
}));
await db.insert(schema.epgPrograms).values(values);
totalInserted += chunk.length;
}
console.log(loggerLabel, `Saved ${totalInserted} programs`);
return { success: true, count: totalInserted };
} catch (error) {
console.error(loggerLabel, 'Error saving EPG programs:', error);
throw error;
}
}
);
/**
* Get programs for a specific channel
*/
ipcMain.handle(
'EPG_DB_GET_CHANNEL_PROGRAMS',
async (_event, channelId: string, fromTime?: string, toTime?: string) => {
try {
const db = await getDatabase();
let query;
// Apply time filters if provided
if (fromTime && toTime) {
query = db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channelId),
gte(schema.epgPrograms.stop, fromTime),
lte(schema.epgPrograms.start, toTime)
)
);
} else {
// Return all programs for this channel
query = db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channelId));
}
const results = await query.orderBy(schema.epgPrograms.start);
return results;
} catch (error) {
console.error(
loggerLabel,
'Error getting channel programs:',
error
);
throw error;
}
}
);
/**
* Get current program for a channel (what's on now)
*/
ipcMain.handle(
'EPG_DB_GET_CURRENT_PROGRAM',
async (_event, channelId: string) => {
try {
const db = await getDatabase();
const now = new Date().toISOString();
const result = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channelId),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.limit(1);
return result[0] || null;
} catch (error) {
console.error(loggerLabel, 'Error getting current program:', error);
throw error;
}
}
);
/**
* Full-text search EPG programs using FTS5 with LIKE fallback
* Handles Cyrillic and other Unicode text properly
* Includes channel display name via JOIN
*/
ipcMain.handle(
'EPG_DB_SEARCH_PROGRAMS',
async (_event, searchTerm: string, limit = 50) => {
try {
const db = await getDatabase();
const trimmedTerm = searchTerm.trim();
if (!trimmedTerm) {
return [];
}
// Use LIKE for substring matching (works better with Cyrillic)
// This is more intuitive for users expecting exact substring matches
const likePattern = `%${trimmedTerm}%`;
// JOIN with epg_channels to get channel display name
// Include all programs (past and future) for catchup/archive feature
const results = await db.all(sql`
SELECT
p.*,
c.display_name as channel_name
FROM epg_programs p
LEFT JOIN epg_channels c ON p.channel_id = c.id
WHERE (
p.title LIKE ${likePattern}
OR p.description LIKE ${likePattern}
OR p.category LIKE ${likePattern}
)
ORDER BY p.start
LIMIT ${limit}
`);
return results;
} catch (error) {
console.error(loggerLabel, 'Error searching EPG programs:', error);
throw error;
}
}
);
/**
* Get all EPG channels
*/
ipcMain.handle('EPG_DB_GET_CHANNELS', async () => {
try {
const db = await getDatabase();
const results = await db
.select()
.from(schema.epgChannels)
.orderBy(schema.epgChannels.displayName);
return results;
} catch (error) {
console.error(loggerLabel, 'Error getting EPG channels:', error);
throw error;
}
});
/**
* Get channel by ID or display name
*/
ipcMain.handle(
'EPG_DB_GET_CHANNEL',
async (_event, channelIdOrName: string) => {
try {
const db = await getDatabase();
// Try exact ID match first
let result = await db
.select()
.from(schema.epgChannels)
.where(eq(schema.epgChannels.id, channelIdOrName))
.limit(1);
if (result.length > 0) return result[0];
// Try display name match (case-insensitive)
result = await db
.select()
.from(schema.epgChannels)
.where(
sql`LOWER(${schema.epgChannels.displayName}) = LOWER(${channelIdOrName})`
)
.limit(1);
return result[0] || null;
} catch (error) {
console.error(loggerLabel, 'Error getting EPG channel:', error);
throw error;
}
}
);
/**
* Clear all EPG data for a specific source URL
*/
ipcMain.handle('EPG_DB_CLEAR_SOURCE', async (_event, sourceUrl: string) => {
try {
const db = await getDatabase();
// Deleting channels will cascade delete programs due to foreign key
await db
.delete(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, sourceUrl));
console.log(loggerLabel, `Cleared EPG data for source: ${sourceUrl}`);
return { success: true };
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG source:', error);
throw error;
}
});
/**
* Clear all EPG data
*/
ipcMain.handle('EPG_DB_CLEAR_ALL', async () => {
try {
const db = await getDatabase();
await db.delete(schema.epgPrograms);
await db.delete(schema.epgChannels);
console.log(loggerLabel, 'Cleared all EPG data');
return { success: true };
} catch (error) {
console.error(loggerLabel, 'Error clearing all EPG data:', error);
throw error;
}
});
/**
* Get EPG statistics
*/
ipcMain.handle('EPG_DB_GET_STATS', async () => {
try {
const db = await getDatabase();
const channelCount = await db
.select({ count: sql<number>`count(*)` })
.from(schema.epgChannels);
const programCount = await db
.select({ count: sql<number>`count(*)` })
.from(schema.epgPrograms);
const now = new Date().toISOString();
const futureProgramCount = await db
.select({ count: sql<number>`count(*)` })
.from(schema.epgPrograms)
.where(gte(schema.epgPrograms.stop, now));
return {
channels: channelCount[0].count,
programs: programCount[0].count,
futurePrograms: futureProgramCount[0].count,
};
} catch (error) {
console.error(loggerLabel, 'Error getting EPG stats:', error);
throw error;
}
});
@@ -0,0 +1,45 @@
/**
* Favorites IPC event handlers
* Operations for managing user's favorite content
*/
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest(
'DB_ADD_FAVORITE',
(contentId: number, playlistId: string, backdropUrl?: string) => ({
contentId,
playlistId,
backdropUrl,
})
);
handleWorkerRequest(
'DB_REMOVE_FAVORITE',
(contentId: number, playlistId: string) => ({
contentId,
playlistId,
})
);
handleWorkerRequest(
'DB_IS_FAVORITE',
(contentId: number, playlistId: string) => ({
contentId,
playlistId,
})
);
handleWorkerRequest('DB_GET_FAVORITES', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest('DB_GET_GLOBAL_FAVORITES', () => ({}));
handleWorkerRequest('DB_GET_ALL_GLOBAL_FAVORITES', () => ({}));
handleWorkerRequest(
'DB_REORDER_GLOBAL_FAVORITES',
(updates: { content_id: number; position: number }[]) => ({
updates,
})
);
@@ -0,0 +1,67 @@
/**
* Playback Position IPC event handlers
* Operations for managing video playback progress
*/
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest(
'DB_SAVE_PLAYBACK_POSITION',
(playlistId: string, data: unknown) => ({
playlistId,
data,
})
);
handleWorkerRequest(
'DB_GET_PLAYBACK_POSITION',
(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) => ({
playlistId,
contentXtreamId,
contentType,
})
);
handleWorkerRequest(
'DB_GET_SERIES_PLAYBACK_POSITIONS',
(playlistId: string, seriesXtreamId: number) => ({
playlistId,
seriesXtreamId,
})
);
handleWorkerRequest(
'DB_GET_RECENT_PLAYBACK_POSITIONS',
(playlistId: string, limit?: number) => ({
playlistId,
limit,
})
);
handleWorkerRequest('DB_GET_ALL_PLAYBACK_POSITIONS', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest(
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
(playlistId: string) => ({
playlistId,
})
);
handleWorkerRequest(
'DB_CLEAR_PLAYBACK_POSITION',
(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) => ({
playlistId,
contentXtreamId,
contentType,
})
);
@@ -0,0 +1,93 @@
/**
* Playlist IPC event handlers
* CRUD operations for playlists
*/
import { ipcMain } from 'electron';
import { databaseWorkerClient } from '../../services/database-worker-client';
import {
handleWorkerRequest,
requestWorkerWithEvents,
} from './worker-events.utils';
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
handleWorkerRequest(
'DB_UPSERT_APP_PLAYLIST',
(playlist: Record<string, unknown>) => playlist
);
handleWorkerRequest(
'DB_UPSERT_APP_PLAYLISTS',
(playlists: Record<string, unknown>[]) => playlists
);
handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({}));
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId }));
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId }));
handleWorkerRequest(
'DB_UPDATE_PLAYLIST',
(
playlistId: string,
updates: {
name?: string;
username?: string;
password?: string;
serverUrl?: string;
lastUpdated?: string;
}
) => ({
playlistId,
updates,
})
);
handleWorkerRequest('DB_GET_APP_STATE', (key: string) => ({ key }));
handleWorkerRequest('DB_SET_APP_STATE', (key: string, value: string) => ({
key,
value,
}));
ipcMain.handle(
'DB_DELETE_PLAYLIST',
async (
event,
playlistId: string,
operationId?: string
) => {
try {
return await requestWorkerWithEvents(
event,
'DB_DELETE_PLAYLIST',
{
playlistId,
operationId,
}
);
} catch (error) {
console.error('Error handling DB_DELETE_PLAYLIST:', error);
throw error;
}
}
);
ipcMain.handle(
'DB_DELETE_ALL_PLAYLISTS',
async (event, operationId?: string) => {
try {
return await requestWorkerWithEvents(
event,
'DB_DELETE_ALL_PLAYLISTS',
{ operationId }
);
} catch (error) {
console.error('Error handling DB_DELETE_ALL_PLAYLISTS:', error);
throw error;
}
}
);
ipcMain.handle('DB_CANCEL_OPERATION', async (_event, operationId: string) => {
try {
return await databaseWorkerClient.cancel(operationId);
} catch (error) {
console.error('Error handling DB_CANCEL_OPERATION:', error);
throw error;
}
});
@@ -0,0 +1,37 @@
/**
* Recently Viewed IPC event handlers
* Operations for managing user's recently viewed content
*/
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest('DB_GET_RECENTLY_VIEWED', () => ({}));
handleWorkerRequest('DB_CLEAR_RECENTLY_VIEWED', () => ({}));
handleWorkerRequest('DB_GET_RECENT_ITEMS', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest(
'DB_ADD_RECENT_ITEM',
(contentId: number, playlistId: string, backdropUrl?: string) => ({
contentId,
playlistId,
backdropUrl,
})
);
handleWorkerRequest(
'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
(playlistId: string) => ({
playlistId,
})
);
handleWorkerRequest(
'DB_REMOVE_RECENT_ITEM',
(contentId: number, playlistId: string) => ({
contentId,
playlistId,
})
);
@@ -0,0 +1,44 @@
import { ipcMain } from 'electron';
import { databaseWorkerClient } from '../../services/database-worker-client';
import type {
DbOperationEvent,
DbWorkerOperation,
} from '../../workers/database-worker.types';
export function forwardWorkerEvent(
event: Electron.IpcMainInvokeEvent,
workerEvent: DbOperationEvent
): void {
if (event.sender.isDestroyed()) {
return;
}
event.sender.send('DB_OPERATION_EVENT', workerEvent);
}
export function handleWorkerRequest<TArgs extends unknown[]>(
channel: DbWorkerOperation,
buildPayload: (...args: TArgs) => unknown
): void {
ipcMain.handle(channel, async (_event, ...args: TArgs) => {
try {
return await databaseWorkerClient.request(
channel,
buildPayload(...args)
);
} catch (error) {
console.error(`Error handling ${channel}:`, error);
throw error;
}
});
}
export async function requestWorkerWithEvents<TResult, TPayload>(
event: Electron.IpcMainInvokeEvent,
channel: DbWorkerOperation,
payload: TPayload
): Promise<TResult> {
return databaseWorkerClient.request<TResult>(channel, payload, {
onEvent: (workerEvent) => forwardWorkerEvent(event, workerEvent),
});
}
@@ -0,0 +1,53 @@
/**
* Xtream-specific IPC event handlers
* Operations for refreshing and managing Xtream playlist data
*/
import { ipcMain } from 'electron';
import { requestWorkerWithEvents } from './worker-events.utils';
ipcMain.handle(
'DB_DELETE_XTREAM_CONTENT',
async (event, playlistId: string, operationId?: string) => {
try {
return await requestWorkerWithEvents(
event,
'DB_DELETE_XTREAM_CONTENT',
{
playlistId,
operationId,
}
);
} catch (error) {
console.error('Error handling DB_DELETE_XTREAM_CONTENT:', error);
throw error;
}
}
);
ipcMain.handle(
'DB_RESTORE_XTREAM_USER_DATA',
async (
event,
playlistId: string,
favorites: unknown[],
recentlyViewed: unknown[],
operationId?: string
) => {
try {
return await requestWorkerWithEvents(
event,
'DB_RESTORE_XTREAM_USER_DATA',
{
playlistId,
favorites,
recentlyViewed,
operationId,
}
);
} catch (error) {
console.error('Error handling DB_RESTORE_XTREAM_USER_DATA:', error);
throw error;
}
}
);
@@ -0,0 +1,60 @@
/**
* This module is responsible on handling all the inter process communications
* between the frontend to the electron backend.
*/
import { app, ipcMain } from 'electron';
import * as os from 'os';
import { environment } from '../../environments/environment';
import {
DEBUG_TRACE_EVENT_CHANNEL,
isRendererApiTraceEnabled,
trace,
} from '../services/debug-trace';
export default class ElectronEvents {
static bootstrapElectronEvents(): Electron.IpcMain {
return ipcMain;
}
}
// Retrieve app version
ipcMain.handle('get-app-version', (event) => {
console.log(`Fetching application version... [v${environment.version}]`);
return environment.version;
});
// Handle App termination
ipcMain.on('quit', (event, code) => {
app.exit(code);
});
ipcMain.on(DEBUG_TRACE_EVENT_CHANNEL, (event, payload) => {
if (!isRendererApiTraceEnabled()) {
return;
}
trace('renderer-api', 'event', {
payload,
senderId: event.sender.id,
url: event.sender.getURL(),
});
});
// Get local IP addresses for remote control URL display
ipcMain.handle('get-local-ip-addresses', () => {
const interfaces = os.networkInterfaces();
const addresses: string[] = [];
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name] || []) {
// Skip internal (loopback) and non-IPv4 addresses
if (iface.family === 'IPv4' && !iface.internal) {
addresses.push(iface.address);
}
}
}
return addresses;
});
@@ -0,0 +1,278 @@
import type EpgEventsType from './epg.events';
const mockWorkerInstances: any[] = [];
const resolveWorkerRuntimeBootstrap = jest.fn();
const getDatabase = jest.fn();
jest.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/mock/app.asar',
},
BrowserWindow: {
getAllWindows: () => [],
},
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('worker_threads', () => {
const { EventEmitter } = require('events');
class MockWorker extends EventEmitter {
postMessage = jest.fn();
terminate = jest.fn().mockResolvedValue(0);
}
return {
Worker: jest.fn().mockImplementation(() => {
const worker = new MockWorker();
mockWorkerInstances.push(worker);
return worker;
}),
};
});
jest.mock('../workers/worker-runtime-paths', () => ({
resolveWorkerRuntimeBootstrap: (...args: unknown[]) =>
resolveWorkerRuntimeBootstrap(...args),
}));
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
describe('EpgEvents', () => {
let EpgEvents: typeof EpgEventsType;
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(async () => {
jest.resetModules();
mockWorkerInstances.length = 0;
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
resolveWorkerRuntimeBootstrap.mockReset();
resolveWorkerRuntimeBootstrap.mockReturnValue({
workerPath: '/mock/workers/epg-parser.worker.js',
workerPathCandidates: ['/mock/workers/epg-parser.worker.js'],
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
});
({ default: EpgEvents } = await import('./epg.events'));
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
getDatabase.mockReset();
});
async function flushPromises(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
it('uses the shared worker bootstrap and passes native module search paths to the EPG worker', async () => {
const fetchPromise = (EpgEvents as unknown as Record<string, any>)[
'fetchEpgFromUrl'
]('https://example.com/guide.xml');
const worker = mockWorkerInstances[0];
const { Worker } = jest.requireMock('worker_threads');
expect(resolveWorkerRuntimeBootstrap).toHaveBeenCalledWith(
expect.objectContaining({
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: expect.stringContaining('workers'),
})
);
expect(Worker).toHaveBeenCalledWith(expect.any(URL), {
resourceLimits: {
maxOldGenerationSizeMb: 4096,
maxYoungGenerationSizeMb: 512,
},
workerData: {
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
},
});
worker.emit('message', { type: 'READY' });
await flushPromises();
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'FETCH_EPG',
url: 'https://example.com/guide.xml',
});
worker.emit('message', {
type: 'EPG_COMPLETE',
stats: { totalChannels: 1, totalPrograms: 2 },
});
await expect(fetchPromise).resolves.toBeUndefined();
});
it('rejects with actionable worker path errors', async () => {
resolveWorkerRuntimeBootstrap.mockImplementation(() => {
const error = new Error(
'Unable to resolve worker "epg-parser.worker.js".\nTried:\n- /missing/epg-parser.worker.js'
);
error.name = 'WorkerPathResolutionError';
throw error;
});
await expect(EpgEvents.clearEpgData()).rejects.toMatchObject({
name: 'WorkerPathResolutionError',
message: expect.stringContaining('epg-parser.worker.js'),
});
});
it('falls back to case-insensitive channel id lookup for EPG programs', async () => {
const select = jest.fn();
const programLimitExact = jest.fn().mockResolvedValue([]);
const channelLimit = jest
.fn()
.mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]);
const programLimitResolved = jest.fn().mockResolvedValue([
{
id: 1,
channelId: 'BBC.ONE.UK',
start: '2026-04-14T10:00:00Z',
stop: '2026-04-14T11:00:00Z',
title: 'News',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
]);
const from = jest
.fn()
.mockReturnValueOnce({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockReturnValue({
limit: programLimitExact,
}),
}),
})
.mockReturnValueOnce({
where: jest.fn().mockReturnValue({
limit: channelLimit,
}),
})
.mockReturnValueOnce({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockReturnValue({
limit: programLimitResolved,
}),
}),
});
select.mockImplementation(() => ({ from }));
getDatabase.mockResolvedValue({ select });
const programs = await (EpgEvents as unknown as Record<string, any>)[
'handleGetChannelPrograms'
]('bbc.one.uk');
expect(programs).toHaveLength(1);
expect(programs[0].channel).toBe('BBC.ONE.UK');
});
it('resolves channel metadata using exact id, case-insensitive id, and display name fallback', async () => {
const where = jest.fn().mockResolvedValue([
{
id: 'BBC.ONE.UK',
displayName: 'BBC One',
iconUrl: 'https://example.com/bbc-one.png',
},
{
id: 'guide-news',
displayName: 'Guide News',
iconUrl: 'https://example.com/guide-news.png',
},
]);
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
getDatabase.mockResolvedValue({ select });
const metadata = await (EpgEvents as unknown as Record<string, any>)[
'handleGetChannelMetadata'
](['BBC.ONE.UK', 'bbc.one.uk', 'Guide News', 'Missing Channel']);
expect(metadata).toEqual({
'BBC.ONE.UK': {
id: 'BBC.ONE.UK',
displayName: 'BBC One',
iconUrl: 'https://example.com/bbc-one.png',
},
'bbc.one.uk': {
id: 'BBC.ONE.UK',
displayName: 'BBC One',
iconUrl: 'https://example.com/bbc-one.png',
},
'Guide News': {
id: 'guide-news',
displayName: 'Guide News',
iconUrl: 'https://example.com/guide-news.png',
},
'Missing Channel': null,
});
});
it('drops malformed EPG rows with invalid stop dates', async () => {
const select = jest.fn();
const from = jest.fn();
const where = jest.fn();
const orderBy = jest.fn();
const limit = jest.fn();
select.mockImplementation(() => ({ from }));
from.mockReturnValue({ where });
where.mockReturnValue({ orderBy });
orderBy.mockReturnValue({ limit });
limit.mockResolvedValue([
{
id: 1,
channelId: 'id2e2cd03c90ad',
start: '2026-04-14T20:00:00+00:00',
stop: '2026-04-14T21:00:00+00:00',
title: 'valid',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
{
id: 2,
channelId: 'id2e2cd03c90ad',
start: '2026-04-14T21:00:00+00:00',
stop: '',
title: 'invalid',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
]);
getDatabase.mockResolvedValue({ select });
const programs = await (EpgEvents as unknown as Record<string, any>)[
'handleGetChannelPrograms'
]('id2e2cd03c90ad');
expect(programs).toHaveLength(1);
expect(programs[0].title).toBe('valid');
});
});
@@ -0,0 +1,835 @@
import { eq, sql } from 'drizzle-orm';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
/**
* EPG Events Handler
* Manages EPG data fetching and querying using worker threads.
* Database operations are performed in the worker thread to avoid blocking the main thread.
*/
export default class EpgEvents {
private static fetchedUrls: Set<string> = new Set();
private static workers: Map<string, Worker> = new Map();
private static readonly loggerLabel = '[EPG Events]';
private static readonly FETCH_TIMEOUT_MS = 5 * 60 * 1000;
private static createEpgWorker(): Worker {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: path.join(__dirname, 'workers'),
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
appPath: app.getAppPath(),
});
const workerURL = pathToFileURL(bootstrap.workerPath);
return new Worker(workerURL, {
resourceLimits: {
maxOldGenerationSizeMb: 4096,
maxYoungGenerationSizeMb: 512,
},
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
},
});
}
/**
* Send EPG progress to all renderer windows
*/
private static sendProgressToRenderer(
url: string,
status: 'queued' | 'loading' | 'complete' | 'error',
stats?: { totalChannels: number; totalPrograms: number },
error?: string,
queuePosition?: number
): void {
const windows = BrowserWindow.getAllWindows();
windows.forEach((win) => {
win.webContents.send('EPG_PROGRESS_UPDATE', {
url,
status,
stats,
error,
queuePosition,
});
});
}
/**
* Bootstrap EPG events
*/
static bootstrapEpgEvents(): Electron.IpcMain {
// Fetch EPG from URLs
ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => {
return await this.handleFetchEpg(args.url);
});
// Get programs for a specific channel
ipcMain.handle(
'GET_CHANNEL_PROGRAMS',
async (_event, args: { channelId: string }) => {
return this.handleGetChannelPrograms(args.channelId);
}
);
// Get all channels from database
ipcMain.handle('EPG_GET_CHANNELS', async () => {
return this.handleGetAllChannels();
});
ipcMain.handle(
'EPG_GET_CHANNEL_METADATA',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetChannelMetadata(args.channelIds);
}
);
// Get channels by range (pagination)
ipcMain.handle(
'EPG_GET_CHANNELS_BY_RANGE',
async (_event, args: { skip: number; limit: number }) => {
return this.handleGetChannelsByRange(args.skip, args.limit);
}
);
// Force fetch (ignore cache)
ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => {
this.fetchedUrls.delete(url);
return await this.handleFetchEpg([url]);
});
// Clear all EPG data
ipcMain.handle('EPG_CLEAR_ALL', async () => {
await this.clearEpgData();
return { success: true };
});
// Check if EPG data for URLs is fresh (not stale)
ipcMain.handle(
'EPG_CHECK_FRESHNESS',
async (
_event,
args: { urls: string[]; maxAgeHours?: number }
): Promise<{ staleUrls: string[]; freshUrls: string[] }> => {
return this.checkEpgFreshness(
args.urls,
args.maxAgeHours ?? 12
);
}
);
return ipcMain;
}
/**
* Check which EPG URLs have fresh data vs stale/missing data
* @param urls - EPG source URLs to check
* @param maxAgeHours - Maximum age in hours before data is considered stale
*/
private static async checkEpgFreshness(
urls: string[],
maxAgeHours: number
): Promise<{ staleUrls: string[]; freshUrls: string[] }> {
const staleUrls: string[] = [];
const freshUrls: string[] = [];
const cutoffTime = new Date(
Date.now() - maxAgeHours * 60 * 60 * 1000
).toISOString();
try {
const db = await getDatabase();
for (const url of urls) {
if (!url?.trim()) continue;
const result = await db
.select({ updatedAt: schema.epgChannels.updatedAt })
.from(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, url))
.limit(1);
const isFresh =
result.length > 0 &&
result[0].updatedAt &&
result[0].updatedAt >= cutoffTime;
if (isFresh) {
freshUrls.push(url);
this.fetchedUrls.add(url);
} else {
staleUrls.push(url);
}
}
} catch (error) {
console.error(
this.loggerLabel,
'Error checking EPG freshness:',
error
);
return { staleUrls: urls, freshUrls: [] };
}
// Log summary
if (freshUrls.length > 0) {
console.log(
this.loggerLabel,
`EPG fresh (skipping): ${freshUrls.length} source(s)`
);
}
if (staleUrls.length > 0) {
console.log(
this.loggerLabel,
`EPG stale (will fetch): ${staleUrls.length} source(s)`
);
}
return { staleUrls, freshUrls };
}
/**
* Handle EPG fetch from URLs
* Automatically skips URLs with fresh data (less than 12 hours old)
* Processes URLs sequentially to avoid SQLite database locking issues
*/
private static async handleFetchEpg(
urls: string[]
): Promise<{ success: boolean; message?: string; skipped?: string[] }> {
const validUrls = urls.filter((url) => url?.trim());
if (validUrls.length === 0) {
return { success: false, message: 'No valid URLs provided' };
}
// Check which URLs have fresh data and can be skipped
const { staleUrls, freshUrls } = await this.checkEpgFreshness(
validUrls,
12
);
if (staleUrls.length === 0) {
return {
success: true,
message: 'All EPG data is fresh',
skipped: freshUrls,
};
}
// Exclude URLs already processed this session — otherwise the loop sends
// a 'queued' status, then fetchEpgFromUrl silently skips the URL and no
// completion update ever arrives, leaving the UI stuck at "queued".
const urlsToFetch = staleUrls.filter(
(url) => !this.fetchedUrls.has(url)
);
if (urlsToFetch.length === 0) {
console.log(
this.loggerLabel,
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
);
return { success: true, skipped: freshUrls };
}
// Send queued status for URLs that will actually be fetched
urlsToFetch.forEach((url, index) => {
this.sendProgressToRenderer(
url,
'queued',
undefined,
undefined,
index + 1
);
});
// Process URLs sequentially to avoid database locking
const errors: string[] = [];
for (let i = 0; i < urlsToFetch.length; i++) {
const url = urlsToFetch[i];
try {
await this.fetchEpgFromUrl(url);
} catch (error) {
console.error(
this.loggerLabel,
`Error fetching EPG from ${url}:`,
error
);
errors.push(
error instanceof Error ? error.message : String(error)
);
}
}
if (errors.length > 0) {
return {
success: errors.length < urlsToFetch.length, // Partial success if some worked
message: errors.join('; '),
skipped: freshUrls,
};
}
return { success: true, skipped: freshUrls };
}
/**
* Fetch EPG from a single URL using worker thread
* The worker handles both parsing AND database operations
*/
private static async fetchEpgFromUrl(url: string): Promise<void> {
// Skip if already fetched this session
if (this.fetchedUrls.has(url)) {
console.log(
this.loggerLabel,
`Skipping already fetched URL: ${url}`
);
return;
}
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = this.createEpgWorker();
} catch (error) {
console.error(
this.loggerLabel,
'Failed to create worker:',
error
);
reject(error);
return;
}
this.workers.set(url, worker);
// Guards against double-settling and keeps the outer loop moving
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
fn();
};
const timeoutId = setTimeout(() => {
const errorMessage = `EPG fetch timed out after ${
this.FETCH_TIMEOUT_MS / 1000
}s`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
url,
'error',
undefined,
errorMessage
);
worker.terminate();
this.workers.delete(url);
settle(() => reject(new Error(errorMessage)));
}, this.FETCH_TIMEOUT_MS);
worker.on(
'message',
async (message: {
type: string;
error?: string;
url?: string;
stats?: { totalChannels: number; totalPrograms: number };
}) => {
try {
switch (message.type) {
case 'READY':
// Notify renderer that loading started
this.sendProgressToRenderer(url, 'loading', {
totalChannels: 0,
totalPrograms: 0,
});
worker.postMessage({ type: 'FETCH_EPG', url });
break;
case 'EPG_PROGRESS':
if (message.stats) {
// Forward progress to renderer
this.sendProgressToRenderer(
url,
'loading',
message.stats
);
}
break;
case 'EPG_COMPLETE':
console.log(
this.loggerLabel,
`EPG parsing complete for ${url}:`,
message.stats
);
// Notify renderer of completion
this.sendProgressToRenderer(
url,
'complete',
message.stats
);
this.fetchedUrls.add(url);
worker.terminate();
this.workers.delete(url);
settle(() => resolve());
break;
case 'EPG_ERROR':
console.error(
this.loggerLabel,
'Worker error:',
message.error
);
// Notify renderer of error
this.sendProgressToRenderer(
url,
'error',
undefined,
message.error
);
worker.terminate();
this.workers.delete(url);
settle(() =>
reject(
new Error(
message.error || 'Unknown error'
)
)
);
break;
}
} catch (err) {
console.error(
this.loggerLabel,
'Error handling message:',
err
);
this.sendProgressToRenderer(
url,
'error',
undefined,
err instanceof Error ? err.message : String(err)
);
worker.terminate();
this.workers.delete(url);
settle(() => reject(err));
}
}
);
worker.on('error', (error) => {
console.error(this.loggerLabel, 'Worker error event:', error);
this.sendProgressToRenderer(
url,
'error',
undefined,
error.message
);
worker.terminate();
this.workers.delete(url);
settle(() => reject(error));
});
worker.on('exit', (code) => {
// If the worker exits without emitting EPG_COMPLETE/EPG_ERROR,
// settle the promise so the outer sequential loop can advance
// instead of hanging forever.
if (settled) return;
const errorMessage = `Worker exited unexpectedly (code ${code})`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
url,
'error',
undefined,
errorMessage
);
this.workers.delete(url);
settle(() => reject(new Error(errorMessage)));
});
});
}
private static normalizeChannelLookupKeys(channelIds: string[]): string[] {
return Array.from(
new Set(
channelIds
.map((channelId) => channelId.trim())
.filter((channelId) => channelId.length > 0)
)
);
}
private static resolveChannelMetadataCandidate(
channelId: string,
candidates: EpgChannelMetadata[]
): EpgChannelMetadata | null {
const lowerChannelId = channelId.toLowerCase();
const exactIdMatch =
candidates.find((candidate) => candidate.id === channelId) ?? null;
if (exactIdMatch) {
return exactIdMatch;
}
const caseInsensitiveIdMatch =
candidates.find(
(candidate) => candidate.id.toLowerCase() === lowerChannelId
) ?? null;
if (caseInsensitiveIdMatch) {
return caseInsensitiveIdMatch;
}
const exactDisplayNameMatch =
candidates.find(
(candidate) => candidate.displayName === channelId
) ?? null;
if (exactDisplayNameMatch) {
return exactDisplayNameMatch;
}
return (
candidates.find(
(candidate) =>
candidate.displayName.toLowerCase() === lowerChannelId
) ?? null
);
}
/**
* Transform database row to flat EpgProgram interface
*/
private static transformDbRowToEpgProgram(row: {
id: number;
channelId: string;
start: string;
stop: string;
title: string;
description: string | null;
category: string | null;
iconUrl: string | null;
rating: string | null;
episodeNum: string | null;
}) {
return {
start: row.start,
stop: row.stop,
channel: row.channelId,
title: row.title,
desc: row.description,
category: row.category,
iconUrl: row.iconUrl,
rating: row.rating,
episodeNum: row.episodeNum,
};
}
private static isValidEpgProgram(program: EpgProgram): boolean {
return Boolean(
program.start &&
program.stop &&
!Number.isNaN(new Date(program.start).getTime()) &&
!Number.isNaN(new Date(program.stop).getTime())
);
}
/**
* Get programs for a specific channel from database
*/
private static async handleGetChannelPrograms(
channelId: string
): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const trimmedChannelId = channelId.trim();
if (!trimmedChannelId) {
return [];
}
// Try exact channel ID match first
let results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, trimmedChannelId))
.orderBy(schema.epgPrograms.start)
.limit(500);
if (results.length > 0) {
return results
.map(this.transformDbRowToEpgProgram)
.filter(this.isValidEpgProgram);
}
// Some playlists provide the right tvg-id with different casing than
// the XMLTV feed. Resolve the canonical channel row before giving up.
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${trimmedChannelId} COLLATE NOCASE`
)
.limit(1);
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel[0].id))
.orderBy(schema.epgPrograms.start)
.limit(500);
if (results.length > 0) {
return results
.map(this.transformDbRowToEpgProgram)
.filter(this.isValidEpgProgram);
}
}
// Try exact display name match before giving up. Using wildcard LIKE
// here can scan the whole table on the Electron main process.
channel = await db
.select()
.from(schema.epgChannels)
.where(eq(schema.epgChannels.displayName, trimmedChannelId))
.limit(1);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${trimmedChannelId} COLLATE NOCASE`
)
.limit(1);
}
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel[0].id))
.orderBy(schema.epgPrograms.start)
.limit(500);
return results
.map(this.transformDbRowToEpgProgram)
.filter(this.isValidEpgProgram);
}
return [];
} catch (error) {
console.error(
this.loggerLabel,
'Error getting channel programs:',
error
);
return [];
}
}
/**
* Get all channels from database
*/
private static async handleGetAllChannels(): Promise<{
channels: Array<{ id: string; displayName: string }>;
programs: never[];
}> {
try {
const db = await getDatabase();
const channels = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
})
.from(schema.epgChannels)
.orderBy(schema.epgChannels.displayName);
return { channels, programs: [] };
} catch (error) {
console.error(
this.loggerLabel,
'Error getting all channels:',
error
);
return { channels: [], programs: [] };
}
}
/**
* Resolve EPG channel metadata for a batch of lookup keys.
*
* Lookup precedence per key:
* 1. exact channel id
* 2. case-insensitive channel id
* 3. exact display name
* 4. case-insensitive display name
*/
private static async handleGetChannelMetadata(
channelIds: string[]
): Promise<Record<string, EpgChannelMetadata | null>> {
try {
const normalizedChannelIds =
this.normalizeChannelLookupKeys(channelIds);
if (normalizedChannelIds.length === 0) {
return {};
}
const db = await getDatabase();
const lowerKeys = Array.from(
new Set(
normalizedChannelIds.map((channelId) =>
channelId.toLowerCase()
)
)
);
const lowerKeyValues = lowerKeys.map((key) => sql`${key}`);
const candidates = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.where(sql`
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
`);
return Object.fromEntries(
normalizedChannelIds.map((channelId) => [
channelId,
this.resolveChannelMetadataCandidate(channelId, candidates),
])
);
} catch (error) {
console.error(
this.loggerLabel,
'Error getting channel metadata:',
error
);
return {};
}
}
/**
* Get channels by range (for pagination) with their programs
*/
private static async handleGetChannelsByRange(
skip: number,
limit: number
): Promise<
Array<{
id: string;
displayName: string;
iconUrl: string | null;
programs: EpgProgram[];
}>
> {
try {
const db = await getDatabase();
const channels = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.orderBy(schema.epgChannels.displayName)
.offset(skip)
.limit(limit);
// Fetch programs for each channel
const channelsWithPrograms = await Promise.all(
channels.map(async (channel) => {
const programs = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel.id))
.orderBy(schema.epgPrograms.start);
return {
...channel,
programs: programs.map(this.transformDbRowToEpgProgram),
};
})
);
return channelsWithPrograms;
} catch (error) {
console.error(
this.loggerLabel,
'Error getting channels by range:',
error
);
return [];
}
}
/**
* Clear all EPG data using worker thread to avoid blocking main thread
*/
static async clearEpgData(): Promise<void> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = this.createEpgWorker();
} catch (error) {
console.error(
this.loggerLabel,
'Failed to create worker for clear:',
error
);
reject(error);
return;
}
worker.on(
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEAR_EPG' });
} else if (message.type === 'CLEAR_COMPLETE') {
console.log(
this.loggerLabel,
'EPG data cleared via worker'
);
this.fetchedUrls.clear();
// Terminate any running fetch workers
this.workers.forEach((w) => w.terminate());
this.workers.clear();
worker.terminate();
resolve();
} else if (message.type === 'EPG_ERROR') {
console.error(
this.loggerLabel,
'Worker clear error:',
message.error
);
worker.terminate();
reject(new Error(message.error || 'Clear failed'));
}
}
);
worker.on('error', (error) => {
console.error(
this.loggerLabel,
'Worker error during clear:',
error
);
worker.terminate();
reject(error);
});
});
}
}
@@ -0,0 +1,63 @@
import { ExternalPlayerSession } from 'shared-interfaces';
import { ExternalPlayerSessionRegistry } from './external-player-session-registry';
describe('ExternalPlayerSessionRegistry', () => {
let updates: ExternalPlayerSession[];
let registry: ExternalPlayerSessionRegistry;
beforeEach(() => {
updates = [];
registry = new ExternalPlayerSessionRegistry((session) => {
updates.push(session);
});
});
it('creates launching sessions and marks them opened', () => {
const session = registry.beginSession({
player: 'mpv',
title: 'Example',
streamUrl: 'https://example.com/video.m3u8',
});
expect(session.status).toBe('launching');
expect(registry.getActiveSessionId()).toBe(session.id);
const opened = registry.markOpened(session.id);
expect(opened?.status).toBe('opened');
expect(updates.at(-1)?.status).toBe('opened');
});
it('keeps close capability and closes the session explicitly', async () => {
const close = jest.fn();
const session = registry.beginSession({
player: 'vlc',
title: 'Example',
streamUrl: 'https://example.com/video.m3u8',
});
registry.attachCloser(session.id, close);
expect(registry.getSession(session.id)?.canClose).toBe(true);
const closed = await registry.closeSession(session.id);
expect(close).toHaveBeenCalled();
expect(closed?.status).toBe('closed');
expect(closed?.canClose).toBe(false);
expect(registry.getActiveSessionId()).toBeNull();
});
it('marks runtime failures as errors without clearing the active id', () => {
const session = registry.beginSession({
player: 'mpv',
title: 'Example',
streamUrl: 'https://example.com/video.m3u8',
});
const errored = registry.markError(session.id, 'Failed to launch');
expect(errored?.status).toBe('error');
expect(errored?.error).toBe('Failed to launch');
expect(registry.getActiveSessionId()).toBe(session.id);
});
});
@@ -0,0 +1,137 @@
import {
ExternalPlayerName,
ExternalPlayerSession,
PlayerContentInfo,
} from 'shared-interfaces';
interface CreateExternalPlayerSessionOptions {
player: ExternalPlayerName;
title: string;
thumbnail?: string | null;
streamUrl: string;
contentInfo?: PlayerContentInfo;
}
interface UpdateExternalPlayerSessionOptions {
status?: ExternalPlayerSession['status'];
error?: string;
canClose?: boolean;
}
interface ExternalPlayerSessionRuntime {
snapshot: ExternalPlayerSession;
close?: () => Promise<void> | void;
}
export class ExternalPlayerSessionRegistry {
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
private activeSessionId: string | null = null;
constructor(
private readonly onUpdate: (session: ExternalPlayerSession) => void
) {}
beginSession(
options: CreateExternalPlayerSessionOptions
): ExternalPlayerSession {
const startedAt = new Date().toISOString();
const session: ExternalPlayerSession = {
id: crypto.randomUUID(),
player: options.player,
status: 'launching',
title: options.title,
thumbnail: options.thumbnail ?? null,
streamUrl: options.streamUrl,
contentInfo: options.contentInfo,
startedAt,
updatedAt: startedAt,
canClose: false,
};
this.sessions.set(session.id, { snapshot: session });
this.activeSessionId = session.id;
this.onUpdate(session);
return session;
}
getActiveSessionId(): string | null {
return this.activeSessionId;
}
getSession(id: string): ExternalPlayerSession | null {
return this.sessions.get(id)?.snapshot ?? null;
}
attachCloser(
id: string,
close: () => Promise<void> | void
): ExternalPlayerSession | null {
const runtime = this.sessions.get(id);
if (!runtime) {
return null;
}
runtime.close = close;
return this.updateSession(id, { canClose: true });
}
updateSession(
id: string,
options: UpdateExternalPlayerSessionOptions
): ExternalPlayerSession | null {
const runtime = this.sessions.get(id);
if (!runtime) {
return null;
}
const next: ExternalPlayerSession = {
...runtime.snapshot,
...options,
updatedAt: new Date().toISOString(),
};
runtime.snapshot = next;
this.onUpdate(next);
return next;
}
markOpened(id: string): ExternalPlayerSession | null {
return this.updateSession(id, { status: 'opened' });
}
markPlaying(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'playing') {
return current;
}
return this.updateSession(id, { status: 'playing' });
}
markClosed(id: string): ExternalPlayerSession | null {
if (this.activeSessionId === id) {
this.activeSessionId = null;
}
return this.updateSession(id, { status: 'closed', canClose: false });
}
markError(id: string, error: string): ExternalPlayerSession | null {
return this.updateSession(id, {
status: 'error',
error,
canClose: false,
});
}
async closeSession(id: string): Promise<ExternalPlayerSession | null> {
const runtime = this.sessions.get(id);
if (!runtime) {
return null;
}
try {
await runtime.close?.();
} finally {
return this.markClosed(id);
}
}
}
@@ -0,0 +1,172 @@
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('../app', () => ({
__esModule: true,
default: {
mainWindow: null,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
VLC_PLAYER_PATH: 'VLC_PLAYER_PATH',
store: {
get: jest.fn(),
set: jest.fn(),
},
}));
jest.mock('../services/stalker-playback-context.service', () => ({
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
}));
import {
buildExternalPlayerSpawnSpec,
isRunningInFlatpak,
parseVlcRcPlaybackState,
parseVlcRcNumericResponse,
resolveExternalPlayerLaunchContext,
shouldReuseMpvInstance,
shouldUseMpvSocketBridge,
} from './player.events';
function createPathExists(existingPaths: string[]) {
return (candidatePath: string) => existingPaths.includes(candidatePath);
}
describe('player.events Flatpak launch helpers', () => {
it('detects Flatpak only on Linux when /.flatpak-info exists', () => {
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'linux')).toBe(
true
);
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'darwin')).toBe(
false
);
expect(isRunningInFlatpak(createPathExists([]), 'linux')).toBe(false);
});
it('keeps direct Linux player launching outside Flatpak', () => {
const launchContext = resolveExternalPlayerLaunchContext('mpv', undefined, {
platform: 'linux',
isFlatpak: false,
pathExists: createPathExists(['/usr/local/bin/mpv']),
});
const spawnSpec = buildExternalPlayerSpawnSpec(launchContext, [
'--ytdl=no',
'https://example.com/stream.m3u8',
]);
expect(launchContext).toEqual({
mode: 'direct',
playerPath: '/usr/local/bin/mpv',
command: '/usr/local/bin/mpv',
argsPrefix: [],
});
expect(spawnSpec).toEqual({
mode: 'direct',
playerPath: '/usr/local/bin/mpv',
command: '/usr/local/bin/mpv',
args: ['--ytdl=no', 'https://example.com/stream.m3u8'],
});
});
it('builds Flatpak host launches with bare player names by default', () => {
const launchContext = resolveExternalPlayerLaunchContext('vlc', undefined, {
platform: 'linux',
isFlatpak: true,
pathExists: createPathExists(['/usr/bin/vlc']),
});
const spawnSpec = buildExternalPlayerSpawnSpec(launchContext, [
'--extraintf=rc',
'https://example.com/stream.m3u8',
]);
expect(launchContext).toEqual({
mode: 'flatpak-host',
playerPath: 'vlc',
command: 'flatpak-spawn',
argsPrefix: ['--host', '--watch-bus', 'vlc'],
});
expect(spawnSpec.args).toEqual([
'--host',
'--watch-bus',
'vlc',
'--extraintf=rc',
'https://example.com/stream.m3u8',
]);
});
it('passes custom host player paths through Flatpak launching unchanged', () => {
const launchContext = resolveExternalPlayerLaunchContext(
'mpv',
'/opt/mpv/bin/mpv',
{
platform: 'linux',
isFlatpak: true,
}
);
expect(launchContext).toEqual({
mode: 'flatpak-host',
playerPath: '/opt/mpv/bin/mpv',
command: 'flatpak-spawn',
argsPrefix: ['--host', '--watch-bus', '/opt/mpv/bin/mpv'],
});
});
it('disables MPV reuse and socket bridging only in Flatpak', () => {
expect(shouldReuseMpvInstance(true, true)).toBe(false);
expect(shouldUseMpvSocketBridge(true)).toBe(false);
expect(shouldReuseMpvInstance(true, false)).toBe(true);
expect(shouldReuseMpvInstance(false, false)).toBe(false);
expect(shouldUseMpvSocketBridge(false)).toBe(true);
});
it('parses numeric VLC RC responses that include the prompt prefix', () => {
expect(
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
Command Line Interface initialized. Type \`help' for help.
> 20
> `)
).toBe('20');
expect(
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
Command Line Interface initialized. Type \`help' for help.
> 9281
> `)
).toBe('9281');
});
it('returns an empty string for non-numeric VLC RC responses', () => {
expect(
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
Command Line Interface initialized. Type \`help' for help.
> ( state playing )
> `)
).toBe('');
});
it('parses VLC RC playback states from status output', () => {
expect(
parseVlcRcPlaybackState(`VLC media player 3.0.20 Vetinari
Command Line Interface initialized. Type \`help' for help.
> ( audio volume: 196 )
( state playing )
> `)
).toBe('playing');
expect(
parseVlcRcPlaybackState(`VLC media player 3.0.20 Vetinari
Command Line Interface initialized. Type \`help' for help.
> ( state stopped )
> `)
).toBe('stopped');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,361 @@
/**
* This module is responsible on handling all the inter process communications
* between the frontend to the electron backend.
*/
import axios from 'axios';
import { app, dialog, ipcMain, WebContents } from 'electron';
import { parse } from 'iptv-playlist-parser';
import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils';
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import {
AUTO_UPDATE_PLAYLISTS,
PLAYLIST_CANCEL_REFRESH,
PLAYLIST_REFRESH,
PLAYLIST_REFRESH_EVENT,
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from 'shared-interfaces';
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
import type {
PlaylistRefreshWorkerMessage,
PlaylistRefreshWorkerResponseMessage,
} from '../workers/playlist-refresh.worker.types';
export default class PlaylistEvents {
static bootstrapPlaylistEvents(): Electron.IpcMain {
return ipcMain;
}
}
const https = require('https');
type ActivePlaylistRefresh = {
reject: (reason?: unknown) => void;
resolve: (value: Playlist) => void;
sender: WebContents;
worker: Worker;
};
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
/**
* Fetches and parses a playlist from a URL
* @param url - The URL to fetch the playlist from
* @param title - Optional title for the playlist
* @returns Parsed playlist object
*/
async function fetchPlaylistFromUrl(
url: string,
title?: string
): Promise<any> {
const agent = new https.Agent({
rejectUnauthorized: false,
});
const result = await axios.get(url, { httpsAgent: agent });
const parsedPlaylist = parse(result.data);
const extractedName =
url && url.length > 1 ? getFilenameFromUrl(url) : '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
const playlistObject = createPlaylistObject(
title ?? playlistName,
parsedPlaylist,
url,
'URL'
);
return playlistObject;
}
/**
* Reads and parses a playlist from a file path
* @param filePath - The path to the playlist file
* @param title - Title for the playlist
* @returns Parsed playlist object
*/
async function fetchPlaylistFromFile(
filePath: string,
title: string
): Promise<any> {
const fileContent = await readFile(filePath, 'utf-8');
const parsedPlaylist = parse(fileContent);
const playlistObject = createPlaylistObject(
title,
parsedPlaylist,
filePath,
'FILE'
);
return playlistObject;
}
function resolvePlaylistRefreshWorker(): Worker {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'playlist-refresh.worker.js',
developmentWorkerDir: __dirname + '/workers',
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
appPath: app.getAppPath(),
});
return new Worker(pathToFileURL(bootstrap.workerPath), {
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
},
});
}
function emitPlaylistRefreshEvent(
sender: WebContents,
event: PlaylistRefreshEvent
): void {
if (sender.isDestroyed()) {
return;
}
sender.send(PLAYLIST_REFRESH_EVENT, event);
}
function createPlaylistRefreshError(error: {
message: string;
name?: string;
stack?: string;
}): Error {
const workerError = new Error(error.message);
workerError.name = error.name || 'PlaylistRefreshWorkerError';
workerError.stack = error.stack || workerError.stack;
return workerError;
}
ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => {
try {
return await fetchPlaylistFromUrl(url, title);
} catch (error) {
console.error('Error fetching playlist:', error);
throw error;
}
});
ipcMain.handle(
'update-playlist-from-file-path',
async (event, filePath, title) => {
try {
return await fetchPlaylistFromFile(filePath, title);
} catch (error) {
console.error('Error reading playlist from file:', error);
throw error;
}
}
);
ipcMain.handle('open-playlist-from-file', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'Playlists', extensions: ['m3u', 'm3u8', 'pls', 'txt'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (canceled || filePaths.length === 0) {
console.log('User canceled file open dialog.');
return null;
}
const filePath = filePaths[0];
try {
// Extract filename from path and remove extension for a clean title
const filename = basename(filePath);
const title = filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file';
return await fetchPlaylistFromFile(filePath, title);
} catch (error) {
console.error('Error reading or parsing the file:', error);
throw new Error('Failed to process the selected file.');
}
});
ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => {
console.log(`Auto-updating ${playlists.length} playlist(s)...`);
const updatedPlaylists = [];
for (const playlist of playlists) {
try {
let playlistObject;
if (playlist.importDate && playlist.url) {
// Update from URL
console.log(
`Updating playlist "${playlist.title}" from URL: ${playlist.url}`
);
playlistObject = await fetchPlaylistFromUrl(
playlist.url,
playlist.title
);
} else if (playlist.filePath) {
// Update from file path
console.log(
`Updating playlist "${playlist.title}" from file: ${playlist.filePath}`
);
playlistObject = await fetchPlaylistFromFile(
playlist.filePath,
playlist.title
);
} else {
console.warn(
`Skipping playlist "${playlist.title}": no URL or file path found`
);
continue;
}
// Preserve user data when updating playlist
updatedPlaylists.push({
...playlistObject,
_id: playlist._id,
autoRefresh: playlist.autoRefresh,
favorites: playlist.favorites || [], // Preserve favorites
userAgent: playlist.userAgent, // Preserve custom user agent
});
console.log(`Successfully updated playlist "${playlist.title}"`);
} catch (error) {
console.error(
`Failed to update playlist "${playlist.title}":`,
error
);
// Continue with other playlists even if one fails
}
}
console.log(`Auto-update completed: ${updatedPlaylists.length} updated`);
return updatedPlaylists;
});
ipcMain.handle(PLAYLIST_REFRESH, async (event, payload: PlaylistRefreshPayload) => {
const worker = resolvePlaylistRefreshWorker();
return await new Promise<Playlist>((resolve, reject) => {
const cleanup = async (): Promise<void> => {
activePlaylistRefreshes.delete(payload.operationId);
worker.removeAllListeners();
await worker.terminate().catch(() => undefined);
};
activePlaylistRefreshes.set(payload.operationId, {
worker,
sender: event.sender,
resolve,
reject,
});
worker.on('message', async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
if (message.type === 'ready') {
worker.postMessage({
type: 'request',
payload,
});
return;
}
if (message.type === 'event') {
emitPlaylistRefreshEvent(event.sender, message.event);
return;
}
await cleanup();
const response = message as PlaylistRefreshWorkerResponseMessage<Playlist>;
if (response.success && response.result) {
resolve(response.result);
return;
}
reject(
createPlaylistRefreshError(
response.error ?? {
message: 'Playlist refresh worker request failed',
}
)
);
});
worker.on('error', async (error) => {
await cleanup();
reject(error);
});
worker.on('exit', async (code) => {
if (!activePlaylistRefreshes.has(payload.operationId)) {
return;
}
await cleanup();
reject(
new Error(
code === 0
? 'Playlist refresh worker exited unexpectedly'
: `Playlist refresh worker stopped with exit code ${code}`
)
);
});
});
});
ipcMain.handle(
PLAYLIST_CANCEL_REFRESH,
async (_event, operationId: string): Promise<{ success: boolean }> => {
const activeRefresh = activePlaylistRefreshes.get(operationId);
if (!activeRefresh) {
return { success: false };
}
activeRefresh.worker.postMessage({
type: 'cancel',
operationId,
});
return { success: true };
}
);
ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => {
try {
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath,
filters: filters || [
{ name: 'All Files', extensions: ['*'] },
],
});
if (canceled || !filePath) {
return null;
}
return filePath;
} catch (error) {
console.error('Error showing save dialog:', error);
throw error;
}
});
ipcMain.handle('write-file', async (event, filePath, content) => {
try {
await writeFile(filePath, content, 'utf-8');
return { success: true };
} catch (error) {
console.error('Error writing file:', error);
throw error;
}
});
@@ -0,0 +1,90 @@
jest.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => [],
},
}));
jest.mock('../../environments/environment', () => ({
environment: {
production: false,
},
}));
import { sanitizePortalDebugEvent } from './portal-debug.events';
describe('sanitizePortalDebugEvent', () => {
it('turns Error objects into plain serializable data', () => {
const error = new Error('boom');
(error as Error & { code?: string }).code = 'ECONNREFUSED';
const event = sanitizePortalDebugEvent({
requestId: 'req-1',
provider: 'xtream',
operation: 'get_account_info',
transport: 'electron-main',
startedAt: new Date().toISOString(),
durationMs: 12,
status: 'error',
request: { url: 'http://localhost:3211/player_api.php' },
error,
});
expect(event.error).toEqual(
expect.objectContaining({
name: 'Error',
message: 'boom',
code: 'ECONNREFUSED',
})
);
});
it('replaces circular references with markers', () => {
const request: Record<string, unknown> = {
method: 'GET',
};
request['self'] = request;
const event = sanitizePortalDebugEvent({
requestId: 'req-2',
provider: 'stalker',
operation: 'get_profile',
transport: 'electron-main',
startedAt: new Date().toISOString(),
durationMs: 4,
status: 'success',
request,
response: { ok: true },
});
expect(event.request).toEqual({
method: 'GET',
self: '[Circular]',
});
});
it('strips functions from nested debug payloads', () => {
const event = sanitizePortalDebugEvent({
requestId: 'req-3',
provider: 'xtream',
operation: 'test',
transport: 'electron-main',
startedAt: new Date().toISOString(),
durationMs: 1,
status: 'success',
request: {
headers: {
Accept: 'application/json',
toJSON: () => 'nope',
},
},
response: undefined,
});
expect(event.request).toEqual({
headers: {
Accept: 'application/json',
toJSON: undefined,
},
});
});
});
@@ -0,0 +1,128 @@
import { BrowserWindow } from 'electron';
import { PORTAL_DEBUG_EVENT, PortalDebugEvent } from 'shared-interfaces';
import { environment } from '../../environments/environment';
const MAX_DEBUG_DEPTH = 6;
function sanitizePortalDebugValue(
value: unknown,
seen = new WeakSet<object>(),
depth = 0
): unknown {
if (
value == null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (depth >= MAX_DEBUG_DEPTH) {
return '[MaxDepth]';
}
if (value instanceof Error) {
const baseError = {
name: value.name,
message: value.message,
stack: value.stack,
} as Record<string, unknown>;
for (const [key, entry] of Object.entries(
value as unknown as Record<string, unknown>
)) {
baseError[key] = sanitizePortalDebugValue(
entry,
seen,
depth + 1
);
}
return baseError;
}
if (value instanceof Date) {
return value.toISOString();
}
if (value instanceof URL) {
return value.toString();
}
if (Array.isArray(value)) {
return value.map((entry) =>
sanitizePortalDebugValue(entry, seen, depth + 1)
);
}
if (value instanceof Map) {
return Object.fromEntries(
[...value.entries()].map(([key, entry]) => [
String(key),
sanitizePortalDebugValue(entry, seen, depth + 1),
])
);
}
if (value instanceof Set) {
return [...value].map((entry) =>
sanitizePortalDebugValue(entry, seen, depth + 1)
);
}
if (typeof value === 'object') {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
const entries = Object.entries(value as Record<string, unknown>).map(
([key, entry]) => [
key,
sanitizePortalDebugValue(entry, seen, depth + 1),
]
);
seen.delete(value);
return Object.fromEntries(entries);
}
return String(value);
}
export function sanitizePortalDebugEvent(
event: PortalDebugEvent
): PortalDebugEvent {
return {
...event,
request: sanitizePortalDebugValue(event.request),
response: sanitizePortalDebugValue(event.response),
error: sanitizePortalDebugValue(event.error),
};
}
export function emitPortalDebugEvent(event: PortalDebugEvent): void {
if (environment.production) {
return;
}
const serializedEvent = sanitizePortalDebugEvent(event);
const windows = BrowserWindow.getAllWindows();
for (const window of windows) {
window.webContents.send(PORTAL_DEBUG_EVENT, serializedEvent);
}
}
@@ -0,0 +1,311 @@
import { BrowserWindow, ipcMain } from 'electron';
import * as http from 'http';
import { httpServer } from '../server/http-server';
import { store } from '../services/store.service';
interface RemoteControlCommand {
type:
| 'channel-select-number'
| 'volume-up'
| 'volume-down'
| 'volume-toggle-mute';
number?: number;
}
interface RemoteControlStatus {
portal: 'm3u' | 'xtream' | 'stalker' | 'unknown';
isLiveView: boolean;
channelName?: string;
channelNumber?: number;
epgTitle?: string;
epgStart?: string;
epgEnd?: string;
supportsVolume?: boolean;
volume?: number;
muted?: boolean;
updatedAt?: string;
}
class RemoteControlEvents {
private remoteControlStatus: RemoteControlStatus = {
portal: 'unknown',
isLiveView: false,
supportsVolume: false,
updatedAt: new Date().toISOString(),
};
/**
* Bootstrap remote control events
*/
bootstrapRemoteControlEvents(): void {
// Register HTTP API endpoints
httpServer.registerRemoteControlHandler(
'/api/remote-control/channel/up',
this.handleChannelUp.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/channel/down',
this.handleChannelDown.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/channel/select-number',
this.handleChannelSelectNumber.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/volume/up',
this.handleVolumeUp.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/volume/down',
this.handleVolumeDown.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/volume/toggle-mute',
this.handleToggleMute.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/status',
this.handleGetStatus.bind(this)
);
// Start HTTP server if remote control is enabled in settings
const remoteControlEnabled = store.get('remoteControl', false);
const remoteControlPort = store.get('remoteControlPort', 8765);
if (remoteControlEnabled) {
httpServer.start(remoteControlPort);
}
// Register IPC handlers (for when the main app wants to use remote control)
ipcMain.handle('REMOTE_CONTROL_CHANNEL_UP', () => {
return this.changeChannelUp();
});
ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => {
return this.changeChannelDown();
});
ipcMain.on(
'REMOTE_CONTROL_STATUS_UPDATE',
(_event, status: Partial<RemoteControlStatus>) => {
this.remoteControlStatus = {
...this.remoteControlStatus,
...status,
updatedAt: new Date().toISOString(),
};
}
);
}
/**
* HTTP handler for channel up
*/
private handleChannelUp(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.changeChannelUp();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
/**
* HTTP handler for channel down
*/
private handleChannelDown(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.changeChannelDown();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
/**
* HTTP handler for selecting channel by number
*/
private handleChannelSelectNumber(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.readJsonBody(req, res, (body) => {
const value = Number(body?.number);
if (!Number.isFinite(value) || value < 1) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid channel number' }));
return;
}
this.sendRemoteCommandToRenderer({
type: 'channel-select-number',
number: Math.floor(value),
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
}
private handleVolumeUp(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.sendRemoteCommandToRenderer({ type: 'volume-up' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
private handleVolumeDown(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.sendRemoteCommandToRenderer({ type: 'volume-down' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
private handleToggleMute(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.sendRemoteCommandToRenderer({ type: 'volume-toggle-mute' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
private handleGetStatus(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(this.remoteControlStatus));
}
/**
* Change to the next channel
*/
private changeChannelUp(): void {
console.log('Channel UP requested');
this.sendChannelChangeToRenderer('up');
}
/**
* Change to the previous channel
*/
private changeChannelDown(): void {
console.log('Channel DOWN requested');
this.sendChannelChangeToRenderer('down');
}
/**
* Send channel change message to the renderer process
*/
private sendChannelChangeToRenderer(direction: 'up' | 'down'): void {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('CHANNEL_CHANGE', { direction });
console.log(`Sent CHANNEL_CHANGE ${direction} to renderer`);
} else {
console.warn('No browser windows found to send channel change');
}
}
private sendRemoteCommandToRenderer(command: RemoteControlCommand): void {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('REMOTE_CONTROL_COMMAND', command);
console.log(
`Sent REMOTE_CONTROL_COMMAND ${command.type} to renderer`
);
} else {
console.warn('No browser windows found to send remote command');
}
}
private readJsonBody(
req: http.IncomingMessage,
res: http.ServerResponse,
callback: (body: any) => void
): void {
let data = '';
let totalSize = 0;
let payloadTooLarge = false;
const MAX_BODY_SIZE = 10 * 1024; // 10KB
req.on('data', (chunk: Buffer) => {
if (payloadTooLarge) return;
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
payloadTooLarge = true;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
data += chunk.toString();
});
req.on('end', () => {
if (payloadTooLarge) return;
if (!data) {
callback({});
return;
}
try {
callback(JSON.parse(data));
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON payload' }));
}
});
}
}
export default new RemoteControlEvents();
@@ -0,0 +1,38 @@
import { ipcMain } from 'electron';
import {
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import { httpServer } from '../server/http-server';
export default class SettingsEvents {
static bootstrapSettingsEvents(): Electron.IpcMain {
return ipcMain;
}
}
ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
console.log('Received SETTINGS_UPDATE with data:', arg);
// Only set values that are defined
if (arg.mpvReuseInstance !== undefined) {
store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance);
}
// Handle remote control settings
if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) {
const enabled = arg.remoteControl ?? store.get('remoteControl', false);
const port = arg.remoteControlPort ?? store.get('remoteControlPort', 8765);
// Save to store
if (arg.remoteControl !== undefined) {
store.set('remoteControl', enabled);
}
if (arg.remoteControlPort !== undefined) {
store.set('remoteControlPort', port);
}
// Update HTTP server
httpServer.updateSettings(enabled, port);
}
});
@@ -0,0 +1,39 @@
import { ipcMain, session } from 'electron';
export default class SharedEvents {
static bootstrapSharedEvents(): Electron.IpcMain {
return ipcMain;
}
}
ipcMain.handle('set-user-agent', (event, userAgent, referer) => {
setUserAgent(userAgent, referer); // TODO: test if defaults needed
return true;
});
/**
* Sets the user agent header for all http requests
* @param userAgent user agent to use
* @param referer referer to use
*/
export function setUserAgent(userAgent: string, referer?: string): void {
if (userAgent === undefined || userAgent === null || userAgent === '') {
userAgent = this.defaultUserAgent;
}
// Remove trailing slash from referer if it exists
let originURL: string;
if (referer?.endsWith('/')) {
originURL = referer.slice(0, -1);
}
session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders['User-Agent'] = userAgent;
details.requestHeaders['Referer'] = referer as string;
details.requestHeaders['Origin'] = originURL as string;
callback({ requestHeaders: details.requestHeaders });
}
);
console.log(`Success: Set "${userAgent}" as user agent header`);
}
@@ -0,0 +1,73 @@
/**
* This module is responsible on handling all the setup events that is submitted by squirrel.
*/
import { app } from 'electron';
import { spawn } from 'child_process';
import { resolve, join, basename } from 'path';
import { environment } from '../../environments/environment';
export default class SquirrelEvents {
private static isAppFirstRun = false;
// app paths
private static appFolder = resolve(process.execPath, '..');
private static appRootFolder = resolve(SquirrelEvents.appFolder, '..');
private static updateExe = resolve(
join(SquirrelEvents.appRootFolder, 'Update.exe')
);
private static exeName = resolve(
join(
SquirrelEvents.appRootFolder,
'app-' + environment.version,
basename(process.execPath)
)
);
static handleEvents(): boolean {
if (process.argv.length === 1 || process.platform !== 'win32') {
return false;
}
switch (process.argv[1]) {
case '--squirrel-install':
case '--squirrel-updated':
// Install desktop and start menu shortcuts
SquirrelEvents.update(['--createShortcut', SquirrelEvents.exeName]);
return true;
case '--squirrel-uninstall':
// Remove desktop and start menu shortcuts
SquirrelEvents.update(['--removeShortcut', SquirrelEvents.exeName]);
return true;
case '--squirrel-obsolete':
app.quit();
return true;
case '--squirrel-firstrun':
// Check if it the first run of the software
SquirrelEvents.isAppFirstRun = true;
return false;
}
return false;
}
static isFirstRun(): boolean {
return SquirrelEvents.isAppFirstRun;
}
private static update(args: Array<string>) {
try {
spawn(SquirrelEvents.updateExe, args, { detached: true }).on(
'close',
() => setTimeout(app.quit, 1000)
);
} catch (error) {
setTimeout(app.quit, 1000);
}
}
}
@@ -0,0 +1,263 @@
/**
* This module handles all Stalker portal related IPC communications
* between the frontend and the electron backend.
*/
import axios, { AxiosRequestConfig } from 'axios';
import { createHash } from 'crypto';
import { ipcMain } from 'electron';
import { PortalDebugEvent, STALKER_REQUEST } from 'shared-interfaces';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
import { emitPortalDebugEvent } from './portal-debug.events';
const LEGACY_DEFAULT_SERIAL = 'BEDACD4569BAF';
function deriveStalkerIdentity(
macAddress: string,
providedSerial?: string
): { serialNumber: string; cfduid: string } {
const normalizedMac = String(macAddress ?? '').trim().toUpperCase();
const md5 = createHash('md5').update(normalizedMac).digest('hex');
const derivedSerial = md5.slice(0, 13).toUpperCase();
const normalizedProvided = String(providedSerial ?? '')
.trim()
.toUpperCase();
const useProvidedSerial =
normalizedProvided.length > 0 &&
normalizedProvided !== LEGACY_DEFAULT_SERIAL;
const serialNumber = useProvidedSerial ? normalizedProvided : derivedSerial;
// Keep serial and __cfduid coherent: serial as prefix + stable MAC hash tail.
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
const cfduid = `${serialPrefix}${md5.slice(serialPrefix.length)}`.slice(
0,
32
);
return { serialNumber, cfduid };
}
export default class StalkerEvents {
static bootstrapStalkerEvents(): Electron.IpcMain {
return ipcMain;
}
}
/**
* Handle Stalker API requests with MAC address cookie and optional Bearer token
*/
ipcMain.handle(
STALKER_REQUEST,
async (
event,
payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
}
) => {
const startedAt = Date.now();
let debugRequest: Record<string, unknown> | undefined;
try {
const { url, macAddress, params, token, serialNumber, requestId } =
payload;
const identity = deriveStalkerIdentity(macAddress, serialNumber);
const effectiveSerialNumber = identity.serialNumber;
const requestParams = { ...params };
// Some providers validate sn/metrics strongly in get_profile.
if (
requestParams.type === 'stb' &&
requestParams.action === 'get_profile'
) {
requestParams.sn = effectiveSerialNumber;
if (typeof requestParams.metrics === 'string') {
try {
const parsedMetrics = JSON.parse(requestParams.metrics);
requestParams.metrics = JSON.stringify({
...(parsedMetrics ?? {}),
sn: effectiveSerialNumber,
});
} catch {
// Keep original metrics payload when malformed.
}
}
}
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
// to preserve forward slashes, matching stalker-to-m3u implementation
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
// Don't encode cmd - it's already a path like /media/12345.mpg
// Encoding would break the path format expected by the server
queryParts.push(`${key}=${String(value)}`);
} else {
// Use encodeURIComponent for other params
queryParts.push(
`${key}=${encodeURIComponent(String(value))}`
);
}
});
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!requestParams['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
// Build final URL with manually constructed query string
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
// Build cookie string matching the working curl example format
// Format: mac=XX:XX:XX:XX:XX:XX; stb_lang=de_DE; timezone=Europe/Berlin; __cfduid=...
// The __cfduid cookie uses the serial number lowercase + random suffix
const cookieString = `mac=${macAddress}; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin; __cfduid=${identity.cfduid}`;
// Build headers - using MAG250 User-Agent that stalker-to-m3u uses
const headers: Record<string, string> = {
Cookie: cookieString,
// Use MAG250 User-Agent matching stalker-to-m3u implementation
'User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
'X-User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
Accept: '*/*',
Connection: 'keep-alive',
'Accept-Language': 'en-US,en;q=0.9',
};
// Add SN (serial number) header if provided - required by some portals
if (effectiveSerialNumber) {
headers['SN'] = effectiveSerialNumber;
}
// Add Authorization header if token is provided
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Determine timeout based on action type
// create_link requests can take longer as server generates stream URL
const isCreateLink = requestParams.action === 'create_link';
const requestTimeout = isCreateLink ? 30000 : 15000;
// Configure axios request
const config: AxiosRequestConfig = {
method: 'GET',
url: fullUrl,
headers,
timeout: requestTimeout,
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
};
debugRequest = {
method: config.method ?? 'GET',
url: fullUrl,
headers,
timeout: requestTimeout,
params: requestParams,
};
const response = await axios(config);
// Check if response is successful
if (response.status >= 400) {
console.error(
'[StalkerEvents] HTTP Error:',
response.status,
response.statusText
);
throw {
message: `HTTP Error: ${response.statusText}`,
status: response.status,
};
}
// Return the response data
if (
params.action === 'create_link' &&
response.data?.js?.cmd &&
typeof response.data.js.cmd === 'string'
) {
rememberStalkerPlaybackContext({
streamUrl: response.data.js.cmd,
portalUrl: url,
macAddress,
serialNumber: effectiveSerialNumber,
token,
});
}
if (requestId) {
const debugEvent: PortalDebugEvent = {
requestId,
provider: 'stalker',
operation: params.action ?? 'unknown',
transport: 'electron-main',
startedAt: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
status: 'success',
request: debugRequest,
response: response.data,
};
emitPortalDebugEvent(debugEvent);
}
return response.data;
} catch (error) {
if (payload.requestId) {
const debugEvent: PortalDebugEvent = {
requestId: payload.requestId,
provider: 'stalker',
operation: payload.params?.action ?? 'unknown',
transport: 'electron-main',
startedAt: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
status: 'error',
request: debugRequest ?? {
method: 'GET',
url: payload.url,
params: payload.params,
},
error,
};
emitPortalDebugEvent(debugEvent);
}
console.error('[StalkerEvents] Request error:', error);
// Format error response
if (axios.isAxiosError(error)) {
const errorResponse = {
type: 'ERROR',
message:
error.response?.data?.message ||
error.message ||
'Failed to fetch data from Stalker portal',
status: error.response?.status || 500,
};
throw errorResponse;
} else if (
error &&
typeof error === 'object' &&
'message' in error
) {
throw error;
} else {
throw {
type: 'ERROR',
message: 'An unknown error occurred',
status: 500,
};
}
}
}
);
@@ -0,0 +1,69 @@
import { app, autoUpdater, dialog, MessageBoxOptions } from 'electron';
import { platform, arch } from 'os';
import { updateServerUrl } from '../constants';
import App from '../app';
export default class UpdateEvents {
// initialize auto update service - most be invoked only in production
static initAutoUpdateService() {
const platform_arch =
platform() === 'win32' ? platform() : platform() + '_' + arch();
const version = app.getVersion();
const feed: Electron.FeedURLOptions = {
url: `${updateServerUrl}/update/${platform_arch}/${version}`,
};
if (!App.isDevelopmentMode()) {
console.log('Initializing auto update service...\n');
autoUpdater.setFeedURL(feed);
UpdateEvents.checkForUpdates();
}
}
// check for updates - most be invoked after initAutoUpdateService() and only in production
static checkForUpdates() {
if (!App.isDevelopmentMode() && autoUpdater.getFeedURL() !== '') {
autoUpdater.checkForUpdates();
}
}
}
autoUpdater.on(
'update-downloaded',
(event, releaseNotes, releaseName, releaseDate) => {
const dialogOpts: MessageBoxOptions = {
type: 'info' as const,
buttons: ['Restart', 'Later'],
title: 'Application Update',
message: process.platform === 'win32' ? releaseNotes : releaseName,
detail:
'A new version has been downloaded. Restart the application to apply the updates.',
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
if (returnValue.response === 0) autoUpdater.quitAndInstall();
});
}
);
autoUpdater.on('checking-for-update', () => {
console.log('Checking for updates...\n');
});
autoUpdater.on('update-available', () => {
console.log('New update available!\n');
});
autoUpdater.on('update-not-available', () => {
console.log('Up to date!\n');
});
autoUpdater.on('before-quit-for-update', () => {
console.log('Application update is about to begin...\n');
});
autoUpdater.on('error', (message) => {
console.error('There was a problem updating the application');
console.error(message, '\n');
});
@@ -0,0 +1,168 @@
import { XTREAM_CANCEL_SESSION } from 'shared-interfaces';
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
jest.mock('./portal-debug.events', () => ({
emitPortalDebugEvent: jest.fn(),
}));
describe('XtreamEvents session cancellation', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(async () => {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
await import('./xtream.events');
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
const pendingRequest = createDeferred<{ status: number; data: unknown }>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
let abortSignal: AbortSignal | undefined;
expect(requestHandler).toBeDefined();
expect(cancelHandler).toBeDefined();
axiosMock.mockImplementation((config: { signal?: AbortSignal }) => {
abortSignal = config.signal;
return pendingRequest.promise;
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === cancelError
);
const requestPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-1',
suppressErrorLog: true,
}) as Promise<unknown>;
expect(abortSignal?.aborted).toBe(false);
const cancelResult = (await cancelHandler?.(
{},
'session-1'
)) as { success: boolean; cancelled: number };
expect(cancelResult).toEqual({ success: true, cancelled: 1 });
expect(abortSignal?.aborted).toBe(true);
pendingRequest.reject(cancelError);
await expect(requestPromise).rejects.toMatchObject({
name: 'AbortError',
status: 499,
});
});
it('counts every matching in-flight request for the same session', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
const firstRequest = createDeferred<{ status: number; data: unknown }>();
const secondRequest = createDeferred<{ status: number; data: unknown }>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
const abortSignals: AbortSignal[] = [];
expect(requestHandler).toBeDefined();
expect(cancelHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { signal?: AbortSignal }) => {
if (config.signal) {
abortSignals.push(config.signal);
}
return firstRequest.promise;
})
.mockImplementationOnce((config: { signal?: AbortSignal }) => {
if (config.signal) {
abortSignals.push(config.signal);
}
return secondRequest.promise;
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === cancelError
);
const firstPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}) as Promise<unknown>;
const secondPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_vod_streams',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}) as Promise<unknown>;
const cancelResult = (await cancelHandler?.(
{},
'session-2'
)) as { success: boolean; cancelled: number };
expect(cancelResult).toEqual({ success: true, cancelled: 2 });
expect(abortSignals).toHaveLength(2);
expect(abortSignals.every((signal) => signal.aborted)).toBe(true);
firstRequest.reject(cancelError);
secondRequest.reject(cancelError);
await expect(firstPromise).rejects.toMatchObject({ name: 'AbortError' });
await expect(secondPromise).rejects.toMatchObject({ name: 'AbortError' });
});
});
@@ -0,0 +1,290 @@
/**
* This module handles all Xtream Codes API related IPC communications
* between the frontend and the electron backend.
*/
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { PortalDebugEvent, XTREAM_CANCEL_SESSION } from 'shared-interfaces';
import { emitPortalDebugEvent } from './portal-debug.events';
export default class XtreamEvents {
static bootstrapXtreamEvents(): Electron.IpcMain {
return ipcMain;
}
}
function formatXtreamError(error: unknown, requestUrl: string, action?: string) {
const parsedUrl = new URL(requestUrl);
const base = {
action,
host: parsedUrl.host,
pathname: parsedUrl.pathname,
};
if (axios.isAxiosError(error)) {
return {
...base,
type: 'AxiosError',
code: error.code,
status: error.response?.status,
message: error.message,
syscall: (error as NodeJS.ErrnoException).syscall,
hostname: (error as any).hostname,
};
}
if (error && typeof error === 'object') {
const errObj = error as Record<string, unknown>;
return {
...base,
type: 'ErrorObject',
status: errObj.status,
message: errObj.message,
};
}
return {
...base,
type: 'UnknownError',
message: String(error),
};
}
/**
* Handle Xtream Codes API requests
*/
ipcMain.handle(
'XTREAM_REQUEST',
async (
event,
payload: {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}
) => {
const startedAt = Date.now();
let activeRequestKey: string | null = null;
try {
const { url, params, requestId, sessionId } = payload;
// Build URL with query parameters
// Xtream API endpoint is always at /player_api.php
const apiUrl = new URL(`${url}/player_api.php`);
Object.entries(params).forEach(([key, value]) => {
apiUrl.searchParams.append(key, value);
});
const controller = new AbortController();
if (requestId || sessionId) {
activeRequestKey = requestId ?? crypto.randomUUID();
activeXtreamRequests.set(activeRequestKey, {
controller,
sessionId,
});
}
// Configure axios request
const config: AxiosRequestConfig = {
method: 'GET',
url: apiUrl.toString(),
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'application/json',
},
timeout: 30000, // 30 seconds timeout for Xtream API
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
signal: controller.signal,
};
const response = await axios(config);
// Check if response is successful
if (response.status >= 400) {
throw {
message: `HTTP Error: ${response.statusText}`,
status: response.status,
};
}
if (requestId) {
const debugEvent: PortalDebugEvent = {
requestId,
provider: 'xtream',
operation: params.action ?? 'unknown',
transport: 'electron-main',
startedAt: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
status: 'success',
request: {
method: config.method ?? 'GET',
url: apiUrl.toString(),
headers: config.headers,
timeout: config.timeout,
params,
},
response: response.data,
};
emitPortalDebugEvent(debugEvent);
}
// Xtream API returns JSON data
return {
payload: response.data,
action: params.action,
};
} catch (error) {
const requestId = payload.requestId;
if (requestId) {
const apiUrl = new URL(`${payload.url}/player_api.php`);
Object.entries(payload.params ?? {}).forEach(([key, value]) => {
apiUrl.searchParams.append(key, value);
});
const debugEvent: PortalDebugEvent = {
requestId,
provider: 'xtream',
operation: payload.params?.action ?? 'unknown',
transport: 'electron-main',
startedAt: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
status: 'error',
request: {
method: 'GET',
url: apiUrl.toString(),
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'application/json',
},
timeout: 30000,
params: payload.params,
},
error,
};
emitPortalDebugEvent(debugEvent);
}
if (!payload.suppressErrorLog) {
console.error(
'[XTREAM_REQUEST] Failed',
formatXtreamError(error, payload.url, payload.params?.action)
);
}
// Format error response
if (axios.isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
throw {
type: 'ERROR',
name: 'AbortError',
message: 'Xtream request cancelled',
status: 499,
};
}
const errorResponse = {
type: 'ERROR',
message:
error.response?.data?.message ||
error.message ||
'Failed to fetch data from Xtream server',
status: error.response?.status || 500,
};
throw errorResponse;
} else if (
error &&
typeof error === 'object' &&
'message' in error
) {
throw error;
} else {
throw {
type: 'ERROR',
message: 'An unknown error occurred',
status: 500,
};
}
} finally {
if (activeRequestKey) {
activeXtreamRequests.delete(activeRequestKey);
}
}
}
);
ipcMain.handle(
XTREAM_CANCEL_SESSION,
async (_event, sessionId: string): Promise<{ success: boolean; cancelled: number }> => {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
let cancelled = 0;
for (const activeRequest of activeXtreamRequests.values()) {
if (activeRequest.sessionId !== sessionId) {
continue;
}
activeRequest.controller.abort();
cancelled += 1;
}
return {
success: cancelled > 0,
cancelled,
};
}
);
type ActiveXtreamRequest = {
controller: AbortController;
sessionId?: string;
};
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
ipcMain.handle(
'XTREAM_PROBE_URL',
async (
_event,
payload: {
url: string;
method?: 'GET' | 'HEAD';
}
) => {
const config: AxiosRequestConfig = {
method: payload.method ?? 'HEAD',
url: payload.url,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
timeout: 10000,
maxRedirects: 5,
validateStatus: () => true,
};
try {
const response = await axios(config);
return {
status: response.status,
url: payload.url,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
};
}
return {
status: 0,
url: payload.url,
};
}
}
);
@@ -0,0 +1,3 @@
{
"$schema": "../../../../../node_modules/nx-electron/src/validation/maker.schema.json"
}
@@ -0,0 +1,208 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
/**
* HTTP server for serving the remote control web app and providing REST API endpoints
*/
export class HttpServer {
private server: http.Server | null = null;
private port = 8765;
private isEnabled = false;
private distPath: string | null = null;
private remoteControlHandlers: Map<
string,
(req: http.IncomingMessage, res: http.ServerResponse) => void
> = new Map();
/**
* Get the path to the remote-control-web static files.
* Lazily computed to avoid calling Electron APIs before app is ready.
*/
private getDistPath(): string {
if (this.distPath) {
return this.distPath;
}
// Path to the built remote-control-web app
// In development: use workspace root
// In production: use app path
const appPath = app.getAppPath();
const isDev = !app.isPackaged;
if (isDev) {
// Development mode - use workspace root
this.distPath = path.join(
process.cwd(),
'dist',
'apps',
'remote-control-web',
'browser'
);
} else {
// Production mode - files are bundled with the app
// electron-builder copies remote-control-web/**/* directly to app root
this.distPath = path.join(
appPath,
'remote-control-web',
'browser'
);
}
console.log('[HTTP Server] Serving from:', this.distPath);
return this.distPath;
}
/**
* Start the HTTP server
*/
start(port?: number): void {
if (port) {
this.port = port;
}
if (this.server) {
console.log('HTTP server is already running');
return;
}
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.listen(this.port, () => {
console.log(`HTTP server listening on port ${this.port}`);
console.log(
`Remote control available at: http://localhost:${this.port}`
);
});
this.isEnabled = true;
}
/**
* Stop the HTTP server
*/
stop(): void {
if (!this.server) {
return;
}
this.server.close(() => {
console.log('HTTP server stopped');
});
this.server = null;
this.isEnabled = false;
}
/**
* Update server settings
*/
updateSettings(enabled: boolean, port: number): void {
const needsRestart = this.isEnabled && enabled && this.port !== port;
if (!enabled && this.isEnabled) {
this.stop();
} else if (enabled && !this.isEnabled) {
this.start(port);
} else if (needsRestart) {
this.stop();
this.start(port);
}
}
/**
* Register a handler for remote control API endpoints
*/
registerRemoteControlHandler(
path: string,
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
): void {
this.remoteControlHandlers.set(path, handler);
}
/**
* Handle incoming HTTP requests
*/
private handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
const url = req.url || '/';
// Handle API requests
if (url.startsWith('/api/remote-control/')) {
const handler = this.remoteControlHandlers.get(url);
if (handler) {
handler(req, res);
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Endpoint not found' }));
return;
}
// Serve static files from the remote-control-web app
this.serveStaticFile(url, res);
}
/**
* Serve static files
*/
private serveStaticFile(url: string, res: http.ServerResponse): void {
// Default to index.html for root path
let filePath = url === '/' ? '/index.html' : url;
// Security: prevent directory traversal
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '');
const fullPath = path.join(this.getDistPath(), filePath);
fs.readFile(fullPath, (err, data) => {
if (err) {
// If file not found, try serving index.html (for Angular routing)
if (err.code === 'ENOENT' && filePath !== '/index.html') {
this.serveStaticFile('/', res);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
return;
}
// Determine content type
const contentType = this.getContentType(fullPath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
}
/**
* Get content type based on file extension
*/
private getContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const contentTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
return contentTypes[ext] || 'application/octet-stream';
}
}
export const httpServer = new HttpServer();
@@ -0,0 +1,293 @@
import type { DatabaseWorkerClient as DatabaseWorkerClientType } from './database-worker-client';
const mockWorkerInstances: any[] = [];
const resolveWorkerRuntimeBootstrap = jest.fn();
jest.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/mock/app.asar',
},
}));
jest.mock('worker_threads', () => {
const { EventEmitter } = require('events');
class MockWorker extends EventEmitter {
postMessage = jest.fn();
terminate = jest.fn().mockResolvedValue(0);
}
return {
Worker: jest.fn().mockImplementation(() => {
const worker = new MockWorker();
mockWorkerInstances.push(worker);
return worker;
}),
};
});
jest.mock('../workers/worker-runtime-paths', () => ({
resolveWorkerRuntimeBootstrap: (...args: unknown[]) =>
resolveWorkerRuntimeBootstrap(...args),
}));
describe('DatabaseWorkerClient', () => {
let DatabaseWorkerClient: typeof DatabaseWorkerClientType;
let clients: DatabaseWorkerClientType[];
beforeEach(async () => {
jest.resetModules();
mockWorkerInstances.length = 0;
resolveWorkerRuntimeBootstrap.mockReset();
resolveWorkerRuntimeBootstrap.mockReturnValue({
workerPath: '/mock/workers/database.worker.js',
workerPathCandidates: ['/mock/workers/database.worker.js'],
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
});
clients = [];
({ DatabaseWorkerClient } = await import('./database-worker-client'));
});
afterEach(async () => {
await Promise.all(
clients.map((client) => client.shutdown().catch(() => undefined))
);
});
function createClient(): DatabaseWorkerClientType {
const client = new DatabaseWorkerClient();
clients.push(client);
return client;
}
async function flushPromises(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
it('resolves requests after the worker reports ready and returns a response', async () => {
const client = createClient();
const requestPromise = client.request('DB_GLOBAL_SEARCH', {
searchTerm: 'matrix',
});
const worker = mockWorkerInstances[0];
const { Worker } = jest.requireMock('worker_threads');
expect(resolveWorkerRuntimeBootstrap).toHaveBeenCalledWith(
expect.objectContaining({
workerFilename: 'database.worker.js',
developmentWorkerDir: expect.stringContaining('workers'),
})
);
expect(Worker).toHaveBeenCalledWith(expect.any(URL), {
workerData: {
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
},
});
worker.emit('message', { type: 'ready' });
await flushPromises();
expect(worker.postMessage).toHaveBeenCalledTimes(1);
const request = worker.postMessage.mock.calls[0][0];
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: true,
result: [{ id: 1 }],
});
await expect(requestPromise).resolves.toEqual([{ id: 1 }]);
});
it('forwards worker events to the pending request callback', async () => {
const client = createClient();
const onEvent = jest.fn();
const requestPromise = client.request(
'DB_SAVE_CONTENT',
{ playlistId: 'xtream-1', streams: [], type: 'movie' },
{ onEvent }
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'ready' });
await flushPromises();
const request = worker.postMessage.mock.calls[0][0];
const event = {
operationId: 'op-1',
operation: 'save-content',
status: 'progress',
current: 10,
total: 100,
};
worker.emit('message', {
type: 'event',
requestId: request.requestId,
event,
});
expect(onEvent).toHaveBeenCalledWith(event);
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: true,
result: { success: true, count: 10 },
});
await expect(requestPromise).resolves.toEqual({
success: true,
count: 10,
});
});
it('turns serialized worker errors into rejected Error instances', async () => {
const client = createClient();
const requestPromise = client.request('DB_DELETE_PLAYLIST', {
playlistId: 'xtream-1',
});
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'ready' });
await flushPromises();
const request = worker.postMessage.mock.calls[0][0];
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: false,
error: {
name: 'SqliteBusyError',
message: 'database is locked',
stack: 'stack-trace',
},
});
await expect(requestPromise).rejects.toMatchObject({
name: 'SqliteBusyError',
message: 'database is locked',
});
});
it('preserves AbortError responses from the worker', async () => {
const client = createClient();
const requestPromise = client.request('DB_SAVE_CONTENT', {
playlistId: 'xtream-1',
streams: [],
type: 'movie',
});
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'ready' });
await flushPromises();
const request = worker.postMessage.mock.calls[0][0];
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: false,
error: {
name: 'AbortError',
message: 'Operation "save-content" was cancelled',
},
});
await expect(requestPromise).rejects.toMatchObject({
name: 'AbortError',
message: 'Operation "save-content" was cancelled',
});
});
it('posts cancel messages to the current worker', async () => {
const client = createClient();
const requestPromise = client.request('DB_DELETE_PLAYLIST', {
playlistId: 'xtream-1',
});
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'ready' });
await flushPromises();
await expect(client.cancel('operation-123')).resolves.toEqual({
success: true,
});
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'cancel',
operationId: 'operation-123',
});
const request = worker.postMessage.mock.calls[0][0];
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: true,
result: { success: true },
});
await expect(requestPromise).resolves.toEqual({ success: true });
});
it('rejects pending work on worker exit and starts a fresh worker for the next request', async () => {
const client = createClient();
const firstRequest = client.request('DB_SEARCH_CONTENT', {
playlistId: 'xtream-1',
searchTerm: 'matrix',
types: ['movie'],
});
const firstWorker = mockWorkerInstances[0];
firstWorker.emit('message', { type: 'ready' });
await flushPromises();
firstWorker.emit('exit', 1);
await expect(firstRequest).rejects.toThrow(
'Database worker stopped with exit code 1'
);
const secondRequest = client.request('DB_SEARCH_CONTENT', {
playlistId: 'xtream-1',
searchTerm: 'neo',
types: ['movie'],
});
const secondWorker = mockWorkerInstances[1];
secondWorker.emit('message', { type: 'ready' });
await flushPromises();
const request = secondWorker.postMessage.mock.calls[0][0];
secondWorker.emit('message', {
type: 'response',
requestId: request.requestId,
success: true,
result: [],
});
await expect(secondRequest).resolves.toEqual([]);
});
it('rejects requests with actionable worker path errors', async () => {
const client = createClient();
resolveWorkerRuntimeBootstrap.mockImplementation(() => {
const error = new Error(
'Unable to resolve worker "database.worker.js".\nTried:\n- /missing/database.worker.js'
);
error.name = 'WorkerPathResolutionError';
throw error;
});
await expect(
client.request('DB_GLOBAL_SEARCH', { searchTerm: 'matrix' })
).rejects.toMatchObject({
name: 'WorkerPathResolutionError',
message: expect.stringContaining('database.worker.js'),
});
});
});
@@ -0,0 +1,293 @@
import { app } from 'electron';
import { randomUUID } from 'crypto';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import type {
DbOperationEvent,
DbWorkerMessage,
DbWorkerOperation,
} from '../workers/database-worker.types';
import {
isDbTraceEnabled,
roundTraceDuration,
summarizeForTrace,
trace,
} from './debug-trace';
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
onEvent?: (event: DbOperationEvent) => void;
operation: DbWorkerOperation;
startedAt: number;
};
type RequestOptions = {
onEvent?: (event: DbOperationEvent) => void;
};
function createWorkerError(error: {
message: string;
name?: string;
stack?: string;
}): Error {
const workerError = new Error(error.message);
workerError.name = error.name || 'DatabaseWorkerError';
workerError.stack = error.stack || workerError.stack;
return workerError;
}
export class DatabaseWorkerClient {
private worker: Worker | null = null;
private readyPromise: Promise<void> | null = null;
private readyResolve: (() => void) | null = null;
private readyReject: ((reason?: unknown) => void) | null = null;
private pendingRequests = new Map<string, PendingRequest>();
private shuttingDown = false;
async request<TResult>(
operation: DbWorkerOperation,
payload: unknown,
options?: RequestOptions
): Promise<TResult> {
await this.ensureWorker();
const requestId = randomUUID();
const startedAt = Date.now();
if (isDbTraceEnabled()) {
trace('db-request', 'dispatch', {
operation,
payload: summarizeForTrace(payload),
requestId,
});
}
return new Promise<TResult>((resolve, reject) => {
this.pendingRequests.set(requestId, {
resolve: resolve as (value: unknown) => void,
reject,
onEvent: options?.onEvent,
operation,
startedAt,
});
this.worker?.postMessage({
type: 'request',
requestId,
operation,
payload,
});
});
}
async cancel(operationId: string): Promise<{ success: boolean }> {
if (!operationId) {
return { success: false };
}
await this.ensureWorker();
this.worker?.postMessage({
type: 'cancel',
operationId,
});
return { success: true };
}
async shutdown(): Promise<void> {
this.shuttingDown = true;
this.readyResolve = null;
this.readyReject = null;
this.readyPromise = null;
if (!this.worker) {
return;
}
const currentWorker = this.worker;
this.worker = null;
this.rejectAllPending(new Error('Database worker shut down'));
try {
await currentWorker.terminate();
} finally {
this.shuttingDown = false;
}
}
private async ensureWorker(): Promise<void> {
if (this.worker && this.readyPromise) {
return this.readyPromise;
}
this.createWorker();
return this.readyPromise!;
}
private createWorker(): void {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'database.worker.js',
developmentWorkerDir: path.join(__dirname, 'workers'),
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
appPath: app.getAppPath(),
});
this.readyPromise = new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
});
try {
const workerURL = pathToFileURL(bootstrap.workerPath);
if (isDbTraceEnabled()) {
trace('db-worker', 'create', {
nativeModuleSearchPaths:
bootstrap.nativeModuleSearchPaths?.length ?? 0,
workerPath: bootstrap.workerPath,
});
}
this.worker = new Worker(workerURL, {
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
},
});
} catch (error) {
this.readyReject?.(error);
this.resetWorkerState();
throw error;
}
this.worker.on('message', (message: DbWorkerMessage) => {
this.handleMessage(message);
});
this.worker.on('error', (error) => {
this.handleWorkerFailure(error);
});
this.worker.on('exit', (code) => {
this.handleWorkerExit(code);
});
}
private handleMessage(message: DbWorkerMessage): void {
if (message.type === 'ready') {
if (isDbTraceEnabled()) {
trace('db-worker', 'ready');
}
this.readyResolve?.();
this.readyResolve = null;
this.readyReject = null;
return;
}
if (message.type === 'event') {
if (isDbTraceEnabled()) {
trace('db-event', 'worker-event', {
event: message.event,
requestId: message.requestId,
});
}
this.pendingRequests.get(message.requestId)?.onEvent?.(message.event);
return;
}
const pendingRequest = this.pendingRequests.get(message.requestId);
if (!pendingRequest) {
return;
}
this.pendingRequests.delete(message.requestId);
if (message.success) {
if (isDbTraceEnabled()) {
trace('db-request', 'resolved', {
durationMs: roundTraceDuration(
Date.now() - pendingRequest.startedAt
),
operation: pendingRequest.operation,
requestId: message.requestId,
result: summarizeForTrace(message.result),
});
}
pendingRequest.resolve(message.result);
return;
}
if (isDbTraceEnabled()) {
trace('db-request', 'failed', {
durationMs: roundTraceDuration(
Date.now() - pendingRequest.startedAt
),
error: message.error,
operation: pendingRequest.operation,
requestId: message.requestId,
});
}
pendingRequest.reject(
createWorkerError(
message.error ?? { message: 'Database worker request failed' }
)
);
}
private handleWorkerFailure(error: Error): void {
if (isDbTraceEnabled()) {
trace('db-worker', 'error', error);
}
if (this.readyReject) {
this.readyReject(error);
}
this.resetWorkerState();
this.rejectAllPending(error);
}
private handleWorkerExit(code: number): void {
if (this.shuttingDown) {
return;
}
if (isDbTraceEnabled()) {
trace('db-worker', 'exit', { code });
}
const error =
code === 0
? new Error('Database worker exited unexpectedly')
: new Error(`Database worker stopped with exit code ${code}`);
if (this.readyReject) {
this.readyReject(error);
}
this.resetWorkerState();
this.rejectAllPending(error);
}
private resetWorkerState(): void {
this.worker = null;
this.readyPromise = null;
this.readyResolve = null;
this.readyReject = null;
}
private rejectAllPending(error: Error): void {
const pendingRequests = [...this.pendingRequests.values()];
this.pendingRequests.clear();
pendingRequests.forEach((request) => {
request.reject(error);
});
}
}
export const databaseWorkerClient = new DatabaseWorkerClient();
@@ -0,0 +1,178 @@
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
const TRACE_PREFIX = '[IPTVnator Trace]';
const MAX_TRACE_ARRAY_ITEMS = 5;
const MAX_TRACE_OBJECT_KEYS = 8;
const MAX_TRACE_STRING_LENGTH = 180;
const MAX_TRACE_DEPTH = 2;
export const DEBUG_TRACE_EVENT_CHANNEL = 'IPTVNATOR_DEBUG_TRACE_EVENT';
function readFlag(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
return value ? TRACE_ENV_TRUE_VALUES.has(value) : false;
}
function truncateString(value: string): string {
if (value.length <= MAX_TRACE_STRING_LENGTH) {
return value;
}
return `${value.slice(0, MAX_TRACE_STRING_LENGTH - 3)}...`;
}
function summarizeObject(
value: Record<string, unknown>,
depth: number
): Record<string, unknown> {
const entries = Object.entries(value);
const summary: Record<string, unknown> = {};
if (
value.constructor?.name &&
value.constructor.name !== 'Object'
) {
summary.__type = value.constructor.name;
}
entries.slice(0, MAX_TRACE_OBJECT_KEYS).forEach(([key, entryValue]) => {
summary[key] = summarizeForTrace(entryValue, depth + 1);
});
if (entries.length > MAX_TRACE_OBJECT_KEYS) {
summary.__moreKeys = entries.length - MAX_TRACE_OBJECT_KEYS;
}
return summary;
}
export function isStartupTraceEnabled(): boolean {
return readFlag('IPTVNATOR_TRACE_STARTUP');
}
export function isRendererApiTraceEnabled(): boolean {
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC');
}
export function isDbTraceEnabled(): boolean {
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB');
}
export function isSqlTraceEnabled(): boolean {
return (
isStartupTraceEnabled() ||
readFlag('IPTVNATOR_TRACE_DB') ||
readFlag('IPTVNATOR_TRACE_SQL')
);
}
export function isWindowTraceEnabled(): boolean {
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_WINDOW');
}
export function isRendererConsoleTraceEnabled(): boolean {
return readFlag('IPTVNATOR_TRACE_RENDERER_CONSOLE');
}
export function roundTraceDuration(durationMs: number): number {
return Math.round(durationMs * 10) / 10;
}
export function compactSqlForTrace(sql: string): string {
return truncateString(sql.replace(/\s+/g, ' ').trim());
}
export function summarizeForTrace(
value: unknown,
depth = 0
): unknown {
if (
value == null ||
typeof value === 'boolean' ||
typeof value === 'number'
) {
return value;
}
if (typeof value === 'string') {
return truncateString(value);
}
if (typeof value === 'bigint') {
return value.toString();
}
if (typeof value === 'function') {
return '[Function]';
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
};
}
if (depth >= MAX_TRACE_DEPTH) {
if (Array.isArray(value)) {
return {
type: 'array',
length: value.length,
};
}
return typeof value === 'object'
? {
type:
(value as { constructor?: { name?: string } }).constructor
?.name ?? 'object',
}
: String(value);
}
if (Array.isArray(value)) {
return {
type: 'array',
length: value.length,
items: value
.slice(0, MAX_TRACE_ARRAY_ITEMS)
.map((entry) => summarizeForTrace(entry, depth + 1)),
};
}
if (typeof value === 'object') {
return summarizeObject(value as Record<string, unknown>, depth);
}
return String(value);
}
export function safeStringifyForTrace(payload: unknown): string {
try {
return JSON.stringify(payload);
} catch (error) {
return JSON.stringify({
fallback: summarizeForTrace(payload),
stringifyError:
error instanceof Error
? truncateString(error.message)
: String(error),
});
}
}
export function trace(
scope: string,
message: string,
payload?: unknown
): void {
if (payload === undefined) {
console.log(`${TRACE_PREFIX}[${scope}] ${message}`);
return;
}
console.log(
`${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace(
summarizeForTrace(payload)
)}`
);
}
@@ -0,0 +1,152 @@
const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
const CONTEXT_TTL_MS = 15 * 60 * 1000;
type PlaybackContext = {
headers: Record<string, string>;
createdAt: number;
};
const playbackContextByStream = new Map<string, PlaybackContext>();
const playbackContextByOrigin = new Map<string, PlaybackContext>();
function normalizeStreamUrl(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
try {
const parsed = new URL(maybeWrapped);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return maybeWrapped.split('?')[0];
}
}
function getOrigin(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
try {
return new URL(maybeWrapped).origin;
} catch {
return '';
}
}
function removeExpiredContextEntries() {
const now = Date.now();
for (const [key, value] of playbackContextByStream.entries()) {
if (now - value.createdAt > CONTEXT_TTL_MS) {
playbackContextByStream.delete(key);
}
}
for (const [key, value] of playbackContextByOrigin.entries()) {
if (now - value.createdAt > CONTEXT_TTL_MS) {
playbackContextByOrigin.delete(key);
}
}
}
export function rememberStalkerPlaybackContext(input: {
streamUrl: string;
portalUrl: string;
macAddress: string;
serialNumber?: string;
token?: string;
}) {
const streamKey = normalizeStreamUrl(input.streamUrl);
if (!streamKey || !input.macAddress) return;
const cookieParts = [
`mac=${input.macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (input.serialNumber) {
cookieParts.push(
`__cfduid=${input.serialNumber.toLowerCase()}e030245495acd6ebfc1`
);
}
const streamOrigin = getOrigin(input.streamUrl) || undefined;
let portalOrigin: string | undefined;
try {
portalOrigin = new URL(input.portalUrl).origin;
} catch {
portalOrigin = undefined;
}
const crossOriginStream =
Boolean(streamOrigin) &&
Boolean(portalOrigin) &&
streamOrigin !== portalOrigin;
const headers: Record<string, string> = crossOriginStream
? {
// Align with known working clients for direct tokenized stream URLs.
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: STALKER_STREAM_RANGE_HEADER,
'Icy-MetaData': '1',
Connection: 'keep-alive',
}
: {
Cookie: cookieParts.join('; '),
'User-Agent': STALKER_MAG_USER_AGENT,
'X-User-Agent': STALKER_MAG_USER_AGENT,
};
if (!crossOriginStream && portalOrigin) {
headers['Origin'] = portalOrigin;
headers['Referer'] = portalOrigin;
}
if (!crossOriginStream && input.serialNumber) {
headers['SN'] = input.serialNumber;
}
if (!crossOriginStream && input.token) {
headers['Authorization'] = `Bearer ${input.token}`;
}
playbackContextByStream.set(streamKey, {
headers,
createdAt: Date.now(),
});
if (streamOrigin) {
playbackContextByOrigin.set(streamOrigin, {
headers,
createdAt: Date.now(),
});
}
removeExpiredContextEntries();
}
export function getStalkerPlaybackContextHeaders(
streamUrl: string
): Record<string, string> | null {
removeExpiredContextEntries();
const streamKey = normalizeStreamUrl(streamUrl);
if (streamKey) {
const entry = playbackContextByStream.get(streamKey);
if (entry) {
return { ...entry.headers };
}
}
const origin = getOrigin(streamUrl);
if (!origin) return null;
const originEntry = playbackContextByOrigin.get(origin);
return originEntry ? { ...originEntry.headers } : null;
}
@@ -0,0 +1,22 @@
import { Conf } from 'electron-conf/main';
import { getElectronConfigDirectory } from 'database';
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH';
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
export type StoreType = {
[WINDOW_BOUNDS]: Electron.Rectangle;
[MPV_PLAYER_PATH]: string;
[VLC_PLAYER_PATH]: string;
[MPV_REUSE_INSTANCE]: boolean;
};
// Export singleton store instance
const electronConfigDirectory = getElectronConfigDirectory();
const storeOptions = electronConfigDirectory
? { dir: electronConfigDirectory }
: {};
export const store = new Conf<StoreType>(storeOptions);
@@ -0,0 +1,142 @@
export const DB_WORKER_OPERATIONS = [
'DB_HAS_CATEGORIES',
'DB_GET_CATEGORIES',
'DB_SAVE_CATEGORIES',
'DB_GET_ALL_CATEGORIES',
'DB_UPDATE_CATEGORY_VISIBILITY',
'DB_HAS_CONTENT',
'DB_GET_CONTENT',
'DB_GET_GLOBAL_RECENTLY_ADDED',
'DB_SAVE_CONTENT',
'DB_CLEAR_XTREAM_IMPORT_CACHE',
'DB_GET_CONTENT_BY_XTREAM_ID',
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
'DB_SEARCH_CONTENT',
'DB_GLOBAL_SEARCH',
'DB_CREATE_PLAYLIST',
'DB_UPSERT_APP_PLAYLIST',
'DB_UPSERT_APP_PLAYLISTS',
'DB_GET_APP_PLAYLISTS',
'DB_GET_APP_PLAYLIST',
'DB_GET_PLAYLIST',
'DB_UPDATE_PLAYLIST',
'DB_DELETE_PLAYLIST',
'DB_GET_APP_STATE',
'DB_SET_APP_STATE',
'DB_DELETE_ALL_PLAYLISTS',
'DB_DELETE_XTREAM_CONTENT',
'DB_RESTORE_XTREAM_USER_DATA',
'DB_ADD_FAVORITE',
'DB_REMOVE_FAVORITE',
'DB_IS_FAVORITE',
'DB_GET_FAVORITES',
'DB_GET_GLOBAL_FAVORITES',
'DB_GET_ALL_GLOBAL_FAVORITES',
'DB_REORDER_GLOBAL_FAVORITES',
'DB_GET_RECENTLY_VIEWED',
'DB_CLEAR_RECENTLY_VIEWED',
'DB_GET_RECENT_ITEMS',
'DB_ADD_RECENT_ITEM',
'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
'DB_REMOVE_RECENT_ITEM',
'DB_SAVE_PLAYBACK_POSITION',
'DB_GET_PLAYBACK_POSITION',
'DB_GET_SERIES_PLAYBACK_POSITIONS',
'DB_GET_RECENT_PLAYBACK_POSITIONS',
'DB_GET_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_PLAYBACK_POSITION',
] as const;
export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number];
export const DB_OPERATION_NAMES = {
SAVE_CONTENT: 'save-content',
DELETE_XTREAM_CONTENT: 'delete-xtream-content',
RESTORE_XTREAM_USER_DATA: 'restore-xtream-user-data',
DELETE_PLAYLIST: 'delete-playlist',
DELETE_ALL_PLAYLISTS: 'delete-all-playlists',
} as const;
export type DbOperationName =
(typeof DB_OPERATION_NAMES)[keyof typeof DB_OPERATION_NAMES];
export const DB_OPERATION_PHASES = {
PREPARING_CONTENT: 'preparing-content',
SAVING_CONTENT: 'saving-content',
COLLECTING_USER_DATA: 'collecting-user-data',
DELETING_FAVORITES: 'deleting-favorites',
DELETING_RECENTLY_VIEWED: 'deleting-recently-viewed',
DELETING_PLAYBACK_POSITIONS: 'deleting-playback-positions',
DELETING_DOWNLOADS: 'deleting-downloads',
DELETING_CONTENT: 'deleting-content',
DELETING_CATEGORIES: 'deleting-categories',
DELETING_PLAYLIST: 'deleting-playlist',
DELETING_PLAYLISTS: 'deleting-playlists',
RESTORING_FAVORITES: 'restoring-favorites',
RESTORING_RECENTLY_VIEWED: 'restoring-recently-viewed',
} as const;
export type DbOperationStatus =
| 'started'
| 'progress'
| 'completed'
| 'cancelled'
| 'error';
export interface DbOperationEvent {
operationId?: string;
operation: DbOperationName | string;
playlistId?: string;
status: DbOperationStatus;
phase?: string;
current?: number;
total?: number;
increment?: number;
error?: string;
}
export interface SerializedWorkerError {
name?: string;
message: string;
stack?: string;
}
export interface DbWorkerRequestMessage<TPayload = unknown> {
type: 'request';
requestId: string;
operation: DbWorkerOperation;
payload: TPayload;
}
export interface DbWorkerCancelMessage {
type: 'cancel';
operationId: string;
}
export interface DbWorkerReadyMessage {
type: 'ready';
}
export interface DbWorkerEventMessage {
type: 'event';
requestId: string;
event: DbOperationEvent;
}
export interface DbWorkerResponseMessage<TResult = unknown> {
type: 'response';
requestId: string;
success: boolean;
result?: TResult;
error?: SerializedWorkerError;
}
export type DbWorkerIncomingMessage =
| DbWorkerRequestMessage
| DbWorkerCancelMessage;
export type DbWorkerMessage =
| DbWorkerReadyMessage
| DbWorkerEventMessage
| DbWorkerResponseMessage;
@@ -0,0 +1,107 @@
import type BetterSqlite3 from 'better-sqlite3';
import * as schema from 'database-schema';
import { getIptvnatorDatabasePath } from 'database-path-utils';
import { workerData } from 'worker_threads';
import type { AppDatabase } from '../database/database.types';
import {
getNativeModuleSearchPaths,
getWorkerDataNativeModuleSearchPaths,
loadNativeModuleFromSearchPaths,
registerNativeModuleSearchPaths,
} from './worker-runtime-paths';
import {
compactSqlForTrace,
isSqlTraceEnabled,
trace,
} from '../services/debug-trace';
let Database: typeof BetterSqlite3;
let drizzleFactory:
| (typeof import('drizzle-orm/better-sqlite3'))['drizzle']
| undefined;
const nativeModuleSearchPaths = [
...getWorkerDataNativeModuleSearchPaths(workerData),
...getNativeModuleSearchPaths({
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
}),
];
registerNativeModuleSearchPaths(nativeModuleSearchPaths);
function loadBetterSqlite3(): typeof BetterSqlite3 {
return loadNativeModuleFromSearchPaths({
moduleName: 'better-sqlite3',
loggerLabel: '[DB Worker]',
searchPaths: nativeModuleSearchPaths,
fallbackRequire: () =>
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('better-sqlite3') as typeof BetterSqlite3,
});
}
function getDrizzleFactory(): (typeof import('drizzle-orm/better-sqlite3'))['drizzle'] {
if (drizzleFactory) {
return drizzleFactory;
}
// Require drizzle only after native lookup paths have been registered.
// Its better-sqlite3 driver resolves the native package at module load time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
drizzleFactory = require('drizzle-orm/better-sqlite3').drizzle as (
typeof import('drizzle-orm/better-sqlite3')
)['drizzle'];
return drizzleFactory;
}
Database = loadBetterSqlite3();
let db: AppDatabase | null = null;
let sqlite: BetterSqlite3.Database | null = null;
export async function getWorkerDatabase(): Promise<AppDatabase> {
if (db) {
return db;
}
const filePath = getIptvnatorDatabasePath();
sqlite = new Database(filePath, {
verbose: isSqlTraceEnabled()
? (sql: string) => {
trace('sql-worker', 'query', {
sql: compactSqlForTrace(sql),
});
}
: undefined,
});
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
if (isSqlTraceEnabled()) {
trace('sql-worker', 'open', {
filePath,
});
}
db = getDrizzleFactory()(sqlite, { schema });
return db;
}
export function closeWorkerDatabase(): void {
if (!sqlite) {
return;
}
sqlite.close();
if (isSqlTraceEnabled()) {
trace('sql-worker', 'close');
}
sqlite = null;
db = null;
}
@@ -0,0 +1,886 @@
import {
closeWorkerDatabase,
getWorkerDatabase,
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import type {
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
} from 'shared-interfaces';
import type {
DbOperationEvent,
DbWorkerIncomingMessage,
DbWorkerMessage,
DbWorkerRequestMessage,
} from './database-worker.types';
import {
DB_OPERATION_NAMES,
DB_OPERATION_PHASES,
} from './database-worker.types';
import {
getAllCategories,
getCategories,
hasCategories,
saveCategories,
updateCategoryVisibility,
} from '../database/operations/category.operations';
import {
addFavorite,
getAllGlobalFavorites,
getFavorites,
getGlobalFavorites,
isFavorite,
removeFavorite,
reorderGlobalFavorites,
} from '../database/operations/favorites.operations';
import {
clearXtreamImportCache,
getContent,
getContentByXtreamId,
getGlobalRecentlyAdded,
globalSearch,
hasContent,
saveContent,
searchContent,
} from '../database/operations/content.operations';
import { setContentBackdropIfMissing } from '../database/operations/content-backdrop.operations';
import {
clearAllPlaybackPositions,
clearPlaybackPosition,
getAllPlaybackPositions,
getPlaybackPosition,
getRecentPlaybackPositions,
getSeriesPlaybackPositions,
savePlaybackPosition,
} from '../database/operations/playback-position.operations';
import {
createPlaylist,
deleteAllPlaylists,
deletePlaylist,
getAppPlaylist,
getAppPlaylists,
getAppState,
getPlaylist,
setAppState,
updatePlaylist,
upsertAppPlaylist,
upsertAppPlaylists,
} from '../database/operations/playlist.operations';
import {
addRecentItem,
clearPlaylistRecentItems,
clearRecentlyViewed,
getRecentItems,
getRecentlyViewed,
removeRecentItem,
} from '../database/operations/recently-viewed.operations';
import {
deleteXtreamContent,
restoreXtreamUserData,
} from '../database/operations/xtream.operations';
const loggerLabel = '[DB Worker]';
const batchDelayMs = Number.parseInt(
process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0',
10
);
type ActiveOperationState = {
cancelled: boolean;
};
type OperationController = {
control: {
checkpoint: () => Promise<void>;
onProgress: (progress: {
phase: string;
current?: number;
total?: number;
increment?: number;
}) => Promise<void>;
};
emitStarted: (event: Partial<DbOperationEvent> & { phase: string }) => void;
emitCompleted: (
event?: Partial<DbOperationEvent> & { phase?: string }
) => void;
emitCancelled: (
event?: Partial<DbOperationEvent> & { phase?: string }
) => void;
emitError: (
error: unknown,
event?: Partial<DbOperationEvent> & { phase?: string }
) => void;
cleanup: () => void;
};
const activeOperations = new Map<string, ActiveOperationState>();
if (!parentPort) {
throw new Error('Database worker must be started with a parent port');
}
function createAbortError(message: string): Error {
const error = new Error(message);
error.name = 'AbortError';
return error;
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
function serializeError(error: unknown) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return {
message: String(error),
};
}
function postMessage(message: DbWorkerMessage): void {
parentPort?.postMessage(message);
}
function postEvent(requestId: string, event: DbOperationEvent): void {
postMessage({
type: 'event',
requestId,
event,
});
}
async function pauseBetweenBatches(): Promise<void> {
if (batchDelayMs <= 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, batchDelayMs));
}
function createOperationController(config: {
operationId?: string;
operation: string;
playlistId?: string;
requestId: string;
cancellable?: boolean;
}): OperationController {
const { operationId, operation, playlistId, requestId } = config;
const cancellable = config.cancellable ?? true;
const activeState =
operationId && cancellable ? { cancelled: false } : null;
if (operationId && activeState) {
activeOperations.set(operationId, activeState);
}
let lastEvent: Partial<DbOperationEvent> = {};
const send = (
status: DbOperationEvent['status'],
event: Partial<DbOperationEvent> = {}
): void => {
const mergedEvent: DbOperationEvent = {
operationId,
operation,
playlistId,
status,
phase: event.phase ?? lastEvent.phase,
current: event.current ?? lastEvent.current,
total: event.total ?? lastEvent.total,
increment: event.increment,
error: event.error,
};
lastEvent = {
phase: mergedEvent.phase,
current: mergedEvent.current,
total: mergedEvent.total,
};
postEvent(requestId, mergedEvent);
};
const checkpoint = async (): Promise<void> => {
if (activeState?.cancelled) {
throw createAbortError(`Operation "${operation}" was cancelled`);
}
await pauseBetweenBatches();
if (activeState?.cancelled) {
throw createAbortError(`Operation "${operation}" was cancelled`);
}
};
return {
control: {
checkpoint,
onProgress: async (progress) => {
send('progress', progress);
},
},
emitStarted: (event) => {
send('started', event);
},
emitCompleted: (event) => {
send('completed', event);
},
emitCancelled: (event) => {
send('cancelled', event);
},
emitError: (error, event) => {
send('error', {
...event,
error: error instanceof Error ? error.message : String(error),
});
},
cleanup: () => {
if (operationId) {
activeOperations.delete(operationId);
}
},
};
}
async function executeTrackedOperation<TResult>(
config: Parameters<typeof createOperationController>[0],
handler: (controller: OperationController) => Promise<TResult>
): Promise<TResult> {
const controller = createOperationController(config);
try {
return await handler(controller);
} catch (error) {
if (isAbortError(error)) {
controller.emitCancelled();
} else {
controller.emitError(error);
}
throw error;
} finally {
controller.cleanup();
}
}
async function executeRequest(message: DbWorkerRequestMessage) {
const db = await getWorkerDatabase();
switch (message.operation) {
case 'DB_HAS_CATEGORIES': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movies' | 'series';
};
return hasCategories(db, payload.playlistId, payload.type);
}
case 'DB_GET_CATEGORIES': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movies' | 'series';
};
return getCategories(db, payload.playlistId, payload.type);
}
case 'DB_SAVE_CATEGORIES': {
const payload = message.payload as {
playlistId: string;
categories: Array<{
category_name: string;
category_id: string | number;
}>;
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];
};
return saveCategories(
db,
payload.playlistId,
payload.categories,
payload.type,
payload.hiddenCategoryXtreamIds
);
}
case 'DB_GET_ALL_CATEGORIES': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movies' | 'series';
};
return getAllCategories(db, payload.playlistId, payload.type);
}
case 'DB_UPDATE_CATEGORY_VISIBILITY': {
const payload = message.payload as {
categoryIds: number[];
hidden: boolean;
};
return updateCategoryVisibility(
db,
payload.categoryIds,
payload.hidden
);
}
case 'DB_HAS_CONTENT': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movie' | 'series';
};
return hasContent(db, payload.playlistId, payload.type);
}
case 'DB_GET_CONTENT': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movie' | 'series';
};
return getContent(db, payload.playlistId, payload.type);
}
case 'DB_GET_GLOBAL_RECENTLY_ADDED': {
const payload = message.payload as {
kind?: 'all' | 'vod' | 'series';
limit?: number;
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
};
return getGlobalRecentlyAdded(
db,
payload.kind,
payload.limit,
payload.playlistType
);
}
case 'DB_SAVE_CONTENT': {
const payload = message.payload as {
playlistId: string;
streams: Array<Record<string, unknown>>;
type: 'live' | 'movie' | 'series';
operationId?: string;
};
return executeTrackedOperation(
{
requestId: message.requestId,
operation: DB_OPERATION_NAMES.SAVE_CONTENT,
operationId: payload.operationId,
playlistId: payload.playlistId,
},
async (controller) => {
controller.emitStarted({
phase: DB_OPERATION_PHASES.PREPARING_CONTENT,
current: 0,
total: payload.streams.length,
});
const result = await saveContent(
db,
payload.playlistId,
payload.streams,
payload.type,
controller.control
);
controller.emitCompleted({
phase: DB_OPERATION_PHASES.SAVING_CONTENT,
current: result.count,
total: result.count,
});
return result;
}
);
}
case 'DB_CLEAR_XTREAM_IMPORT_CACHE': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movie' | 'series';
};
return clearXtreamImportCache(db, payload.playlistId, payload.type);
}
case 'DB_GET_CONTENT_BY_XTREAM_ID': {
const payload = message.payload as {
xtreamId: number;
playlistId: string;
contentType?: 'live' | 'movie' | 'series';
};
return getContentByXtreamId(
db,
payload.xtreamId,
payload.playlistId,
payload.contentType
);
}
case 'DB_SET_CONTENT_BACKDROP_IF_MISSING': {
const payload = message.payload as {
contentId: number;
backdropUrl?: string;
};
return setContentBackdropIfMissing(
db,
payload.contentId,
payload.backdropUrl
);
}
case 'DB_SEARCH_CONTENT': {
const payload = message.payload as {
playlistId: string;
searchTerm: string;
types: string[];
excludeHidden?: boolean;
};
return searchContent(
db,
payload.playlistId,
payload.searchTerm,
payload.types,
payload.excludeHidden
);
}
case 'DB_GLOBAL_SEARCH': {
const payload = message.payload as {
searchTerm: string;
types: string[];
excludeHidden?: boolean;
};
return globalSearch(
db,
payload.searchTerm,
payload.types,
payload.excludeHidden
);
}
case 'DB_CREATE_PLAYLIST': {
return createPlaylist(
db,
message.payload as {
id: string;
name: string;
serverUrl?: string;
username?: string;
password?: string;
macAddress?: string;
url?: string;
type: string;
}
);
}
case 'DB_UPSERT_APP_PLAYLIST': {
return upsertAppPlaylist(
db,
message.payload as Record<string, unknown>
);
}
case 'DB_UPSERT_APP_PLAYLISTS': {
return upsertAppPlaylists(
db,
message.payload as Record<string, unknown>[]
);
}
case 'DB_GET_APP_PLAYLISTS':
return getAppPlaylists(db);
case 'DB_GET_APP_PLAYLIST': {
const payload = message.payload as { playlistId: string };
return getAppPlaylist(db, payload.playlistId);
}
case 'DB_GET_PLAYLIST': {
const payload = message.payload as { playlistId: string };
return getPlaylist(db, payload.playlistId);
}
case 'DB_UPDATE_PLAYLIST': {
const payload = message.payload as {
playlistId: string;
updates: {
name?: string;
username?: string;
password?: string;
serverUrl?: string;
lastUpdated?: string;
};
};
return updatePlaylist(db, payload.playlistId, payload.updates);
}
case 'DB_DELETE_PLAYLIST': {
const payload = message.payload as {
playlistId: string;
operationId?: string;
};
return executeTrackedOperation(
{
requestId: message.requestId,
operation: DB_OPERATION_NAMES.DELETE_PLAYLIST,
operationId: payload.operationId,
playlistId: payload.playlistId,
},
async (controller) => {
controller.emitStarted({
phase: DB_OPERATION_PHASES.DELETING_FAVORITES,
current: 0,
});
const result = await deletePlaylist(
db,
payload.playlistId,
controller.control
);
controller.emitCompleted({
phase: DB_OPERATION_PHASES.DELETING_PLAYLIST,
current: 1,
total: 1,
});
return result;
}
);
}
case 'DB_GET_APP_STATE': {
const payload = message.payload as { key: string };
return getAppState(db, payload.key);
}
case 'DB_SET_APP_STATE': {
const payload = message.payload as { key: string; value: string };
return setAppState(db, payload.key, payload.value);
}
case 'DB_DELETE_ALL_PLAYLISTS': {
const payload = message.payload as { operationId?: string };
return executeTrackedOperation(
{
requestId: message.requestId,
operation: DB_OPERATION_NAMES.DELETE_ALL_PLAYLISTS,
operationId: payload.operationId,
cancellable: false,
},
async (controller) => {
controller.emitStarted({
phase: DB_OPERATION_PHASES.DELETING_FAVORITES,
current: 0,
total: 7,
});
const result = await deleteAllPlaylists(
db,
controller.control
);
controller.emitCompleted({
phase: DB_OPERATION_PHASES.DELETING_PLAYLISTS,
current: 7,
total: 7,
});
return result;
}
);
}
case 'DB_DELETE_XTREAM_CONTENT': {
const payload = message.payload as {
playlistId: string;
operationId?: string;
};
return executeTrackedOperation(
{
requestId: message.requestId,
operation: DB_OPERATION_NAMES.DELETE_XTREAM_CONTENT,
operationId: payload.operationId,
playlistId: payload.playlistId,
},
async (controller) => {
controller.emitStarted({
phase: DB_OPERATION_PHASES.COLLECTING_USER_DATA,
current: 0,
});
const result = await deleteXtreamContent(
db,
payload.playlistId,
controller.control
);
controller.emitCompleted({
phase: DB_OPERATION_PHASES.DELETING_CATEGORIES,
});
return result;
}
);
}
case 'DB_RESTORE_XTREAM_USER_DATA': {
const payload = message.payload as {
playlistId: string;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
operationId?: string;
};
return executeTrackedOperation(
{
requestId: message.requestId,
operation: DB_OPERATION_NAMES.RESTORE_XTREAM_USER_DATA,
operationId: payload.operationId,
playlistId: payload.playlistId,
},
async (controller) => {
const totalItems =
payload.favorites.length +
payload.recentlyViewed.length;
controller.emitStarted({
phase: DB_OPERATION_PHASES.RESTORING_FAVORITES,
current: 0,
total: totalItems,
});
const result = await restoreXtreamUserData(
db,
payload.playlistId,
payload.favorites,
payload.recentlyViewed,
controller.control
);
controller.emitCompleted({
phase: DB_OPERATION_PHASES.RESTORING_RECENTLY_VIEWED,
current: totalItems,
total: totalItems,
});
return result;
}
);
}
case 'DB_ADD_FAVORITE': {
const payload = message.payload as {
contentId: number;
playlistId: string;
backdropUrl?: string;
};
return addFavorite(db, payload.contentId, payload.playlistId, {
backdropUrl: payload.backdropUrl,
});
}
case 'DB_REMOVE_FAVORITE': {
const payload = message.payload as {
contentId: number;
playlistId: string;
};
return removeFavorite(db, payload.contentId, payload.playlistId);
}
case 'DB_IS_FAVORITE': {
const payload = message.payload as {
contentId: number;
playlistId: string;
};
return isFavorite(db, payload.contentId, payload.playlistId);
}
case 'DB_GET_FAVORITES': {
const payload = message.payload as { playlistId: string };
return getFavorites(db, payload.playlistId);
}
case 'DB_GET_GLOBAL_FAVORITES':
return getGlobalFavorites(db);
case 'DB_GET_ALL_GLOBAL_FAVORITES':
return getAllGlobalFavorites(db);
case 'DB_REORDER_GLOBAL_FAVORITES': {
const payload = message.payload as {
updates: { content_id: number; position: number }[];
};
return reorderGlobalFavorites(db, payload.updates);
}
case 'DB_GET_RECENTLY_VIEWED':
return getRecentlyViewed(db);
case 'DB_CLEAR_RECENTLY_VIEWED':
return clearRecentlyViewed(db);
case 'DB_GET_RECENT_ITEMS': {
const payload = message.payload as { playlistId: string };
return getRecentItems(db, payload.playlistId);
}
case 'DB_ADD_RECENT_ITEM': {
const payload = message.payload as {
contentId: number;
playlistId: string;
backdropUrl?: string;
};
return addRecentItem(db, payload.contentId, payload.playlistId, {
backdropUrl: payload.backdropUrl,
});
}
case 'DB_CLEAR_PLAYLIST_RECENT_ITEMS': {
const payload = message.payload as { playlistId: string };
return clearPlaylistRecentItems(db, payload.playlistId);
}
case 'DB_REMOVE_RECENT_ITEM': {
const payload = message.payload as {
contentId: number;
playlistId: string;
};
return removeRecentItem(db, payload.contentId, payload.playlistId);
}
case 'DB_SAVE_PLAYBACK_POSITION': {
const payload = message.payload as {
playlistId: string;
data: {
contentXtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
positionSeconds: number;
durationSeconds?: number;
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
};
};
return savePlaybackPosition(db, payload.playlistId, payload.data);
}
case 'DB_GET_PLAYBACK_POSITION': {
const payload = message.payload as {
playlistId: string;
contentXtreamId: number;
contentType: 'vod' | 'episode';
};
return getPlaybackPosition(
db,
payload.playlistId,
payload.contentXtreamId,
payload.contentType
);
}
case 'DB_GET_SERIES_PLAYBACK_POSITIONS': {
const payload = message.payload as {
playlistId: string;
seriesXtreamId: number;
};
return getSeriesPlaybackPositions(
db,
payload.playlistId,
payload.seriesXtreamId
);
}
case 'DB_GET_RECENT_PLAYBACK_POSITIONS': {
const payload = message.payload as {
playlistId: string;
limit?: number;
};
return getRecentPlaybackPositions(
db,
payload.playlistId,
payload.limit
);
}
case 'DB_GET_ALL_PLAYBACK_POSITIONS': {
const payload = message.payload as { playlistId: string };
return getAllPlaybackPositions(db, payload.playlistId);
}
case 'DB_CLEAR_ALL_PLAYBACK_POSITIONS': {
const payload = message.payload as { playlistId: string };
return clearAllPlaybackPositions(db, payload.playlistId);
}
case 'DB_CLEAR_PLAYBACK_POSITION': {
const payload = message.payload as {
playlistId: string;
contentXtreamId: number;
contentType: 'vod' | 'episode';
};
return clearPlaybackPosition(
db,
payload.playlistId,
payload.contentXtreamId,
payload.contentType
);
}
}
}
parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const activeOperation = activeOperations.get(message.operationId);
if (activeOperation) {
activeOperation.cancelled = true;
}
return;
}
try {
const result = await executeRequest(message);
postMessage({
type: 'response',
requestId: message.requestId,
success: true,
result,
});
} catch (error) {
console.error(
loggerLabel,
`Error handling ${message.operation}:`,
error
);
postMessage({
type: 'response',
requestId: message.requestId,
success: false,
error: serializeError(error),
});
}
});
process.on('exit', () => {
closeWorkerDatabase();
});
postMessage({ type: 'ready' });
@@ -0,0 +1,423 @@
import type BetterSqlite3 from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { getIptvnatorDatabasePath } from 'database-path-utils';
import { Readable } from 'stream';
import { parentPort, workerData } from 'worker_threads';
import { createGunzip } from 'zlib';
import {
ParsedChannel,
ParsedProgram,
StreamingEpgParser,
} from './epg-streaming-parser';
import { shouldGunzipEpgResponse } from './epg-response-utils';
import {
getNativeModuleSearchPaths,
getWorkerDataNativeModuleSearchPaths,
loadNativeModuleFromSearchPaths,
registerNativeModuleSearchPaths,
} from './worker-runtime-paths';
let Database: typeof BetterSqlite3;
const nativeModuleSearchPaths = [
...getWorkerDataNativeModuleSearchPaths(workerData),
...getNativeModuleSearchPaths({
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
}),
];
registerNativeModuleSearchPaths(nativeModuleSearchPaths);
function loadBetterSqlite3(): typeof BetterSqlite3 {
return loadNativeModuleFromSearchPaths({
moduleName: 'better-sqlite3',
loggerLabel: '[EPG Worker]',
searchPaths: nativeModuleSearchPaths,
fallbackRequire: () =>
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('better-sqlite3') as typeof BetterSqlite3,
});
}
Database = loadBetterSqlite3();
/**
* Streaming EPG Parser Worker
* Uses SAX parsing to process XML incrementally without loading entire file into memory.
* Supports both regular and gzipped EPG files.
* Performs database operations directly to avoid blocking the main thread.
*/
interface WorkerMessage {
type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG';
url?: string;
}
interface WorkerResponse {
type:
| 'EPG_COMPLETE'
| 'EPG_ERROR'
| 'EPG_PROGRESS'
| 'CLEAR_COMPLETE'
| 'READY';
error?: string;
url?: string;
stats?: {
totalChannels: number;
totalPrograms: number;
};
}
const loggerLabel = '[EPG Worker]';
// Batch size for database inserts
const CHANNEL_BATCH_SIZE = 100;
const PROGRAM_BATCH_SIZE = 1000;
/**
* Database helper class for EPG operations
* Creates its own connection to avoid blocking main thread
*/
class EpgDatabase {
private db: BetterSqlite3.Database;
private knownChannelIds: Set<string> = new Set();
// Prepared statements for better performance
private insertChannelStmt: BetterSqlite3.Statement;
private insertProgramStmt: BetterSqlite3.Statement;
private deleteChannelsStmt: BetterSqlite3.Statement;
constructor() {
const dbPath = getIptvnatorDatabasePath();
this.db = new Database(dbPath);
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL'); // Better concurrent write performance
this.db.pragma('busy_timeout = 5000');
// Prepare statements
// Use INSERT OR REPLACE to update existing channels and refresh updated_at
// Use strftime with ISO format for consistent date comparison
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
`);
}
/**
* Clear existing EPG data for a source URL
*/
clearSourceData(sourceUrl: string): void {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
for (const channel of channels) {
const displayName =
channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert a batch of programs
* Only inserts programs for known channels to avoid FK constraint failures
*/
insertPrograms(programs: ParsedProgram[]): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const prog of programs) {
// Skip if channel not known
if (!this.knownChannelIds.has(prog.channel)) continue;
const title = prog.title?.[0]?.value || 'Unknown';
const description = prog.desc?.[0]?.value || null;
const category = prog.category?.[0]?.value || null;
const iconUrl = prog.icon?.[0]?.src || null;
const rating = prog.rating?.[0]?.value || null;
const episodeNum = prog.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
prog.channel,
prog.start,
prog.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum
);
insertedCount++;
} catch (err) {
// Skip individual failures (e.g., FK constraint)
}
}
});
insertMany(programs);
return insertedCount;
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Fetches and parses EPG data from URL using streaming
* Inserts directly into SQLite to avoid blocking main thread
*/
async function fetchAndParseEpgStreaming(url: string): Promise<void> {
console.log(loggerLabel, `Fetching EPG from ${url}`);
// Create database connection in worker
const epgDb = new EpgDatabase();
// Old rows for this source are retained until the first successful insert
// batch arrives — see `hasClearedSource` below. That way, a fetch or parse
// that yields zero channels leaves the existing data intact instead of
// wiping it and leaving the URL permanently stale.
let hasClearedSource = false;
try {
const response = await fetch(url.trim());
const isGzipped = shouldGunzipEpgResponse(url, response);
if (response.url && response.url !== url) {
console.log(
loggerLabel,
`Resolved EPG redirect: ${url} -> ${response.url}`
);
}
console.log(
loggerLabel,
`EPG response detected as gzipped: ${isGzipped}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const parser = new StreamingEpgParser(
(channels) => {
// Clear old rows in the same transaction as the first insert
// so we never end up with zero rows on a failed/empty parse.
epgDb.insertChannels(channels, url, !hasClearedSource);
hasClearedSource = true;
},
(programs) => {
// Insert programs directly into database
epgDb.insertPrograms(programs);
},
(totalChannels, totalPrograms) => {
// Send progress to main thread (lightweight)
const response: WorkerResponse = {
type: 'EPG_PROGRESS',
stats: { totalChannels, totalPrograms },
};
parentPort?.postMessage(response);
},
CHANNEL_BATCH_SIZE,
PROGRAM_BATCH_SIZE
);
// Convert web stream to Node.js stream
const nodeStream = Readable.fromWeb(response.body as any);
return new Promise((resolve, reject) => {
let dataStream: Readable = nodeStream;
if (isGzipped) {
const gunzip = createGunzip();
dataStream = nodeStream.pipe(gunzip);
gunzip.on('error', (err) => {
console.error(loggerLabel, 'Gunzip error:', err);
epgDb.close();
reject(err);
});
}
dataStream.on('data', (chunk: Buffer) => {
try {
parser.write(chunk.toString('utf-8'));
} catch (err) {
console.error(loggerLabel, 'Parse error:', err);
epgDb.close();
reject(err);
}
});
dataStream.on('end', () => {
try {
const stats = parser.finish();
console.log(
loggerLabel,
`Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs`
);
// Close database connection
epgDb.close();
// Treat empty results as failure rather than silently
// marking the URL as fetched — that causes the freshness
// check to re-trigger a fetch every session and hides the
// real problem (unreachable feed, SAX parse failure, etc.).
if (stats.totalChannels === 0) {
const errorMessage = `EPG parse produced 0 channels — feed may be unreachable or unsupported`;
console.error(loggerLabel, `${errorMessage}: ${url}`);
const response: WorkerResponse = {
type: 'EPG_ERROR',
url,
error: errorMessage,
};
parentPort?.postMessage(response);
reject(new Error(errorMessage));
return;
}
const response: WorkerResponse = {
type: 'EPG_COMPLETE',
url,
stats: {
totalChannels: stats.totalChannels,
totalPrograms: stats.totalPrograms,
},
};
parentPort?.postMessage(response);
resolve();
} catch (err) {
epgDb.close();
reject(err);
}
});
dataStream.on('error', (err) => {
console.error(loggerLabel, 'Stream error:', err);
epgDb.close();
reject(err);
});
});
} catch (error) {
epgDb.close();
throw error;
}
}
/**
* Clears all EPG data from the database
* Runs in worker thread to avoid blocking main thread
*/
function clearAllEpgData(): void {
const dbPath = getIptvnatorDatabasePath();
const db = new Database(dbPath);
try {
console.log(loggerLabel, 'Clearing all EPG data...');
// Delete programs first (foreign key constraint)
db.exec('DELETE FROM epg_programs');
// Then delete channels
db.exec('DELETE FROM epg_channels');
console.log(loggerLabel, 'All EPG data cleared');
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG data:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
};
parentPort?.postMessage(errorResponse);
} finally {
db.close();
}
}
/**
* Worker message handler
*/
if (parentPort) {
parentPort.on('message', async (message: WorkerMessage) => {
try {
if (
message.type === 'FETCH_EPG' ||
message.type === 'FORCE_FETCH'
) {
await fetchAndParseEpgStreaming(message.url!);
} else if (message.type === 'CLEAR_EPG') {
clearAllEpgData();
}
} catch (error) {
console.error(loggerLabel, 'Worker error:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
url: message.url,
};
parentPort?.postMessage(errorResponse);
}
});
// Notify parent that worker is ready
parentPort.postMessage({ type: 'READY' });
} else {
console.error(loggerLabel, 'parentPort is not available!');
}
@@ -0,0 +1,59 @@
import { shouldGunzipEpgResponse } from './epg-response-utils';
describe('shouldGunzipEpgResponse', () => {
it('returns true for original .gz URLs', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide.xml.gz', {
headers: new Headers(),
url: 'https://example.com/guide.xml.gz',
})
).toBe(true);
});
it('returns true when a redirect lands on a .gz URL', () => {
expect(
shouldGunzipEpgResponse('http://epg.vcboy.com', {
headers: new Headers(),
url: 'http://iptv-worker.sapireli.workers.dev/epg.xml.gz',
})
).toBe(true);
});
it('returns false for gzip content-encoding without a gzip payload', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: new Headers([['content-encoding', 'gzip']]),
url: 'https://example.com/guide',
})
).toBe(false);
});
it('returns true for gzip mime types', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: new Headers([['content-type', 'application/gzip']]),
url: 'https://example.com/guide',
})
).toBe(true);
});
it('returns true when content-disposition advertises a .gz filename', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: new Headers([
['content-disposition', 'attachment; filename="guide.xml.gz"'],
]),
url: 'https://example.com/guide',
})
).toBe(true);
});
it('returns false for plain XML responses', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide.xml', {
headers: new Headers([['content-type', 'application/xml']]),
url: 'https://example.com/guide.xml',
})
).toBe(false);
});
});
@@ -0,0 +1,39 @@
function hasGzipPath(url: string | null | undefined): boolean {
if (!url) {
return false;
}
try {
return new URL(url).pathname.toLowerCase().endsWith('.gz');
} catch {
return url.toLowerCase().endsWith('.gz');
}
}
/**
* Detect whether an EPG response should be gunzipped.
* Some providers redirect plain-looking URLs to a `.gz` payload.
*/
export function shouldGunzipEpgResponse(
originalUrl: string,
response: { headers: Headers; url?: string }
): boolean {
if (hasGzipPath(originalUrl) || hasGzipPath(response.url)) {
return true;
}
const contentType = response.headers.get('content-type');
if (
contentType &&
/(application\/gzip|application\/x-gzip)/i.test(contentType)
) {
return true;
}
const contentDisposition = response.headers.get('content-disposition');
if (contentDisposition?.toLowerCase().includes('.gz')) {
return true;
}
return false;
}
@@ -0,0 +1,57 @@
import {
parseXmltvDate,
StreamingEpgParser,
type ParsedChannel,
type ParsedProgram,
} from './epg-streaming-parser';
describe('parseXmltvDate', () => {
it('normalizes XMLTV timestamps with timezone offsets', () => {
expect(parseXmltvDate('20260415053700 +0000')).toBe(
'2026-04-15T05:37:00+00:00'
);
});
});
describe('StreamingEpgParser', () => {
it('flushes pending channels before the first programme batch', () => {
const callbackOrder: string[] = [];
const channelIdsByBatch: string[][] = [];
const programmeChannelsByBatch: string[][] = [];
const parser = new StreamingEpgParser(
(channels: ParsedChannel[]) => {
callbackOrder.push('channels');
channelIdsByBatch.push(channels.map((channel) => channel.id));
},
(programs: ParsedProgram[]) => {
callbackOrder.push('programs');
programmeChannelsByBatch.push(
programs.map((program) => program.channel)
);
},
() => undefined
);
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<tv>',
...Array.from({ length: 101 }, (_, index) => {
const id = `channel-${index + 1}`;
return `<channel id="${id}"><display-name>${id}</display-name></channel>`;
}),
'<programme start="20260415053700 +0000" stop="20260415062100 +0000" channel="channel-101"><title>Late channel</title></programme>',
'</tv>',
].join('');
parser.write(xml);
parser.finish();
expect(callbackOrder).toEqual(['channels', 'channels', 'programs']);
expect(channelIdsByBatch).toEqual([
Array.from({ length: 100 }, (_, index) => `channel-${index + 1}`),
['channel-101'],
]);
expect(programmeChannelsByBatch).toEqual([['channel-101']]);
});
});
@@ -0,0 +1,309 @@
import { SaxesParser, SaxesTagPlain } from 'saxes';
export interface ParsedTextValue {
lang: string;
value: string;
}
export interface ParsedIcon {
src: string;
width?: number;
height?: number;
}
export interface ParsedRating {
system: string;
value: string;
}
export interface ParsedEpisodeNum {
system: string;
value: string;
}
export interface ParsedChannel {
id: string;
displayName: ParsedTextValue[];
icon: ParsedIcon[];
url: string[];
}
export interface ParsedProgram {
start: string;
stop: string;
channel: string;
title: ParsedTextValue[];
desc: ParsedTextValue[];
category: ParsedTextValue[];
date: string;
episodeNum: ParsedEpisodeNum[];
icon: ParsedIcon[];
rating: ParsedRating[];
}
/**
* Parse XMLTV datetime format to ISO string
* Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss
*/
export function parseXmltvDate(dateStr: string): string {
if (!dateStr) return '';
const match = dateStr.match(
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/
);
if (!match) return dateStr;
const [, year, month, day, hour, minute, second, tz] = match;
let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
if (tz) {
isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`;
} else {
isoString += 'Z';
}
return isoString;
}
/**
* Streaming EPG parser using SAX
*/
export class StreamingEpgParser {
private parser: SaxesParser;
private channels: ParsedChannel[] = [];
private programs: ParsedProgram[] = [];
private totalChannels = 0;
private totalPrograms = 0;
// Current element being parsed
private currentChannel: Partial<ParsedChannel> | null = null;
private currentProgram: Partial<ParsedProgram> | null = null;
private currentTextContent = '';
private currentLang = '';
// For nested elements
private elementStack: string[] = [];
constructor(
private readonly onChannelsBatch: (channels: ParsedChannel[]) => void,
private readonly onProgramsBatch: (programs: ParsedProgram[]) => void,
private readonly onProgress: (channels: number, programs: number) => void,
private readonly channelBatchSize = 100,
private readonly programBatchSize = 1000
) {
this.parser = new SaxesParser();
this.setupParser();
}
private setupParser(): void {
this.parser.on('opentag', (tag: SaxesTagPlain) => {
this.elementStack.push(tag.name);
this.currentTextContent = '';
switch (tag.name) {
case 'channel':
this.currentChannel = {
id: (tag.attributes['id'] as string) || '',
displayName: [],
icon: [],
url: [],
};
break;
case 'programme':
// XMLTV feeds typically emit all <channel> entries before
// the first <programme>. Flush any pending channel batch
// so downstream program consumers can resolve late-channel
// IDs immediately instead of dropping the first rows.
this.flushChannels();
this.currentProgram = {
start: parseXmltvDate(
(tag.attributes['start'] as string) || ''
),
stop: parseXmltvDate(
(tag.attributes['stop'] as string) || ''
),
channel: (tag.attributes['channel'] as string) || '',
title: [],
desc: [],
category: [],
date: '',
episodeNum: [],
icon: [],
rating: [],
};
break;
case 'icon':
if (this.currentChannel) {
this.currentChannel.icon!.push({
src: (tag.attributes['src'] as string) || '',
width: tag.attributes['width']
? parseInt(tag.attributes['width'] as string)
: undefined,
height: tag.attributes['height']
? parseInt(tag.attributes['height'] as string)
: undefined,
});
} else if (this.currentProgram) {
this.currentProgram.icon!.push({
src: (tag.attributes['src'] as string) || '',
width: tag.attributes['width']
? parseInt(tag.attributes['width'] as string)
: undefined,
height: tag.attributes['height']
? parseInt(tag.attributes['height'] as string)
: undefined,
});
}
break;
case 'display-name':
case 'title':
case 'desc':
case 'category':
this.currentLang = (tag.attributes['lang'] as string) || '';
break;
case 'rating':
if (this.currentProgram) {
const system =
(tag.attributes['system'] as string) || '';
this.currentProgram.rating!.push({ system, value: '' });
}
break;
case 'episode-num':
if (this.currentProgram) {
const system =
(tag.attributes['system'] as string) || '';
this.currentProgram.episodeNum!.push({
system,
value: '',
});
}
break;
}
});
this.parser.on('text', (text: string) => {
this.currentTextContent += text;
});
this.parser.on('closetag', (tag: SaxesTagPlain) => {
const text = this.currentTextContent.trim();
if (this.currentChannel) {
switch (tag.name) {
case 'display-name':
this.currentChannel.displayName!.push({
lang: this.currentLang,
value: text,
});
break;
case 'url':
if (text) this.currentChannel.url!.push(text);
break;
case 'channel':
this.channels.push(this.currentChannel as ParsedChannel);
this.totalChannels++;
this.currentChannel = null;
if (this.channels.length >= this.channelBatchSize) {
this.flushChannels();
}
break;
}
}
if (this.currentProgram) {
switch (tag.name) {
case 'title':
this.currentProgram.title!.push({
lang: this.currentLang,
value: text,
});
break;
case 'desc':
this.currentProgram.desc!.push({
lang: this.currentLang,
value: text,
});
break;
case 'category':
this.currentProgram.category!.push({
lang: this.currentLang,
value: text,
});
break;
case 'date':
this.currentProgram.date = text;
break;
case 'value':
if (
this.elementStack.includes('rating') &&
this.currentProgram.rating!.length > 0
) {
this.currentProgram.rating![
this.currentProgram.rating!.length - 1
].value = text;
}
break;
case 'episode-num':
if (this.currentProgram.episodeNum!.length > 0) {
this.currentProgram.episodeNum![
this.currentProgram.episodeNum!.length - 1
].value = text;
}
break;
case 'programme':
this.programs.push(this.currentProgram as ParsedProgram);
this.totalPrograms++;
if (this.programs.length >= this.programBatchSize) {
this.flushChannels();
this.flushPrograms();
}
this.currentProgram = null;
break;
}
}
this.elementStack.pop();
this.currentTextContent = '';
});
}
private flushChannels(): void {
if (this.channels.length > 0) {
this.onChannelsBatch([...this.channels]);
this.channels = [];
this.onProgress(this.totalChannels, this.totalPrograms);
}
}
private flushPrograms(): void {
if (this.programs.length > 0) {
this.onProgramsBatch([...this.programs]);
this.programs = [];
this.onProgress(this.totalChannels, this.totalPrograms);
}
}
write(chunk: string): void {
this.parser.write(chunk);
}
finish(): { totalChannels: number; totalPrograms: number } {
this.parser.close();
this.flushChannels();
this.flushPrograms();
return {
totalChannels: this.totalChannels,
totalPrograms: this.totalPrograms,
};
}
}
@@ -0,0 +1,188 @@
import axios from 'axios';
import { parentPort } from 'worker_threads';
import { parse } from 'iptv-playlist-parser';
import { readFile } from 'node:fs/promises';
import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils';
import type {
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from 'shared-interfaces';
import type {
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage,
} from './playlist-refresh.worker.types';
const https = require('https');
type ActiveRefreshState = {
cancelled: boolean;
controller: AbortController;
};
const activeRefreshes = new Map<string, ActiveRefreshState>();
if (!parentPort) {
throw new Error('Playlist refresh worker must be started with a parent port');
}
function postMessage(message: PlaylistRefreshWorkerMessage<Playlist>): void {
parentPort?.postMessage(message);
}
function serializeError(error: unknown) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return {
message: String(error),
};
}
function createAbortError(operationId: string): Error {
const error = new Error(`Playlist refresh "${operationId}" was cancelled`);
error.name = 'AbortError';
return error;
}
function emitEvent(
payload: PlaylistRefreshPayload,
partial: Omit<PlaylistRefreshEvent, 'operationId' | 'playlistId'>
): void {
postMessage({
type: 'event',
event: {
operationId: payload.operationId,
playlistId: payload.playlistId,
...partial,
},
});
}
function checkpoint(payload: PlaylistRefreshPayload): void {
const active = activeRefreshes.get(payload.operationId);
if (active?.cancelled) {
throw createAbortError(payload.operationId);
}
}
async function fetchPlaylistFromUrl(
payload: PlaylistRefreshPayload,
controller: AbortController
): Promise<Playlist> {
emitEvent(payload, { status: 'started', phase: 'fetching' });
checkpoint(payload);
const agent = new https.Agent({
rejectUnauthorized: false,
});
const result = await axios.get(payload.url!, {
httpsAgent: agent,
signal: controller.signal,
timeout: 30000,
});
checkpoint(payload);
emitEvent(payload, { status: 'progress', phase: 'parsing' });
const parsedPlaylist = parse(result.data);
checkpoint(payload);
const extractedName =
payload.url && payload.url.length > 1
? getFilenameFromUrl(payload.url)
: '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
return createPlaylistObject(
payload.title || playlistName,
parsedPlaylist,
payload.url,
'URL'
);
}
async function fetchPlaylistFromFile(
payload: PlaylistRefreshPayload
): Promise<Playlist> {
emitEvent(payload, { status: 'started', phase: 'reading-file' });
checkpoint(payload);
const fileContent = await readFile(payload.filePath!, 'utf-8');
checkpoint(payload);
emitEvent(payload, { status: 'progress', phase: 'parsing' });
const parsedPlaylist = parse(fileContent);
checkpoint(payload);
return createPlaylistObject(
payload.title,
parsedPlaylist,
payload.filePath,
'FILE'
);
}
async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist> {
const controller = new AbortController();
activeRefreshes.set(payload.operationId, {
cancelled: false,
controller,
});
try {
const playlist = payload.url
? await fetchPlaylistFromUrl(payload, controller)
: await fetchPlaylistFromFile(payload);
checkpoint(payload);
return playlist;
} finally {
activeRefreshes.delete(payload.operationId);
}
}
parentPort.on('message', async (message: PlaylistRefreshWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const active = activeRefreshes.get(message.operationId);
if (active) {
active.cancelled = true;
active.controller.abort();
}
return;
}
try {
const result = await executeRefresh(message.payload);
postMessage({
type: 'response',
success: true,
result,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error: error instanceof Error ? error.message : String(error),
});
}
postMessage({
type: 'response',
success: false,
error: serializeError(error),
});
}
});
postMessage({ type: 'ready' });
@@ -0,0 +1,40 @@
import type { PlaylistRefreshEvent, PlaylistRefreshPayload } from 'shared-interfaces';
export interface PlaylistRefreshWorkerRequestMessage {
type: 'request';
payload: PlaylistRefreshPayload;
}
export interface PlaylistRefreshWorkerCancelMessage {
type: 'cancel';
operationId: string;
}
export interface PlaylistRefreshWorkerReadyMessage {
type: 'ready';
}
export interface PlaylistRefreshWorkerEventMessage {
type: 'event';
event: PlaylistRefreshEvent;
}
export interface PlaylistRefreshWorkerResponseMessage<TResult = unknown> {
type: 'response';
success: boolean;
result?: TResult;
error?: {
name?: string;
message: string;
stack?: string;
};
}
export type PlaylistRefreshWorkerIncomingMessage =
| PlaylistRefreshWorkerRequestMessage
| PlaylistRefreshWorkerCancelMessage;
export type PlaylistRefreshWorkerMessage<TResult = unknown> =
| PlaylistRefreshWorkerReadyMessage
| PlaylistRefreshWorkerEventMessage
| PlaylistRefreshWorkerResponseMessage<TResult>;
@@ -0,0 +1,180 @@
import * as path from 'path';
import {
getNativeModuleSearchPaths,
loadNativeModuleFromSearchPaths,
registerNativeModuleSearchPaths,
resolveWorkerRuntimeBootstrap,
} from './worker-runtime-paths';
describe('worker-runtime-paths', () => {
it('resolves the development worker path when the worker file exists', () => {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: false,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/workspace/dist/apps/electron-backend/workers',
fileExists: (filePath) =>
filePath ===
'/workspace/dist/apps/electron-backend/workers/database.worker.js',
});
expect(bootstrap).toEqual({
workerPath:
'/workspace/dist/apps/electron-backend/workers/database.worker.js',
workerPathCandidates: [
'/workspace/dist/apps/electron-backend/workers/database.worker.js',
],
});
});
it('prefers process.resourcesPath for packaged worker resolution', () => {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/Applications/IPTVnator.app/Contents/Resources',
appPath:
'/Applications/IPTVnator.app/Contents/Resources/app.asar',
fileExists: (filePath) =>
filePath ===
'/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js',
});
expect(bootstrap.workerPath).toBe(
'/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js'
);
expect(bootstrap.nativeModuleSearchPaths).toEqual([
'/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/node_modules',
'/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/electron-backend/node_modules',
'/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/dist/apps/electron-backend/node_modules',
]);
});
it('falls back to appPath dirname when the resourcesPath candidate is missing', () => {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/tmp/runtime-resources',
appPath: '/opt/IPTVnator/resources/app.asar',
fileExists: (filePath) =>
filePath ===
'/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js',
});
expect(bootstrap.workerPath).toBe(
'/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js'
);
expect(bootstrap.workerPathCandidates).toEqual([
'/tmp/runtime-resources/dist/apps/electron-backend/workers/database.worker.js',
'/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js',
]);
});
it('throws actionable errors when the worker file cannot be resolved', () => {
expect(() =>
resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/resources',
appPath: '/opt/IPTVnator/resources/app.asar',
fileExists: () => false,
})
).toThrow(
[
'Unable to resolve worker "database.worker.js".',
'Tried:',
'- /resources/dist/apps/electron-backend/workers/database.worker.js',
'- /opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js',
].join('\n')
);
});
it('de-duplicates native module search paths when resource roots match', () => {
expect(
getNativeModuleSearchPaths({
resourcesPath: '/resources',
appPath: '/resources/app.asar',
})
).toEqual([
'/resources/app.asar.unpacked/node_modules',
'/resources/app.asar.unpacked/electron-backend/node_modules',
'/resources/app.asar.unpacked/dist/apps/electron-backend/node_modules',
]);
});
it('loads native modules from the first working candidate path', () => {
const requireFactory = jest.fn((entryPath: string) => {
if (entryPath === path.join('/second', 'index.js')) {
return ((moduleName: string) => ({
moduleName,
entryPath,
})) as NodeRequire;
}
throw new Error('bad path');
});
const moduleValue = loadNativeModuleFromSearchPaths({
moduleName: 'better-sqlite3',
loggerLabel: '[Worker]',
searchPaths: ['/first', '/second'],
fileExists: (searchPath) => searchPath !== '/first-missing',
requireFactory,
});
expect(moduleValue).toEqual({
moduleName: 'better-sqlite3',
entryPath: path.join('/second', 'index.js'),
});
});
it('registers native module search paths in NODE_PATH and module globalPaths', () => {
const env: NodeJS.ProcessEnv = {};
const moduleApi = {
globalPaths: ['/global'],
};
const registeredPaths = registerNativeModuleSearchPaths(
['/first', '/second', '/global'],
{
env,
moduleApi,
}
);
expect(registeredPaths).toEqual(['/first', '/second', '/global']);
expect(env.NODE_PATH).toBe(
['/first', '/second', '/global'].join(path.delimiter)
);
expect(moduleApi.globalPaths).toEqual(['/first', '/second', '/global']);
});
it('throws aggregated errors when no native module candidate works', () => {
expect(() =>
loadNativeModuleFromSearchPaths({
moduleName: 'better-sqlite3',
loggerLabel: '[Worker]',
searchPaths: ['/missing', '/broken'],
fileExists: (searchPath) => searchPath === '/broken',
requireFactory: () => {
throw new Error('Cannot find module');
},
fallbackRequire: () => {
throw new Error('fallback failed');
},
})
).toThrow(
[
'[Worker] Unable to load native module "better-sqlite3".',
'Tried:',
'- /missing',
'- /broken',
'Failures:',
'- /missing (missing)',
'- /broken (Cannot find module)',
'- require(better-sqlite3) (fallback failed)',
].join('\n')
);
});
});
@@ -0,0 +1,261 @@
import { existsSync } from 'fs';
import { createRequire } from 'module';
import * as path from 'path';
const WORKER_RELATIVE_PATH = [
'dist',
'apps',
'electron-backend',
'workers',
] as const;
const NATIVE_MODULE_RELATIVE_PATHS = [
['app.asar.unpacked', 'node_modules'],
['app.asar.unpacked', 'electron-backend', 'node_modules'],
['app.asar.unpacked', 'dist', 'apps', 'electron-backend', 'node_modules'],
] as const;
type FileExists = (filePath: string) => boolean;
type ModuleRequireFactory = typeof createRequire;
type NodeModuleApi = {
globalPaths: string[];
_initPaths?: () => void;
};
export interface WorkerBootstrapData {
nativeModuleSearchPaths?: string[];
}
export interface ResolveWorkerRuntimeBootstrapOptions {
isPackaged: boolean;
workerFilename: string;
developmentWorkerDir: string;
resourcesPath?: string;
appPath?: string;
fileExists?: FileExists;
}
export interface WorkerRuntimeBootstrap {
workerPath: string;
workerPathCandidates: string[];
nativeModuleSearchPaths?: string[];
}
export interface LoadNativeModuleOptions<TModule> {
moduleName: string;
searchPaths: string[];
loggerLabel: string;
fallbackRequire?: () => TModule;
fileExists?: FileExists;
requireFactory?: ModuleRequireFactory;
}
function dedupePaths(paths: Array<string | undefined>): string[] {
return [...new Set(paths.filter((value): value is string => Boolean(value)))];
}
export function registerNativeModuleSearchPaths(
searchPaths: string[],
options?: {
env?: NodeJS.ProcessEnv;
moduleApi?: NodeModuleApi;
}
): string[] {
const env = options?.env ?? process.env;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const moduleApi =
options?.moduleApi ??
(require('module') as NodeModuleApi);
const mergedPaths = dedupePaths([
...searchPaths,
...(env.NODE_PATH?.split(path.delimiter) ?? []),
...moduleApi.globalPaths,
]);
if (mergedPaths.length === 0) {
return [];
}
env.NODE_PATH = mergedPaths.join(path.delimiter);
if (typeof moduleApi._initPaths === 'function') {
moduleApi._initPaths();
} else {
moduleApi.globalPaths.splice(0, moduleApi.globalPaths.length, ...mergedPaths);
}
for (let index = mergedPaths.length - 1; index >= 0; index -= 1) {
const currentPath = mergedPaths[index];
if (!moduleApi.globalPaths.includes(currentPath)) {
moduleApi.globalPaths.unshift(currentPath);
}
}
return mergedPaths;
}
export function getPackagedResourceRoots(options: {
resourcesPath?: string;
appPath?: string;
}): string[] {
return dedupePaths([
options.resourcesPath,
options.appPath ? path.dirname(options.appPath) : undefined,
]);
}
export function getNativeModuleSearchPaths(options: {
resourcesPath?: string;
appPath?: string;
}): string[] {
return dedupePaths(
getPackagedResourceRoots(options).flatMap((root) =>
NATIVE_MODULE_RELATIVE_PATHS.map((segments) =>
path.join(root, ...segments)
)
)
);
}
export function getPackagedWorkerPathCandidates(options: {
workerFilename: string;
resourcesPath?: string;
appPath?: string;
}): string[] {
return dedupePaths(
getPackagedResourceRoots(options).map((root) =>
path.join(root, ...WORKER_RELATIVE_PATH, options.workerFilename)
)
);
}
function createWorkerResolutionError(
workerFilename: string,
workerPathCandidates: string[]
): Error {
const error = new Error(
[
`Unable to resolve worker "${workerFilename}".`,
'Tried:',
...workerPathCandidates.map((candidate) => `- ${candidate}`),
].join('\n')
);
error.name = 'WorkerPathResolutionError';
return error;
}
export function resolveWorkerRuntimeBootstrap(
options: ResolveWorkerRuntimeBootstrapOptions
): WorkerRuntimeBootstrap {
const fileExists = options.fileExists ?? existsSync;
if (!options.isPackaged) {
const workerPath = path.join(
options.developmentWorkerDir,
options.workerFilename
);
const workerPathCandidates = [workerPath];
if (!fileExists(workerPath)) {
throw createWorkerResolutionError(
options.workerFilename,
workerPathCandidates
);
}
return { workerPath, workerPathCandidates };
}
const workerPathCandidates = getPackagedWorkerPathCandidates(options);
const workerPath = workerPathCandidates.find((candidate) =>
fileExists(candidate)
);
if (!workerPath) {
throw createWorkerResolutionError(
options.workerFilename,
workerPathCandidates
);
}
return {
workerPath,
workerPathCandidates,
nativeModuleSearchPaths: getNativeModuleSearchPaths(options),
};
}
export function getWorkerDataNativeModuleSearchPaths(
value: unknown
): string[] {
if (!value || typeof value !== 'object') {
return [];
}
if (!('nativeModuleSearchPaths' in value)) {
return [];
}
const searchPaths = value.nativeModuleSearchPaths;
if (!Array.isArray(searchPaths)) {
return [];
}
return dedupePaths(
searchPaths.filter(
(searchPath): searchPath is string => typeof searchPath === 'string'
)
);
}
export function loadNativeModuleFromSearchPaths<TModule>(
options: LoadNativeModuleOptions<TModule>
): TModule {
const fileExists = options.fileExists ?? existsSync;
const requireFactory = options.requireFactory ?? createRequire;
const attemptedPaths: string[] = [];
const failedPaths: string[] = [];
for (const searchPath of dedupePaths(options.searchPaths)) {
attemptedPaths.push(searchPath);
if (!fileExists(searchPath)) {
failedPaths.push(`${searchPath} (missing)`);
continue;
}
try {
const nativeRequire = requireFactory(
path.join(searchPath, 'index.js')
);
return nativeRequire(options.moduleName) as TModule;
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
failedPaths.push(`${searchPath} (${message})`);
}
}
if (options.fallbackRequire) {
try {
return options.fallbackRequire();
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
failedPaths.push(`require(${options.moduleName}) (${message})`);
}
}
const error = new Error(
[
`${options.loggerLabel} Unable to load native module "${options.moduleName}".`,
'Tried:',
...attemptedPaths.map((searchPath) => `- ${searchPath}`),
...(failedPaths.length > 0 ? ['Failures:', ...failedPaths.map((failure) => `- ${failure}`)] : []),
].join('\n')
);
error.name = 'NativeModuleResolutionError';
throw error;
}
@@ -0,0 +1 @@
Assets Folder
@@ -0,0 +1,7 @@
declare const __BUILD_VERSION__: string;
export const environment = {
production: true,
version: __BUILD_VERSION__,
windowTitle: 'KiraTV',
};
@@ -0,0 +1,7 @@
declare const __BUILD_VERSION__: string;
export const environment = {
production: true,
version: __BUILD_VERSION__,
windowTitle: 'IPTVnator',
};
@@ -0,0 +1,7 @@
declare const __BUILD_VERSION__: string;
export const environment = {
production: false,
version: __BUILD_VERSION__,
windowTitle: 'IPTVnator',
};
+109
View File
@@ -0,0 +1,109 @@
import { app, BrowserWindow } from 'electron';
import { getElectronUserDataPath } from 'database';
import fixPath from 'fix-path';
import App from './app/app';
import { initDatabase } from './app/database/connection';
import DatabaseEvents from './app/events/database.events';
import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from './app/events/database/downloads.events';
import ElectronEvents from './app/events/electron.events';
import EpgEvents from './app/events/epg.events';
import PlayerEvents from './app/events/player.events';
import PlaylistEvents from './app/events/playlist.events';
import RemoteControlEvents from './app/events/remote-control.events';
import SettingsEvents from './app/events/settings.events';
import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
import { databaseWorkerClient } from './app/services/database-worker-client';
import XtreamEvents from './app/events/xtream.events';
app.setName('iptvnator');
const electronUserDataPath = getElectronUserDataPath();
if (electronUserDataPath) {
app.setPath('userData', electronUserDataPath);
}
export default class Main {
static initialize() {
if (SquirrelEvents.handleEvents()) {
// squirrel event handled (except first run event) and app will exit in 1000ms, so don't do anything else
app.quit();
}
}
static bootstrapApp() {
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-app');
}
App.main(app, BrowserWindow);
}
static async bootstrapAppEvents() {
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:start');
}
// Initialize database before other events
await initDatabase();
if (isStartupTraceEnabled()) {
trace('startup', 'init-database:done');
}
ElectronEvents.bootstrapElectronEvents();
PlaylistEvents.bootstrapPlaylistEvents();
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Set main window for downloads and reset stale downloads
if (App.mainWindow) {
setDownloadsMainWindow(App.mainWindow);
}
await resetStaleDownloads();
if (isStartupTraceEnabled()) {
trace('startup', 'reset-stale-downloads:done');
}
// initialize auto updater service
if (!App.isDevelopmentMode()) {
// UpdateEvents.initAutoUpdateService();
}
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
}
}
}
fixPath();
// handle setup events as quickly as possible
Main.initialize();
// bootstrap app
Main.bootstrapApp();
// Bootstrap app events after Electron app is ready
app.whenReady().then(async () => {
if (isStartupTraceEnabled()) {
trace('startup', 'app.whenReady');
}
await Main.bootstrapAppEvents();
});
app.on('before-quit', () => {
void databaseWorkerClient.shutdown();
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"files": ["../../global.d.ts"],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
],
"include": ["**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"moduleResolution": "nodenext",
"module": "NodeNext"
},
"include": ["**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}