diff --git a/CLAUDE.md b/CLAUDE.md index 0d97a20b8..757c186cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -549,7 +549,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - `epg.events.ts` - EPG fetch and parsing (uses worker) - `xtream.events.ts` - Xtream Codes API - `stalker.events.ts` - Stalker portal API - - `player.events.ts` - External player (MPV, VLC) integration + - `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers - `settings.events.ts` - App settings - `electron.events.ts` - App version, etc. diff --git a/apps/electron-backend/src/app/events/external-player-launch-context.ts b/apps/electron-backend/src/app/events/external-player-launch-context.ts new file mode 100644 index 000000000..e4908922b --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-launch-context.ts @@ -0,0 +1,338 @@ +import path from 'path'; +import { + ExternalPlayerName, + parseExternalPlayerArguments, + type ExternalPlayerArgumentsInput, +} from '@iptvnator/shared/interfaces'; +import { existsSync, readdirSync } from 'fs'; + +export type PathExists = (path: string) => boolean; +export type ReadDirectory = (path: string) => string[]; + +export type ExternalPlayerLaunchMode = 'direct' | 'flatpak-host'; + +export interface PlayerPathOptions { + platform?: NodeJS.Platform; + isFlatpak?: boolean; + pathExists?: PathExists; + readDirectory?: ReadDirectory; +} + +export interface ExternalPlayerLaunchContext { + mode: ExternalPlayerLaunchMode; + playerPath: string; + command: string; + argsPrefix: string[]; +} + +export interface ExternalPlayerSpawnSpec { + mode: ExternalPlayerLaunchMode; + playerPath: string; + command: string; + args: string[]; +} + +export function isRunningInFlatpak( + pathExists: PathExists = existsSync, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'linux' && pathExists('/.flatpak-info'); +} + +export function normalizeCustomPlayerPath( + value: string | null | undefined +): string | null { + const trimmedValue = value?.trim(); + return trimmedValue ? trimmedValue : null; +} + +export function normalizePlayerPathForStore( + value: string | null | undefined +): string { + return normalizeCustomPlayerPath(value) ?? ''; +} + +const macOSAppBundleExecutableNames: Record = { + mpv: 'mpv', + vlc: 'VLC', +}; + +function getMacOSAppBundleExecutableName(player: ExternalPlayerName): string { + return macOSAppBundleExecutableNames[player]; +} + +function removeTrailingPathSeparators(value: string): string { + return value.replace(/[\\/]+$/, '') || value; +} + +function resolveMacOSAppBundlePlayerPath( + player: ExternalPlayerName, + playerPath: string, + platform: NodeJS.Platform +): string { + if (platform !== 'darwin') { + return playerPath; + } + + const appBundlePath = removeTrailingPathSeparators(playerPath); + + if (!/\.app$/i.test(appBundlePath)) { + return playerPath; + } + + return path.join( + appBundlePath, + 'Contents', + 'MacOS', + getMacOSAppBundleExecutableName(player) + ); +} + +function getDefaultPlayerPath( + player: ExternalPlayerName, + options: PlayerPathOptions = {} +): string { + const { + platform = process.platform, + isFlatpak = isRunningInFlatpak(), + pathExists = existsSync, + readDirectory = readdirSync, + } = options; + + if (platform === 'linux' && isFlatpak) { + return player; + } + + if (player === 'mpv') { + return getDefaultMpvPath({ + platform, + isFlatpak, + pathExists, + readDirectory, + }); + } + + return getDefaultVlcPath({ + platform, + isFlatpak, + pathExists, + readDirectory, + }); +} + +export function resolveExternalPlayerLaunchContext( + player: ExternalPlayerName, + customPlayerPath?: string, + options: PlayerPathOptions = {} +): ExternalPlayerLaunchContext { + const { + platform = process.platform, + isFlatpak = isRunningInFlatpak(), + pathExists = existsSync, + readDirectory = readdirSync, + } = options; + const playerPath = + normalizeCustomPlayerPath(customPlayerPath) ?? + getDefaultPlayerPath(player, { + platform, + isFlatpak, + pathExists, + readDirectory, + }); + const resolvedPlayerPath = resolveMacOSAppBundlePlayerPath( + player, + playerPath, + platform + ); + + if (platform === 'linux' && isFlatpak) { + return { + mode: 'flatpak-host', + playerPath: resolvedPlayerPath, + command: 'flatpak-spawn', + argsPrefix: ['--host', '--watch-bus', resolvedPlayerPath], + }; + } + + return { + mode: 'direct', + playerPath: resolvedPlayerPath, + command: resolvedPlayerPath, + argsPrefix: [], + }; +} + +export function buildExternalPlayerSpawnSpec( + launchContext: ExternalPlayerLaunchContext, + playerArgs: string[] +): ExternalPlayerSpawnSpec { + return { + mode: launchContext.mode, + playerPath: launchContext.playerPath, + command: launchContext.command, + args: [...launchContext.argsPrefix, ...playerArgs], + }; +} + +export { parseExternalPlayerArguments }; + +export function buildPlayerArgsWithCustomArguments( + customArguments: ExternalPlayerArgumentsInput, + playerArgs: string[] +): string[] { + return [...parseExternalPlayerArguments(customArguments), ...playerArgs]; +} + +export function shouldReuseMpvInstance( + requestedReuseInstance: boolean, + isFlatpak: boolean = isRunningInFlatpak() +): boolean { + return !isFlatpak && requestedReuseInstance; +} + +export function shouldUseMpvSocketBridge( + isFlatpak: boolean = isRunningInFlatpak() +): boolean { + return !isFlatpak; +} + +export function shouldReuseVlcInstance( + requestedReuseInstance: boolean, + isFlatpak: boolean = isRunningInFlatpak() +): boolean { + return !isFlatpak && requestedReuseInstance; +} + +export function getDefaultMpvPath(options: PlayerPathOptions = {}): string { + const { + platform = process.platform, + isFlatpak = isRunningInFlatpak(), + pathExists = existsSync, + } = options; + + if (platform === 'linux' && isFlatpak) { + return 'mpv'; + } + + if (platform === 'win32') { + const windowsPaths = [ + path.join('C:', 'Program Files', 'mpv', 'mpv.exe'), + path.join('C:', 'Program Files (x86)', 'mpv', 'mpv.exe'), + ]; + + for (const mpvPath of windowsPaths) { + if (pathExists(mpvPath)) { + return mpvPath; + } + } + return 'mpv'; + } else if (platform === 'linux') { + const linuxPaths = [ + '/usr/bin/mpv', + '/usr/local/bin/mpv', + '/snap/bin/mpv', + ]; + + for (const mpvPath of linuxPaths) { + if (pathExists(mpvPath)) { + return mpvPath; + } + } + return 'mpv'; + } else if (platform === 'darwin') { + const macosPaths = [ + '/Applications/mpv.app/Contents/MacOS/mpv', + '/opt/homebrew/bin/mpv', + '/usr/local/bin/mpv', + ]; + + for (const mpvPath of macosPaths) { + if (pathExists(mpvPath)) { + return mpvPath; + } + } + return 'mpv'; + } + + return 'mpv'; +} + +export function getDefaultVlcPath(options: PlayerPathOptions = {}): string { + const { + platform = process.platform, + isFlatpak = isRunningInFlatpak(), + pathExists = existsSync, + readDirectory = readdirSync, + } = options; + + if (platform === 'linux' && isFlatpak) { + return 'vlc'; + } + + if (platform === 'win32') { + const windowsPaths = [ + path.join('C:', 'Program Files', 'VideoLAN', 'VLC', 'vlc.exe'), + path.join( + 'C:', + 'Program Files (x86)', + 'VideoLAN', + 'VLC', + 'vlc.exe' + ), + ]; + + for (const vlcPath of windowsPaths) { + if (pathExists(vlcPath)) { + return vlcPath; + } + } + return 'vlc'; + } else if (platform === 'linux') { + const linuxPaths = [ + '/usr/bin/vlc', + '/usr/local/bin/vlc', + '/snap/bin/vlc', + ]; + + for (const vlcPath of linuxPaths) { + if (pathExists(vlcPath)) { + return vlcPath; + } + } + return 'vlc'; + } else if (platform === 'darwin') { + const macosPaths = [ + '/Applications/VLC.app/Contents/MacOS/VLC', + ...getHomebrewCaskVlcPaths(readDirectory), + ]; + + for (const vlcPath of macosPaths) { + if (pathExists(vlcPath)) { + return vlcPath; + } + } + return 'vlc'; + } + return 'vlc'; +} + +function getHomebrewCaskVlcPaths(readDirectory: ReadDirectory): string[] { + const caskroomPath = '/opt/homebrew/Caskroom/vlc'; + + try { + return readDirectory(caskroomPath) + .filter((entry) => entry.trim().length > 0) + .map((entry) => + path.join( + caskroomPath, + entry, + 'VLC.app', + 'Contents', + 'MacOS', + 'VLC' + ) + ); + } catch { + return []; + } +} diff --git a/apps/electron-backend/src/app/events/external-player-playback-request.ts b/apps/electron-backend/src/app/events/external-player-playback-request.ts new file mode 100644 index 000000000..d28f22223 --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-playback-request.ts @@ -0,0 +1,85 @@ +import { getStalkerPlaybackContextHeaders } from '../services/stalker-playback-context.service'; + +export interface EffectiveExternalPlaybackRequest { + mergedHeaders: Record; + effectiveOrigin?: string; + effectiveReferer?: string; + effectiveUserAgent?: string; + headerFields: string[]; +} + +export function buildHttpHeaderFields( + origin?: string, + headers?: Record +): string[] { + const fields: string[] = []; + const normalizedHeaders = headers ?? {}; + + if ( + origin && + normalizedHeaders['Origin'] === undefined && + normalizedHeaders['origin'] === undefined + ) { + fields.push(`Origin: ${origin}`); + } + + Object.entries(normalizedHeaders).forEach(([name, value]) => { + if (!name || value === undefined || value === null) return; + const trimmedValue = String(value).trim(); + if (!trimmedValue) return; + fields.push(`${name}: ${trimmedValue}`); + }); + + return fields; +} + +export function isStalkerDirectStreamProfile( + headers: Record +): boolean { + const icyMetaData = headers['Icy-MetaData'] ?? headers['icy-metadata']; + const userAgent = headers['User-Agent'] ?? headers['user-agent']; + + return ( + String(icyMetaData).trim() === '1' && + String(userAgent).trim().toLowerCase() === 'ksplayer' + ); +} + +export function resolveEffectiveExternalPlaybackRequest(options: { + url: string; + userAgent?: string; + referer?: string; + origin?: string; + headers?: Record; +}): EffectiveExternalPlaybackRequest { + const fallbackHeaders = getStalkerPlaybackContextHeaders(options.url) ?? {}; + const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders) + ? fallbackHeaders + : { + ...fallbackHeaders, + ...(options.headers ?? {}), + }; + const effectiveOrigin = + options.origin ?? + mergedHeaders['Origin'] ?? + mergedHeaders['origin'] ?? + undefined; + const effectiveReferer = + options.referer ?? + mergedHeaders['Referer'] ?? + mergedHeaders['referer'] ?? + undefined; + const effectiveUserAgent = + options.userAgent ?? + mergedHeaders['User-Agent'] ?? + mergedHeaders['user-agent'] ?? + undefined; + + return { + mergedHeaders, + effectiveOrigin, + effectiveReferer, + effectiveUserAgent, + headerFields: buildHttpHeaderFields(effectiveOrigin, mergedHeaders), + }; +} diff --git a/apps/electron-backend/src/app/events/external-player-runtime.ts b/apps/electron-backend/src/app/events/external-player-runtime.ts new file mode 100644 index 000000000..29cfee380 --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-runtime.ts @@ -0,0 +1,118 @@ +import { + EXTERNAL_PLAYER_SESSION_UPDATE, + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; +import App from '../app'; +import { isExternalPlayerTraceEnabled, trace } from '../services/debug-trace'; +import { ExternalPlayerLaunchContext } from './external-player-launch-context'; +import { ExternalPlayerSessionRegistry } from './external-player-session-registry'; + +export interface ExternalPlaybackSnapshot { + positionSeconds: number; + durationSeconds: number | null; +} + +export function traceExternalPlayer(message: string, payload?: unknown): void { + if (!isExternalPlayerTraceEnabled()) { + return; + } + + trace('external-player', message, payload); +} + +function sendExternalPlayerSessionUpdate(session: ExternalPlayerSession): void { + if (App.mainWindow && !App.mainWindow.isDestroyed()) { + App.mainWindow.webContents.send( + EXTERNAL_PLAYER_SESSION_UPDATE, + session + ); + } +} + +export const externalPlayerSessions = new ExternalPlayerSessionRegistry( + sendExternalPlayerSessionUpdate +); + +export function buildPlayerStartError( + player: 'MPV' | 'VLC', + error: Error, + launchContext: ExternalPlayerLaunchContext +): Error { + const guidance = + launchContext.mode === 'flatpak-host' + ? `Make sure ${player} is installed on the host system and reachable via Flatpak host spawning at '${launchContext.playerPath}'.` + : `Make sure ${player} is installed and the path '${launchContext.playerPath}' is correct.`; + + return new Error( + `Failed to start ${player} player: ${error.message}. ${guidance}` + ); +} + +export function sendPlayerErrorNotification( + player: 'MPV' | 'VLC', + error: string +): void { + if (!App.mainWindow || App.mainWindow.isDestroyed()) { + return; + } + + let userMessage = error; + + if (error.includes('Failed to open')) { + userMessage = + 'Failed to open stream. The URL may be invalid or the server is not responding.'; + } else if ( + error.includes('Protocol not found') || + error.includes('Unsupported protocol') + ) { + userMessage = + 'Unsupported stream protocol. Please check the stream URL.'; + } else if ( + error.includes('Connection refused') || + error.includes('Could not connect') + ) { + userMessage = + 'Cannot connect to the stream server. Please check your internet connection.'; + } else if (error.includes('403') || error.includes('Forbidden')) { + userMessage = + 'Access denied. The stream may require valid credentials or headers.'; + } else if (error.includes('404') || error.includes('Not Found')) { + userMessage = 'Stream not found. The URL may be incorrect or expired.'; + } else if (error.includes('Timed out') || error.includes('timeout')) { + userMessage = 'Connection timed out. The server is not responding.'; + } + + App.mainWindow.webContents.send('player-error', { + player, + error: userMessage, + originalError: error, + }); +} + +export function sendPlaybackPositionUpdate( + sessionId: string, + contentInfo: PlayerContentInfo, + snapshot: ExternalPlaybackSnapshot +): void { + if (!App.mainWindow || App.mainWindow.isDestroyed()) { + return; + } + + externalPlayerSessions.markPlaying(sessionId); + App.mainWindow.webContents.send('playback-position-update', { + sessionId, + positionSeconds: snapshot.positionSeconds, + durationSeconds: snapshot.durationSeconds, + ...contentInfo, + }); +} + +export function maskUrlForLogs(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return rawUrl; + } +} diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts new file mode 100644 index 000000000..adc15746a --- /dev/null +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -0,0 +1,514 @@ +import { ChildProcess, spawn } from 'child_process'; +import { createConnection } from 'net'; +import { PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + MPV_PLAYER_ARGUMENTS, + MPV_PLAYER_PATH, + MPV_REUSE_INSTANCE, + store, +} from '../services/store.service'; +import { + buildExternalPlayerSpawnSpec, + buildPlayerArgsWithCustomArguments, + getDefaultMpvPath, + isRunningInFlatpak, + normalizeCustomPlayerPath, + parseExternalPlayerArguments, + PlayerPathOptions, + resolveExternalPlayerLaunchContext, + shouldReuseMpvInstance, + shouldUseMpvSocketBridge, +} from './external-player-launch-context'; +import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request'; +import { + buildPlayerStartError, + externalPlayerSessions, + maskUrlForLogs, + sendPlaybackPositionUpdate, + sendPlayerErrorNotification, + traceExternalPlayer, +} from './external-player-runtime'; + +export interface OpenExternalPlayerRequest { + url: string; + title: string; + thumbnail?: string; + userAgent?: string; + referer?: string; + origin?: string; + contentInfo?: PlayerContentInfo; + startTime?: number; + headers?: Record; +} + +let mpvProcess: ChildProcess | null = null; +let mpvSocketPath: string | null = null; +let positionPollingInterval: NodeJS.Timeout | null = null; + +function getMpvPath(options: PlayerPathOptions = {}): string { + return ( + normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ?? + getDefaultMpvPath(options) + ); +} + +async function getMpvProperty( + socketPath: string, + property: string +): Promise { + return new Promise((resolve) => { + const client = createConnection(socketPath); + const request = + JSON.stringify({ + command: ['get_property', property], + }) + '\n'; + + let data = ''; + + client.on('connect', () => { + client.write(request); + }); + + const timeoutHandle = setTimeout(() => { + client.destroy(); + resolve(null); + }, 2000); + + client.on('data', (chunk) => { + data += chunk.toString(); + if (data.includes('\n')) { + clearTimeout(timeoutHandle); + try { + const lines = data.split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + const response = JSON.parse(line); + if (response.data !== undefined) { + client.destroy(); + resolve(response.data); + return; + } + } + } catch { + // Ignore partial JSON while MPV is still writing. + } + } + }); + + client.on('end', () => { + clearTimeout(timeoutHandle); + if (data && !data.includes('\n')) { + try { + const response = JSON.parse(data); + resolve(response.data ?? null); + } catch { + resolve(null); + } + } + }); + + client.on('error', () => { + clearTimeout(timeoutHandle); + resolve(null); + }); + }); +} + +function stopPositionPolling(): void { + if (positionPollingInterval) { + clearInterval(positionPollingInterval); + positionPollingInterval = null; + } +} + +function shouldIgnoreMpvStdoutLine(line: string): boolean { + return /^(\(Paused\)\s*)?AV:\s/.test(line); +} + +function startPositionPolling( + socketPath: string, + contentInfo: PlayerContentInfo, + sessionId: string +): void { + stopPositionPolling(); + + setTimeout(() => { + positionPollingInterval = setInterval(async () => { + try { + const position = await getMpvProperty(socketPath, 'time-pos'); + const duration = await getMpvProperty(socketPath, 'duration'); + + if (position !== null) { + sendPlaybackPositionUpdate(sessionId, contentInfo, { + positionSeconds: Math.floor(position), + durationSeconds: duration ? Math.floor(duration) : null, + }); + } + } catch { + stopPositionPolling(); + } + }, 5000); + }, 2000); +} + +function sendMpvCommand( + command: string, + args: Array +): Promise { + return new Promise((resolve, reject) => { + if (!mpvSocketPath) { + reject(new Error('No MPV socket path available')); + return; + } + + const client = createConnection(mpvSocketPath); + const request = JSON.stringify({ command: [command, ...args] }) + '\n'; + + client.on('connect', () => { + traceExternalPlayer('mpv ipc command', { + command, + argsCount: args.length, + }); + client.write(request); + client.end(); + resolve(); + }); + + client.on('error', (err) => { + console.error('MPV socket error:', err); + reject(err); + }); + }); +} + +export function setMpvReuseInstance(reuseInstance: boolean): void { + traceExternalPlayer('set mpv reuse instance', { reuseInstance }); + store.set(MPV_REUSE_INSTANCE, reuseInstance); + + if (!reuseInstance && mpvProcess && !mpvProcess.killed) { + traceExternalPlayer('clean up mpv process after disabling reuse'); + mpvProcess.kill(); + mpvProcess = null; + mpvSocketPath = null; + stopPositionPolling(); + } +} + +export async function openMpvPlayer({ + url, + title, + thumbnail, + userAgent, + referer, + origin, + contentInfo, + startTime, + headers, +}: OpenExternalPlayerRequest) { + const session = externalPlayerSessions.beginSession({ + player: 'mpv', + title, + thumbnail, + streamUrl: url, + contentInfo, + }); + + try { + const isFlatpak = isRunningInFlatpak(); + const mpvLaunchContext = resolveExternalPlayerLaunchContext( + 'mpv', + getMpvPath({ isFlatpak }), + { isFlatpak } + ); + const customMpvArguments = store.get(MPV_PLAYER_ARGUMENTS, ''); + const requestedReuseInstance = store.get(MPV_REUSE_INSTANCE, false); + const reuseInstance = shouldReuseMpvInstance( + requestedReuseInstance, + isFlatpak + ); + const useMpvSocketBridge = shouldUseMpvSocketBridge(isFlatpak); + const { + effectiveOrigin, + effectiveReferer, + effectiveUserAgent, + headerFields, + } = resolveEffectiveExternalPlaybackRequest({ + url, + userAgent, + referer, + origin, + headers, + }); + + traceExternalPlayer('open mpv player', { + path: mpvLaunchContext.playerPath, + launchMode: mpvLaunchContext.mode, + requestedReuseInstance, + reuseInstance, + stream: maskUrlForLogs(url), + hasUserAgent: Boolean(effectiveUserAgent), + hasReferer: Boolean(effectiveReferer), + hasOrigin: Boolean(effectiveOrigin), + headerCount: headerFields.length, + hasContentInfo: Boolean(contentInfo), + startTime: startTime ?? null, + customArgumentCount: + parseExternalPlayerArguments(customMpvArguments).length, + }); + + if ( + reuseInstance && + mpvProcess && + !mpvProcess.killed && + mpvSocketPath + ) { + traceExternalPlayer('reuse existing mpv instance'); + try { + if (effectiveUserAgent) { + await sendMpvCommand('set_property', [ + 'user-agent', + effectiveUserAgent, + ]); + } + if (effectiveReferer) { + await sendMpvCommand('set_property', [ + 'referrer', + effectiveReferer, + ]); + } + if (headerFields.length > 0) { + await sendMpvCommand('set_property', [ + 'http-header-fields', + headerFields.join(','), + ]); + } + + const loadFileArgs: Array = [url, 'replace']; + const loadFileOptions: string[] = []; + + if (title) { + loadFileOptions.push(`force-media-title=${title}`); + } + if (loadFileOptions.length > 0) { + loadFileArgs.push(-1, loadFileOptions.join(',')); + } + + await sendMpvCommand('loadfile', loadFileArgs); + traceExternalPlayer('loaded new url in existing mpv instance'); + + externalPlayerSessions.attachCloser(session.id, async () => { + try { + await sendMpvCommand('quit', []); + } catch { + if (mpvProcess && !mpvProcess.killed) { + mpvProcess.kill(); + } + } + }); + + if (startTime) { + await sendMpvCommand('seek', [ + String(startTime), + 'absolute', + ]); + } + + if (contentInfo) { + startPositionPolling( + mpvSocketPath, + contentInfo, + session.id + ); + } else { + stopPositionPolling(); + } + + return externalPlayerSessions.markOpened(session.id) ?? session; + } catch (err) { + console.error('Failed to send command to existing MPV:', err); + mpvProcess = null; + mpvSocketPath = null; + stopPositionPolling(); + } + } + + traceExternalPlayer('create new mpv instance'); + + let socketPath: string | null = null; + const args: string[] = []; + + if (useMpvSocketBridge) { + socketPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\mpv-${Date.now()}` + : `/tmp/mpvsocket-${Date.now()}`; + args.push(`--input-ipc-server=${socketPath}`, '--idle=yes'); + } + + args.push('--ytdl=no'); + + if (effectiveUserAgent) { + args.push(`--user-agent=${effectiveUserAgent}`); + } + + if (effectiveReferer) { + args.push(`--referrer=${effectiveReferer}`); + } + + if (headerFields.length > 0) { + args.push(`--http-header-fields=${headerFields.join(',')}`); + } + + if (title) { + args.push(`--force-media-title=${title}`); + } + + if (startTime) { + args.push(`--start=${startTime}`); + } + + args.push(url); + + await new Promise((resolve, reject) => { + const spawnSpec = buildExternalPlayerSpawnSpec( + mpvLaunchContext, + buildPlayerArgsWithCustomArguments(customMpvArguments, args) + ); + const proc = spawn(spawnSpec.command, spawnSpec.args, { + shell: false, + detached: !reuseInstance, + stdio: reuseInstance ? ['ignore', 'pipe', 'pipe'] : 'ignore', + }); + + if (proc.stdout) { + proc.stdout.on('data', (data) => { + const lines = data + .toString() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (const output of lines) { + if (shouldIgnoreMpvStdoutLine(output)) { + continue; + } + + traceExternalPlayer('mpv stdout', { output }); + + if ( + output.includes('Failed to open') || + output.includes('Error opening') || + output.includes('Protocol not found') || + output.includes('Connection refused') || + output.includes('error') || + output.includes('403') || + output.includes('404') + ) { + console.error('[MPV ERROR from stdout]:', output); + sendPlayerErrorNotification('MPV', output); + } + } + }); + } + + if (proc.stderr) { + proc.stderr.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.error('[MPV stderr]:', output); + + if ( + output.includes('Failed to open') || + output.includes( + 'Exiting... (Errors when loading file)' + ) || + output.includes('Error opening') || + output.includes('Protocol not found') || + output.includes('Connection refused') || + output.includes('error') || + output.includes('403') || + output.includes('404') + ) { + console.error('[MPV ERROR]:', output); + sendPlayerErrorNotification('MPV', output); + } + } + }); + } + + proc.on('error', (err) => { + console.error('Failed to start MPV player:', err); + mpvProcess = null; + mpvSocketPath = null; + stopPositionPolling(); + externalPlayerSessions.markError( + session.id, + `Failed to start MPV player: ${err.message}` + ); + reject(buildPlayerStartError('MPV', err, mpvLaunchContext)); + }); + + proc.on('exit', (code) => { + traceExternalPlayer('mpv exited', { code }); + mpvProcess = null; + mpvSocketPath = null; + stopPositionPolling(); + + if (code !== 0 && code !== null) { + console.error( + `[MPV ERROR] MPV exited with error code ${code}` + ); + sendPlayerErrorNotification( + 'MPV', + `MPV player closed unexpectedly (exit code: ${code})` + ); + externalPlayerSessions.markError( + session.id, + `MPV player closed unexpectedly (exit code: ${code})` + ); + return; + } + + externalPlayerSessions.markClosed(session.id); + }); + + if (reuseInstance && socketPath) { + mpvProcess = proc; + mpvSocketPath = socketPath; + traceExternalPlayer('stored mpv process for reuse', { + socketPath, + }); + } else { + proc.unref(); + } + + externalPlayerSessions.attachCloser(session.id, async () => { + if (!proc.killed) { + proc.kill(); + } + }); + + if (useMpvSocketBridge && contentInfo && socketPath) { + startPositionPolling(socketPath, contentInfo, session.id); + } + + setTimeout(() => { + if (!proc.killed) { + resolve(); + } + }, 100); + }); + + return externalPlayerSessions.markOpened(session.id) ?? session; + } catch (error) { + console.error('Error opening MPV player:', error); + mpvProcess = null; + mpvSocketPath = null; + stopPositionPolling(); + externalPlayerSessions.markError( + session.id, + error instanceof Error ? error.message : String(error) + ); + throw error; + } +} diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index e04241c92..be939905a 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -4,6 +4,10 @@ jest.mock('electron', () => ({ }, })); +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + jest.mock('../app', () => ({ __esModule: true, default: { @@ -27,6 +31,8 @@ jest.mock('../services/stalker-playback-context.service', () => ({ })); import { ipcMain } from 'electron'; +import { spawn, type ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; import { MPV_PLAYER_PATH, store, @@ -46,11 +52,40 @@ import { shouldReuseVlcInstance, shouldUseMpvSocketBridge, } from './player.events'; +import { openVlcPlayer } from './vlc-session.service'; function createPathExists(existingPaths: string[]) { return (candidatePath: string) => existingPaths.includes(candidatePath); } +function createReadDirectory(entriesByPath: Record) { + return (directoryPath: string) => entriesByPath[directoryPath] ?? []; +} + +function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + killed: false, + kill: jest.fn(() => true), + stderr: null, + stdout: null, + unref: jest.fn(), + }) as unknown as ChildProcess; +} + +async function waitForSpawnCallCount(count: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if ((spawn as unknown as jest.Mock).mock.calls.length >= count) { + return; + } + + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + + throw new Error(`Expected ${count} VLC spawn calls`); +} + function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown { const handleMock = ipcMain.handle as unknown as jest.Mock; const calls = handleMock.mock.calls as Array< @@ -191,6 +226,30 @@ describe('player.events Flatpak launch helpers', () => { }); }); + it('resolves versioned Homebrew Cask VLC app installs on macOS', () => { + const caskVlcPath = + '/opt/homebrew/Caskroom/vlc/3.0.21/VLC.app/Contents/MacOS/VLC'; + const launchContext = resolveExternalPlayerLaunchContext( + 'vlc', + undefined, + { + platform: 'darwin', + isFlatpak: false, + pathExists: createPathExists([caskVlcPath]), + readDirectory: createReadDirectory({ + '/opt/homebrew/Caskroom/vlc': ['3.0.21'], + }), + } + ); + + expect(launchContext).toEqual({ + mode: 'direct', + playerPath: caskVlcPath, + command: caskVlcPath, + argsPrefix: [], + }); + }); + it('keeps custom macOS executable player paths unchanged', () => { const launchContext = resolveExternalPlayerLaunchContext( 'mpv', @@ -329,6 +388,79 @@ describe('player.events external player path settings', () => { }); }); +describe('openVlcPlayer', () => { + beforeEach(() => { + (spawn as unknown as jest.Mock).mockReset(); + (store.get as unknown as jest.Mock).mockImplementation( + (_key: string, fallback?: unknown) => fallback + ); + }); + + it('rejects when VLC process spawning emits an error', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + + const openPromise = openVlcPlayer({ + title: 'Broken VLC', + url: 'https://example.com/live.m3u8', + }); + proc.emit('error', new Error('spawn vlc ENOENT')); + + await expect(openPromise).rejects.toThrow( + 'Failed to start VLC player: spawn vlc ENOENT' + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('rejects when VLC RC retry spawning also emits an error', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const firstProc = createMockChildProcess(); + const retryProc = createMockChildProcess(); + (spawn as unknown as jest.Mock) + .mockReturnValueOnce(firstProc) + .mockReturnValueOnce(retryProc); + (store.get as unknown as jest.Mock).mockImplementation( + (key: string, fallback?: unknown) => + key === VLC_REUSE_INSTANCE ? true : fallback + ); + + const openPromise = openVlcPlayer({ + title: 'Broken reusable VLC', + url: 'https://example.com/live.m3u8', + }); + + await waitForSpawnCallCount(1); + firstProc.emit('error', new Error('spawn vlc ENOENT')); + await waitForSpawnCallCount(2); + retryProc.emit('error', new Error('spawn vlc ENOENT')); + + await expect(openPromise).rejects.toThrow( + 'Failed to start VLC player: spawn vlc ENOENT' + ); + expect(spawn).toHaveBeenCalledTimes(2); + expect((spawn as unknown as jest.Mock).mock.calls[0][1]).toContain( + '--extraintf=rc' + ); + expect( + (spawn as unknown as jest.Mock).mock.calls[1][1] + ).not.toContain('--extraintf=rc'); + } finally { + consoleErrorSpy.mockRestore(); + } + }); +}); + describe('buildVlcEnqueueCommands', () => { it('clears the playlist and adds the URL with no extra options', () => { expect( diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 460d41f28..d004ed2b4 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -1,30 +1,36 @@ import { ipcMain } from 'electron'; import { CLOSE_EXTERNAL_PLAYER_SESSION, - EXTERNAL_PLAYER_SESSION_UPDATE, - ExternalPlayerName, - ExternalPlayerSession, - parseExternalPlayerArguments, - type ExternalPlayerArgumentsInput, + PlayerContentInfo, } from '@iptvnator/shared/interfaces'; -import App from '../app'; import { - MPV_PLAYER_ARGUMENTS, MPV_PLAYER_PATH, - MPV_REUSE_INSTANCE, store, - VLC_PLAYER_ARGUMENTS, VLC_PLAYER_PATH, - VLC_REUSE_INSTANCE, } from '../services/store.service'; +import { normalizePlayerPathForStore } from './external-player-launch-context'; +import { + externalPlayerSessions, + traceExternalPlayer, +} from './external-player-runtime'; +import { openMpvPlayer, setMpvReuseInstance } from './mpv-session.service'; +import { openVlcPlayer, setVlcReuseInstance } from './vlc-session.service'; -import { ChildProcess, spawn } from 'child_process'; -import { existsSync } from 'fs'; -import { AddressInfo, createConnection, createServer } from 'net'; -import path from 'path'; -import { isExternalPlayerTraceEnabled, trace } from '../services/debug-trace'; -import { getStalkerPlaybackContextHeaders } from '../services/stalker-playback-context.service'; -import { ExternalPlayerSessionRegistry } from './external-player-session-registry'; +export { + buildExternalPlayerSpawnSpec, + buildPlayerArgsWithCustomArguments, + isRunningInFlatpak, + parseExternalPlayerArguments, + resolveExternalPlayerLaunchContext, + shouldReuseMpvInstance, + shouldReuseVlcInstance, + shouldUseMpvSocketBridge, +} from './external-player-launch-context'; +export { + buildVlcEnqueueCommands, + parseVlcRcNumericResponse, + parseVlcRcPlaybackState, +} from './vlc-session.service'; export default class PlayerEvents { static bootstrapPlayerEvents(): Electron.IpcMain { @@ -32,1064 +38,31 @@ export default class PlayerEvents { } } -// Keep track of the running MPV process for reuse -let mpvProcess: ChildProcess | null = null; -let mpvSocketPath: string | null = null; -let positionPollingInterval: NodeJS.Timeout | null = null; - -// Keep track of the running VLC process for reuse -let vlcProcess: ChildProcess | null = null; -let vlcRcPort: number | null = null; - -function traceExternalPlayer(message: string, payload?: unknown): void { - if (!isExternalPlayerTraceEnabled()) { - return; - } - - trace('external-player', message, payload); -} - -interface ExternalPlaybackSnapshot { - positionSeconds: number; - durationSeconds: number | null; -} - -function sendExternalPlayerSessionUpdate(session: ExternalPlayerSession) { - if (App.mainWindow && !App.mainWindow.isDestroyed()) { - App.mainWindow.webContents.send( - EXTERNAL_PLAYER_SESSION_UPDATE, - session - ); - } -} - -const externalPlayerSessions = new ExternalPlayerSessionRegistry( - sendExternalPlayerSessionUpdate -); - -type PathExists = (path: string) => boolean; - -type ExternalPlayerLaunchMode = 'direct' | 'flatpak-host'; - -interface PlayerPathOptions { - platform?: NodeJS.Platform; - isFlatpak?: boolean; - pathExists?: PathExists; -} - -interface ExternalPlayerLaunchContext { - mode: ExternalPlayerLaunchMode; - playerPath: string; - command: string; - argsPrefix: string[]; -} - -interface ExternalPlayerSpawnSpec { - mode: ExternalPlayerLaunchMode; - playerPath: string; - command: string; - args: string[]; -} - -export function isRunningInFlatpak( - pathExists: PathExists = existsSync, - platform: NodeJS.Platform = process.platform -): boolean { - return platform === 'linux' && pathExists('/.flatpak-info'); -} - -function normalizeCustomPlayerPath( - value: string | null | undefined -): string | null { - const trimmedValue = value?.trim(); - return trimmedValue ? trimmedValue : null; -} - -function normalizePlayerPathForStore(value: string | null | undefined): string { - return normalizeCustomPlayerPath(value) ?? ''; -} - -const macOSAppBundleExecutableNames: Record = { - mpv: 'mpv', - vlc: 'VLC', -}; - -function getMacOSAppBundleExecutableName(player: ExternalPlayerName): string { - return macOSAppBundleExecutableNames[player]; -} - -function removeTrailingPathSeparators(value: string): string { - return value.replace(/[\\/]+$/, '') || value; -} - -function resolveMacOSAppBundlePlayerPath( - player: ExternalPlayerName, - playerPath: string, - platform: NodeJS.Platform -): string { - if (platform !== 'darwin') { - return playerPath; - } - - const appBundlePath = removeTrailingPathSeparators(playerPath); - - if (!/\.app$/i.test(appBundlePath)) { - return playerPath; - } - - return path.join( - appBundlePath, - 'Contents', - 'MacOS', - getMacOSAppBundleExecutableName(player) - ); -} - -function getDefaultPlayerPath( - player: ExternalPlayerName, - options: PlayerPathOptions = {} -): string { - const { - platform = process.platform, - isFlatpak = isRunningInFlatpak(), - pathExists = existsSync, - } = options; - - if (platform === 'linux' && isFlatpak) { - return player; - } - - if (player === 'mpv') { - return getDefaultMpvPath({ platform, isFlatpak, pathExists }); - } - - return getDefaultVlcPath({ platform, isFlatpak, pathExists }); -} - -export function resolveExternalPlayerLaunchContext( - player: ExternalPlayerName, - customPlayerPath?: string, - options: PlayerPathOptions = {} -): ExternalPlayerLaunchContext { - const { - platform = process.platform, - isFlatpak = isRunningInFlatpak(), - pathExists = existsSync, - } = options; - const playerPath = - normalizeCustomPlayerPath(customPlayerPath) ?? - getDefaultPlayerPath(player, { - platform, - isFlatpak, - pathExists, - }); - const resolvedPlayerPath = resolveMacOSAppBundlePlayerPath( - player, - playerPath, - platform - ); - - if (platform === 'linux' && isFlatpak) { - return { - mode: 'flatpak-host', - playerPath: resolvedPlayerPath, - command: 'flatpak-spawn', - argsPrefix: ['--host', '--watch-bus', resolvedPlayerPath], - }; - } - - return { - mode: 'direct', - playerPath: resolvedPlayerPath, - command: resolvedPlayerPath, - argsPrefix: [], - }; -} - -export function buildExternalPlayerSpawnSpec( - launchContext: ExternalPlayerLaunchContext, - playerArgs: string[] -): ExternalPlayerSpawnSpec { - return { - mode: launchContext.mode, - playerPath: launchContext.playerPath, - command: launchContext.command, - args: [...launchContext.argsPrefix, ...playerArgs], - }; -} - -export { parseExternalPlayerArguments }; - -export function buildPlayerArgsWithCustomArguments( - customArguments: ExternalPlayerArgumentsInput, - playerArgs: string[] -): string[] { - return [...parseExternalPlayerArguments(customArguments), ...playerArgs]; -} - -export function shouldReuseMpvInstance( - requestedReuseInstance: boolean, - isFlatpak: boolean = isRunningInFlatpak() -): boolean { - return !isFlatpak && requestedReuseInstance; -} - -export function shouldUseMpvSocketBridge( - isFlatpak: boolean = isRunningInFlatpak() -): boolean { - return !isFlatpak; -} - -export function shouldReuseVlcInstance( - requestedReuseInstance: boolean, - isFlatpak: boolean = isRunningInFlatpak() -): boolean { - return !isFlatpak && requestedReuseInstance; -} - -export function buildVlcEnqueueCommands(options: { - url: string; - title?: string; - userAgent?: string; - referer?: string; - origin?: string; - headers?: Record; - startTime?: number; -}): string[] { - const inputOptions: string[] = []; - - if (options.userAgent) { - inputOptions.push(`:http-user-agent=${options.userAgent}`); - } - if (options.referer) { - inputOptions.push(`:http-referrer=${options.referer}`); - } else if (options.origin) { - inputOptions.push(`:http-referrer=${options.origin}`); - } - Object.entries(options.headers ?? {}).forEach(([name, value]) => { - if (!name || value === undefined || value === null) return; - const trimmedValue = String(value).trim(); - if (!trimmedValue) return; - inputOptions.push(`:http-header=${name}: ${trimmedValue}`); - }); - if (options.title) { - inputOptions.push(`:meta-title=${options.title}`); - } - - const inputLine = - inputOptions.length > 0 - ? `${options.url} ${inputOptions.join(' ')}` - : options.url; - - const commands = ['clear', `add ${inputLine}`]; - - if (options.startTime && Number.isFinite(options.startTime)) { - commands.push(`seek ${Math.floor(options.startTime)}`); - } - - return commands; -} - -function sendVlcRcCommand(port: number, command: string): Promise { - return new Promise((resolve, reject) => { - const client = createConnection({ port, host: '127.0.0.1' }); - let settled = false; - - const finish = (err?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeoutHandle); - if (!client.destroyed) client.destroy(); - if (err) { - reject(err); - return; - } - - resolve(); - }; - - const timeoutHandle = setTimeout( - () => finish(new Error('VLC RC command timed out')), - 2000 - ); - - client.on('connect', () => { - client.write(`${command}\n`); - }); - // The VLC RC interface emits its '> ' prompt after each command finishes. - client.on('data', (chunk) => { - if (chunk.toString().includes('>')) { - finish(); - } - }); - client.on('error', (err) => finish(err)); - }); -} - -async function sendVlcRcCommands( - port: number, - commands: string[] -): Promise { - for (const command of commands) { - await sendVlcRcCommand(port, command); - } -} - -function buildPlayerStartError( - player: 'MPV' | 'VLC', - error: Error, - launchContext: ExternalPlayerLaunchContext -): Error { - const guidance = - launchContext.mode === 'flatpak-host' - ? `Make sure ${player} is installed on the host system and reachable via Flatpak host spawning at '${launchContext.playerPath}'.` - : `Make sure ${player} is installed and the path '${launchContext.playerPath}' is correct.`; - - return new Error( - `Failed to start ${player} player: ${error.message}. ${guidance}` - ); -} - -// Helper function to send error notifications to the renderer -function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) { - if (App.mainWindow && !App.mainWindow.isDestroyed()) { - // Make error message more user-friendly - let userMessage = error; - - if (error.includes('Failed to open')) { - userMessage = - 'Failed to open stream. The URL may be invalid or the server is not responding.'; - } else if ( - error.includes('Protocol not found') || - error.includes('Unsupported protocol') - ) { - userMessage = - 'Unsupported stream protocol. Please check the stream URL.'; - } else if ( - error.includes('Connection refused') || - error.includes('Could not connect') - ) { - userMessage = - 'Cannot connect to the stream server. Please check your internet connection.'; - } else if (error.includes('403') || error.includes('Forbidden')) { - userMessage = - 'Access denied. The stream may require valid credentials or headers.'; - } else if (error.includes('404') || error.includes('Not Found')) { - userMessage = - 'Stream not found. The URL may be incorrect or expired.'; - } else if (error.includes('Timed out') || error.includes('timeout')) { - userMessage = 'Connection timed out. The server is not responding.'; - } - - App.mainWindow.webContents.send('player-error', { - player, - error: userMessage, - originalError: error, - }); - } -} - -function sendPlaybackPositionUpdate( - sessionId: string, - contentInfo: any, - snapshot: ExternalPlaybackSnapshot -) { - if (!App.mainWindow || App.mainWindow.isDestroyed()) { - return; - } - - externalPlayerSessions.markPlaying(sessionId); - App.mainWindow.webContents.send('playback-position-update', { - sessionId, - positionSeconds: snapshot.positionSeconds, - durationSeconds: snapshot.durationSeconds, - ...contentInfo, - }); -} - -// Query MPV property via IPC -async function getMpvProperty( - socketPath: string, - property: string -): Promise { - return new Promise((resolve) => { - const client = createConnection(socketPath); - const request = - JSON.stringify({ - command: ['get_property', property], - }) + '\n'; - - let data = ''; - - client.on('connect', () => { - client.write(request); - }); - - const timeoutHandle = setTimeout(() => { - client.destroy(); - resolve(null); - }, 2000); - - client.on('data', (chunk) => { - data += chunk.toString(); - if (data.includes('\n')) { - clearTimeout(timeoutHandle); - try { - const lines = data.split('\n'); - for (const line of lines) { - if (!line.trim()) continue; - const response = JSON.parse(line); - if (response.data !== undefined) { - client.destroy(); - resolve(response.data); - return; - } - } - } catch (e) { - // ignore parse errors - } - } - }); - - client.on('end', () => { - clearTimeout(timeoutHandle); - // Fallback if no newline was detected but connection closed - if (data && !data.includes('\n')) { - try { - const response = JSON.parse(data); - resolve(response.data ?? null); - } catch (e) { - resolve(null); - } - } - }); - - client.on('error', (err) => { - clearTimeout(timeoutHandle); - resolve(null); - }); - }); -} - -export function parseVlcRcNumericResponse(data: string): string { - const match = data.match(/>\s*(-?\d+(?:\.\d+)?)/); - return match ? match[1] : ''; -} - -export function parseVlcRcPlaybackState(data: string): string | null { - const match = data.match(/\(\s*state\s+([^)]+)\s*\)/i); - return match ? match[1].trim().toLowerCase() : null; -} - -function stopPositionPolling() { - if (positionPollingInterval) { - clearInterval(positionPollingInterval); - positionPollingInterval = null; - } -} - -function maskUrlForLogs(rawUrl: string): string { - try { - const parsed = new URL(rawUrl); - return `${parsed.origin}${parsed.pathname}`; - } catch { - return rawUrl; - } -} - -function shouldIgnoreMpvStdoutLine(line: string): boolean { - // Suppress MPV realtime progress spam. - return /^(\(Paused\)\s*)?AV:\s/.test(line); -} - -function buildHttpHeaderFields( - origin?: string, - headers?: Record -): string[] { - const fields: string[] = []; - const normalizedHeaders = headers ?? {}; - - if ( - origin && - normalizedHeaders['Origin'] === undefined && - normalizedHeaders['origin'] === undefined - ) { - fields.push(`Origin: ${origin}`); - } - - Object.entries(normalizedHeaders).forEach(([name, value]) => { - if (!name || value === undefined || value === null) return; - const trimmedValue = String(value).trim(); - if (!trimmedValue) return; - fields.push(`${name}: ${trimmedValue}`); - }); - - return fields; -} - -function isStalkerDirectStreamProfile( - headers: Record -): boolean { - const icyMetaData = headers['Icy-MetaData'] ?? headers['icy-metadata']; - const userAgent = headers['User-Agent'] ?? headers['user-agent']; - - return ( - String(icyMetaData).trim() === '1' && - String(userAgent).trim().toLowerCase() === 'ksplayer' - ); -} - -function startPositionPolling( - socketPath: string, - contentInfo: any, - sessionId: string -) { - stopPositionPolling(); - - // Initial delay to let video load - setTimeout(() => { - positionPollingInterval = setInterval(async () => { - try { - const position = await getMpvProperty(socketPath, 'time-pos'); - const duration = await getMpvProperty(socketPath, 'duration'); - - if (position !== null && App.mainWindow) { - sendPlaybackPositionUpdate(sessionId, contentInfo, { - positionSeconds: Math.floor(position), - durationSeconds: duration ? Math.floor(duration) : null, - }); - } - } catch (err) { - // MPV may have closed, stop polling - stopPositionPolling(); - } - }, 5000); - }, 2000); -} - -// VLC Polling -let vlcPollingInterval: NodeJS.Timeout | null = null; - -function stopVlcPositionPolling() { - if (vlcPollingInterval) { - clearInterval(vlcPollingInterval); - vlcPollingInterval = null; - } -} - -async function getVlcCommandResponse( - port: number, - command: string -): Promise { - return new Promise((resolve) => { - const client = createConnection({ port, host: '127.0.0.1' }); - let data = ''; - let resolved = false; - - const done = (result: string) => { - if (resolved) return; - resolved = true; - clearTimeout(timeoutHandle); - if (!client.destroyed) client.destroy(); - resolve(result); - }; - - const timeoutHandle = setTimeout(() => done(''), 2000); - - client.on('connect', () => { - client.write(command + '\n'); - }); - - client.on('data', (chunk) => { - data += chunk.toString(); - // VLC prompt '>' means command finished - if (data.includes('>')) { - done(data); - } - }); - - client.on('error', () => done('')); - }); -} - -async function getVlcProperty(port: number, command: string): Promise { - return parseVlcRcNumericResponse( - await getVlcCommandResponse(port, command) - ); -} - -async function getVlcPlaybackState(port: number): Promise { - return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status')); -} - -async function getVlcPlaybackSnapshot( - port: number -): Promise { - const timeStr = await getVlcProperty(port, 'get_time'); - const lenStr = await getVlcProperty(port, 'get_length'); - - const position = parseInt(timeStr, 10); - const duration = parseInt(lenStr, 10); - - if (isNaN(position)) { - return null; - } - - return { - positionSeconds: position, - durationSeconds: !isNaN(duration) ? duration : null, - }; -} - -function startVlcPositionPolling( - port: number, - contentInfo: any, - sessionId: string, - onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void, - onStopped?: () => void -) { - stopVlcPositionPolling(); - - // VLC needs time to start and bind port - setTimeout(() => { - vlcPollingInterval = setInterval(async () => { - try { - const snapshot = await getVlcPlaybackSnapshot(port); - - if (snapshot && App.mainWindow) { - onSnapshot?.(snapshot); - sendPlaybackPositionUpdate( - sessionId, - contentInfo, - snapshot - ); - return; - } - - const playbackState = await getVlcPlaybackState(port); - if (playbackState === 'stopped') { - onStopped?.(); - stopVlcPositionPolling(); - } - } catch (err) { - stopVlcPositionPolling(); - } - }, 2000); - }, 1500); -} - -// Helper function to send command to MPV via IPC -function sendMpvCommand( - command: string, - args: Array -): Promise { - return new Promise((resolve, reject) => { - if (!mpvSocketPath) { - reject(new Error('No MPV socket path available')); - return; - } - - const client = createConnection(mpvSocketPath); - // MPV expects commands in the format: { "command": ["cmd", "arg1", "arg2"] } - const request = JSON.stringify({ command: [command, ...args] }) + '\n'; - - client.on('connect', () => { - traceExternalPlayer('mpv ipc command', { - command, - argsCount: args.length, - }); - client.write(request); - client.end(); - resolve(); - }); - - client.on('error', (err) => { - console.error('MPV socket error:', err); - reject(err); - }); - }); -} - ipcMain.handle( 'OPEN_MPV_PLAYER', async ( - event, + _event, url: string, title: string, thumbnail?: string, userAgent?: string, referer?: string, origin?: string, - contentInfo?: any, + contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record - ) => { - const session = externalPlayerSessions.beginSession({ - player: 'mpv', + ) => + openMpvPlayer({ + url, title, thumbnail, - streamUrl: url, + userAgent, + referer, + origin, contentInfo, - }); - - try { - const isFlatpak = isRunningInFlatpak(); - const mpvLaunchContext = resolveExternalPlayerLaunchContext( - 'mpv', - getMpvPath({ isFlatpak }), - { isFlatpak } - ); - const customMpvArguments = store.get(MPV_PLAYER_ARGUMENTS, ''); - const requestedReuseInstance = store.get(MPV_REUSE_INSTANCE, false); - const reuseInstance = shouldReuseMpvInstance( - requestedReuseInstance, - isFlatpak - ); - const useMpvSocketBridge = shouldUseMpvSocketBridge(isFlatpak); - const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {}; - const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders) - ? fallbackHeaders - : { - ...fallbackHeaders, - ...(headers ?? {}), - }; - const effectiveOrigin = - origin ?? - mergedHeaders['Origin'] ?? - mergedHeaders['origin'] ?? - undefined; - const effectiveReferer = - referer ?? - mergedHeaders['Referer'] ?? - mergedHeaders['referer'] ?? - undefined; - const effectiveUserAgent = - userAgent ?? - mergedHeaders['User-Agent'] ?? - mergedHeaders['user-agent'] ?? - undefined; - const headerFields = buildHttpHeaderFields( - effectiveOrigin, - mergedHeaders - ); - - traceExternalPlayer('open mpv player', { - path: mpvLaunchContext.playerPath, - launchMode: mpvLaunchContext.mode, - requestedReuseInstance, - reuseInstance, - stream: maskUrlForLogs(url), - hasUserAgent: Boolean(effectiveUserAgent), - hasReferer: Boolean(effectiveReferer), - hasOrigin: Boolean(effectiveOrigin), - headerCount: headerFields.length, - hasContentInfo: Boolean(contentInfo), - startTime: startTime ?? null, - customArgumentCount: - parseExternalPlayerArguments(customMpvArguments).length, - }); - - // If reuse is enabled and there's an existing process, try to use it - if ( - reuseInstance && - mpvProcess && - !mpvProcess.killed && - mpvSocketPath - ) { - traceExternalPlayer('reuse existing mpv instance'); - try { - if (effectiveUserAgent) { - await sendMpvCommand('set_property', [ - 'user-agent', - effectiveUserAgent, - ]); - } - if (effectiveReferer) { - await sendMpvCommand('set_property', [ - 'referrer', - effectiveReferer, - ]); - } - if (headerFields.length > 0) { - await sendMpvCommand('set_property', [ - 'http-header-fields', - headerFields.join(','), - ]); - } - - const loadFileArgs: Array = [ - url, - 'replace', - ]; - const loadFileOptions: string[] = []; - - // loadfile args are: url, flags, index, options - // Index must be numeric; options are a comma-separated key=value list. - if (title) { - loadFileOptions.push(`force-media-title=${title}`); - } - if (loadFileOptions.length > 0) { - loadFileArgs.push(-1, loadFileOptions.join(',')); - } - - await sendMpvCommand('loadfile', loadFileArgs); - traceExternalPlayer( - 'loaded new url in existing mpv instance' - ); - - externalPlayerSessions.attachCloser( - session.id, - async () => { - try { - await sendMpvCommand('quit', []); - } catch { - if (mpvProcess && !mpvProcess.killed) { - mpvProcess.kill(); - } - } - } - ); - - // Seek if startTime provided - if (startTime) { - await sendMpvCommand('seek', [ - String(startTime), - 'absolute', - ]); - } - - // Restart polling with new content info - if (contentInfo) { - startPositionPolling( - mpvSocketPath, - contentInfo, - session.id - ); - } else { - stopPositionPolling(); - } - - return ( - externalPlayerSessions.markOpened(session.id) ?? session - ); - } catch (err) { - console.error( - 'Failed to send command to existing MPV:', - err - ); - // If it fails, clear the reference and create a new one - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); - // Fall through to create new instance - } - } - - // Create new MPV process - traceExternalPlayer('create new mpv instance'); - - let socketPath: string | null = null; - const args: string[] = []; - - if (useMpvSocketBridge) { - socketPath = - process.platform === 'win32' - ? `\\\\.\\pipe\\mpv-${Date.now()}` - : `/tmp/mpvsocket-${Date.now()}`; - args.push(`--input-ipc-server=${socketPath}`, '--idle=yes'); - } - - // IPTV URLs often look like generic web pages to ytdl_hook and can fail with 403. - // Force MPV to open them directly instead of probing via yt-dlp/youtube-dl. - args.push('--ytdl=no'); - - // Add user agent if provided - if (effectiveUserAgent) { - args.push(`--user-agent=${effectiveUserAgent}`); - } - - // Add referer if provided - if (effectiveReferer) { - args.push(`--referrer=${effectiveReferer}`); - } - - if (headerFields.length > 0) { - args.push(`--http-header-fields=${headerFields.join(',')}`); - } - - // Add title if provided - if (title) { - args.push(`--force-media-title=${title}`); - } - - // Add start time if provided - if (startTime) { - args.push(`--start=${startTime}`); - } - - // Add URL last - args.push(url); - - // Wrap spawn in a promise to catch startup errors - await new Promise((resolve, reject) => { - const spawnSpec = buildExternalPlayerSpawnSpec( - mpvLaunchContext, - buildPlayerArgsWithCustomArguments(customMpvArguments, args) - ); - const proc = spawn(spawnSpec.command, spawnSpec.args, { - shell: false, - detached: !reuseInstance, - // Only pipe stdio when reusing instance; use 'ignore' for detached to allow clean shutdown - stdio: reuseInstance - ? ['ignore', 'pipe', 'pipe'] - : 'ignore', - }); - - // Capture stdout - if (proc.stdout) { - proc.stdout.on('data', (data) => { - const lines = data - .toString() - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - for (const output of lines) { - if (shouldIgnoreMpvStdoutLine(output)) { - continue; - } - - traceExternalPlayer('mpv stdout', { output }); - - // MPV sometimes outputs errors to stdout instead of stderr - if ( - output.includes('Failed to open') || - output.includes('Error opening') || - output.includes('Protocol not found') || - output.includes('Connection refused') || - output.includes('error') || - output.includes('403') || - output.includes('404') - ) { - console.error( - '[MPV ERROR from stdout]:', - output - ); - sendPlayerErrorNotification('MPV', output); - } - } - }); - } - - // Capture stderr (MPV outputs most messages here) - if (proc.stderr) { - proc.stderr.on('data', (data) => { - const output = data.toString().trim(); - if (output) { - console.error('[MPV stderr]:', output); - - // Check for common error patterns and send notifications - if ( - output.includes('Failed to open') || - output.includes( - 'Exiting... (Errors when loading file)' - ) || - output.includes('Error opening') || - output.includes('Protocol not found') || - output.includes('Connection refused') || - output.includes('error') || - output.includes('403') || - output.includes('404') - ) { - console.error('[MPV ERROR]:', output); - sendPlayerErrorNotification('MPV', output); - } - } - }); - } - - proc.on('error', (err) => { - console.error('Failed to start MPV player:', err); - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); - externalPlayerSessions.markError( - session.id, - `Failed to start MPV player: ${err.message}` - ); - reject(buildPlayerStartError('MPV', err, mpvLaunchContext)); - }); - - proc.on('exit', (code) => { - traceExternalPlayer('mpv exited', { code }); - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); - - // Log non-zero exit codes as errors and notify user - if (code !== 0 && code !== null) { - console.error( - `[MPV ERROR] MPV exited with error code ${code}` - ); - sendPlayerErrorNotification( - 'MPV', - `MPV player closed unexpectedly (exit code: ${code})` - ); - externalPlayerSessions.markError( - session.id, - `MPV player closed unexpectedly (exit code: ${code})` - ); - return; - } - - externalPlayerSessions.markClosed(session.id); - }); - - // Store the process reference if reuse is enabled - if (reuseInstance && socketPath) { - mpvProcess = proc; - mpvSocketPath = socketPath; - traceExternalPlayer('stored mpv process for reuse', { - socketPath, - }); - } else { - // Detach the process so it can continue running independently - proc.unref(); - } - - externalPlayerSessions.attachCloser(session.id, async () => { - if (!proc.killed) { - proc.kill(); - } - }); - - // Start polling if content info is provided - if (useMpvSocketBridge && contentInfo && socketPath) { - startPositionPolling(socketPath, contentInfo, session.id); - } - - // Resolve immediately if spawn succeeds (error event would fire if it fails) - // Give a small window to catch immediate spawn errors - setTimeout(() => { - if (!proc.killed) { - resolve(); - } - }, 100); - }); - - return externalPlayerSessions.markOpened(session.id) ?? session; - } catch (error) { - console.error('Error opening MPV player:', error); - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); - externalPlayerSessions.markError( - session.id, - error instanceof Error ? error.message : String(error) - ); - throw error; - } - } + startTime, + headers, + }) ); ipcMain.handle( @@ -1104,464 +77,34 @@ ipcMain.handle( ); ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { - traceExternalPlayer('set mpv reuse instance', { reuseInstance }); - store.set(MPV_REUSE_INSTANCE, reuseInstance); - - // If disabling reuse, kill the existing process - if (!reuseInstance && mpvProcess && !mpvProcess.killed) { - traceExternalPlayer('clean up mpv process after disabling reuse'); - mpvProcess.kill(); - mpvProcess = null; - mpvSocketPath = null; - } + setMpvReuseInstance(reuseInstance); }); ipcMain.handle( 'OPEN_VLC_PLAYER', async ( - event, + _event, url: string, title: string, thumbnail?: string, userAgent?: string, referer?: string, origin?: string, - contentInfo?: any, + contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record - ) => { - const session = externalPlayerSessions.beginSession({ - player: 'vlc', + ) => + openVlcPlayer({ + url, title, thumbnail, - streamUrl: url, + userAgent, + referer, + origin, contentInfo, - }); - - try { - const isFlatpak = isRunningInFlatpak(); - const vlcLaunchContext = resolveExternalPlayerLaunchContext( - 'vlc', - getVlcPath({ isFlatpak }), - { isFlatpak } - ); - const customVlcArguments = store.get(VLC_PLAYER_ARGUMENTS, ''); - const requestedReuseInstance = store.get(VLC_REUSE_INSTANCE, false); - const reuseInstance = shouldReuseVlcInstance( - requestedReuseInstance, - isFlatpak - ); - const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {}; - const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders) - ? fallbackHeaders - : { - ...fallbackHeaders, - ...(headers ?? {}), - }; - const effectiveOrigin = - origin ?? - mergedHeaders['Origin'] ?? - mergedHeaders['origin'] ?? - undefined; - const effectiveReferer = - referer ?? - mergedHeaders['Referer'] ?? - mergedHeaders['referer'] ?? - undefined; - const effectiveUserAgent = - userAgent ?? - mergedHeaders['User-Agent'] ?? - mergedHeaders['user-agent'] ?? - undefined; - traceExternalPlayer('open vlc player', { - path: vlcLaunchContext.playerPath, - launchMode: vlcLaunchContext.mode, - requestedReuseInstance, - reuseInstance, - stream: maskUrlForLogs(url), - hasUserAgent: Boolean(effectiveUserAgent), - hasReferer: Boolean(effectiveReferer), - hasOrigin: Boolean(effectiveOrigin), - hasContentInfo: Boolean(contentInfo), - startTime: startTime ?? null, - customArgumentCount: - parseExternalPlayerArguments(customVlcArguments).length, - }); - - // Try to drive an already-running VLC via its RC port instead of - // spawning a new instance. - if ( - reuseInstance && - vlcProcess && - !vlcProcess.killed && - vlcRcPort - ) { - traceExternalPlayer('reuse existing vlc instance', { - rcPort: vlcRcPort, - }); - try { - const enqueueCommands = buildVlcEnqueueCommands({ - url, - title, - userAgent: effectiveUserAgent, - referer: effectiveReferer, - origin: effectiveOrigin, - headers: mergedHeaders, - startTime, - }); - await sendVlcRcCommands(vlcRcPort, enqueueCommands); - traceExternalPlayer( - 'loaded new url in existing vlc instance' - ); - - const reusedRcPort = vlcRcPort; - let lastReusedSnapshot: ExternalPlaybackSnapshot | null = - null; - externalPlayerSessions.attachCloser( - session.id, - async () => { - try { - await sendVlcRcCommand(reusedRcPort, 'stop'); - } catch { - if (vlcProcess && !vlcProcess.killed) { - vlcProcess.kill(); - } - } - } - ); - - if (contentInfo) { - startVlcPositionPolling( - reusedRcPort, - contentInfo, - session.id, - (snapshot) => { - lastReusedSnapshot = snapshot; - }, - () => { - if ( - lastReusedSnapshot && - contentInfo && - externalPlayerSessions.getSession( - session.id - )?.status !== 'closed' - ) { - sendPlaybackPositionUpdate( - session.id, - contentInfo, - lastReusedSnapshot - ); - } - externalPlayerSessions.markClosed(session.id); - } - ); - } else { - stopVlcPositionPolling(); - } - - return ( - externalPlayerSessions.markOpened(session.id) ?? session - ); - } catch (err) { - console.error( - 'Failed to reuse existing VLC, spawning fresh:', - err - ); - // Tracked process is not responsive; clear state and fall - // through to spawn a brand new VLC. - if (vlcProcess && !vlcProcess.killed) { - try { - vlcProcess.kill(); - } catch { - /* ignore */ - } - } - vlcProcess = null; - vlcRcPort = null; - stopVlcPositionPolling(); - } - } - - // Get free port for RC interface (always allocate when reuse is - // enabled so future calls can drive this instance). - let rcPort = 0; - if (contentInfo || reuseInstance) { - try { - rcPort = await getFreePort(); - traceExternalPlayer('using vlc rc port', { rcPort }); - } catch (e) { - console.error('Failed to get free port for VLC:', e); - } - } - - const args: string[] = []; - - if (rcPort > 0) { - args.push('--extraintf=rc'); - args.push(`--rc-host=127.0.0.1:${rcPort}`); - // args.push('--rc-quiet'); // Suppress status messages in stdout - } - - // Add user agent if provided (VLC uses :http-user-agent= format) - if (effectiveUserAgent) { - args.push(`:http-user-agent=${effectiveUserAgent}`); - } - - // Add referer if provided (VLC uses :http-referrer= format) - if (effectiveReferer) { - args.push(`:http-referrer=${effectiveReferer}`); - } - - // Note: VLC doesn't have a direct origin option, but origin is typically - // included in referer for most IPTV use cases - if (effectiveOrigin && !effectiveReferer) { - args.push(`:http-referrer=${effectiveOrigin}`); - } - - if (Object.keys(mergedHeaders).length > 0) { - Object.entries(mergedHeaders).forEach(([name, value]) => { - if (!name || value === undefined || value === null) return; - const trimmedValue = String(value).trim(); - if (!trimmedValue) return; - args.push(`:http-header=${name}: ${trimmedValue}`); - }); - } - - // Add start time if provided - if (startTime) { - args.push(`--start-time=${startTime}`); - } - - // Add URL and per-input options last - args.push(url); - if (title) { - args.push(`:meta-title=${title}`); - } - - traceExternalPlayer('vlc args prepared', { - argCount: args.length, - hasRcPort: rcPort > 0, - }); - let lastVlcSnapshot: ExternalPlaybackSnapshot | null = null; - - // Wrap spawn in a promise to catch startup errors - await new Promise((resolve, reject) => { - const spawnVlc = (playerArgs: string[], isRetry = false) => { - const spawnSpec = buildExternalPlayerSpawnSpec( - vlcLaunchContext, - buildPlayerArgsWithCustomArguments( - customVlcArguments, - playerArgs - ) - ); - const trackProcess = reuseInstance && !isRetry; - const proc = spawn(spawnSpec.command, spawnSpec.args, { - shell: false, - detached: !trackProcess, - stdio: trackProcess - ? ['ignore', 'pipe', 'pipe'] - : 'ignore', - }); - - if (trackProcess && rcPort > 0) { - vlcProcess = proc; - vlcRcPort = rcPort; - traceExternalPlayer('tracking vlc process for reuse', { - rcPort, - }); - } - - const markVlcSessionClosed = () => { - if ( - externalPlayerSessions.getSession(session.id) - ?.status === 'closed' - ) { - return; - } - - if (lastVlcSnapshot && contentInfo) { - sendPlaybackPositionUpdate( - session.id, - contentInfo, - lastVlcSnapshot - ); - } - - externalPlayerSessions.markClosed(session.id); - }; - - const flushVlcPlaybackPosition = async () => { - if (isRetry || rcPort <= 0 || !contentInfo) { - return; - } - - const snapshot = - (await getVlcPlaybackSnapshot(rcPort)) ?? - lastVlcSnapshot; - if (!snapshot) { - return; - } - - lastVlcSnapshot = snapshot; - sendPlaybackPositionUpdate( - session.id, - contentInfo, - snapshot - ); - }; - - externalPlayerSessions.attachCloser( - session.id, - async () => { - await flushVlcPlaybackPosition(); - if (!proc.killed) { - proc.kill(); - } - } - ); - - // Start polling if we have port and content info and NOT retrying (RC disabled on retry) - if (!isRetry && rcPort > 0 && contentInfo) { - startVlcPositionPolling( - rcPort, - contentInfo, - session.id, - (snapshot) => { - lastVlcSnapshot = snapshot; - }, - () => { - markVlcSessionClosed(); - } - ); - } - - // Capture stdout - if (proc.stdout) { - proc.stdout.on('data', (data) => { - const output = data.toString().trim(); - if (output) { - traceExternalPlayer('vlc stdout', { output }); - } - }); - } - - // Capture stderr - if (proc.stderr) { - proc.stderr.on('data', (data) => { - const output = data.toString().trim(); - if (output) { - console.error('[VLC stderr]:', output); - } - }); - } - - proc.on('error', (err) => { - console.error('Failed to start VLC player:', err); - if (vlcProcess === proc) { - vlcProcess = null; - vlcRcPort = null; - } - if (!isRetry && rcPort > 0) { - traceExternalPlayer( - 'retry vlc without rc interface after start error' - ); - // Retry without RC args - const retryArgs = playerArgs.filter( - (arg) => - !arg.includes('--extraintf') && - !arg.includes('--rc-host') && - !arg.includes('--rc-quiet') - ); - spawnVlc(retryArgs, true); - } else { - externalPlayerSessions.markError( - session.id, - `Failed to start VLC player: ${err.message}` - ); - reject( - buildPlayerStartError( - 'VLC', - err, - vlcLaunchContext - ) - ); - } - }); - - proc.on('exit', (code) => { - traceExternalPlayer('vlc exited', { code }); - if (vlcProcess === proc) { - vlcProcess = null; - vlcRcPort = null; - } - stopVlcPositionPolling(); - - if ( - lastVlcSnapshot && - contentInfo && - externalPlayerSessions.getSession(session.id) - ?.status !== 'closed' - ) { - sendPlaybackPositionUpdate( - session.id, - contentInfo, - lastVlcSnapshot - ); - } - - if (code === 1 && !isRetry && rcPort > 0) { - traceExternalPlayer( - 'retry vlc without rc interface after exit' - ); - stopVlcPositionPolling(); - const retryArgs = playerArgs.filter( - (arg) => - !arg.includes('--extraintf') && - !arg.includes('--rc-host') && - !arg.includes('--rc-quiet') - ); - spawnVlc(retryArgs, true); - return; - } - - // Log non-zero exit codes as errors and notify user - if (code !== 0 && code !== null) { - console.error( - `[VLC ERROR] VLC exited with error code ${code}` - ); - sendPlayerErrorNotification( - 'VLC', - `VLC player closed unexpectedly (exit code: ${code})` - ); - externalPlayerSessions.markError( - session.id, - `VLC player closed unexpectedly (exit code: ${code})` - ); - return; - } - - externalPlayerSessions.markClosed(session.id); - }); - - if (!trackProcess) { - proc.unref(); - } - }; - - spawnVlc(args); - resolve(); - }); - - return externalPlayerSessions.markOpened(session.id) ?? session; - } catch (error) { - console.error('Error opening VLC player:', error); - externalPlayerSessions.markError( - session.id, - error instanceof Error ? error.message : String(error) - ); - throw error; - } - } + startTime, + headers, + }) ); ipcMain.handle( @@ -1576,16 +119,7 @@ ipcMain.handle( ); ipcMain.handle('SET_VLC_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { - traceExternalPlayer('set vlc reuse instance', { reuseInstance }); - store.set(VLC_REUSE_INSTANCE, reuseInstance); - - // If disabling reuse, kill the existing tracked process - if (!reuseInstance && vlcProcess && !vlcProcess.killed) { - traceExternalPlayer('clean up vlc process after disabling reuse'); - vlcProcess.kill(); - vlcProcess = null; - vlcRcPort = null; - } + setVlcReuseInstance(reuseInstance); }); ipcMain.handle( @@ -1594,153 +128,3 @@ ipcMain.handle( return externalPlayerSessions.closeSession(sessionId); } ); - -function getMpvPath(options: PlayerPathOptions = {}) { - return ( - normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ?? - getDefaultMpvPath(options) - ); -} - -function getVlcPath(options: PlayerPathOptions = {}) { - return ( - normalizeCustomPlayerPath(store.get(VLC_PLAYER_PATH)) ?? - getDefaultVlcPath(options) - ); -} - -function getDefaultMpvPath(options: PlayerPathOptions = {}) { - const { - platform = process.platform, - isFlatpak = isRunningInFlatpak(), - pathExists = existsSync, - } = options; - - if (platform === 'linux' && isFlatpak) { - return 'mpv'; - } - - if (platform === 'win32') { - // Check multiple common Windows paths - const windowsPaths = [ - path.join('C:', 'Program Files', 'mpv', 'mpv.exe'), - path.join('C:', 'Program Files (x86)', 'mpv', 'mpv.exe'), - ]; - - // Check if any of the paths exist - for (const mpvPath of windowsPaths) { - if (pathExists(mpvPath)) { - return mpvPath; - } - } - // Default to just 'mpv' if it's in PATH - return 'mpv'; - } else if (platform === 'linux') { - // Check multiple common Linux paths - const linuxPaths = [ - '/usr/bin/mpv', - '/usr/local/bin/mpv', - '/snap/bin/mpv', - ]; - - for (const mpvPath of linuxPaths) { - if (pathExists(mpvPath)) { - return mpvPath; - } - } - return 'mpv'; - } else if (platform === 'darwin') { - // Check multiple common macOS paths - const macosPaths = [ - '/Applications/mpv.app/Contents/MacOS/mpv', - '/opt/homebrew/bin/mpv', - '/usr/local/bin/mpv', - ]; - - for (const mpvPath of macosPaths) { - if (pathExists(mpvPath)) { - return mpvPath; - } - } - return 'mpv'; - } - - // Fallback to just 'mpv' hoping it's in PATH - return 'mpv'; -} - -function getDefaultVlcPath(options: PlayerPathOptions = {}) { - const { - platform = process.platform, - isFlatpak = isRunningInFlatpak(), - pathExists = existsSync, - } = options; - - if (platform === 'linux' && isFlatpak) { - return 'vlc'; - } - - if (platform === 'win32') { - // Check multiple common Windows paths (64-bit and 32-bit) - const windowsPaths = [ - path.join('C:', 'Program Files', 'VideoLAN', 'VLC', 'vlc.exe'), - path.join( - 'C:', - 'Program Files (x86)', - 'VideoLAN', - 'VLC', - 'vlc.exe' - ), - ]; - - for (const vlcPath of windowsPaths) { - if (pathExists(vlcPath)) { - return vlcPath; - } - } - return 'vlc'; - } else if (platform === 'linux') { - // Check multiple common Linux paths - const linuxPaths = [ - '/usr/bin/vlc', - '/usr/local/bin/vlc', - '/snap/bin/vlc', - ]; - - for (const vlcPath of linuxPaths) { - if (pathExists(vlcPath)) { - return vlcPath; - } - } - return 'vlc'; - } else if (platform === 'darwin') { - // Check multiple common macOS paths - const macosPaths = [ - '/Applications/VLC.app/Contents/MacOS/VLC', - '/opt/homebrew/Caskroom/vlc/*/VLC.app/Contents/MacOS/VLC', - ]; - - for (const vlcPath of macosPaths) { - if (pathExists(vlcPath)) { - return vlcPath; - } - } - return 'vlc'; - } - return 'vlc'; -} - -function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo; - const port = address.port; - server.close(() => { - resolve(port); - }); - }); - }); -} diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts new file mode 100644 index 000000000..4d7f5bdcf --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -0,0 +1,696 @@ +import { ChildProcess, spawn } from 'child_process'; +import { AddressInfo, createConnection, createServer } from 'net'; +import { PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + VLC_PLAYER_ARGUMENTS, + VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, + store, +} from '../services/store.service'; +import { + buildExternalPlayerSpawnSpec, + buildPlayerArgsWithCustomArguments, + getDefaultVlcPath, + isRunningInFlatpak, + normalizeCustomPlayerPath, + parseExternalPlayerArguments, + PlayerPathOptions, + resolveExternalPlayerLaunchContext, + shouldReuseVlcInstance, +} from './external-player-launch-context'; +import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request'; +import { + buildPlayerStartError, + ExternalPlaybackSnapshot, + externalPlayerSessions, + maskUrlForLogs, + sendPlaybackPositionUpdate, + sendPlayerErrorNotification, + traceExternalPlayer, +} from './external-player-runtime'; + +export interface OpenVlcPlayerRequest { + url: string; + title: string; + thumbnail?: string; + userAgent?: string; + referer?: string; + origin?: string; + contentInfo?: PlayerContentInfo; + startTime?: number; + headers?: Record; +} + +let vlcProcess: ChildProcess | null = null; +let vlcRcPort: number | null = null; +let vlcPollingInterval: NodeJS.Timeout | null = null; + +function getVlcPath(options: PlayerPathOptions = {}): string { + return ( + normalizeCustomPlayerPath(store.get(VLC_PLAYER_PATH)) ?? + getDefaultVlcPath(options) + ); +} + +export function buildVlcEnqueueCommands(options: { + url: string; + title?: string; + userAgent?: string; + referer?: string; + origin?: string; + headers?: Record; + startTime?: number; +}): string[] { + const inputOptions: string[] = []; + + if (options.userAgent) { + inputOptions.push(`:http-user-agent=${options.userAgent}`); + } + if (options.referer) { + inputOptions.push(`:http-referrer=${options.referer}`); + } else if (options.origin) { + inputOptions.push(`:http-referrer=${options.origin}`); + } + Object.entries(options.headers ?? {}).forEach(([name, value]) => { + if (!name || value === undefined || value === null) return; + const trimmedValue = String(value).trim(); + if (!trimmedValue) return; + inputOptions.push(`:http-header=${name}: ${trimmedValue}`); + }); + if (options.title) { + inputOptions.push(`:meta-title=${options.title}`); + } + + const inputLine = + inputOptions.length > 0 + ? `${options.url} ${inputOptions.join(' ')}` + : options.url; + + const commands = ['clear', `add ${inputLine}`]; + + if (options.startTime && Number.isFinite(options.startTime)) { + commands.push(`seek ${Math.floor(options.startTime)}`); + } + + return commands; +} + +function sendVlcRcCommand(port: number, command: string): Promise { + return new Promise((resolve, reject) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let settled = false; + + const finish = (err?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + if (!client.destroyed) client.destroy(); + if (err) { + reject(err); + return; + } + + resolve(); + }; + + const timeoutHandle = setTimeout( + () => finish(new Error('VLC RC command timed out')), + 2000 + ); + + client.on('connect', () => { + client.write(`${command}\n`); + }); + client.on('data', (chunk) => { + if (chunk.toString().includes('>')) { + finish(); + } + }); + client.on('error', (err) => finish(err)); + }); +} + +async function sendVlcRcCommands( + port: number, + commands: string[] +): Promise { + for (const command of commands) { + await sendVlcRcCommand(port, command); + } +} + +export function parseVlcRcNumericResponse(data: string): string { + const match = data.match(/>\s*(-?\d+(?:\.\d+)?)/); + return match ? match[1] : ''; +} + +export function parseVlcRcPlaybackState(data: string): string | null { + const match = data.match(/\(\s*state\s+([^)]+)\s*\)/i); + return match ? match[1].trim().toLowerCase() : null; +} + +function stopVlcPositionPolling(): void { + if (vlcPollingInterval) { + clearInterval(vlcPollingInterval); + vlcPollingInterval = null; + } +} + +async function getVlcCommandResponse( + port: number, + command: string +): Promise { + return new Promise((resolve) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let data = ''; + let resolved = false; + + const done = (result: string) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutHandle); + if (!client.destroyed) client.destroy(); + resolve(result); + }; + + const timeoutHandle = setTimeout(() => done(''), 2000); + + client.on('connect', () => { + client.write(command + '\n'); + }); + + client.on('data', (chunk) => { + data += chunk.toString(); + if (data.includes('>')) { + done(data); + } + }); + + client.on('error', () => done('')); + }); +} + +async function getVlcProperty(port: number, command: string): Promise { + return parseVlcRcNumericResponse( + await getVlcCommandResponse(port, command) + ); +} + +async function getVlcPlaybackState(port: number): Promise { + return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status')); +} + +async function getVlcPlaybackSnapshot( + port: number +): Promise { + const timeStr = await getVlcProperty(port, 'get_time'); + const lenStr = await getVlcProperty(port, 'get_length'); + + const position = parseInt(timeStr, 10); + const duration = parseInt(lenStr, 10); + + if (isNaN(position)) { + return null; + } + + return { + positionSeconds: position, + durationSeconds: !isNaN(duration) ? duration : null, + }; +} + +function startVlcPositionPolling( + port: number, + contentInfo: PlayerContentInfo, + sessionId: string, + onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void, + onStopped?: () => void +): void { + stopVlcPositionPolling(); + + setTimeout(() => { + vlcPollingInterval = setInterval(async () => { + try { + const snapshot = await getVlcPlaybackSnapshot(port); + + if (snapshot) { + onSnapshot?.(snapshot); + sendPlaybackPositionUpdate( + sessionId, + contentInfo, + snapshot + ); + return; + } + + const playbackState = await getVlcPlaybackState(port); + if (playbackState === 'stopped') { + onStopped?.(); + stopVlcPositionPolling(); + } + } catch { + stopVlcPositionPolling(); + } + }, 2000); + }, 1500); +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address() as AddressInfo; + const port = address.port; + server.close(() => { + resolve(port); + }); + }); + }); +} + +export function setVlcReuseInstance(reuseInstance: boolean): void { + traceExternalPlayer('set vlc reuse instance', { reuseInstance }); + store.set(VLC_REUSE_INSTANCE, reuseInstance); + + if (!reuseInstance && vlcProcess && !vlcProcess.killed) { + traceExternalPlayer('clean up vlc process after disabling reuse'); + vlcProcess.kill(); + vlcProcess = null; + vlcRcPort = null; + stopVlcPositionPolling(); + } +} + +export async function openVlcPlayer({ + url, + title, + thumbnail, + userAgent, + referer, + origin, + contentInfo, + startTime, + headers, +}: OpenVlcPlayerRequest) { + const session = externalPlayerSessions.beginSession({ + player: 'vlc', + title, + thumbnail, + streamUrl: url, + contentInfo, + }); + + try { + const isFlatpak = isRunningInFlatpak(); + const vlcLaunchContext = resolveExternalPlayerLaunchContext( + 'vlc', + getVlcPath({ isFlatpak }), + { isFlatpak } + ); + const customVlcArguments = store.get(VLC_PLAYER_ARGUMENTS, ''); + const requestedReuseInstance = store.get(VLC_REUSE_INSTANCE, false); + const reuseInstance = shouldReuseVlcInstance( + requestedReuseInstance, + isFlatpak + ); + const { + mergedHeaders, + effectiveOrigin, + effectiveReferer, + effectiveUserAgent, + } = resolveEffectiveExternalPlaybackRequest({ + url, + userAgent, + referer, + origin, + headers, + }); + traceExternalPlayer('open vlc player', { + path: vlcLaunchContext.playerPath, + launchMode: vlcLaunchContext.mode, + requestedReuseInstance, + reuseInstance, + stream: maskUrlForLogs(url), + hasUserAgent: Boolean(effectiveUserAgent), + hasReferer: Boolean(effectiveReferer), + hasOrigin: Boolean(effectiveOrigin), + hasContentInfo: Boolean(contentInfo), + startTime: startTime ?? null, + customArgumentCount: + parseExternalPlayerArguments(customVlcArguments).length, + }); + + if (reuseInstance && vlcProcess && !vlcProcess.killed && vlcRcPort) { + traceExternalPlayer('reuse existing vlc instance', { + rcPort: vlcRcPort, + }); + try { + const enqueueCommands = buildVlcEnqueueCommands({ + url, + title, + userAgent: effectiveUserAgent, + referer: effectiveReferer, + origin: effectiveOrigin, + headers: mergedHeaders, + startTime, + }); + await sendVlcRcCommands(vlcRcPort, enqueueCommands); + traceExternalPlayer('loaded new url in existing vlc instance'); + + const reusedRcPort = vlcRcPort; + let lastReusedSnapshot: ExternalPlaybackSnapshot | null = null; + externalPlayerSessions.attachCloser(session.id, async () => { + try { + await sendVlcRcCommand(reusedRcPort, 'stop'); + } catch { + if (vlcProcess && !vlcProcess.killed) { + vlcProcess.kill(); + } + } + }); + + if (contentInfo) { + startVlcPositionPolling( + reusedRcPort, + contentInfo, + session.id, + (snapshot) => { + lastReusedSnapshot = snapshot; + }, + () => { + if ( + lastReusedSnapshot && + externalPlayerSessions.getSession(session.id) + ?.status !== 'closed' + ) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastReusedSnapshot + ); + } + externalPlayerSessions.markClosed(session.id); + } + ); + } else { + stopVlcPositionPolling(); + } + + return externalPlayerSessions.markOpened(session.id) ?? session; + } catch (err) { + console.error( + 'Failed to reuse existing VLC, spawning fresh:', + err + ); + if (vlcProcess && !vlcProcess.killed) { + try { + vlcProcess.kill(); + } catch { + // Ignore cleanup failures. + } + } + vlcProcess = null; + vlcRcPort = null; + stopVlcPositionPolling(); + } + } + + let rcPort = 0; + if (contentInfo || reuseInstance) { + try { + rcPort = await getFreePort(); + traceExternalPlayer('using vlc rc port', { rcPort }); + } catch (e) { + console.error('Failed to get free port for VLC:', e); + } + } + + const args: string[] = []; + + if (rcPort > 0) { + args.push('--extraintf=rc'); + args.push(`--rc-host=127.0.0.1:${rcPort}`); + } + + if (effectiveUserAgent) { + args.push(`:http-user-agent=${effectiveUserAgent}`); + } + + if (effectiveReferer) { + args.push(`:http-referrer=${effectiveReferer}`); + } + + if (effectiveOrigin && !effectiveReferer) { + args.push(`:http-referrer=${effectiveOrigin}`); + } + + if (Object.keys(mergedHeaders).length > 0) { + Object.entries(mergedHeaders).forEach(([name, value]) => { + if (!name || value === undefined || value === null) return; + const trimmedValue = String(value).trim(); + if (!trimmedValue) return; + args.push(`:http-header=${name}: ${trimmedValue}`); + }); + } + + if (startTime) { + args.push(`--start-time=${startTime}`); + } + + args.push(url); + if (title) { + args.push(`:meta-title=${title}`); + } + + traceExternalPlayer('vlc args prepared', { + argCount: args.length, + hasRcPort: rcPort > 0, + }); + let lastVlcSnapshot: ExternalPlaybackSnapshot | null = null; + + await new Promise((resolve, reject) => { + let settled = false; + + const resolveSpawn = () => { + if (settled) { + return; + } + + settled = true; + resolve(); + }; + + const rejectSpawn = (error: Error) => { + if (settled) { + return; + } + + settled = true; + reject(error); + }; + + const spawnVlc = (playerArgs: string[], isRetry = false) => { + const spawnSpec = buildExternalPlayerSpawnSpec( + vlcLaunchContext, + buildPlayerArgsWithCustomArguments( + customVlcArguments, + playerArgs + ) + ); + const trackProcess = reuseInstance && !isRetry; + const proc = spawn(spawnSpec.command, spawnSpec.args, { + shell: false, + detached: !trackProcess, + stdio: trackProcess ? ['ignore', 'pipe', 'pipe'] : 'ignore', + }); + + proc.once('spawn', resolveSpawn); + + if (trackProcess && rcPort > 0) { + vlcProcess = proc; + vlcRcPort = rcPort; + traceExternalPlayer('tracking vlc process for reuse', { + rcPort, + }); + } + + const markVlcSessionClosed = () => { + if ( + externalPlayerSessions.getSession(session.id) + ?.status === 'closed' + ) { + return; + } + + if (lastVlcSnapshot && contentInfo) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastVlcSnapshot + ); + } + + externalPlayerSessions.markClosed(session.id); + }; + + const flushVlcPlaybackPosition = async () => { + if (isRetry || rcPort <= 0 || !contentInfo) { + return; + } + + const snapshot = + (await getVlcPlaybackSnapshot(rcPort)) ?? + lastVlcSnapshot; + if (!snapshot) { + return; + } + + lastVlcSnapshot = snapshot; + sendPlaybackPositionUpdate( + session.id, + contentInfo, + snapshot + ); + }; + + externalPlayerSessions.attachCloser(session.id, async () => { + await flushVlcPlaybackPosition(); + if (!proc.killed) { + proc.kill(); + } + }); + + if (!isRetry && rcPort > 0 && contentInfo) { + startVlcPositionPolling( + rcPort, + contentInfo, + session.id, + (snapshot) => { + lastVlcSnapshot = snapshot; + }, + () => { + markVlcSessionClosed(); + } + ); + } + + if (proc.stdout) { + proc.stdout.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + traceExternalPlayer('vlc stdout', { output }); + } + }); + } + + if (proc.stderr) { + proc.stderr.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.error('[VLC stderr]:', output); + } + }); + } + + proc.on('error', (err) => { + console.error('Failed to start VLC player:', err); + if (vlcProcess === proc) { + vlcProcess = null; + vlcRcPort = null; + } + if (!isRetry && rcPort > 0) { + traceExternalPlayer( + 'retry vlc without rc interface after start error' + ); + const retryArgs = playerArgs.filter( + (arg) => + !arg.includes('--extraintf') && + !arg.includes('--rc-host') && + !arg.includes('--rc-quiet') + ); + spawnVlc(retryArgs, true); + } else { + externalPlayerSessions.markError( + session.id, + `Failed to start VLC player: ${err.message}` + ); + rejectSpawn( + buildPlayerStartError('VLC', err, vlcLaunchContext) + ); + } + }); + + proc.on('exit', (code) => { + traceExternalPlayer('vlc exited', { code }); + if (vlcProcess === proc) { + vlcProcess = null; + vlcRcPort = null; + } + stopVlcPositionPolling(); + + if ( + lastVlcSnapshot && + contentInfo && + externalPlayerSessions.getSession(session.id) + ?.status !== 'closed' + ) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastVlcSnapshot + ); + } + + if (code === 1 && !isRetry && rcPort > 0) { + traceExternalPlayer( + 'retry vlc without rc interface after exit' + ); + stopVlcPositionPolling(); + const retryArgs = playerArgs.filter( + (arg) => + !arg.includes('--extraintf') && + !arg.includes('--rc-host') && + !arg.includes('--rc-quiet') + ); + spawnVlc(retryArgs, true); + return; + } + + if (code !== 0 && code !== null) { + console.error( + `[VLC ERROR] VLC exited with error code ${code}` + ); + sendPlayerErrorNotification( + 'VLC', + `VLC player closed unexpectedly (exit code: ${code})` + ); + externalPlayerSessions.markError( + session.id, + `VLC player closed unexpectedly (exit code: ${code})` + ); + return; + } + + externalPlayerSessions.markClosed(session.id); + }); + + if (!trackProcess) { + proc.unref(); + } + }; + + spawnVlc(args); + }); + + return externalPlayerSessions.markOpened(session.id) ?? session; + } catch (error) { + console.error('Error opening VLC player:', error); + externalPlayerSessions.markError( + session.id, + error instanceof Error ? error.message : String(error) + ); + throw error; + } +} diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 28b55b73d..309c6873c 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -189,6 +189,30 @@ MPV or VLC instance reuse is active and an existing process is reused, subsequen streams are loaded through MPV IPC or VLC RC commands and new process arguments are not re-applied until a fresh process starts. +## Electron External Player Ownership + +External MPV/VLC integration is split across focused main-process modules: + +- `apps/electron-backend/src/app/events/player.events.ts` registers IPC handlers + and settings updates only. +- `apps/electron-backend/src/app/events/external-player-launch-context.ts` + resolves Flatpak spawning, default executable paths, macOS `.app` bundles, + custom argv merging, spawn specs, and reuse decisions. +- `apps/electron-backend/src/app/events/external-player-playback-request.ts` + builds the effective playback request, including Stalker direct-stream fallback + metadata and external-player request headers. +- `apps/electron-backend/src/app/events/external-player-runtime.ts` owns shared + session tracking, trace logging, renderer notifications, playback-position + forwarding, and user-facing start errors. +- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns MPV process, + socket, reuse, cleanup, and progress polling lifecycle. +- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns VLC process, + RC interface, reuse, cleanup, command parsing, and progress polling lifecycle. + +Keep player-specific process state in the MPV/VLC session modules. Shared spawn, +request-header, session-registry, and notification helpers belong in the +`external-player-*` modules so IPC registration stays small and reviewable. + ## Flatpak External Players Flatpak cannot execute host-installed `mpv` or `vlc` binaries directly from the sandbox.