fix(playback): clarify external player launch feedback (#1388)

This commit is contained in:
4gray authored and GitHub committed 2026-08-09 13:33:07 +02:00
1 parent 8442747c37
commit d73acd6bfc
101 files changed
+8819 -850

No files matched your search

@@ -0,0 +1,150 @@
import type { ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import {
ExternalPlayerProcessTeardownGate,
terminateExternalPlayerProcess,
waitForExternalPlayerProcessExit,
} from './external-player-process';
function createMockChildProcess(): ChildProcess {
return Object.assign(new EventEmitter(), {
exitCode: null,
killed: false,
kill: jest.fn(() => true),
signalCode: null,
stderr: null,
stdout: null,
unref: jest.fn(),
}) as unknown as ChildProcess;
}
describe('external player process teardown', () => {
it('accepts close as confirmed termination after a spawn error', async () => {
const child = createMockChildProcess();
child.on('error', () => undefined);
let settled = false;
const exit = waitForExternalPlayerProcessExit(child).then(() => {
settled = true;
});
child.emit(
'error',
Object.assign(new Error('spawn failed'), { code: 'ENOENT' })
);
await Promise.resolve();
expect(settled).toBe(false);
child.emit('close', -2, null);
await expect(exit).resolves.toBeUndefined();
});
it('does not treat a process error as confirmed exit', async () => {
const child = createMockChildProcess();
child.on('error', () => undefined);
let settled = false;
const exit = waitForExternalPlayerProcessExit(child).then(() => {
settled = true;
});
child.emit('error', new Error('kill failed'));
await Promise.resolve();
expect(settled).toBe(false);
Object.defineProperty(child, 'exitCode', { value: 0 });
child.emit('exit', 0);
await expect(exit).resolves.toBeUndefined();
});
it('escalates teardown and resolves only after confirmed exit', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const teardown = terminateExternalPlayerProcess(child);
expect(child.kill).toHaveBeenNthCalledWith(1);
await jest.advanceTimersByTimeAsync(3_000);
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
child.emit('close', null, 'SIGKILL');
await expect(teardown).resolves.toBeUndefined();
} finally {
jest.useRealTimers();
}
});
it('allows a protocol quit grace period before forced termination', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const teardown = terminateExternalPlayerProcess(child, {
sendTerminationSignal: false,
});
expect(child.kill).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(3_000);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
child.emit('exit', null, 'SIGKILL');
await expect(teardown).resolves.toBeUndefined();
} finally {
jest.useRealTimers();
}
});
it('rejects after a bounded forced-teardown wait', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const teardown = terminateExternalPlayerProcess(child);
const result = expect(teardown).rejects.toThrow(
'External player process did not exit'
);
await jest.advanceTimersByTimeAsync(5_000);
await result;
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
} finally {
jest.useRealTimers();
}
});
it('blocks replacement launches until an unconfirmed child actually exits', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const gate = new ExternalPlayerProcessTeardownGate();
const teardown = gate.terminate(child);
const rejection = expect(teardown).rejects.toThrow(
'External player process did not exit'
);
expect(() => gate.assertLaunchAllowed()).toThrow(
'previous external player is still shutting down'
);
await jest.advanceTimersByTimeAsync(5_000);
await rejection;
expect(() => gate.assertLaunchAllowed()).toThrow(
'previous external player is still shutting down'
);
Object.defineProperty(child, 'exitCode', { value: 0 });
child.emit('exit', 0);
expect(() => gate.assertLaunchAllowed()).not.toThrow();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,189 @@
import type { ChildProcess } from 'child_process';
const TERMINATION_GRACE_PERIOD_MS = 3_000;
const FORCED_TERMINATION_WAIT_MS = 2_000;
interface TerminateExternalPlayerProcessOptions {
sendTerminationSignal?: boolean;
}
const EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR =
'Cannot launch player because the previous external player is still shutting down';
/**
* Serializes replacement launches against exact children whose exit has not
* been confirmed. `ChildProcess.killed` only means a signal was sent, so it is
* deliberately not used as a release condition.
*/
export class ExternalPlayerProcessTeardownGate {
private readonly pending = new Map<ChildProcess, () => void>();
assertLaunchAllowed(): void {
for (const child of this.pending.keys()) {
if (hasExited(child)) {
this.release(child);
}
}
if (this.pending.size > 0) {
throw new Error(EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR);
}
}
/**
* Guard replacement launches before a potentially slow protocol-level
* quit command is dispatched. `terminate()` keeps using the same exact
* child registration once that command completes or fails.
*/
beginTeardown(child: ChildProcess): void {
this.track(child);
}
async terminate(
child: ChildProcess,
options: TerminateExternalPlayerProcessOptions = {}
): Promise<void> {
this.track(child);
try {
await terminateExternalPlayerProcess(child, options);
} finally {
if (hasExited(child)) {
this.release(child);
}
}
}
terminateInBackground(child: ChildProcess): void {
void this.terminate(child).catch(() => {
// The child remains registered until an exact exit/close event.
// A later launch therefore still fails closed.
});
}
private track(child: ChildProcess): void {
if (hasExited(child) || this.pending.has(child)) {
return;
}
const release = () => this.release(child);
this.pending.set(child, release);
child.once('exit', release);
child.once('close', release);
}
private release(child: ChildProcess): void {
const release = this.pending.get(child);
if (!release) {
return;
}
child.off('exit', release);
child.off('close', release);
this.pending.delete(child);
}
}
export const externalPlayerProcessTeardownGate =
new ExternalPlayerProcessTeardownGate();
/**
* Resolves only after the exact child has stopped. A sent termination signal
* is not itself proof that a replacement can be started safely.
*/
export function waitForExternalPlayerProcessExit(
child: ChildProcess
): Promise<void> {
if (hasExited(child)) {
return Promise.resolve();
}
return new Promise((resolve) => {
const complete = () => {
child.off('exit', complete);
child.off('close', complete);
resolve();
};
child.once('exit', complete);
child.once('close', complete);
});
}
export async function terminateExternalPlayerProcess(
child: ChildProcess,
options: TerminateExternalPlayerProcessOptions = {}
): Promise<void> {
if (hasExited(child)) {
return;
}
if (
options.sendTerminationSignal !== false &&
!hasExited(child) &&
!child.killed
) {
try {
child.kill();
} catch {
// The forced termination attempt below still gets a chance to
// confirm that the exact child stopped.
}
}
if (
await waitForExternalPlayerProcessExitWithin(
child,
TERMINATION_GRACE_PERIOD_MS
)
) {
return;
}
try {
child.kill('SIGKILL');
} catch {
// Keep waiting for the bounded confirmation window. If the child does
// not report exit, reject so callers cannot launch a replacement.
}
if (
await waitForExternalPlayerProcessExitWithin(
child,
FORCED_TERMINATION_WAIT_MS
)
) {
return;
}
throw new Error('External player process did not exit');
}
function waitForExternalPlayerProcessExitWithin(
child: ChildProcess,
timeoutMs: number
): Promise<boolean> {
if (hasExited(child)) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const complete = (exited: boolean) => {
child.off('exit', onExit);
child.off('close', onExit);
if (timer) {
clearTimeout(timer);
}
resolve(exited);
};
const onExit = () => complete(true);
child.once('exit', onExit);
child.once('close', onExit);
timer = setTimeout(() => complete(hasExited(child)), timeoutMs);
timer.unref();
});
}
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
@@ -47,7 +47,27 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(registry.getActiveSessionId()).toBeNull();
});
it('marks the session closed even when the runtime close fails', async () => {
it('does not invoke a stale closer for an already closed session', async () => {
const close = jest.fn();
const session = registry.beginSession({
player: 'mpv',
title: 'Closed',
streamUrl: 'https://example.com/closed.m3u8',
});
registry.attachCloser(session.id, close);
registry.markClosed(session.id);
const closed = await registry.closeSession(session.id);
expect(close).not.toHaveBeenCalled();
expect(closed).toMatchObject({
id: session.id,
status: 'closed',
canClose: false,
});
});
it('keeps the session live when runtime close cannot be confirmed', async () => {
const close = jest.fn().mockRejectedValue(new Error('close failed'));
const session = registry.beginSession({
player: 'vlc',
@@ -57,12 +77,14 @@ describe('ExternalPlayerSessionRegistry', () => {
registry.attachCloser(session.id, close);
const closed = await registry.closeSession(session.id);
await expect(registry.closeSession(session.id)).rejects.toThrow(
'close failed'
);
expect(close).toHaveBeenCalled();
expect(closed?.status).toBe('closed');
expect(closed?.canClose).toBe(false);
expect(registry.getActiveSessionId()).toBeNull();
expect(registry.getSession(session.id)?.status).toBe('launching');
expect(registry.getSession(session.id)?.canClose).toBe(true);
expect(registry.getActiveSessionId()).toBe(session.id);
});
it('marks runtime failures as errors without clearing the active id', () => {
@@ -78,4 +100,121 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(errored?.error).toBe('Failed to launch');
expect(registry.getActiveSessionId()).toBe(session.id);
});
it('does not overwrite terminal status with late lifecycle updates', () => {
const failed = registry.beginSession({
player: 'mpv',
title: 'Failed',
streamUrl: 'https://example.com/failed.m3u8',
});
registry.markError(failed.id, 'Failed to launch');
expect(registry.markOpened(failed.id)?.status).toBe('error');
const closed = registry.beginSession({
player: 'vlc',
title: 'Closed',
streamUrl: 'https://example.com/closed.m3u8',
});
registry.markClosed(closed.id);
expect(registry.markPlaying(closed.id)?.status).toBe('closed');
expect(
registry.markError(closed.id, 'Late process exit')?.status
).toBe('closed');
expect(updates.at(-1)).toMatchObject({
id: closed.id,
status: 'closed',
});
});
it('keeps confirmed playback ahead of a late opened acknowledgement', () => {
const session = registry.beginSession({
player: 'mpv',
title: 'Fast playback',
streamUrl: 'https://example.com/fast.m3u8',
});
expect(registry.markPlaying(session.id)?.status).toBe('playing');
expect(registry.markOpened(session.id)?.status).toBe('playing');
});
it('restores a previous live session after a replacement fails', () => {
const previous = registry.beginSession({
player: 'mpv',
title: 'Previous',
streamUrl: 'https://example.com/previous.m3u8',
});
registry.attachCloser(previous.id, jest.fn());
registry.markOpened(previous.id);
const replacement = registry.beginSession({
player: 'mpv',
title: 'Replacement',
streamUrl: 'https://example.com/replacement.m3u8',
});
registry.markError(replacement.id, 'teardown unconfirmed');
const restored = registry.restoreActiveSession(
previous.id,
replacement.id
);
expect(restored).toMatchObject({
id: previous.id,
status: 'opened',
canClose: true,
});
expect(registry.getActiveSessionId()).toBe(previous.id);
expect(updates.at(-1)).toMatchObject({
id: previous.id,
restoredFromSessionId: replacement.id,
});
});
it('does not restore over a newer active session', () => {
const previous = registry.beginSession({
player: 'mpv',
title: 'Previous',
streamUrl: 'https://example.com/previous.m3u8',
});
registry.markOpened(previous.id);
const failedReplacement = registry.beginSession({
player: 'mpv',
title: 'Failed replacement',
streamUrl: 'https://example.com/failed.m3u8',
});
const newer = registry.beginSession({
player: 'vlc',
title: 'Newer',
streamUrl: 'https://example.com/newer.m3u8',
});
const updateCount = updates.length;
expect(
registry.restoreActiveSession(previous.id, failedReplacement.id)
).toBeNull();
expect(registry.getActiveSessionId()).toBe(newer.id);
expect(updates).toHaveLength(updateCount);
});
it('does not restore an unclosable terminal error session', () => {
const terminal = registry.beginSession({
player: 'mpv',
title: 'Terminal failure',
streamUrl: 'https://example.com/terminal.m3u8',
});
registry.markError(terminal.id, 'No player process remains');
const replacement = registry.beginSession({
player: 'vlc',
title: 'Replacement',
streamUrl: 'https://example.com/replacement.m3u8',
});
const updateCount = updates.length;
expect(
registry.restoreActiveSession(terminal.id, replacement.id)
).toBeNull();
expect(registry.getActiveSessionId()).toBe(replacement.id);
expect(updates).toHaveLength(updateCount);
});
});
@@ -23,6 +23,19 @@ interface ExternalPlayerSessionRuntime {
close?: () => Promise<void> | void;
}
interface MarkExternalPlayerSessionErrorOptions {
canClose?: boolean;
}
function isRestorableSession(session: ExternalPlayerSession): boolean {
return (
session.status === 'launching' ||
session.status === 'opened' ||
session.status === 'playing' ||
(session.status === 'error' && session.canClose)
);
}
export class ExternalPlayerSessionRegistry {
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
private activeSessionId: string | null = null;
@@ -62,6 +75,32 @@ export class ExternalPlayerSessionRegistry {
return this.sessions.get(id)?.snapshot ?? null;
}
/**
* Re-publish the exact still-live session that a failed replacement had
* temporarily displaced. Its existing closer remains attached.
*/
restoreActiveSession(
id: string,
displacedSessionId: string
): ExternalPlayerSession | null {
const runtime = this.sessions.get(id);
if (
!runtime ||
!isRestorableSession(runtime.snapshot) ||
this.activeSessionId !== displacedSessionId
) {
return null;
}
this.activeSessionId = id;
this.onUpdate({
...runtime.snapshot,
updatedAt: new Date().toISOString(),
restoredFromSessionId: displacedSessionId,
});
return runtime.snapshot;
}
attachCloser(
id: string,
close: () => Promise<void> | void
@@ -95,12 +134,21 @@ export class ExternalPlayerSessionRegistry {
}
markOpened(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status !== 'launching') {
return current;
}
return this.updateSession(id, { status: 'opened' });
}
markPlaying(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'playing') {
if (
!current ||
current.status === 'playing' ||
current.status === 'error' ||
current.status === 'closed'
) {
return current;
}
@@ -108,17 +156,30 @@ export class ExternalPlayerSessionRegistry {
}
markClosed(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'closed') {
return current;
}
if (this.activeSessionId === id) {
this.activeSessionId = null;
}
return this.updateSession(id, { status: 'closed', canClose: false });
}
markError(id: string, error: string): ExternalPlayerSession | null {
markError(
id: string,
error: string,
options: MarkExternalPlayerSessionErrorOptions = {}
): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'closed') {
return current;
}
return this.updateSession(id, {
status: 'error',
error,
canClose: false,
canClose: options.canClose ?? false,
});
}
@@ -128,13 +189,19 @@ export class ExternalPlayerSessionRegistry {
return null;
}
try {
await runtime.close?.();
} catch {
// Close failures must not keep the session in a live state; the
// registry still reports it as closed below.
// A renderer can deliver a delayed duplicate Stop after the exact
// child has already exited and a newer external player owns the
// process slot. Never re-enter the terminal session's saved closer:
// its protocol endpoint may since have been reused by another child.
if (runtime.snapshot.status === 'closed') {
return runtime.snapshot;
}
// A failed closer cannot prove that the underlying process stopped.
// Preserve the live session and propagate the failure so callers do
// not start a replacement process alongside it.
await runtime.close?.();
return this.markClosed(id);
}
}
@@ -0,0 +1,325 @@
import { ChildProcess } from 'child_process';
import { createConnection } from 'net';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
externalPlayerSessions,
traceExternalPlayer,
} from './external-player-runtime';
const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
export interface MpvReuseAttemptState {
contentMutated: boolean;
teardownUnconfirmed: boolean;
}
interface MpvReuseOptions {
session: ExternalPlayerSession;
previousProcessSessionId: string | null;
url: string;
title: string;
effectiveUserAgent?: string;
effectiveReferer?: string;
headerFields: string[];
contentInfo?: PlayerContentInfo;
startTime?: number;
state: MpvReuseAttemptState;
startPositionPolling: (
socketPath: string,
contentInfo: PlayerContentInfo,
sessionId: string
) => void;
stopPositionPolling: () => void;
}
function sendMpvCommand(
socketPath: string,
command: string,
args: Array<string | number>,
shouldDispatch?: () => boolean
): Promise<boolean> {
return new Promise((resolve, reject) => {
const client = createConnection(socketPath);
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
let settled = false;
let timeoutHandle: NodeJS.Timeout | null = null;
const complete = (error?: Error, dispatched = true) => {
if (settled) return;
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
if (!dispatched && !client.destroyed) client.destroy();
if (error) {
reject(error);
} else {
resolve(dispatched);
}
};
client.on('connect', () => {
if (shouldDispatch && !shouldDispatch()) {
complete(undefined, false);
return;
}
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
});
try {
client.write(request);
client.end();
complete();
} catch (error) {
complete(
error instanceof Error ? error : new Error(String(error))
);
}
});
client.on('error', (error) => complete(error));
timeoutHandle = setTimeout(() => {
complete(new Error('MPV IPC command timed out'));
client.destroy();
}, MPV_IPC_COMMAND_TIMEOUT_MS);
timeoutHandle.unref();
});
}
/** Owns the one MPV child/socket retained when instance reuse is enabled. */
export class MpvReusableProcess {
private process: ChildProcess | null = null;
private socketPath: string | null = null;
private processSessionId: string | null = null;
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
currentSessionId(): string | null {
return this.processSessionId;
}
sessionIdFor(process: ChildProcess, fallback: string): string {
return this.processSessionIds.get(process) ?? fallback;
}
owns(process: ChildProcess, sessionId: string): boolean {
return this.processSessionIds.get(process) === sessionId;
}
track(process: ChildProcess, socketPath: string, sessionId: string): void {
this.process = process;
this.socketPath = socketPath;
this.processSessionId = sessionId;
this.processSessionIds.set(process, sessionId);
}
clear(process: ChildProcess): boolean {
if (this.process !== process) return false;
this.process = null;
this.socketPath = null;
this.processSessionId = null;
return true;
}
stopStored(
reason: string,
stopPositionPolling: () => void,
guardFutureLaunches = false
): void {
const process = this.process;
if (!process || process.killed) return;
traceExternalPlayer(reason);
if (guardFutureLaunches) {
externalPlayerProcessTeardownGate.terminateInBackground(process);
} else {
process.kill();
}
this.clear(process);
stopPositionPolling();
}
async tryReuse(
options: MpvReuseOptions
): Promise<ExternalPlayerSession | null> {
const reusedProcess = this.process;
const reusedSocketPath = this.socketPath;
if (!reusedProcess || reusedProcess.killed || !reusedSocketPath) {
return null;
}
traceExternalPlayer('reuse existing mpv instance');
const { session, state } = options;
const reusedProcessSessionId =
this.processSessionIds.get(reusedProcess) ??
options.previousProcessSessionId;
let closeRequested = false;
let retryableClose: Promise<void> | null = null;
let launchClose: Promise<void> | null = null;
const ownsReusedProcess = () =>
this.processSessionIds.get(reusedProcess) === session.id ||
externalPlayerSessions.getActiveSessionId() === session.id;
const closeReusedProcess = async () => {
externalPlayerProcessTeardownGate.beginTeardown(reusedProcess);
try {
await sendMpvCommand(reusedSocketPath, 'quit', []);
} catch {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
return;
}
await externalPlayerProcessTeardownGate.terminate(reusedProcess, {
sendTerminationSignal: false,
});
};
const finishRequestedClose = async () => {
if (launchClose) {
try {
await launchClose;
} catch (error) {
state.teardownUnconfirmed = true;
throw error;
}
}
return externalPlayerSessions.markClosed(session.id) ?? session;
};
externalPlayerSessions.attachCloser(session.id, () => {
closeRequested = true;
if (!ownsReusedProcess()) return;
if (!retryableClose) {
const closeAttempt = closeReusedProcess();
retryableClose = closeAttempt;
launchClose ??= closeAttempt;
void closeAttempt.catch((error) => {
if (retryableClose === closeAttempt) retryableClose = null;
state.teardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
);
});
}
return retryableClose;
});
try {
await this.applyReuseCommands(
options,
reusedSocketPath,
() => !closeRequested
);
if (closeRequested) return await finishRequestedClose();
state.contentMutated = true;
this.processSessionId = session.id;
this.processSessionIds.set(reusedProcess, session.id);
options.stopPositionPolling();
if (options.startTime) {
await sendMpvCommand(
reusedSocketPath,
'seek',
[String(options.startTime), 'absolute'],
() => !closeRequested
);
}
if (closeRequested) return await finishRequestedClose();
if (options.contentInfo) {
options.startPositionPolling(
reusedSocketPath,
options.contentInfo,
session.id
);
} else {
options.stopPositionPolling();
}
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
const current = externalPlayerSessions.getSession(session.id);
if (current?.status === 'closed') return current;
if (closeRequested) return await finishRequestedClose();
console.error('Failed to send command to existing MPV:', error);
if (state.contentMutated) {
if (reusedProcessSessionId) {
this.processSessionIds.set(
reusedProcess,
reusedProcessSessionId
);
} else {
this.processSessionIds.delete(reusedProcess);
}
}
try {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
} catch (teardownError) {
if (state.contentMutated) {
this.processSessionIds.set(reusedProcess, session.id);
}
state.teardownUnconfirmed = true;
throw teardownError;
}
this.clear(reusedProcess);
options.stopPositionPolling();
if (closeRequested) return await finishRequestedClose();
return null;
}
}
private async applyReuseCommands(
options: MpvReuseOptions,
socketPath: string,
shouldDispatch: () => boolean
): Promise<void> {
if (options.effectiveUserAgent) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
['user-agent', options.effectiveUserAgent],
shouldDispatch
);
if (!dispatched) return;
}
if (options.effectiveReferer) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
['referrer', options.effectiveReferer],
shouldDispatch
);
if (!dispatched) return;
}
if (options.headerFields.length > 0) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
[
'http-header-fields',
joinMpvHeaderFields(options.headerFields),
],
shouldDispatch
);
if (!dispatched) return;
}
if (!shouldDispatch()) return;
const loadFileArgs: Array<string | number> = [options.url, 'replace'];
if (options.title) {
loadFileArgs.push(-1, `force-media-title=${options.title}`);
}
const dispatched = await sendMpvCommand(
socketPath,
'loadfile',
loadFileArgs,
shouldDispatch
);
if (!dispatched) return;
traceExternalPlayer('loaded new url in existing mpv instance');
}
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import { ChildProcess, spawn } from 'child_process';
import { spawn } from 'child_process';
import { createConnection } from 'net';
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import {
@@ -29,6 +29,11 @@ import {
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
MpvReusableProcess,
MpvReuseAttemptState,
} from './mpv-reusable-process';
export interface OpenExternalPlayerRequest {
url: string;
@@ -42,8 +47,7 @@ export interface OpenExternalPlayerRequest {
headers?: Record<string, string>;
}
let mpvProcess: ChildProcess | null = null;
let mpvSocketPath: string | null = null;
const reusableMpvProcess = new MpvReusableProcess();
let positionPollingInterval: NodeJS.Timeout | null = null;
function getMpvPath(options: PlayerPathOptions = {}): string {
@@ -152,53 +156,16 @@ function startPositionPolling(
}, 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);
});
});
}
function killStoredMpvProcess(reason: string): void {
if (!mpvProcess || mpvProcess.killed) {
return;
}
traceExternalPlayer(reason);
mpvProcess.kill();
mpvProcess = null;
mpvSocketPath = null;
stopPositionPolling();
}
export function setMpvReuseInstance(reuseInstance: boolean): void {
traceExternalPlayer('set mpv reuse instance', { reuseInstance });
store.set(MPV_REUSE_INSTANCE, reuseInstance);
if (!reuseInstance) {
killStoredMpvProcess('clean up mpv process after disabling reuse');
reusableMpvProcess.stopStored(
'clean up mpv process after disabling reuse',
stopPositionPolling,
true
);
}
}
@@ -208,7 +175,10 @@ export function setMpvReuseInstance(reuseInstance: boolean): void {
* app and keeps playing after quit.
*/
export function shutdownMpvSession(): void {
killStoredMpvProcess('kill reused mpv process on app shutdown');
reusableMpvProcess.stopStored(
'kill reused mpv process on app shutdown',
stopPositionPolling
);
}
export async function openMpvPlayer({
@@ -222,6 +192,9 @@ export async function openMpvPlayer({
startTime,
headers,
}: OpenExternalPlayerRequest) {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
const session = externalPlayerSessions.beginSession({
player: 'mpv',
title,
@@ -229,6 +202,11 @@ export async function openMpvPlayer({
streamUrl: url,
contentInfo,
});
const reuseState: MpvReuseAttemptState = {
teardownUnconfirmed: false,
contentMutated: false,
};
let freshTeardownUnconfirmed = false;
try {
const isFlatpak = isRunningInFlatpak();
@@ -273,80 +251,22 @@ export async function openMpvPlayer({
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',
joinMpvHeaderFields(headerFields),
]);
}
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();
}
if (reuseInstance) {
const reused = await reusableMpvProcess.tryReuse({
session,
previousProcessSessionId,
url,
title,
effectiveUserAgent,
effectiveReferer,
headerFields,
contentInfo,
startTime,
state: reuseState,
startPositionPolling,
stopPositionPolling,
});
if (reused) return reused;
}
traceExternalPlayer('create new mpv instance');
@@ -373,7 +293,9 @@ export async function openMpvPlayer({
}
if (headerFields.length > 0) {
args.push(`--http-header-fields=${joinMpvHeaderFields(headerFields)}`);
args.push(
`--http-header-fields=${joinMpvHeaderFields(headerFields)}`
);
}
if (title) {
@@ -387,10 +309,36 @@ export async function openMpvPlayer({
args.push(url);
await new Promise<void>((resolve, reject) => {
let settled = false;
let startConfirmationTimer: NodeJS.Timeout | null = null;
const resolveLaunch = () => {
if (settled) {
return;
}
settled = true;
if (startConfirmationTimer) {
clearTimeout(startConfirmationTimer);
}
resolve();
};
const rejectLaunch = (error: Error) => {
if (settled) {
return;
}
settled = true;
if (startConfirmationTimer) {
clearTimeout(startConfirmationTimer);
}
reject(error);
};
const spawnSpec = buildExternalPlayerSpawnSpec(
mpvLaunchContext,
buildPlayerArgsWithCustomArguments(customMpvArguments, args)
);
// Reuse teardown yields while waiting for the old child. Another
// external process may enter teardown during that window, so the
// process-wide invariant must be checked at the actual spawn too.
externalPlayerProcessTeardownGate.assertLaunchAllowed();
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: !reuseInstance,
@@ -455,20 +403,28 @@ export async function openMpvPlayer({
proc.on('error', (err) => {
console.error('Failed to start MPV player:', err);
mpvProcess = null;
mpvSocketPath = null;
const processSessionId = reusableMpvProcess.sessionIdFor(
proc,
session.id
);
reusableMpvProcess.clear(proc);
stopPositionPolling();
externalPlayerSessions.markError(
session.id,
processSessionId,
`Failed to start MPV player: ${err.message}`
);
reject(buildPlayerStartError('MPV', err, mpvLaunchContext));
rejectLaunch(
buildPlayerStartError('MPV', err, mpvLaunchContext)
);
});
proc.on('exit', (code) => {
traceExternalPlayer('mpv exited', { code });
mpvProcess = null;
mpvSocketPath = null;
const processSessionId = reusableMpvProcess.sessionIdFor(
proc,
session.id
);
reusableMpvProcess.clear(proc);
stopPositionPolling();
if (code !== 0 && code !== null) {
@@ -480,18 +436,19 @@ export async function openMpvPlayer({
`MPV player closed unexpectedly (exit code: ${code})`
);
externalPlayerSessions.markError(
session.id,
processSessionId,
`MPV player closed unexpectedly (exit code: ${code})`
);
resolveLaunch();
return;
}
externalPlayerSessions.markClosed(session.id);
externalPlayerSessions.markClosed(processSessionId);
resolveLaunch();
});
if (reuseInstance && socketPath) {
mpvProcess = proc;
mpvSocketPath = socketPath;
reusableMpvProcess.track(proc, socketPath, session.id);
traceExternalPlayer('stored mpv process for reuse', {
socketPath,
});
@@ -500,8 +457,27 @@ export async function openMpvPlayer({
}
externalPlayerSessions.attachCloser(session.id, async () => {
if (!proc.killed) {
proc.kill();
if (
reuseInstance &&
!reusableMpvProcess.owns(proc, session.id)
) {
return;
}
try {
await externalPlayerProcessTeardownGate.terminate(proc);
} catch (error) {
const teardownError =
error instanceof Error
? error
: new Error(String(error));
freshTeardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
teardownError.message,
{ canClose: true }
);
rejectLaunch(teardownError);
throw teardownError;
}
});
@@ -509,22 +485,38 @@ export async function openMpvPlayer({
startPositionPolling(socketPath, contentInfo, session.id);
}
setTimeout(() => {
startConfirmationTimer = setTimeout(() => {
if (!proc.killed) {
resolve();
resolveLaunch();
}
}, 100);
startConfirmationTimer.unref();
});
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
console.error('Error opening MPV player:', error);
mpvProcess = null;
mpvSocketPath = null;
stopPositionPolling();
if (!reuseState.teardownUnconfirmed && !freshTeardownUnconfirmed) {
stopPositionPolling();
}
const restoredSession =
reuseState.teardownUnconfirmed &&
!reuseState.contentMutated &&
displacedSessionId
? externalPlayerSessions.restoreActiveSession(
displacedSessionId,
session.id
)
: null;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error)
error instanceof Error ? error.message : String(error),
{
canClose:
freshTeardownUnconfirmed ||
(reuseState.teardownUnconfirmed &&
(reuseState.contentMutated || !restoredSession)),
}
);
throw error;
}
@@ -0,0 +1,167 @@
import { createConnection } from 'net';
import { ExternalPlaybackSnapshot } from './external-player-runtime';
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;
}
export function sendVlcRcCommand(
port: number,
command: string,
onDispatched?: () => void,
shouldDispatch?: () => boolean
): Promise<boolean> {
return new Promise((resolve, reject) => {
const client = createConnection({ port, host: '127.0.0.1' });
let settled = false;
const finish = (error?: Error, dispatched = true) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
if (error) reject(error);
else resolve(dispatched);
};
const timeoutHandle = setTimeout(
() => finish(new Error('VLC RC command timed out')),
2_000
);
client.on('connect', () => {
if (shouldDispatch && !shouldDispatch()) {
finish(undefined, false);
return;
}
try {
client.write(`${command}\n`);
onDispatched?.();
} catch (error) {
finish(
error instanceof Error ? error : new Error(String(error))
);
}
});
client.on('data', (chunk) => {
if (chunk.toString().includes('>')) finish();
});
client.on('error', (error) => finish(error));
});
}
export async function sendVlcRcCommands(
port: number,
commands: string[],
onCommandSent?: (command: string, index: number) => void,
shouldDispatch?: () => boolean
): Promise<void> {
for (const [index, command] of commands.entries()) {
if (shouldDispatch && !shouldDispatch()) return;
const dispatched = await sendVlcRcCommand(
port,
command,
() => onCommandSent?.(command, index),
shouldDispatch
);
if (!dispatched) return;
}
}
export function parseVlcRcNumericResponse(data: string): string {
return data.match(/>\s*(-?\d+(?:\.\d+)?)/)?.[1] ?? '';
}
export function parseVlcRcPlaybackState(data: string): string | null {
return (
data
.match(/\(\s*state\s+([^)]+)\s*\)/i)?.[1]
?.trim()
.toLowerCase() ?? null
);
}
function getVlcCommandResponse(port: number, command: string): Promise<string> {
return new Promise((resolve) => {
const client = createConnection({ port, host: '127.0.0.1' });
let data = '';
let settled = false;
const finish = (result: string) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
resolve(result);
};
const timeoutHandle = setTimeout(() => finish(''), 2_000);
client.on('connect', () => client.write(`${command}\n`));
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('>')) finish(data);
});
client.on('error', () => finish(''));
});
}
export async function getVlcPlaybackState(
port: number
): Promise<string | null> {
return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status'));
}
export async function getVlcPlaybackSnapshot(
port: number
): Promise<ExternalPlaybackSnapshot | null> {
const time = parseInt(
parseVlcRcNumericResponse(
await getVlcCommandResponse(port, 'get_time')
),
10
);
const duration = parseInt(
parseVlcRcNumericResponse(
await getVlcCommandResponse(port, 'get_length')
),
10
);
if (Number.isNaN(time)) return null;
return {
positionSeconds: time,
durationSeconds: Number.isNaN(duration) ? null : duration,
};
}
@@ -0,0 +1,257 @@
import { ChildProcess } from 'child_process';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
ExternalPlaybackSnapshot,
externalPlayerSessions,
sendPlaybackPositionUpdate,
traceExternalPlayer,
} from './external-player-runtime';
import {
buildVlcEnqueueCommands,
sendVlcRcCommand,
sendVlcRcCommands,
} from './vlc-rc';
export interface VlcReuseAttemptState {
contentMutated: boolean;
teardownUnconfirmed: boolean;
closeRequested: boolean;
requestedClose: Promise<void> | null;
}
interface VlcReuseOptions {
session: ExternalPlayerSession;
previousProcessSessionId: string | null;
url: string;
title: string;
effectiveUserAgent?: string;
effectiveReferer?: string;
effectiveOrigin?: string;
mergedHeaders: Record<string, string>;
contentInfo?: PlayerContentInfo;
startTime?: number;
state: VlcReuseAttemptState;
startPositionPolling: (
port: number,
contentInfo: PlayerContentInfo,
sessionId: string,
onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void,
onStopped?: () => void
) => void;
stopPositionPolling: () => void;
}
/** Owns the one VLC child/RC port retained when instance reuse is enabled. */
export class VlcReusableProcess {
private process: ChildProcess | null = null;
private rcPort: number | null = null;
private processSessionId: string | null = null;
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
currentSessionId(): string | null {
return this.processSessionId;
}
sessionIdFor(process: ChildProcess, fallback: string): string {
return this.processSessionIds.get(process) ?? fallback;
}
owns(process: ChildProcess, sessionId: string): boolean {
return this.processSessionIds.get(process) === sessionId;
}
track(process: ChildProcess, rcPort: number, sessionId: string): void {
this.process = process;
this.rcPort = rcPort;
this.processSessionId = sessionId;
this.processSessionIds.set(process, sessionId);
}
clear(process: ChildProcess): boolean {
if (this.process !== process) return false;
this.process = null;
this.rcPort = null;
this.processSessionId = null;
return true;
}
stopStored(
reason: string,
stopPositionPolling: () => void,
guardFutureLaunches = false
): void {
const process = this.process;
if (!process || process.killed) return;
traceExternalPlayer(reason);
if (guardFutureLaunches) {
externalPlayerProcessTeardownGate.terminateInBackground(process);
} else {
process.kill();
}
this.clear(process);
stopPositionPolling();
}
async tryReuse(
options: VlcReuseOptions
): Promise<ExternalPlayerSession | null> {
const reusedProcess = this.process;
const reusedRcPort = this.rcPort;
if (!reusedProcess || reusedProcess.killed || !reusedRcPort)
return null;
traceExternalPlayer('reuse existing vlc instance', {
rcPort: reusedRcPort,
});
const { session, state } = options;
const reusedProcessSessionId =
this.processSessionIds.get(reusedProcess) ??
options.previousProcessSessionId;
let closeRequested = false;
let retryableClose: Promise<void> | null = null;
let launchClose: Promise<void> | null = null;
const ownsReusedProcess = () =>
this.processSessionIds.get(reusedProcess) === session.id ||
externalPlayerSessions.getActiveSessionId() === session.id;
const closeReusedProcess = async () => {
externalPlayerProcessTeardownGate.beginTeardown(reusedProcess);
try {
await sendVlcRcCommand(reusedRcPort, 'quit');
} catch {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
return;
}
await externalPlayerProcessTeardownGate.terminate(reusedProcess, {
sendTerminationSignal: false,
});
};
const finishRequestedClose = async () => {
if (launchClose) {
try {
await launchClose;
} catch (error) {
state.teardownUnconfirmed = true;
throw error;
}
}
return externalPlayerSessions.markClosed(session.id) ?? session;
};
externalPlayerSessions.attachCloser(session.id, () => {
closeRequested = true;
state.closeRequested = true;
if (!ownsReusedProcess()) return;
if (!retryableClose) {
const closeAttempt = closeReusedProcess();
retryableClose = closeAttempt;
launchClose ??= closeAttempt;
void closeAttempt.catch((error) => {
if (retryableClose === closeAttempt) retryableClose = null;
state.teardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
);
});
}
state.requestedClose = retryableClose;
return retryableClose;
});
try {
await sendVlcRcCommands(
reusedRcPort,
buildVlcEnqueueCommands({
url: options.url,
title: options.title,
userAgent: options.effectiveUserAgent,
referer: options.effectiveReferer,
origin: options.effectiveOrigin,
headers: options.mergedHeaders,
startTime: options.startTime,
}),
(_command, index) => {
if (index !== 0) return;
state.contentMutated = true;
this.processSessionId = session.id;
this.processSessionIds.set(reusedProcess, session.id);
options.stopPositionPolling();
},
() => !closeRequested
);
if (closeRequested) return await finishRequestedClose();
traceExternalPlayer('loaded new url in existing vlc instance');
let lastSnapshot: ExternalPlaybackSnapshot | null = null;
if (options.contentInfo) {
options.startPositionPolling(
reusedRcPort,
options.contentInfo,
session.id,
(snapshot) => {
lastSnapshot = snapshot;
},
() => {
if (
lastSnapshot &&
externalPlayerSessions.getSession(session.id)
?.status !== 'closed'
) {
sendPlaybackPositionUpdate(
session.id,
options.contentInfo as PlayerContentInfo,
lastSnapshot
);
}
externalPlayerSessions.markClosed(session.id);
}
);
} else {
options.stopPositionPolling();
}
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
const current = externalPlayerSessions.getSession(session.id);
if (current?.status === 'closed') return current;
if (closeRequested) return await finishRequestedClose();
console.error(
'Failed to reuse existing VLC, spawning fresh:',
error
);
if (state.contentMutated) {
if (reusedProcessSessionId) {
this.processSessionIds.set(
reusedProcess,
reusedProcessSessionId
);
} else {
this.processSessionIds.delete(reusedProcess);
}
}
try {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
} catch (teardownError) {
if (state.contentMutated) {
this.processSessionIds.set(reusedProcess, session.id);
}
state.teardownUnconfirmed = true;
throw teardownError;
}
this.clear(reusedProcess);
options.stopPositionPolling();
if (closeRequested) return await finishRequestedClose();
return null;
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { ChildProcess, spawn } from 'child_process';
import { AddressInfo, createConnection, createServer } from 'net';
import { spawn } from 'child_process';
import { AddressInfo, createServer } from 'net';
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import {
VLC_PLAYER_ARGUMENTS,
@@ -28,6 +28,17 @@ import {
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import { getVlcPlaybackSnapshot, getVlcPlaybackState } from './vlc-rc';
export {
buildVlcEnqueueCommands,
parseVlcRcNumericResponse,
parseVlcRcPlaybackState,
} from './vlc-rc';
import {
VlcReusableProcess,
VlcReuseAttemptState,
} from './vlc-reusable-process';
export interface OpenVlcPlayerRequest {
url: string;
@@ -41,8 +52,7 @@ export interface OpenVlcPlayerRequest {
headers?: Record<string, string>;
}
let vlcProcess: ChildProcess | null = null;
let vlcRcPort: number | null = null;
const reusableVlcProcess = new VlcReusableProcess();
let vlcPollingInterval: NodeJS.Timeout | null = null;
function getVlcPath(options: PlayerPathOptions = {}): string {
@@ -52,103 +62,6 @@ function getVlcPath(options: PlayerPathOptions = {}): string {
);
}
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);
@@ -156,69 +69,6 @@ function stopVlcPositionPolling(): void {
}
}
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,
@@ -270,23 +120,16 @@ function getFreePort(): Promise<number> {
});
}
function killStoredVlcProcess(reason: string): void {
if (!vlcProcess || vlcProcess.killed) {
return;
}
traceExternalPlayer(reason);
vlcProcess.kill();
vlcProcess = null;
vlcRcPort = null;
stopVlcPositionPolling();
}
export function setVlcReuseInstance(reuseInstance: boolean): void {
traceExternalPlayer('set vlc reuse instance', { reuseInstance });
store.set(VLC_REUSE_INSTANCE, reuseInstance);
if (!reuseInstance) {
killStoredVlcProcess('clean up vlc process after disabling reuse');
reusableVlcProcess.stopStored(
'clean up vlc process after disabling reuse',
stopVlcPositionPolling,
true
);
}
}
@@ -296,7 +139,10 @@ export function setVlcReuseInstance(reuseInstance: boolean): void {
* playing after quit.
*/
export function shutdownVlcSession(): void {
killStoredVlcProcess('kill reused vlc process on app shutdown');
reusableVlcProcess.stopStored(
'kill reused vlc process on app shutdown',
stopVlcPositionPolling
);
}
export async function openVlcPlayer({
@@ -310,6 +156,9 @@ export async function openVlcPlayer({
startTime,
headers,
}: OpenVlcPlayerRequest) {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
const previousProcessSessionId = reusableVlcProcess.currentSessionId();
const session = externalPlayerSessions.beginSession({
player: 'vlc',
title,
@@ -317,6 +166,13 @@ export async function openVlcPlayer({
streamUrl: url,
contentInfo,
});
const reuseState: VlcReuseAttemptState = {
teardownUnconfirmed: false,
contentMutated: false,
closeRequested: false,
requestedClose: null,
};
let freshTeardownUnconfirmed = false;
try {
const isFlatpak = isRunningInFlatpak();
@@ -358,79 +214,23 @@ export async function openVlcPlayer({
parseExternalPlayerArguments(customVlcArguments).length,
});
if (reuseInstance && vlcProcess && !vlcProcess.killed && vlcRcPort) {
traceExternalPlayer('reuse existing vlc instance', {
rcPort: vlcRcPort,
if (reuseInstance) {
const reused = await reusableVlcProcess.tryReuse({
session,
previousProcessSessionId,
url,
title,
effectiveUserAgent,
effectiveReferer,
effectiveOrigin,
mergedHeaders,
contentInfo,
startTime,
state: reuseState,
startPositionPolling: startVlcPositionPolling,
stopPositionPolling: stopVlcPositionPolling,
});
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();
}
if (reused) return reused;
}
let rcPort = 0;
@@ -488,6 +288,8 @@ export async function openVlcPlayer({
await new Promise<void>((resolve, reject) => {
let settled = false;
let closeRequested = false;
let requestedClose: Promise<void> | null = null;
const resolveSpawn = () => {
if (settled) {
@@ -508,6 +310,50 @@ export async function openVlcPlayer({
};
const spawnVlc = (playerArgs: string[], isRetry = false) => {
if (reuseState.closeRequested) {
closeRequested = true;
const pendingReuseClose =
reuseState.requestedClose ?? Promise.resolve();
void pendingReuseClose.then(() => {
externalPlayerSessions.markClosed(session.id);
resolveSpawn();
}, rejectSpawn);
return;
}
if (
externalPlayerSessions.getSession(session.id)?.status ===
'closed'
) {
closeRequested = true;
resolveSpawn();
return;
}
try {
// Port allocation and reuse fallback both yield. Another
// exact child can enter teardown during either wait, so
// the process-wide invariant must be checked at the
// actual spawn boundary as well as at request entry.
externalPlayerProcessTeardownGate.assertLaunchAllowed();
} catch (error) {
const launchError =
error instanceof Error
? error
: new Error(String(error));
if (settled) {
const current = externalPlayerSessions.getSession(
session.id
);
if (current?.status !== 'closed') {
externalPlayerSessions.markError(
session.id,
launchError.message
);
}
} else {
rejectSpawn(launchError);
}
return;
}
const spawnSpec = buildExternalPlayerSpawnSpec(
vlcLaunchContext,
buildPlayerArgsWithCustomArguments(
@@ -515,18 +361,24 @@ export async function openVlcPlayer({
playerArgs
)
);
const trackProcess = reuseInstance && !isRetry;
// Reuse ownership exists only when an RC port was allocated.
// Without it this child is a normal one-shot process whose
// exact session closer must still terminate it.
const trackProcess = reuseInstance && !isRetry && rcPort > 0;
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: !trackProcess,
stdio: trackProcess ? ['ignore', 'pipe', 'pipe'] : 'ignore',
});
proc.once('spawn', resolveSpawn);
proc.once('spawn', () => {
if (!closeRequested) {
resolveSpawn();
}
});
if (trackProcess && rcPort > 0) {
vlcProcess = proc;
vlcRcPort = rcPort;
reusableVlcProcess.track(proc, rcPort, session.id);
traceExternalPlayer('tracking vlc process for reuse', {
rcPort,
});
@@ -571,11 +423,45 @@ export async function openVlcPlayer({
);
};
externalPlayerSessions.attachCloser(session.id, async () => {
await flushVlcPlaybackPosition();
if (!proc.killed) {
proc.kill();
externalPlayerSessions.attachCloser(session.id, () => {
closeRequested = true;
if (
trackProcess &&
!reusableVlcProcess.owns(proc, session.id)
) {
return;
}
if (!requestedClose) {
// Position flush uses two bounded RC requests. Guard
// the exact child before either request yields so no
// replacement can reuse or overlap it while Stop is
// still preparing the teardown.
externalPlayerProcessTeardownGate.beginTeardown(proc);
const closeAttempt = (async () => {
await flushVlcPlaybackPosition();
await externalPlayerProcessTeardownGate.terminate(
proc
);
})();
requestedClose = closeAttempt;
void closeAttempt.catch((error) => {
if (requestedClose === closeAttempt) {
requestedClose = null;
}
const teardownError =
error instanceof Error
? error
: new Error(String(error));
freshTeardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
teardownError.message,
{ canClose: true }
);
rejectSpawn(teardownError);
});
}
return requestedClose;
});
if (!isRetry && rcPort > 0 && contentInfo) {
@@ -612,9 +498,21 @@ export async function openVlcPlayer({
proc.on('error', (err) => {
console.error('Failed to start VLC player:', err);
if (vlcProcess === proc) {
vlcProcess = null;
vlcRcPort = null;
const processSessionId = reusableVlcProcess.sessionIdFor(
proc,
session.id
);
reusableVlcProcess.clear(proc);
if (closeRequested) {
void requestedClose?.then(() => {
// A spawn failure reports `close` without an
// `exit` event. Close the exact session before
// settling OPEN_VLC_PLAYER so the renderer
// cannot receive a stale `opened` result.
externalPlayerSessions.markClosed(session.id);
resolveSpawn();
}, rejectSpawn);
return;
}
if (!isRetry && rcPort > 0) {
traceExternalPlayer(
@@ -629,7 +527,7 @@ export async function openVlcPlayer({
spawnVlc(retryArgs, true);
} else {
externalPlayerSessions.markError(
session.id,
processSessionId,
`Failed to start VLC player: ${err.message}`
);
rejectSpawn(
@@ -640,13 +538,15 @@ export async function openVlcPlayer({
proc.on('exit', (code) => {
traceExternalPlayer('vlc exited', { code });
if (vlcProcess === proc) {
vlcProcess = null;
vlcRcPort = null;
}
const processSessionId = reusableVlcProcess.sessionIdFor(
proc,
session.id
);
reusableVlcProcess.clear(proc);
stopVlcPositionPolling();
if (
!closeRequested &&
lastVlcSnapshot &&
contentInfo &&
externalPlayerSessions.getSession(session.id)
@@ -659,7 +559,12 @@ export async function openVlcPlayer({
);
}
if (code === 1 && !isRetry && rcPort > 0) {
if (
code === 1 &&
!closeRequested &&
!isRetry &&
rcPort > 0
) {
traceExternalPlayer(
'retry vlc without rc interface after exit'
);
@@ -683,13 +588,15 @@ export async function openVlcPlayer({
`VLC player closed unexpectedly (exit code: ${code})`
);
externalPlayerSessions.markError(
session.id,
processSessionId,
`VLC player closed unexpectedly (exit code: ${code})`
);
resolveSpawn();
return;
}
externalPlayerSessions.markClosed(session.id);
externalPlayerSessions.markClosed(processSessionId);
resolveSpawn();
});
if (!trackProcess) {
@@ -703,9 +610,24 @@ export async function openVlcPlayer({
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
console.error('Error opening VLC player:', error);
const restoredSession =
reuseState.teardownUnconfirmed &&
!reuseState.contentMutated &&
displacedSessionId
? externalPlayerSessions.restoreActiveSession(
displacedSessionId,
session.id
)
: null;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error)
error instanceof Error ? error.message : String(error),
{
canClose:
freshTeardownUnconfirmed ||
(reuseState.teardownUnconfirmed &&
(reuseState.contentMutated || !restoredSession)),
}
);
throw error;
}