refactor(electron): split external player sessions

Split the Electron external-player IPC monolith into focused launch-context, playback-request, runtime, MPV session, and VLC session modules. Includes Greptile follow-up fixes for Homebrew Cask VLC path resolution and VLC spawn-error promise handling, with regression coverage.
This commit is contained in:
4gray authored and GitHub committed 2026-05-26 23:15:28 +02:00
1 parent 701cf23299
commit edaa981061
9 files changed
+1955 -1664

No files matched your search

+1 -1
View File
@@ -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.
@@ -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<ExternalPlayerName, string> = {
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 [];
}
}
@@ -0,0 +1,85 @@
import { getStalkerPlaybackContextHeaders } from '../services/stalker-playback-context.service';
export interface EffectiveExternalPlaybackRequest {
mergedHeaders: Record<string, string>;
effectiveOrigin?: string;
effectiveReferer?: string;
effectiveUserAgent?: string;
headerFields: string[];
}
export function buildHttpHeaderFields(
origin?: string,
headers?: Record<string, string>
): 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<string, string>
): 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<string, string>;
}): 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),
};
}
@@ -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;
}
}
@@ -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<string, string>;
}
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<number | null> {
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<string | number>
): Promise<void> {
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<string | number> = [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<void>((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;
}
}
@@ -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<string, string[]>) {
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<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if ((spawn as unknown as jest.Mock).mock.calls.length >= count) {
return;
}
await new Promise<void>((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(
File diff suppressed because it is too large. Load diff
@@ -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<string, string>;
}
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<string, string>;
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<void> {
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<void> {
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<string> {
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<string> {
return parseVlcRcNumericResponse(
await getVlcCommandResponse(port, command)
);
}
async function getVlcPlaybackState(port: number): Promise<string | null> {
return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status'));
}
async function getVlcPlaybackSnapshot(
port: number
): Promise<ExternalPlaybackSnapshot | null> {
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<number> {
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<void>((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;
}
}
@@ -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.