fix(player): bind reused mpv headers to source

This commit is contained in:
4gray committed 2026-07-27 12:45:58 +02:00
1 parent 87faef7463
commit 3129d22a71
3 files changed
+316 -78

No files matched your search

@@ -101,6 +101,49 @@ describe('external player shutdown on app quit', () => {
expect(proc.kill).toHaveBeenCalledTimes(1);
});
it('scopes initial reusable MPV request headers to the first source', async () => {
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
mockStoreValues({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
});
await openMpvPlayer({
mainOwnedHeaders: {
Authorization: 'Bearer initial-token',
Cookie: 'session=initial-cookie',
Referer: 'https://portal.example/player/',
'User-Agent': 'InitialAgent/1.0',
},
title: 'Initial managed source',
url: 'https://portal.example/initial.m3u8',
});
const spawnArgs = (spawn as unknown as jest.Mock).mock
.calls[0][1] as string[];
const scopeStart = spawnArgs.indexOf('--{');
const scopeEnd = spawnArgs.indexOf('--}');
expect(scopeStart).toBeGreaterThanOrEqual(0);
expect(scopeEnd).toBeGreaterThan(scopeStart);
expect(spawnArgs.slice(scopeStart + 1, scopeEnd)).toEqual([
'--user-agent=InitialAgent/1.0',
'--referrer=https://portal.example/player/',
'--http-header-fields=Authorization: Bearer initial-token,Cookie: session=initial-cookie,Referer: https://portal.example/player/,User-Agent: InitialAgent/1.0',
'--force-media-title=Initial managed source',
'https://portal.example/initial.m3u8',
]);
expect(spawnArgs.slice(0, scopeStart).join(',')).not.toContain(
'initial-token'
);
expect(spawnArgs.slice(scopeEnd + 1).join(',')).not.toContain(
'initial-token'
);
shutdownMpvSession();
});
it('does not track non-reusable MPV processes for shutdown', async () => {
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
@@ -46,6 +46,20 @@ let mpvProcess: ChildProcess | null = null;
let mpvSocketPath: string | null = null;
let positionPollingInterval: NodeJS.Timeout | null = null;
let mpvReuseOperationQueue: Promise<void> = Promise.resolve();
let mpvCommandRequestId = 0;
type MpvCommandArgument =
| string
| number
| boolean
| Record<string, string>;
interface MpvIpcResponse {
error?: unknown;
request_id?: unknown;
}
const MPV_COMMAND_TIMEOUT_MS = 2000;
function getMpvPath(options: PlayerPathOptions = {}): string {
return (
@@ -155,30 +169,125 @@ function startPositionPolling(
function sendMpvCommand(
command: string,
args: Array<string | number>
args: MpvCommandArgument[]
): Promise<void> {
return new Promise((resolve, reject) => {
if (!mpvSocketPath) {
const socketPath = mpvSocketPath;
if (!socketPath) {
reject(new Error('No MPV socket path available'));
return;
}
const client = createConnection(mpvSocketPath);
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
mpvCommandRequestId += 1;
const requestId = mpvCommandRequestId;
const client = createConnection(socketPath);
const request =
JSON.stringify({
command: [command, ...args],
request_id: requestId,
}) + '\n';
let responseBuffer = '';
let settled = false;
const settle = (error?: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutHandle);
client.destroy();
if (error) {
reject(error);
return;
}
resolve();
};
const timeoutHandle = setTimeout(() => {
settle(
new Error(
`MPV IPC command "${command}" timed out awaiting acknowledgement`
)
);
}, MPV_COMMAND_TIMEOUT_MS);
client.on('connect', () => {
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
requestId,
});
client.write(request);
client.end();
resolve();
try {
client.write(request);
} catch (error) {
settle(
error instanceof Error
? error
: new Error('Failed to write MPV IPC command')
);
}
});
client.on('data', (chunk) => {
responseBuffer += chunk.toString();
let newlineIndex = responseBuffer.indexOf('\n');
while (newlineIndex >= 0) {
const line = responseBuffer.slice(0, newlineIndex).trim();
responseBuffer = responseBuffer.slice(newlineIndex + 1);
newlineIndex = responseBuffer.indexOf('\n');
if (!line) {
continue;
}
let response: MpvIpcResponse;
try {
response = JSON.parse(line) as MpvIpcResponse;
} catch {
continue;
}
if (response.request_id !== requestId) {
continue;
}
if (response.error !== 'success') {
settle(
new Error(
`MPV IPC command "${command}" failed: ${String(
response.error ?? 'unknown error'
)}`
)
);
return;
}
settle();
return;
}
});
client.on('end', () => {
settle(
new Error(
`MPV IPC connection ended before "${command}" was acknowledged`
)
);
});
client.on('close', () => {
settle(
new Error(
`MPV IPC connection closed before "${command}" was acknowledged`
)
);
});
client.on('error', (err) => {
console.error('MPV socket error:', err);
reject(err);
settle(err);
});
});
}
@@ -296,31 +405,21 @@ export async function openMpvPlayer({
traceExternalPlayer('reuse existing mpv instance');
try {
await enqueueMpvReuseOperation(async () => {
await sendMpvCommand('set_property', [
'user-agent',
effectiveUserAgent ?? '',
]);
await sendMpvCommand('set_property', [
'referrer',
effectiveReferer ?? '',
]);
await sendMpvCommand('set_property', [
'http-header-fields',
headerFields.join(','),
]);
const loadFileArgs: Array<string | number> = [
// `loadfile` returns before network loading begins. Keeping
// the HTTP identity in file-local options binds it to this
// playlist entry instead of a mutable process-wide window.
const loadFileOptions: Record<string, string> = {
'force-media-title': title,
'http-header-fields': headerFields.join(','),
referrer: effectiveReferer ?? '',
'user-agent': effectiveUserAgent ?? '',
};
const loadFileArgs: MpvCommandArgument[] = [
url,
'replace',
-1,
loadFileOptions,
];
const loadFileOptions: string[] = [];
if (title) {
loadFileOptions.push(`force-media-title=${title}`);
}
if (loadFileOptions.length > 0) {
loadFileArgs.push(-1, loadFileOptions.join(','));
}
await sendMpvCommand('loadfile', loadFileArgs);
@@ -356,9 +455,9 @@ export async function openMpvPlayer({
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (err) {
console.error('Failed to send command to existing MPV:', err);
mpvProcess = null;
mpvSocketPath = null;
stopPositionPolling();
killStoredMpvProcess(
'discard reused mpv process after IPC command failure'
);
}
}
@@ -377,6 +476,11 @@ export async function openMpvPlayer({
args.push('--ytdl=no');
if (reuseInstance) {
// Do not leave the first source's credentials as process defaults.
args.push('--{');
}
if (effectiveUserAgent) {
args.push(`--user-agent=${effectiveUserAgent}`);
}
@@ -399,6 +503,10 @@ export async function openMpvPlayer({
args.push(url);
if (reuseInstance) {
args.push('--}');
}
await new Promise<void>((resolve, reject) => {
const spawnSpec = buildExternalPlayerSpawnSpec(
mpvLaunchContext,
@@ -545,10 +545,55 @@ describe('player.events Stalker playback contexts', () => {
});
describe('openMpvPlayer reusable request isolation', () => {
const ipcCommands: unknown[][] = [];
interface MockMpvIpcRequest {
command: unknown[];
request_id?: number;
}
interface RecordedMpvIpcRequest {
payload: MockMpvIpcRequest;
socket: EventEmitter;
}
const ipcRequests: RecordedMpvIpcRequest[] = [];
async function waitForIpcRequestCount(count: number): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (ipcRequests.length >= count) {
return;
}
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
throw new Error(`Expected ${count} MPV IPC requests`);
}
async function flushIpcTurns(count = 4): Promise<void> {
for (let attempt = 0; attempt < count; attempt += 1) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
function acknowledgeRequest(index: number): void {
const request = ipcRequests[index];
request.socket.emit(
'data',
Buffer.from(
`${JSON.stringify({
error: 'success',
request_id: request.payload.request_id,
})}\n`
)
);
}
beforeEach(() => {
ipcCommands.length = 0;
ipcRequests.length = 0;
mockGetStalkerPlaybackContextHeaders.mockReset();
mockGetStalkerPlaybackContextHeaders.mockReturnValue(undefined);
mockCreateConnection.mockReset();
@@ -558,10 +603,10 @@ describe('openMpvPlayer reusable request isolation', () => {
destroyed: false,
end: jest.fn(),
write: jest.fn((request: string) => {
const payload = JSON.parse(request) as {
command: unknown[];
};
ipcCommands.push(payload.command);
const payload = JSON.parse(
request
) as MockMpvIpcRequest;
ipcRequests.push({ payload, socket });
return true;
}),
});
@@ -589,7 +634,7 @@ describe('openMpvPlayer reusable request isolation', () => {
url: 'https://seed.example/stream.m3u8',
});
await openMpvPlayer({
const managedOpen = openMpvPlayer({
mainOwnedHeaders: {
Authorization: 'Bearer managed-token',
Cookie: 'session=managed-cookie',
@@ -600,48 +645,62 @@ describe('openMpvPlayer reusable request isolation', () => {
title: '',
url: 'https://portal.example/managed.m3u8',
});
await waitForIpcRequestCount(1);
acknowledgeRequest(0);
await managedOpen;
expect(ipcCommands).toEqual([
['set_property', 'user-agent', 'ManagedAgent/1.0'],
[
'set_property',
'referrer',
'https://portal.example/player/',
],
[
'set_property',
'http-header-fields',
'Authorization: Bearer managed-token,Cookie: session=managed-cookie,Origin: https://portal.example,Referer: https://portal.example/player/,User-Agent: ManagedAgent/1.0',
],
expect(ipcRequests.map(({ payload }) => payload.command)).toEqual([
[
'loadfile',
'https://portal.example/managed.m3u8',
'replace',
-1,
{
'force-media-title': '',
'http-header-fields':
'Authorization: Bearer managed-token,Cookie: session=managed-cookie,Origin: https://portal.example,Referer: https://portal.example/player/,User-Agent: ManagedAgent/1.0',
referrer: 'https://portal.example/player/',
'user-agent': 'ManagedAgent/1.0',
},
],
]);
expect(ipcRequests[0].payload.request_id).toEqual(expect.any(Number));
ipcCommands.length = 0;
await openMpvPlayer({
const plainOpen = openMpvPlayer({
title: '',
url: 'https://plain.example/public.m3u8',
});
await waitForIpcRequestCount(2);
acknowledgeRequest(1);
await plainOpen;
expect(ipcCommands).toEqual([
['set_property', 'user-agent', ''],
['set_property', 'referrer', ''],
['set_property', 'http-header-fields', ''],
expect(ipcRequests[1].payload.command).toEqual(
[
'loadfile',
'https://plain.example/public.m3u8',
'replace',
-1,
{
'force-media-title': '',
'http-header-fields': '',
referrer: '',
'user-agent': '',
},
],
]);
expect(JSON.stringify(ipcCommands)).not.toContain('managed-token');
expect(JSON.stringify(ipcCommands)).not.toContain('managed-cookie');
);
expect(ipcRequests[1].payload.request_id).toEqual(expect.any(Number));
expect(ipcRequests[1].payload.request_id).not.toBe(
ipcRequests[0].payload.request_id
);
expect(JSON.stringify(ipcRequests[1].payload)).not.toContain(
'managed-token'
);
expect(JSON.stringify(ipcRequests[1].payload)).not.toContain(
'managed-cookie'
);
});
it('serializes concurrent managed and plain loads so their request properties cannot interleave', async () => {
it('waits for each daemon ACK before issuing the next concurrent source switch', async () => {
await openMpvPlayer({
title: '',
url: 'https://seed.example/stream.m3u8',
@@ -662,32 +721,60 @@ describe('openMpvPlayer reusable request isolation', () => {
url: 'https://plain.example/concurrent.m3u8',
});
let managedSettled = false;
managedOpen.finally(() => {
managedSettled = true;
});
await waitForIpcRequestCount(1);
ipcRequests[0].socket.emit(
'data',
Buffer.from(
`${JSON.stringify({ event: 'start-file' })}\n${JSON.stringify({
error: 'success',
request_id:
(ipcRequests[0].payload.request_id ?? 0) + 10_000,
})}\n`
)
);
await flushIpcTurns();
const requestsBeforeManagedAck = ipcRequests.length;
const managedSettledBeforeAck = managedSettled;
acknowledgeRequest(0);
await waitForIpcRequestCount(2);
const requestsAfterManagedAck = ipcRequests.length;
acknowledgeRequest(1);
await Promise.all([managedOpen, plainOpen]);
expect(ipcCommands).toEqual([
['set_property', 'user-agent', 'ConcurrentAgent/1.0'],
[
'set_property',
'referrer',
'https://portal.example/player/',
],
[
'set_property',
'http-header-fields',
'Authorization: Bearer concurrent-token,Cookie: session=concurrent-cookie,Referer: https://portal.example/player/,User-Agent: ConcurrentAgent/1.0',
],
expect(managedSettledBeforeAck).toBe(false);
expect(requestsBeforeManagedAck).toBe(1);
expect(requestsAfterManagedAck).toBe(2);
expect(ipcRequests.map(({ payload }) => payload.command)).toEqual([
[
'loadfile',
'https://portal.example/concurrent.m3u8',
'replace',
-1,
{
'force-media-title': '',
'http-header-fields':
'Authorization: Bearer concurrent-token,Cookie: session=concurrent-cookie,Referer: https://portal.example/player/,User-Agent: ConcurrentAgent/1.0',
referrer: 'https://portal.example/player/',
'user-agent': 'ConcurrentAgent/1.0',
},
],
['set_property', 'user-agent', ''],
['set_property', 'referrer', ''],
['set_property', 'http-header-fields', ''],
[
'loadfile',
'https://plain.example/concurrent.m3u8',
'replace',
-1,
{
'force-media-title': '',
'http-header-fields': '',
referrer: '',
'user-agent': '',
},
],
]);
});