fix(playback): wait for mpv's reply to every reused-instance IPC command (#1876)

Every reused-instance MPV IPC command now carries a request_id and waits, within the existing 2 s bound, for mpv's matching reply; an error or missing reply falls back to a fresh launch like a socket failure, and the racing post-loadfile seek is gone. MPV launches run one at a time through a launch queue so overlapping launches never act on the same child; Stop on a queued launch delegates to the in-flight launch's own closer, a failed exit stays retryable, and the dock keeps the newest launch. Position polling and output-error reporting moved out of mpv-session.service.ts, which leaves the max-lines baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 22:42:43 +02:00
1 parent 94570bc387
commit 4e9795ac54
21 files changed
+2920 -394

No files matched your search

@@ -30,6 +30,26 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(updates.at(-1)?.status).toBe('opened');
});
it('numbers sessions in the order they began', () => {
const first = registry.beginSession({
player: 'mpv',
title: 'First',
streamUrl: 'https://example.com/one.m3u8',
});
const second = registry.beginSession({
player: 'mpv',
title: 'Second',
streamUrl: 'https://example.com/two.m3u8',
});
expect(first.launchOrder).toEqual(expect.any(Number));
expect(second.launchOrder).toBeGreaterThan(first.launchOrder ?? 0);
// Later updates keep the order the session began with.
expect(registry.markOpened(first.id)?.launchOrder).toBe(
first.launchOrder
);
});
it('keeps close capability and closes the session explicitly', async () => {
const close = jest.fn();
const session = registry.beginSession({
@@ -144,9 +164,9 @@ describe('ExternalPlayerSessionRegistry', () => {
registry.markClosed(closed.id);
expect(registry.markPlaying(closed.id)?.status).toBe('closed');
expect(
registry.markError(closed.id, 'Late process exit')?.status
).toBe('closed');
expect(registry.markError(closed.id, 'Late process exit')?.status).toBe(
'closed'
);
expect(updates.at(-1)).toMatchObject({
id: closed.id,
status: 'closed',
@@ -44,6 +44,7 @@ function isRestorableSession(session: ExternalPlayerSession): boolean {
export class ExternalPlayerSessionRegistry {
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
private activeSessionId: string | null = null;
private launchCount = 0;
constructor(
private readonly onUpdate: (session: ExternalPlayerSession) => void
@@ -63,6 +64,7 @@ export class ExternalPlayerSessionRegistry {
contentInfo: options.contentInfo,
startedAt,
updatedAt: startedAt,
launchOrder: ++this.launchCount,
canClose: false,
};
@@ -0,0 +1,298 @@
jest.mock('net', () => ({
createConnection: jest.fn(),
}));
jest.mock('./external-player-runtime', () => ({
traceExternalPlayer: jest.fn(),
}));
import { EventEmitter } from 'events';
import { createConnection } from 'net';
import {
MPV_IPC_COMMAND_TIMEOUT_MS,
MpvIpcCommandError,
sendMpvCommand,
} from './mpv-ipc-command';
type SocketMock = EventEmitter & {
write: jest.Mock;
destroy: jest.Mock;
destroyed: boolean;
};
const createConnectionMock = createConnection as unknown as jest.Mock;
const socketPath = '/tmp/mpv-ipc-command.sock';
const streamUrl = 'https://example.com/stream.m3u8?token=secret-token';
function nextSocket(): SocketMock {
const socket: SocketMock = Object.assign(new EventEmitter(), {
write: jest.fn(() => true),
destroy: jest.fn(() => {
socket.destroyed = true;
}),
destroyed: false,
});
createConnectionMock.mockReturnValueOnce(socket);
return socket;
}
function writtenRequest(socket: SocketMock): {
command: Array<string | number>;
request_id: number;
} {
return JSON.parse(String(socket.write.mock.calls[0][0]).trim());
}
function reply(socket: SocketMock, ...lines: Array<object | string>): void {
socket.emit(
'data',
Buffer.from(
lines
.map((line) =>
typeof line === 'string' ? line : JSON.stringify(line)
)
.join('\n') + '\n'
)
);
}
async function settle(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe('sendMpvCommand', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('sends a request_id and resolves with the matching reply', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [
streamUrl,
'replace',
-1,
'start=0',
]);
socket.emit('connect');
const request = writtenRequest(socket);
expect(request.command).toEqual([
'loadfile',
streamUrl,
'replace',
-1,
'start=0',
]);
expect(Number.isInteger(request.request_id)).toBe(true);
expect(String(socket.write.mock.calls[0][0]).endsWith('\n')).toBe(true);
reply(socket, {
data: { playlist_entry_id: 1 },
request_id: request.request_id,
error: 'success',
});
await expect(pending).resolves.toEqual({
request_id: request.request_id,
error: 'success',
data: { playlist_entry_id: 1 },
});
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('gives consecutive commands distinct request ids', async () => {
const first = nextSocket();
const second = nextSocket();
const firstPending = sendMpvCommand(socketPath, 'set_property', [
'user-agent',
'agent',
]);
const secondPending = sendMpvCommand(socketPath, 'set_property', [
'referrer',
'https://example.com/',
]);
first.emit('connect');
second.emit('connect');
const firstId = writtenRequest(first).request_id;
const secondId = writtenRequest(second).request_id;
expect(secondId).not.toBe(firstId);
reply(first, { request_id: firstId, error: 'success' });
reply(second, { request_id: secondId, error: 'success' });
await expect(firstPending).resolves.toMatchObject({
request_id: firstId,
});
await expect(secondPending).resolves.toMatchObject({
request_id: secondId,
});
});
it('resolves false without writing when dispatch is vetoed', async () => {
const socket = nextSocket();
const onDispatched = jest.fn();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl], {
shouldDispatch: () => false,
onDispatched,
});
socket.emit('connect');
await expect(pending).resolves.toBe(false);
expect(socket.write).not.toHaveBeenCalled();
expect(onDispatched).not.toHaveBeenCalled();
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('rejects with mpv error text after the command was dispatched', async () => {
const socket = nextSocket();
const onDispatched = jest.fn();
const pending = sendMpvCommand(socketPath, 'seek', ['10', 'absolute'], {
onDispatched,
});
socket.emit('connect');
expect(onDispatched).toHaveBeenCalledTimes(1);
reply(socket, {
request_id: writtenRequest(socket).request_id,
error: 'error running command',
});
await expect(pending).rejects.toMatchObject({
name: 'MpvIpcCommandError',
command: 'seek',
dispatched: true,
mpvError: 'error running command',
message: 'mpv seek: error running command',
});
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it.each([
['a missing error', {}],
['a non-string error', { error: 0 }],
])(
'rejects a matching reply with %s instead of confirming it',
async (_label, fields) => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('connect');
reply(socket, {
request_id: writtenRequest(socket).request_id,
data: { playlist_entry_id: 1 },
...fields,
});
await expect(pending).rejects.toMatchObject({
name: 'MpvIpcCommandError',
dispatched: true,
mpvError: null,
message: 'mpv loadfile: malformed reply',
});
}
);
it('rejects an undispatched command on a socket error without echoing arguments', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('error', new Error('connect ENOENT'));
const error = (await pending.catch((caught) => caught)) as unknown;
expect(error).toBeInstanceOf(MpvIpcCommandError);
expect(error).toMatchObject({
dispatched: false,
mpvError: null,
message: 'mpv loadfile: connect ENOENT',
});
expect(socket.write).not.toHaveBeenCalled();
expect(String((error as Error).message)).not.toContain('secret');
});
it('rejects when the socket closes before a reply', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'quit', []);
socket.emit('connect');
socket.emit('close');
await expect(pending).rejects.toMatchObject({
command: 'quit',
dispatched: true,
message: 'mpv quit: socket closed before reply',
});
});
it('times out a reply that never comes', async () => {
jest.useFakeTimers();
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('connect');
let settled = false;
void pending.catch(() => (settled = true));
await jest.advanceTimersByTimeAsync(MPV_IPC_COMMAND_TIMEOUT_MS - 1);
expect(settled).toBe(false);
await jest.advanceTimersByTimeAsync(1);
expect(settled).toBe(true);
await expect(pending).rejects.toMatchObject({
dispatched: true,
message: 'mpv loadfile: reply timed out',
});
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('skips events, foreign replies and malformed lines, and joins split chunks', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('connect');
const requestId = writtenRequest(socket).request_id;
let settled = false;
void pending.then(
() => (settled = true),
() => (settled = true)
);
reply(
socket,
{ event: 'start-file', playlist_entry_id: 1 },
{
event: 'end-file',
reason: 'error',
file_error: 'loading failed',
},
{ request_id: requestId + 1, error: 'error running command' },
'not json',
'42'
);
await settle();
expect(settled).toBe(false);
const ownReply = JSON.stringify({
request_id: requestId,
error: 'success',
});
socket.emit('data', Buffer.from(ownReply.slice(0, 10)));
await settle();
expect(settled).toBe(false);
socket.emit('data', Buffer.from(ownReply.slice(10) + '\n'));
await expect(pending).resolves.toMatchObject({
request_id: requestId,
error: 'success',
});
});
it('ignores data that arrives after settling', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'quit', []);
socket.emit('connect');
const requestId = writtenRequest(socket).request_id;
reply(socket, { request_id: requestId, error: 'success' });
await expect(pending).resolves.toMatchObject({ error: 'success' });
expect(() =>
reply(socket, { request_id: requestId, error: 'late failure' })
).not.toThrow();
expect(() => socket.emit('close')).not.toThrow();
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,158 @@
import { createConnection } from 'net';
import { traceExternalPlayer } from './external-player-runtime';
/**
* Bounds connect, write and the wait for mpv's reply. It matches the IPC
* quit bound the reuse teardown relies on before its exit windows start.
*/
export const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
/** One newline-delimited JSON reply of mpv's IPC protocol. */
export interface MpvIpcReply {
request_id: number;
error: string;
data?: unknown;
}
export interface MpvIpcCommandOptions {
/** Checked once connected; a `false` skips the write and resolves `false`. */
shouldDispatch?: () => boolean;
/** Runs right after the command was written, before any reply. */
onDispatched?: () => void;
}
/**
* A command mpv answered with an error, or one whose reply never came.
* `dispatched` tells whether the command reached the socket, so callers can
* treat the player state as possibly changed. The message never carries
* command arguments, which may include stream URLs.
*/
export class MpvIpcCommandError extends Error {
constructor(
readonly command: string,
message: string,
readonly dispatched: boolean,
readonly mpvError: string | null = null
) {
super(`mpv ${command}: ${message}`);
this.name = 'MpvIpcCommandError';
}
}
let nextRequestId = 1;
function parseReplyLine(line: string): Partial<MpvIpcReply> | null {
try {
const parsed: unknown = JSON.parse(line);
return parsed && typeof parsed === 'object'
? (parsed as Partial<MpvIpcReply>)
: null;
} catch {
return null;
}
}
/**
* Sends one command to a reused mpv over its IPC socket and resolves with
* mpv's matching reply. Every command carries a `request_id`; event lines
* and replies to other requests on the same connection are skipped. Resolves
* `false` when `shouldDispatch` vetoed the write, and rejects with
* {@link MpvIpcCommandError} on an error reply, a missing reply, or a socket
* failure.
*/
export function sendMpvCommand(
socketPath: string,
command: string,
args: Array<string | number>,
options: MpvIpcCommandOptions = {}
): Promise<MpvIpcReply | false> {
return new Promise((resolve, reject) => {
const requestId = nextRequestId++;
const client = createConnection(socketPath);
const request =
JSON.stringify({
command: [command, ...args],
request_id: requestId,
}) + '\n';
let settled = false;
let dispatched = false;
let buffered = '';
let timeoutHandle: NodeJS.Timeout | null = null;
const settle = (
outcome: { reply: MpvIpcReply | false } | { error: Error }
) => {
if (settled) return;
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
if ('error' in outcome) {
reject(outcome.error);
} else {
resolve(outcome.reply);
}
};
const fail = (message: string, mpvError: string | null = null) =>
settle({
error: new MpvIpcCommandError(
command,
message,
dispatched,
mpvError
),
});
const consumeLine = (line: string) => {
const reply = parseReplyLine(line);
if (!reply || reply.request_id !== requestId) return;
// mpv always answers with a string `error`; anything else is not
// a confirmation and must not skip the fallback.
if (typeof reply.error !== 'string') {
fail('malformed reply');
return;
}
const error = reply.error;
traceExternalPlayer('mpv ipc reply', { command, error });
if (error === 'success') {
settle({
reply: { request_id: requestId, error, data: reply.data },
});
} else {
fail(error, error);
}
};
client.on('connect', () => {
if (options.shouldDispatch && !options.shouldDispatch()) {
settle({ reply: false });
return;
}
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
});
try {
client.write(request);
dispatched = true;
options.onDispatched?.();
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
});
client.on('data', (chunk) => {
if (settled) return;
buffered += chunk.toString();
const lines = buffered.split('\n');
buffered = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) consumeLine(line);
if (settled) return;
}
});
client.on('error', (error) => fail(error.message));
client.on('close', () => fail('socket closed before reply'));
timeoutHandle = setTimeout(
() => fail('reply timed out'),
MPV_IPC_COMMAND_TIMEOUT_MS
);
timeoutHandle.unref();
});
}
@@ -0,0 +1,55 @@
import { EventEmitter } from 'events';
export type MpvSocketMock = EventEmitter & {
write: jest.Mock;
end: jest.Mock;
destroy: jest.Mock;
};
export interface MpvSocketMockOptions {
/** Collects every chunk written to the socket. */
written?: string[];
/** Emit `connect` on the next tick; `false` leaves that to the test. */
connect?: boolean;
/**
* mpv's `error` field for a written command, `'success'` by default.
* `null` leaves the command unanswered.
*/
reply?: (command: Array<string | number>) => string | null;
}
interface MpvIpcRequest {
command: Array<string | number>;
request_id: number;
}
/**
* A reused-instance mpv IPC socket: records writes and answers each command
* on the next tick with a reply carrying its `request_id`, as mpv does.
*/
export function createMpvSocketMock({
written,
connect = true,
reply = () => 'success',
}: MpvSocketMockOptions = {}): MpvSocketMock {
const socket: MpvSocketMock = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => {
written?.push(chunk);
const request = JSON.parse(chunk.trim()) as MpvIpcRequest;
const error = reply(request.command);
if (error !== null) {
const line =
JSON.stringify({ request_id: request.request_id, error }) +
'\n';
setImmediate(() => socket.emit('data', Buffer.from(line)));
}
return true;
}),
end: jest.fn(),
destroy: jest.fn(),
});
if (connect) {
setImmediate(() => socket.emit('connect'));
}
return socket;
}
@@ -0,0 +1,856 @@
/**
* MPV launch queue: launches run one at a time, only the newest queued one
* runs, and Stop on a queued launch delegates to the in-flight launch's and
* the stored player's own closers. Reply handling lives in
* `mpv-session.service.ipc-reply.spec.ts`.
*/
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('child_process', () => ({
spawn: jest.fn(),
}));
jest.mock('net', () => ({
createConnection: jest.fn(),
createServer: jest.fn(),
}));
jest.mock('../app', () => ({
__esModule: true,
default: {
mainWindow: null,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
store: {
get: jest.fn(),
set: jest.fn(),
},
}));
import {
MPV_PLAYER_PATH,
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import { externalPlayerSessions } from './external-player-runtime';
import { MPV_IPC_COMMAND_TIMEOUT_MS } from './mpv-ipc-command';
import { MpvSocketMock } from './mpv-ipc-socket.test-helpers';
import {
openMpvPlayer,
setMpvReuseInstance,
shutdownMpvSession,
} from './mpv-session.service';
import {
spawnMock,
createMockChildProcess,
mockMpvSockets,
nextTick,
launchReusableSession,
mockUnansweredSockets,
mockSocketsQuittingInto,
commandsOf,
writtenRequestId,
} from './mpv-session.test-helpers';
describe('MPV launch queue', () => {
beforeEach(() => {
jest.clearAllMocks();
shutdownMpvSession();
});
afterEach(() => {
jest.useRealTimers();
shutdownMpvSession();
});
it('runs a quick second launch only after the first one settles', async () => {
const { proc } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const commandOf = (socket: MpvSocketMock) =>
(
JSON.parse(String(socket.write.mock.calls[0][0])) as {
command: string[];
}
).command[0];
const answer = (socket: MpvSocketMock, id: number) =>
socket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: id, error: 'success' }) + '\n'
)
);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId = await writtenRequestId(sockets[0]);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
userAgent: 'IPTVnator test agent',
});
await nextTick();
await nextTick();
// The second launch waits in the queue: nothing else reached mpv.
expect(sockets).toHaveLength(1);
answer(sockets[0], firstId);
await expect(first).resolves.toMatchObject({ status: 'opened' });
answer(sockets[1], await writtenRequestId(sockets[1]));
while (sockets.length < 3) await nextTick();
answer(sockets[2], await writtenRequestId(sockets[2]));
await expect(second).resolves.toMatchObject({ status: 'opened' });
expect(sockets.map(commandOf)).toEqual([
'loadfile',
'set_property',
'loadfile',
]);
expect(proc.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('lets Stop on a queued launch cancel it and stop the in-flight player', async () => {
const { proc } = await launchReusableSession();
const sockets = mockSocketsQuittingInto(proc);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
await writtenRequestId(sockets[0]);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
// The queued launch is already the session the dock shows and stops.
const queuedId = externalPlayerSessions.getActiveSessionId() as string;
expect(externalPlayerSessions.getSession(queuedId)).toMatchObject({
title: 'Channel B',
status: 'launching',
canClose: true,
});
// Stop goes through the in-flight launch's own closer: quit, exit.
await expect(
externalPlayerSessions.closeSession(queuedId)
).resolves.toMatchObject({ id: queuedId, status: 'closed' });
expect(proc.exitCode).toBe(0);
// The quit player's socket closes before any reply.
sockets[0].emit('close');
await expect(first).resolves.toMatchObject({ status: 'closed' });
await expect(second).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
// Nothing started playing again after Stop.
expect(commandsOf(sockets)).toEqual(['loadfile', 'quit']);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('lets Stop on the newest queued launch cancel every launch queued before it', async () => {
const { proc } = await launchReusableSession();
const sockets = mockSocketsQuittingInto(proc);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
await writtenRequestId(sockets[0]);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const third = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
const newestId = externalPlayerSessions.getActiveSessionId() as string;
await expect(
externalPlayerSessions.closeSession(newestId)
).resolves.toMatchObject({ status: 'closed' });
sockets[0].emit('close');
await expect(first).resolves.toMatchObject({ status: 'closed' });
await expect(second).resolves.toMatchObject({ status: 'closed' });
await expect(third).resolves.toMatchObject({ status: 'closed' });
expect(proc.exitCode).toBe(0);
expect(commandsOf(sockets)).toEqual(['loadfile', 'quit']);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('hands the dock back to the live player when a queued launch is blocked by its teardown', async () => {
const { proc } = await launchReusableSession();
// The first launch's loadfile fails and its teardown never sees the
// child exit, so the queued launch finds the teardown gate closed.
(proc.kill as jest.Mock).mockImplementation(() => true);
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId =
externalPlayerSessions.getActiveSessionId() as string;
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const secondId =
externalPlayerSessions.getActiveSessionId() as string;
const secondRejection = expect(second).rejects.toThrow(
'previous external player is still shutting down'
);
await jest.advanceTimersByTimeAsync(6_000);
await firstRejection;
await secondRejection;
// The session owning the still-running child is active again
// and can be stopped; the blocked launch is a terminal error.
expect(externalPlayerSessions.getActiveSessionId()).toBe(firstId);
expect(externalPlayerSessions.getSession(firstId)).toMatchObject({
status: 'error',
canClose: true,
});
expect(externalPlayerSessions.getSession(secondId)).toMatchObject({
status: 'error',
canClose: false,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('runs only the newest queued launch once the in-flight one settles', async () => {
await launchReusableSession();
const sockets = mockUnansweredSockets();
const answer = (socket: MpvSocketMock, id: number) =>
socket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: id, error: 'success' }) + '\n'
)
);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId = await writtenRequestId(sockets[0]);
const skipped = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const newest = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
const newestId = externalPlayerSessions.getActiveSessionId();
answer(sockets[0], firstId);
await expect(first).resolves.toMatchObject({ status: 'opened' });
// The skipped launch never reached mpv nor took the dock.
await expect(skipped).resolves.toMatchObject({ status: 'closed' });
expect(externalPlayerSessions.getActiveSessionId()).toBe(newestId);
answer(sockets[1], await writtenRequestId(sockets[1]));
await expect(newest).resolves.toMatchObject({
id: newestId,
status: 'opened',
});
expect(sockets).toHaveLength(2);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('keeps a queued launch stoppable when its Stop cannot confirm the exit', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
mockUnansweredSockets();
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
await jest.advanceTimersByTimeAsync(0);
// Stop goes through the in-flight launch's closer: its quit gets
// no reply, and the bounded teardown cannot confirm the exit.
const closing = externalPlayerSessions.closeSession(queuedId);
const closeRejection = expect(closing).rejects.toThrow(
'External player process did not exit'
);
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
// A retried Stop reaches the still-running child again.
const killsBeforeRetry = (proc.kill as jest.Mock).mock.calls.length;
const retry = externalPlayerSessions.closeSession(queuedId);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
expect((proc.kill as jest.Mock).mock.calls.length).toBeGreaterThan(
killsBeforeRetry
);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(retry).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('lets a late Stop for an older queued launch cancel only that launch', async () => {
const { proc } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const answer = (socket: MpvSocketMock, id: number) =>
socket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: id, error: 'success' }) + '\n'
)
);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId = await writtenRequestId(sockets[0]);
const older = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const olderId = externalPlayerSessions.getActiveSessionId() as string;
const newest = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
await expect(
externalPlayerSessions.closeSession(olderId)
).resolves.toMatchObject({ id: olderId, status: 'closed' });
expect(proc.kill).not.toHaveBeenCalled();
answer(sockets[0], firstId);
await expect(first).resolves.toMatchObject({ status: 'opened' });
await expect(older).resolves.toMatchObject({ status: 'closed' });
answer(sockets[1], await writtenRequestId(sockets[1]));
await expect(newest).resolves.toMatchObject({ status: 'opened' });
expect(commandsOf(sockets)).toEqual(['loadfile', 'loadfile']);
expect(proc.kill).not.toHaveBeenCalled();
});
it('settles a fresh launch and its queued Stop when the exit cannot be confirmed', async () => {
const proc = createMockChildProcess();
(proc.kill as jest.Mock).mockImplementation(() => {
Object.defineProperty(proc, 'killed', {
value: true,
configurable: true,
});
return true;
});
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
jest.useFakeTimers();
try {
// The first launch spawns fresh; the second queues during its
// start-up confirmation window.
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// The Stop arrives over IPC, after the first launch spawned.
await jest.advanceTimersByTimeAsync(0);
let firstSettled = false;
void first.then(
() => (firstSettled = true),
() => (firstSettled = true)
);
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(6_000);
expect(firstSettled).toBe(true);
await firstRejection;
await closeRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('never lets a retried queued Stop reach a player started later', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
jest.useFakeTimers();
const later = createMockChildProcess();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// The Stop arrives after the first launch wrote its loadfile.
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await firstRejection;
await closeRejection;
await expect(queued).resolves.toMatchObject({ status: 'error' });
// The stuck child exits on its own and a new stream starts.
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
spawnMock.mockReturnValueOnce(later);
const next = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
await jest.advanceTimersByTimeAsync(100);
await expect(next).resolves.toMatchObject({ status: 'opened' });
// A retried Stop for the old queued launch stays on its own
// targets and leaves the new player alone.
const retry = externalPlayerSessions.closeSession(queuedId);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
await expect(retry).resolves.toMatchObject({ status: 'closed' });
expect(later.kill).not.toHaveBeenCalled();
} finally {
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('restores the last started launch when a queued one is blocked by its teardown', async () => {
const proc = createMockChildProcess();
(proc.kill as jest.Mock).mockImplementation(() => {
Object.defineProperty(proc, 'killed', {
value: true,
configurable: true,
});
return true;
});
spawnMock.mockReturnValueOnce(proc);
// Reuse off: the detached child is never stored for reuse.
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: false,
})[key] ?? fallback
);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId =
externalPlayerSessions.getActiveSessionId() as string;
const skipped = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const newest = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
const newestId =
externalPlayerSessions.getActiveSessionId() as string;
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const newestRejection = expect(newest).rejects.toThrow(
'previous external player is still shutting down'
);
// A delayed Stop for the first launch cannot confirm its exit.
const closeRejection = expect(
externalPlayerSessions.closeSession(firstId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(6_000);
await closeRejection;
await firstRejection;
await expect(skipped).resolves.toMatchObject({ status: 'closed' });
await newestRejection;
// The dock goes back to the session owning the stuck child.
expect(externalPlayerSessions.getActiveSessionId()).toBe(firstId);
expect(externalPlayerSessions.getSession(firstId)).toMatchObject({
status: 'error',
canClose: true,
});
expect(externalPlayerSessions.getSession(newestId)).toMatchObject({
status: 'error',
canClose: false,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('lets a queued Stop reach the child a failed launch is tearing down', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
// The first launch's loadfile fails; its teardown then waits for an
// exit that never comes.
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : null,
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
for (
let tick = 0;
tick < 50 && !(proc.kill as jest.Mock).mock.calls.length;
tick += 1
) {
await jest.advanceTimersByTimeAsync(10);
}
expect(proc.kill).toHaveBeenCalled();
// Stop during that teardown must not report success while the
// child is still alive.
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
// A retry reaches the same child and settles once it exits; the
// retried quit gets no reply within its bound.
const retry = externalPlayerSessions.closeSession(queuedId);
await jest.advanceTimersByTimeAsync(0);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
await expect(retry).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('keeps a queued Stop working after the previous owner closed during a failed handback', async () => {
const { proc, session: previous } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const loadfileId = await (async () => {
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
return (
JSON.parse(String(sockets[0].write.mock.calls[0][0])) as {
request_id: number;
}
).request_id;
})();
// A late Stop for the previous session closes it; the child now
// belongs to the first launch.
await expect(
externalPlayerSessions.closeSession(previous.id)
).resolves.toMatchObject({ status: 'closed' });
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// mpv rejects the load: the child is handed back to the closed
// previous session while the first launch tears it down.
sockets[0].emit(
'data',
Buffer.from(
JSON.stringify({
request_id: loadfileId,
error: 'error running command',
}) + '\n'
)
);
await jest.advanceTimersByTimeAsync(10);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('keeps a queued Stop working when reuse is disabled before the in-flight load', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
// The first launch still waits on its user-agent reply, so the
// child still belongs to the session before it.
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
userAgent: 'IPTVnator test agent',
});
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// Disabling reuse clears the stored child before it has exited.
setMpvReuseInstance(false);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
// The first launch has settled; a retried Stop must still reach
// the live child and settle only once it exits.
const killsBeforeRetry = (proc.kill as jest.Mock).mock.calls.length;
const retry = externalPlayerSessions.closeSession(queuedId);
let retried = false;
void retry.then(
() => (retried = true),
() => (retried = true)
);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
expect(retried).toBe(false);
expect((proc.kill as jest.Mock).mock.calls.length).toBeGreaterThan(
killsBeforeRetry
);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(retry).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('starts a queued launch on the fresh player a failed launch spawned', async () => {
const { proc } = await launchReusableSession();
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
// The first launch's loadfile fails; every later command succeeds.
// Each loadfile records how many players had been spawned by then.
let rejectedLoadfile = false;
const spawnsAtLoadfile: number[] = [];
mockMpvSockets({
reply: (command) => {
if (command[0] === 'loadfile') {
spawnsAtLoadfile.push(spawnMock.mock.calls.length);
}
if (command[0] === 'loadfile' && !rejectedLoadfile) {
rejectedLoadfile = true;
return 'error running command';
}
return 'success';
},
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
await expect(first).resolves.toMatchObject({ status: 'opened' });
const secondSession = await second;
// The second launch loaded into the replacement the first one's
// fallback spawned, not into the child that fallback tore down.
expect(spawnsAtLoadfile).toEqual([1, 2]);
expect(
externalPlayerSessions.getSession(secondSession.id)?.status
).toBe('opened');
expect(proc.kill).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(freshProc.kill).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
});
@@ -0,0 +1,182 @@
import {
ExternalPlayerSession,
readExternalPlayerErrorCode,
} from '@iptvnator/shared/interfaces';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import { externalPlayerSessions } from './external-player-runtime';
/** What a launch knows about its place in the queue when it starts. */
export interface QueuedMpvLaunch {
/** Whether this launch waited behind another one. */
queued: boolean;
/**
* Whether Stop or shutdown cancelled this launch while it waited, or a
* newer launch is queued behind it.
*/
cancelled: () => boolean;
/** This launch's own queued Stop, if one was requested. */
pendingStop: () => Promise<void> | null;
/** Called once the launch really starts acting on a player. */
markInFlight: () => void;
/** The last launch that started before this one runs. */
lastStartedSessionId: () => string | null;
}
/**
* Runs MPV launches one at a time. A reused launch waits for mpv's replies
* (bounded per command) and a failed one for its teardown and fallback
* spawn, so overlapping launches would act on the same child. Queued, each
* launch starts from the player the previous one left behind, and only the
* newest queued launch runs. An idle queue starts a launch synchronously.
*
* Until a queued launch starts, its Stop cancels it and every launch queued
* before it. Stop on the newest one also closes, through their own closers,
* the in-flight launch and the session owning the stored player, so the
* exact-child, retry and settle rules of those closers apply.
*/
export class MpvLaunchQueue {
private pending: Promise<unknown> | null = null;
private latest = 0;
private cancelledThrough = 0;
private inFlightSessionId: string | null = null;
// The last launch that actually started. With reuse off its detached
// child is not stored, so it is the only handle on that player once the
// launch settled with an unconfirmed teardown.
private lastStartedSessionId: string | null = null;
constructor(private readonly storedOwnerSessionId: () => string | null) {}
/** Cancels every launch queued so far, for app shutdown. */
cancelAll(): void {
this.cancelledThrough = this.latest;
}
enqueue(
session: ExternalPlayerSession,
launch: (queue: QueuedMpvLaunch) => Promise<ExternalPlayerSession>
): Promise<ExternalPlayerSession> {
const previous = this.pending;
const number = ++this.latest;
let stop: Promise<void> | null = null;
// Fixed at the first Stop, so a retry never reaches a newer player.
let stopTargets: string[] | null = null;
if (previous) {
externalPlayerSessions.attachCloser(session.id, () => {
this.cancelledThrough = Math.max(this.cancelledThrough, number);
// A late Stop for an older queued launch only cancels it.
stopTargets ??=
number === this.latest ? this.stopTargets(session.id) : [];
stop = Promise.all(
stopTargets.map((id) =>
externalPlayerSessions.closeSession(id)
)
).then(() => undefined);
return stop;
});
}
const queue: QueuedMpvLaunch = {
queued: previous !== null,
cancelled: () =>
number <= this.cancelledThrough || number !== this.latest,
pendingStop: () => stop,
markInFlight: () => {
this.inFlightSessionId = session.id;
this.lastStartedSessionId = session.id;
},
lastStartedSessionId: () => this.lastStartedSessionId,
};
const run = () => launch(queue);
// The queue is released before the caller sees the result, so a
// launch made right after this one settles starts synchronously.
let settled: Promise<unknown> | null = null;
const result = (previous ? previous.then(run) : run()).finally(() => {
if (this.inFlightSessionId === session.id) {
this.inFlightSessionId = null;
}
if (this.pending === settled) this.pending = null;
});
settled = result.catch(() => undefined);
this.pending = settled;
return result;
}
private stopTargets(sessionId: string): string[] {
const targets: string[] = [];
for (const id of [
this.inFlightSessionId,
this.storedOwnerSessionId(),
this.lastStartedSessionId,
]) {
// Only a session with its own closer can confirm its teardown;
// closing one without would just mark it closed.
if (
id &&
id !== sessionId &&
!targets.includes(id) &&
externalPlayerSessions.getSession(id)?.canClose
) {
targets.push(id);
}
}
return targets;
}
}
/**
* Settles a queued launch that will not start. When its own Stop is still
* closing the in-flight player, the outcome waits for it: a Stop that
* cannot confirm the exit leaves a closable error, so Stop can be retried.
*/
export async function settleCancelledQueuedLaunch(
session: ExternalPlayerSession,
pendingStop: Promise<void> | null
): Promise<ExternalPlayerSession> {
if (pendingStop) {
try {
await pendingStop;
} catch (error) {
return (
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
) ?? session
);
}
}
return externalPlayerSessions.markClosed(session.id) ?? session;
}
/**
* A queued launch starts after an earlier one may have left a child whose
* exit is unconfirmed. Then the dock goes back to the session that still
* owns that child; failing that, this session keeps its queued closer so
* Stop can still reach the child.
*/
export function assertQueuedLaunchAllowed(
session: ExternalPlayerSession,
restoreCandidates: Array<string | null>
): void {
try {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
} catch (error) {
let restored: ExternalPlayerSession | null = null;
for (const id of restoreCandidates) {
if (!id || id === session.id) continue;
restored = externalPlayerSessions.restoreActiveSession(
id,
session.id
);
if (restored) break;
}
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{
code: readExternalPlayerErrorCode(error) ?? undefined,
canClose: !restored,
}
);
throw error;
}
}
@@ -0,0 +1,69 @@
import type { ChildProcess } from 'child_process';
import {
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
function shouldIgnoreMpvStdoutLine(line: string): boolean {
return /^(\(Paused\)\s*)?AV:\s/.test(line);
}
/**
* Reports what a reusable MPV child prints: stream errors on stdout or stderr
* reach the renderer as player errors; the periodic status line is ignored.
*/
export function attachMpvOutputHandlers(proc: ChildProcess): void {
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);
}
}
});
}
}
@@ -0,0 +1,121 @@
import { createConnection } from 'net';
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import { sendPlaybackPositionUpdate } from './external-player-runtime';
/**
* Playback-position polling of an external MPV over its IPC socket. One poll
* runs at a time across launches: starting a new one stops the previous one.
*/
let positionPollingInterval: NodeJS.Timeout | null = null;
// The first poll waits for MPV to open its IPC socket. The handle is kept so
// a player that exits during that wait does not start an orphaned interval.
let positionPollingDelay: NodeJS.Timeout | null = null;
// Bumped on every stop. A poll that was already awaiting an IPC reply when
// polling stopped (for example when a reused player loads the next stream)
// must not report what it reads under the previous content.
let positionPollingGeneration = 0;
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);
});
});
}
export function stopPositionPolling(): void {
positionPollingGeneration += 1;
if (positionPollingDelay) {
clearTimeout(positionPollingDelay);
positionPollingDelay = null;
}
if (positionPollingInterval) {
clearInterval(positionPollingInterval);
positionPollingInterval = null;
}
}
export function startPositionPolling(
socketPath: string,
contentInfo: PlayerContentInfo,
sessionId: string
): void {
stopPositionPolling();
const generation = positionPollingGeneration;
const isCurrent = () => generation === positionPollingGeneration;
positionPollingDelay = setTimeout(() => {
positionPollingDelay = null;
positionPollingInterval = setInterval(async () => {
try {
const position = await getMpvProperty(socketPath, 'time-pos');
if (!isCurrent()) return;
const duration = await getMpvProperty(socketPath, 'duration');
if (!isCurrent()) return;
if (position !== null) {
sendPlaybackPositionUpdate(sessionId, contentInfo, {
positionSeconds: Math.floor(position),
durationSeconds: duration ? Math.floor(duration) : null,
});
}
} catch {
stopPositionPolling();
}
}, 5000);
}, 2000);
}
@@ -1,20 +1,22 @@
import { ChildProcess } from 'child_process';
import { createConnection } from 'net';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
externalPlayerSessions,
traceExternalPlayer,
} from './external-player-runtime';
import { sendMpvCommand } from './mpv-ipc-command';
/**
* The per-file start of a reuse load: the requested offset, else zero. A
* `seek` right after `loadfile` runs before the file is loaded and fails,
* so the offset travels with the load itself.
* `seek` right after `loadfile` runs before the file is loaded and mpv
* rejects it, so the offset travels with the load itself and no seek is
* sent on the reuse path.
*/
function reuseStartSeconds(startTime: number | undefined): number {
return typeof startTime === 'number' &&
@@ -24,7 +26,9 @@ function reuseStartSeconds(startTime: number | undefined): number {
: 0;
}
const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
export interface MpvReuseAttemptState {
contentMutated: boolean;
@@ -50,68 +54,33 @@ interface MpvReuseOptions {
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>();
// Bumped whenever the stored player is stopped (reuse disabled or app
// quitting). Launches run one at a time, so the only thing that can
// overtake an in-flight reuse attempt is that stop, which cancels it.
private stopGeneration = 0;
currentSessionId(): string | null {
return this.processSessionId;
}
/**
* The session that owns the stored child right now. A failed reuse
* attempt hands the child back to its previous owner while it tears it
* down, so this reads the per-child mapping, not the last recorded id.
*/
storedOwnerSessionId(): string | null {
if (!this.process) return null;
return (
this.processSessionIds.get(this.process) ?? this.processSessionId
);
}
sessionIdFor(process: ChildProcess, fallback: string): string {
return this.processSessionIds.get(process) ?? fallback;
}
@@ -140,6 +109,10 @@ export class MpvReusableProcess {
stopPositionPolling: () => void,
guardFutureLaunches = false
): void {
// A launch still waiting for mpv's reply must not fall back to a
// fresh, again-reusable player once reuse is disabled or the app
// quits: cancel it so it settles its own session instead.
this.stopGeneration += 1;
const process = this.process;
if (!process || process.killed) return;
traceExternalPlayer(reason);
@@ -163,6 +136,29 @@ export class MpvReusableProcess {
traceExternalPlayer('reuse existing mpv instance');
const { session, state } = options;
const generation = this.stopGeneration;
const cancelled = () => generation !== this.stopGeneration;
// The stored-player stop is already tearing the child down; settle
// only once its exit is confirmed, else stay a closable error so Stop
// can retry while the teardown gate blocks further launches.
const settleCancelled = async () => {
traceExternalPlayer('mpv reuse cancelled by a stored-player stop');
try {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
} catch (error) {
state.teardownUnconfirmed = true;
return (
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
) ?? session
);
}
return externalPlayerSessions.markClosed(session.id) ?? session;
};
const reusedProcessSessionId =
this.processSessionIds.get(reusedProcess) ??
options.previousProcessSessionId;
@@ -170,7 +166,18 @@ export class MpvReusableProcess {
let retryableClose: Promise<void> | null = null;
let launchClose: Promise<void> | null = null;
// Launches run one at a time, so nothing else acts on this child while
// this attempt is in flight: its Stop always reaches the child then,
// whoever the per-child mapping names (the previous owner before
// loadfile or during a failed attempt's teardown, possibly closed
// already). Once the attempt settles, ownership decides as before.
let attemptInFlight = true;
// A Stop of this session that could not confirm the exit keeps the
// right to retry that exact child after the attempt settles.
let ownsFailedTeardown = false;
const ownsReusedProcess = () =>
attemptInFlight ||
ownsFailedTeardown ||
this.processSessionIds.get(reusedProcess) === session.id ||
externalPlayerSessions.getActiveSessionId() === session.id;
const closeReusedProcess = async () => {
@@ -208,6 +215,7 @@ export class MpvReusableProcess {
launchClose ??= closeAttempt;
void closeAttempt.catch((error) => {
if (retryableClose === closeAttempt) retryableClose = null;
ownsFailedTeardown = true;
state.teardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
@@ -219,28 +227,34 @@ export class MpvReusableProcess {
return retryableClose;
});
try {
await this.applyReuseCommands(
options,
reusedSocketPath,
() => !closeRequested
);
if (closeRequested) return await finishRequestedClose();
// Once `loadfile` reaches the socket the player content may have
// changed, whatever mpv replies, so the attempted session owns the
// child from the write onwards: an exit or a stale Stop for the
// previous session during the reply wait then lands on the right
// session instead of terminating the new playback, and the previous
// session's position poll can no longer read the new stream's
// position under the old content.
const onContentDispatched = () => {
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
);
}
try {
await this.applyReuseCommands(
options,
reusedSocketPath,
() => !closeRequested && !cancelled(),
onContentDispatched
);
if (closeRequested) return await finishRequestedClose();
if (cancelled()) return await settleCancelled();
if (hasExited(reusedProcess)) {
// The child exited while its reply was in flight; its exit
// handler has already settled the session that owned it.
return externalPlayerSessions.getSession(session.id) ?? session;
}
if (options.contentInfo) {
options.startPositionPolling(
@@ -256,7 +270,17 @@ export class MpvReusableProcess {
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 (cancelled()) return await settleCancelled();
if (current?.status === 'error' && hasExited(reusedProcess)) {
// The child died while its reply was in flight and its exit
// handler already reported the failure; a fresh launch under
// this terminal session could never be shown as opened.
return current;
}
console.error(
'Failed to send command to existing MPV:',
redactSensitiveData(error)
);
if (state.contentMutated) {
if (reusedProcessSessionId) {
@@ -282,21 +306,27 @@ export class MpvReusableProcess {
this.clear(reusedProcess);
options.stopPositionPolling();
if (closeRequested) return await finishRequestedClose();
// Reuse may have been disabled, or the app may be quitting,
// while the teardown above waited for the child to exit.
if (cancelled()) return await settleCancelled();
return null;
} finally {
attemptInFlight = false;
}
}
private async applyReuseCommands(
options: MpvReuseOptions,
socketPath: string,
shouldDispatch: () => boolean
shouldDispatch: () => boolean,
onContentDispatched: () => void
): Promise<void> {
if (options.effectiveUserAgent) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
['user-agent', options.effectiveUserAgent],
shouldDispatch
{ shouldDispatch }
);
if (!dispatched) return;
}
@@ -305,7 +335,7 @@ export class MpvReusableProcess {
socketPath,
'set_property',
['referrer', options.effectiveReferer],
shouldDispatch
{ shouldDispatch }
);
if (!dispatched) return;
}
@@ -317,7 +347,7 @@ export class MpvReusableProcess {
'http-header-fields',
joinMpvHeaderFields(options.headerFields),
],
shouldDispatch
{ shouldDispatch }
);
if (!dispatched) return;
}
@@ -335,13 +365,13 @@ export class MpvReusableProcess {
-1,
fileOptions.join(','),
];
const dispatched = await sendMpvCommand(
const reply = await sendMpvCommand(
socketPath,
'loadfile',
loadFileArgs,
shouldDispatch
{ shouldDispatch, onDispatched: onContentDispatched }
);
if (!dispatched) return;
if (!reply) return;
traceExternalPlayer('loaded new url in existing mpv instance');
}
}
@@ -0,0 +1,663 @@
/**
* Reply handling of the reused-instance MPV IPC path. Every command carries a
* `request_id` and the launch waits, bounded, for mpv's matching reply; an
* error reply or a missing one falls back to a fresh launch exactly like a
* socket failure. Teardown, ownership and Stop-interleaving coverage lives in
* `mpv-session.service.spec.ts`.
*/
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('child_process', () => ({
spawn: jest.fn(),
}));
jest.mock('net', () => ({
createConnection: jest.fn(),
createServer: jest.fn(),
}));
jest.mock('../app', () => ({
__esModule: true,
default: {
mainWindow: null,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
store: {
get: jest.fn(),
set: jest.fn(),
},
}));
import {
MPV_PLAYER_PATH,
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import * as externalPlayerRuntime from './external-player-runtime';
import { externalPlayerSessions } from './external-player-runtime';
import { MPV_IPC_COMMAND_TIMEOUT_MS } from './mpv-ipc-command';
import {
createMpvSocketMock,
MpvSocketMock,
} from './mpv-ipc-socket.test-helpers';
import {
openMpvPlayer,
setMpvReuseInstance,
shutdownMpvSession,
} from './mpv-session.service';
import {
spawnMock,
createConnectionMock,
createMockChildProcess,
mockMpvSockets,
nextTick,
launchReusableInstance,
launchReusableSession,
mockUnansweredSockets,
writtenRequestId,
} from './mpv-session.test-helpers';
describe('reused MPV IPC replies', () => {
beforeEach(() => {
jest.clearAllMocks();
shutdownMpvSession();
});
afterEach(() => {
jest.useRealTimers();
shutdownMpvSession();
});
it('opens the session once mpv acknowledges the content command', async () => {
const reusedProc = await launchReusableInstance();
const written: string[] = [];
mockMpvSockets({ written });
const session = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
startTime: 30,
});
expect(session.status).toBe('opened');
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(reusedProc.kill).not.toHaveBeenCalled();
const requests = written.map(
(chunk) =>
JSON.parse(chunk.trim()) as {
command: Array<string | number>;
request_id: number;
}
);
// The offset travels with loadfile; no seek races the load.
expect(requests.map((request) => request.command[0])).toEqual([
'loadfile',
]);
expect(Number.isInteger(requests[0].request_id)).toBe(true);
});
it('falls back to a fresh launch when mpv rejects the content command', async () => {
const reusedProc = await launchReusableInstance();
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const session = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8?token=secret-token',
});
expect(reusedProc.kill).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(session.status).toBe('opened');
expect(externalPlayerSessions.getSession(session.id)?.status).toBe(
'opened'
);
const logged = JSON.stringify(errorSpy.mock.calls);
expect(logged).toContain('mpv loadfile: error running command');
expect(logged).not.toContain('secret-token');
} finally {
errorSpy.mockRestore();
}
});
it('falls back to a fresh launch when mpv never replies', async () => {
const reusedProc = await launchReusableInstance();
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
mockMpvSockets({ reply: () => null });
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await jest.advanceTimersByTimeAsync(MPV_IPC_COMMAND_TIMEOUT_MS - 1);
expect(reusedProc.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(reusedProc.kill).toHaveBeenCalledTimes(1);
// Exit of the reused child, the fresh spawn and its start
// confirmation all run on timers.
await jest.advanceTimersByTimeAsync(500);
await expect(opening).resolves.toMatchObject({
status: 'opened',
});
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'mpv loadfile: reply timed out'
);
} finally {
errorSpy.mockRestore();
}
});
it('ignores events and other replies while waiting for its own', async () => {
await launchReusableInstance();
const sockets: MpvSocketMock[] = [];
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({ reply: () => null });
sockets.push(socket);
return socket;
});
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
let settled = false;
void opening.then(
() => (settled = true),
() => (settled = true)
);
while (!sockets[0]?.write.mock.calls.length) {
await nextTick();
}
const { request_id: requestId } = JSON.parse(
sockets[0].write.mock.calls[0][0] as string
) as { request_id: number };
const push = (...lines: object[]) =>
sockets[0].emit(
'data',
Buffer.from(
lines.map((line) => JSON.stringify(line)).join('\n') + '\n'
)
);
// mpv broadcasts events on every connection and may still be
// answering an earlier request; none of these is this reply.
push(
{ event: 'start-file', playlist_entry_id: 1 },
{
event: 'end-file',
reason: 'error',
file_error: 'loading failed',
},
{ request_id: requestId + 1000, error: 'error running command' }
);
await nextTick();
await nextTick();
expect(settled).toBe(false);
expect(spawnMock).toHaveBeenCalledTimes(1);
push({ request_id: requestId, error: 'success' });
await expect(opening).resolves.toMatchObject({ status: 'opened' });
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(sockets).toHaveLength(1);
});
it('lets a stale Stop for the previous session pass while the loadfile reply is pending', async () => {
const { proc, session: previous } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
const requestId = await writtenRequestId(sockets[0]);
// The child already belongs to the new session: the old closer
// must neither kill it nor send quit.
await expect(
externalPlayerSessions.closeSession(previous.id)
).resolves.toMatchObject({ id: previous.id, status: 'closed' });
expect(proc.kill).not.toHaveBeenCalled();
expect(sockets).toHaveLength(1);
sockets[0].emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
await expect(opening).resolves.toMatchObject({ status: 'opened' });
expect(proc.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('settles the new session as closed when the child exits during the reply wait', async () => {
const { proc, session: previous } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
});
const requestId = await writtenRequestId(sockets[0]);
const replacementId =
externalPlayerSessions.getActiveSessionId() as string;
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
sockets[0].emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
// The exit landed on the session that owned the child at that
// moment, and a dead player is never reported as opened.
await expect(opening).resolves.toMatchObject({
id: replacementId,
status: 'closed',
});
expect(externalPlayerSessions.getSession(previous.id)?.status).toBe(
'opened'
);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('stops the previous position poll as soon as loadfile is written', async () => {
jest.useFakeTimers();
const contentInfo = {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod' as const,
};
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
const sockets = mockUnansweredSockets();
const first = openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
contentInfo,
});
await jest.advanceTimersByTimeAsync(100);
await first;
// The first session polls 2 s after launch, then every 5 s: the
// next poll is due at 7 s, inside the reply window of a loadfile
// written at 6.5 s.
await jest.advanceTimersByTimeAsync(6_500);
const pollsBefore = sockets.length;
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
contentInfo: { ...contentInfo, contentXtreamId: 8 },
});
await jest.advanceTimersByTimeAsync(0);
const loadfileSocket = sockets[pollsBefore];
const requestId = await writtenRequestId(loadfileSocket);
await jest.advanceTimersByTimeAsync(1_000);
const commandsAfterLoadfile = sockets
.slice(pollsBefore + 1)
.flatMap((socket) =>
socket.write.mock.calls.map(
([request]) =>
(JSON.parse(String(request)) as { command: string[] })
.command[0]
)
);
expect(commandsAfterLoadfile).not.toContain('get_property');
loadfileSocket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
await jest.advanceTimersByTimeAsync(0);
await expect(opening).resolves.toMatchObject({ status: 'opened' });
});
it('drops a position read that was in flight when loadfile was written', async () => {
// The previous test's player exits on a real tick, and any MPV exit
// stops polling; let it land before this test starts its poll.
await nextTick();
await nextTick();
jest.useFakeTimers();
const sendPosition = jest.spyOn(
externalPlayerRuntime,
'sendPlaybackPositionUpdate'
);
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
const sockets = mockUnansweredSockets();
const commandOf = (socket: MpvSocketMock) =>
socket.write.mock.calls.length
? (
JSON.parse(String(socket.write.mock.calls[0][0])) as {
command: string[];
}
).command
: [];
const first = openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
});
await jest.advanceTimersByTimeAsync(100);
const previous = await first;
// The first poll fires 2 s + 5 s after launch and waits for time-pos.
await jest.advanceTimersByTimeAsync(6_950);
const timePosSocket = sockets.find(
(socket) => commandOf(socket)[1] === 'time-pos'
);
expect(timePosSocket).toBeDefined();
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await jest.advanceTimersByTimeAsync(0);
const loadfileSocket = sockets.find(
(socket) => commandOf(socket)[0] === 'loadfile'
) as MpvSocketMock;
const requestId = await writtenRequestId(loadfileSocket);
// The pending read now returns the new stream's position.
timePosSocket?.emit(
'data',
Buffer.from(JSON.stringify({ data: 12.5, error: 'success' }) + '\n')
);
await jest.advanceTimersByTimeAsync(10);
loadfileSocket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
await jest.advanceTimersByTimeAsync(0);
await expect(opening).resolves.toMatchObject({ status: 'opened' });
// An unguarded poll would now ask for the duration and, once that
// read times out, report the new position under the old content.
await jest.advanceTimersByTimeAsync(MPV_IPC_COMMAND_TIMEOUT_MS + 100);
expect(
sockets.some((socket) => commandOf(socket)[1] === 'duration')
).toBe(false);
expect(sendPosition).not.toHaveBeenCalledWith(
previous.id,
expect.anything(),
expect.anything()
);
sendPosition.mockRestore();
});
it('keeps a reuse cancelled by disabling reuse stoppable until its child exits', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
jest.useFakeTimers();
try {
const opening = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
setMpvReuseInstance(false);
sockets[0].emit('close');
await jest.advanceTimersByTimeAsync(6_000);
// The child never confirmed its exit: the session stays a
// closable error instead of a closed one Stop cannot retry.
await expect(opening).resolves.toMatchObject({
status: 'error',
canClose: true,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('keeps Stop working for a reused session restored after a later attempt fails', async () => {
const { proc } = await launchReusableSession();
// Exit is never confirmed, so a failing later attempt cannot finish
// its teardown and the previous owner is restored.
(proc.kill as jest.Mock).mockImplementation(() => true);
mockMpvSockets();
const restored = await openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
expect(restored.status).toBe('opened');
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({ connect: false });
setImmediate(() =>
socket.emit('error', new Error('reuse command failed'))
);
return socket;
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const failing = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const rejection = expect(failing).rejects.toThrow(
'External player process did not exit'
);
await jest.advanceTimersByTimeAsync(6_000);
await rejection;
expect(externalPlayerSessions.getActiveSessionId()).toBe(
restored.id
);
const written: string[] = [];
mockMpvSockets({ written });
let closed = false;
const closing = externalPlayerSessions
.closeSession(restored.id)
.then((session) => {
closed = true;
return session;
});
await jest.advanceTimersByTimeAsync(10);
// Stop reaches the child and waits for its exit.
expect(
written.map(
(chunk) =>
(JSON.parse(chunk.trim()) as { command: string[] })
.command[0]
)
).toContain('quit');
expect(closed).toBe(false);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(closing).resolves.toMatchObject({
id: restored.id,
status: 'closed',
});
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it.each([
['reuse is disabled', () => setMpvReuseInstance(false)],
['the app shuts down', () => shutdownMpvSession()],
])(
'does not fall back to a fresh player when %s during the reply wait',
async (_label, stopStored) => {
const { proc } = await launchReusableSession();
// The kill is sent but its exit is reported only later, after
// the socket close already rejected the pending loadfile.
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
const opening = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
await writtenRequestId(sockets[0]);
stopStored();
sockets[0].emit('close');
await nextTick();
await nextTick();
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(opening).resolves.toMatchObject({
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
}
);
it.each([
['reuse is disabled', () => setMpvReuseInstance(false)],
['the app shuts down', () => shutdownMpvSession()],
])(
'does not fall back to a fresh player when %s during the failed-reuse teardown',
async (_label, stopStored) => {
const { proc } = await launchReusableSession();
// mpv rejects the content command, and the teardown that
// follows waits for an exit that is reported only later.
(proc.kill as jest.Mock).mockImplementation(() => true);
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile'
? 'error running command'
: 'success',
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const opening = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
while (!(proc.kill as jest.Mock).mock.calls.length) {
await nextTick();
}
stopStored();
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(opening).resolves.toMatchObject({
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
}
}
);
it('keeps the errored session when the child crashes during the reply wait', async () => {
const { proc, session: previous } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await writtenRequestId(sockets[0]);
const replacementId =
externalPlayerSessions.getActiveSessionId() as string;
// A crash closes the IPC socket before any reply: the exit
// handler reports it, and no fresh player is launched under a
// session that could never be shown as opened.
Object.defineProperty(proc, 'exitCode', { value: 1 });
proc.emit('exit', 1);
sockets[0].emit('close');
await expect(opening).resolves.toMatchObject({
id: replacementId,
status: 'error',
errorCode: 'closed-unexpectedly',
});
expect(externalPlayerSessions.getSession(previous.id)?.status).toBe(
'opened'
);
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
}
});
});
@@ -61,6 +61,11 @@ import {
setVlcReuseInstance,
shutdownVlcSession,
} from './vlc-session.service';
import {
createMpvSocketMock,
MpvSocketMock,
MpvSocketMockOptions,
} from './mpv-ipc-socket.test-helpers';
function createMockChildProcess(): ChildProcess {
return Object.assign(new EventEmitter(), {
@@ -95,6 +100,13 @@ function mockStoreValues(values: Record<string, unknown>): void {
);
}
/** Every reused-instance IPC connection answers like a healthy mpv. */
function mockMpvSockets(options: MpvSocketMockOptions = {}): void {
(createConnection as unknown as jest.Mock).mockImplementation(() =>
createMpvSocketMock(options)
);
}
describe('external player shutdown on app quit', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -263,15 +275,7 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const current = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -305,15 +309,7 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
const written: string[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => written.push(chunk)),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets({ written });
const reused = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -394,15 +390,7 @@ describe('external player shutdown on app quit', () => {
expect(launchArgs).toContain('--start=42');
const written: string[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => written.push(chunk)),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets({ written });
const loadfileOptionsOf = async (startTime: number | undefined) => {
written.length = 0;
await openMpvPlayer({
@@ -456,15 +444,7 @@ describe('external player shutdown on app quit', () => {
// Capture the JSON IPC traffic of the second, reused launch.
const written: string[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => written.push(chunk)),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets({ written });
await openMpvPlayer({
title: 'Stalker live stream',
@@ -509,15 +489,7 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const reused = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -560,15 +532,7 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const reused = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -622,27 +586,15 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const session = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
const sockets: EventEmitter[] = [];
const sockets: MpvSocketMock[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
const socket = createMpvSocketMock({ connect: false });
sockets.push(socket);
return socket;
});
@@ -676,19 +628,12 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
const sockets: Array<
EventEmitter & { write: jest.Mock; end: jest.Mock }
> = [];
const sockets: MpvSocketMock[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
const socket = createMpvSocketMock({
connect: sockets.length >= 2,
});
sockets.push(socket);
if (sockets.length > 2) {
setImmediate(() => socket.emit('connect'));
}
return socket;
});
@@ -720,7 +665,7 @@ describe('external player shutdown on app quit', () => {
expect(spawn).toHaveBeenCalledTimes(1);
});
it('does not spawn a replacement when Stop interrupts reused MPV seek', async () => {
it('does not spawn a replacement when Stop interrupts a reused MPV loadfile reply', async () => {
shutdownMpvSession();
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
@@ -733,17 +678,14 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
const sockets: EventEmitter[] = [];
// The content command is written but mpv never answers it; the quit
// connection that Stop opens answers normally.
const sockets: MpvSocketMock[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
const socket = createMpvSocketMock({
reply: sockets.length === 0 ? () => null : undefined,
});
sockets.push(socket);
if (sockets.length === 1 || sockets.length === 3) {
setImmediate(() => socket.emit('connect'));
}
return socket;
});
@@ -752,19 +694,19 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/two.m3u8',
startTime: 120,
});
while (sockets.length < 2) {
while (!sockets[0]?.write.mock.calls.length) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const replacementId =
externalPlayerSessions.getActiveSessionId() as string;
const closing = externalPlayerSessions.closeSession(replacementId);
while (sockets.length < 3) {
while (sockets.length < 2) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
sockets[1].emit('error', new Error('connection closed'));
sockets[0].emit('error', new Error('connection closed'));
await expect(closing).resolves.toMatchObject({
id: replacementId,
@@ -972,22 +914,11 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
let connectionCount = 0;
(createConnection as unknown as jest.Mock).mockImplementation(() => {
connectionCount += 1;
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => {
if (connectionCount === 1) {
socket.emit('connect');
} else {
socket.emit('error', new Error('seek failed'));
}
});
return socket;
// mpv rejects the content command after it was written, so the
// player may already have changed.
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
jest.useFakeTimers();
@@ -1153,22 +1084,11 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
let connectionCount = 0;
(createConnection as unknown as jest.Mock).mockImplementation(() => {
connectionCount += 1;
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => {
if (connectionCount === 1) {
socket.emit('connect');
} else {
socket.emit('error', new Error('seek failed'));
}
});
return socket;
// mpv rejects the content command after it was written, so the
// player may already have changed.
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
const session = await openMpvPlayer({
@@ -1,6 +1,6 @@
import { spawn } from 'child_process';
import { createConnection } from 'net';
import {
ExternalPlayerSession,
PlayerContentInfo,
readExternalPlayerErrorCode,
} from '@iptvnator/shared/interfaces';
@@ -28,15 +28,25 @@ import {
buildPlayerStartError,
externalPlayerSessions,
maskUrlForLogs,
sendPlaybackPositionUpdate,
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
assertQueuedLaunchAllowed,
MpvLaunchQueue,
QueuedMpvLaunch,
settleCancelledQueuedLaunch,
} from './mpv-launch-queue';
import {
MpvReusableProcess,
MpvReuseAttemptState,
} from './mpv-reusable-process';
import { attachMpvOutputHandlers } from './mpv-output-errors';
import {
startPositionPolling,
stopPositionPolling,
} from './mpv-position-polling';
export interface OpenExternalPlayerRequest {
url: string;
@@ -51,11 +61,6 @@ export interface OpenExternalPlayerRequest {
}
const reusableMpvProcess = new MpvReusableProcess();
let positionPollingInterval: NodeJS.Timeout | null = null;
// The first poll waits for MPV to open its IPC socket. The handle is kept so
// a player that exits during that wait does not start an orphaned interval.
let positionPollingDelay: NodeJS.Timeout | null = null;
function getMpvPath(options: PlayerPathOptions = {}): string {
return (
normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ??
@@ -63,110 +68,6 @@ function getMpvPath(options: PlayerPathOptions = {}): string {
);
}
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 (positionPollingDelay) {
clearTimeout(positionPollingDelay);
positionPollingDelay = null;
}
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();
positionPollingDelay = setTimeout(() => {
positionPollingDelay = null;
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);
}
export function setMpvReuseInstance(reuseInstance: boolean): void {
traceExternalPlayer('set mpv reuse instance', { reuseInstance });
store.set(MPV_REUSE_INSTANCE, reuseInstance);
@@ -186,33 +87,71 @@ export function setMpvReuseInstance(reuseInstance: boolean): void {
* app and keeps playing after quit.
*/
export function shutdownMpvSession(): void {
mpvLaunchQueue.cancelAll();
reusableMpvProcess.stopStored(
'kill reused mpv process on app shutdown',
stopPositionPolling
);
}
export async function openMpvPlayer({
url,
title,
thumbnail,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers,
}: OpenExternalPlayerRequest) {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
// MPV launches run one at a time; see MpvLaunchQueue. Each launch begins its
// session at once, so the newest launch is the active one Stop targets.
const mpvLaunchQueue = new MpvLaunchQueue(() =>
reusableMpvProcess.storedOwnerSessionId()
);
export function openMpvPlayer(
request: OpenExternalPlayerRequest
): Promise<ExternalPlayerSession> {
try {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
} catch (error) {
return Promise.reject(error);
}
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
const session = externalPlayerSessions.beginSession({
player: 'mpv',
title,
thumbnail,
streamUrl: url,
contentInfo,
title: request.title,
thumbnail: request.thumbnail,
streamUrl: request.url,
contentInfo: request.contentInfo,
});
return mpvLaunchQueue.enqueue(session, (queue) =>
launchMpvPlayer(request, session, displacedSessionId, queue)
);
}
async function launchMpvPlayer(
{
url,
title,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers,
}: OpenExternalPlayerRequest,
session: ExternalPlayerSession,
displacedSessionId: string | null,
queue: QueuedMpvLaunch
): Promise<ExternalPlayerSession> {
const current = externalPlayerSessions.getSession(session.id);
// Stopped while it waited in the queue, directly or by a later Stop.
if (current?.status === 'closed') return current;
if (queue.queued && queue.cancelled()) {
return settleCancelledQueuedLaunch(session, queue.pendingStop());
}
if (queue.queued) {
// The last started launch owns the child the gate waits for; the
// session displaced at enqueue time is the fallback.
assertQueuedLaunchAllowed(session, [
queue.lastStartedSessionId(),
displacedSessionId,
]);
}
queue.markInFlight();
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
const reuseState: MpvReuseAttemptState = {
teardownUnconfirmed: false,
contentMutated: false,
@@ -356,61 +295,7 @@ export async function openMpvPlayer({
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);
}
}
});
}
attachMpvOutputHandlers(proc);
proc.on('error', (err) => {
console.error('Failed to start MPV player:', err);
@@ -0,0 +1,132 @@
/**
* Shared fixtures for the reused-MPV specs: a mock child, a reusable
* instance to hand launches over to, and IPC sockets that answer, stay
* silent, or let `quit` stop the child.
*/
import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { createConnection } from 'net';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import {
MPV_PLAYER_PATH,
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import {
createMpvSocketMock,
MpvSocketMock,
MpvSocketMockOptions,
} from './mpv-ipc-socket.test-helpers';
import { openMpvPlayer } from './mpv-session.service';
export const spawnMock = spawn as unknown as jest.Mock;
export const createConnectionMock = createConnection as unknown as jest.Mock;
export function createMockChildProcess(): ChildProcess {
const proc = Object.assign(new EventEmitter(), {
exitCode: null,
killed: false,
kill: jest.fn(),
signalCode: null,
stderr: null,
stdout: null,
unref: jest.fn(),
}) as unknown as ChildProcess;
(proc.kill as jest.Mock).mockImplementation(() => {
setImmediate(() => {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
});
return true;
});
return proc;
}
export function mockMpvSockets(options: MpvSocketMockOptions = {}): void {
createConnectionMock.mockImplementation(() => createMpvSocketMock(options));
}
export function nextTick(): Promise<void> {
return new Promise<void>((resolve) => setImmediate(resolve));
}
/** Spawns the reusable instance a later launch is handed over to. */
export async function launchReusableInstance(): Promise<ChildProcess> {
return (await launchReusableSession()).proc;
}
export async function launchReusableSession(): Promise<{
proc: ChildProcess;
session: ExternalPlayerSession;
}> {
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
const session = await openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
return { proc, session };
}
/** A socket whose written command mpv never answers; the test replies. */
export function mockUnansweredSockets(): MpvSocketMock[] {
const sockets: MpvSocketMock[] = [];
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({ reply: () => null });
sockets.push(socket);
return socket;
});
return sockets;
}
/**
* Like mockUnansweredSockets, but a `quit` succeeds and the child then exits,
* as a real mpv does.
*/
export function mockSocketsQuittingInto(proc: ChildProcess): MpvSocketMock[] {
const sockets: MpvSocketMock[] = [];
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({
reply: (command) => {
if (command[0] !== 'quit') return null;
setImmediate(() => {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
});
return 'success';
},
});
sockets.push(socket);
return socket;
});
return sockets;
}
export function commandsOf(sockets: MpvSocketMock[]): string[] {
return sockets.map(
(socket) =>
(
JSON.parse(String(socket.write.mock.calls[0][0])) as {
command: string[];
}
).command[0]
);
}
export async function writtenRequestId(socket: MpvSocketMock): Promise<number> {
while (!socket?.write.mock.calls.length) {
await nextTick();
}
return (
JSON.parse(socket.write.mock.calls[0][0] as string) as {
request_id: number;
}
).request_id;
}
@@ -62,6 +62,49 @@ describe('ExternalPlaybackService', () => {
expect(service.visibleSession()).toEqual(next);
});
it('keeps the newest launch when an older launch republishes as launching', () => {
const older = createSession({
id: 'session-older',
startedAt: '2026-03-07T10:00:00.000Z',
});
const newer = createSession({
id: 'session-newer',
title: 'Newer Example',
startedAt: '2026-03-07T10:00:01.000Z',
});
listener?.(older);
listener?.(newer);
// The older launch is still settling and republishes itself.
listener?.({
...older,
canClose: true,
updatedAt: '2026-03-07T10:00:02.000Z',
});
expect(service.activeSession()?.id).toBe('session-newer');
});
it('orders launches made in the same millisecond by launch order', () => {
const older = createSession({ id: 'session-older', launchOrder: 1 });
const newer = createSession({
id: 'session-newer',
title: 'Newer Example',
launchOrder: 2,
});
listener?.(older);
listener?.(newer);
// Same startedAt: only the launch order tells the two apart.
listener?.({
...older,
canClose: true,
updatedAt: '2026-03-07T10:00:02.000Z',
});
expect(service.activeSession()?.id).toBe('session-newer');
});
it('matches only active non-error sessions to content info', () => {
const playing = createSession({
status: 'playing',
@@ -4,6 +4,17 @@ import {
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
/** Launch order when both carry it, else the start time. */
function startedBefore(
session: ExternalPlayerSession,
other: ExternalPlayerSession
): boolean {
if (session.launchOrder !== undefined && other.launchOrder !== undefined) {
return session.launchOrder < other.launchOrder;
}
return Date.parse(session.startedAt) < Date.parse(other.startedAt);
}
@Injectable({
providedIn: 'root',
})
@@ -105,17 +116,25 @@ export class ExternalPlaybackService {
const current = this.activeSession();
const restoresCurrentReplacement =
current?.id === session.restoredFromSessionId;
// A launching update means a new launch only when it did not start
// before the current one: an older launch still settling (MPV runs
// launches one at a time) must not take the dock back from it.
const isLatestLaunch =
session.status === 'launching' &&
(!current ||
current.id === session.id ||
!startedBefore(session, current));
if (
!current ||
current.id === session.id ||
session.status === 'launching' ||
isLatestLaunch ||
restoresCurrentReplacement
) {
this.activeSession.set(session);
}
if (session.status === 'launching' || restoresCurrentReplacement) {
if (isLatestLaunch || restoresCurrentReplacement) {
this.dismissedSessionId.set(null);
}
}