Proyecto App Linux
This commit is contained in:
@@ -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();
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "NodeNext"
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user