diff --git a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts index 4d4424e0e..7787e7eab 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index 6b1c9dc91..181ca6cf3 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -46,6 +46,20 @@ let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; let positionPollingInterval: NodeJS.Timeout | null = null; let mpvReuseOperationQueue: Promise = Promise.resolve(); +let mpvCommandRequestId = 0; + +type MpvCommandArgument = + | string + | number + | boolean + | Record; + +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 + args: MpvCommandArgument[] ): Promise { 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 = [ + // `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 = { + '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((resolve, reject) => { const spawnSpec = buildExternalPlayerSpawnSpec( mpvLaunchContext, diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index 35a61d56d..c0e0955c1 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -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 { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (ipcRequests.length >= count) { + return; + } + + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + + throw new Error(`Expected ${count} MPV IPC requests`); + } + + async function flushIpcTurns(count = 4): Promise { + for (let attempt = 0; attempt < count; attempt += 1) { + await new Promise((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': '', + }, ], ]); });