From 9aa93079bd960f4ef1ecf12ebd7e68f42637fdd7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:32:24 +0200 Subject: [PATCH] fix(player): clear reused mpv request headers --- .../src/app/events/mpv-session.service.ts | 59 ++++--- .../src/app/events/player.events.spec.ts | 161 ++++++++++++++++++ 2 files changed, 196 insertions(+), 24 deletions(-) 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 5aa05b185..6b1c9dc91 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -45,6 +45,7 @@ export interface OpenExternalPlayerRequest { let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; let positionPollingInterval: NodeJS.Timeout | null = null; +let mpvReuseOperationQueue: Promise = Promise.resolve(); function getMpvPath(options: PlayerPathOptions = {}): string { return ( @@ -182,6 +183,17 @@ function sendMpvCommand( }); } +function enqueueMpvReuseOperation( + operation: () => Promise +): Promise { + const queuedOperation = mpvReuseOperationQueue.then(operation); + mpvReuseOperationQueue = queuedOperation.then( + () => undefined, + () => undefined + ); + return queuedOperation; +} + function killStoredMpvProcess(reason: string): void { if (!mpvProcess || mpvProcess.killed) { return; @@ -283,36 +295,42 @@ export async function openMpvPlayer({ ) { traceExternalPlayer('reuse existing mpv instance'); try { - if (effectiveUserAgent) { + await enqueueMpvReuseOperation(async () => { await sendMpvCommand('set_property', [ 'user-agent', - effectiveUserAgent, + effectiveUserAgent ?? '', ]); - } - if (effectiveReferer) { await sendMpvCommand('set_property', [ 'referrer', - effectiveReferer, + effectiveReferer ?? '', ]); - } - if (headerFields.length > 0) { await sendMpvCommand('set_property', [ 'http-header-fields', headerFields.join(','), ]); - } - const loadFileArgs: Array = [url, 'replace']; - const loadFileOptions: string[] = []; + const loadFileArgs: Array = [ + url, + 'replace', + ]; + const loadFileOptions: string[] = []; - if (title) { - loadFileOptions.push(`force-media-title=${title}`); - } - if (loadFileOptions.length > 0) { - loadFileArgs.push(-1, loadFileOptions.join(',')); - } + if (title) { + loadFileOptions.push(`force-media-title=${title}`); + } + if (loadFileOptions.length > 0) { + loadFileArgs.push(-1, loadFileOptions.join(',')); + } - await sendMpvCommand('loadfile', loadFileArgs); + await sendMpvCommand('loadfile', loadFileArgs); + + if (startTime) { + await sendMpvCommand('seek', [ + String(startTime), + 'absolute', + ]); + } + }); traceExternalPlayer('loaded new url in existing mpv instance'); externalPlayerSessions.attachCloser(session.id, async () => { @@ -325,13 +343,6 @@ export async function openMpvPlayer({ } }); - if (startTime) { - await sendMpvCommand('seek', [ - String(startTime), - 'absolute', - ]); - } - if (contentInfo) { startPositionPolling( mpvSocketPath, 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 96f038d0d..35a61d56d 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -8,6 +8,13 @@ jest.mock('child_process', () => ({ spawn: jest.fn(), })); +const mockCreateConnection = jest.fn(); + +jest.mock('net', () => ({ + ...jest.requireActual('net'), + createConnection: mockCreateConnection, +})); + jest.mock('../app', () => ({ __esModule: true, default: { @@ -40,6 +47,7 @@ import { ipcMain } from 'electron'; import { spawn, type ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import { + MPV_REUSE_INSTANCE, MPV_PLAYER_PATH, store, VLC_PLAYER_PATH, @@ -59,6 +67,10 @@ import { shouldUseMpvSocketBridge, } from './player.events'; import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request'; +import { + openMpvPlayer, + shutdownMpvSession, +} from './mpv-session.service'; import { openVlcPlayer } from './vlc-session.service'; function createPathExists(existingPaths: string[]) { @@ -532,6 +544,155 @@ describe('player.events Stalker playback contexts', () => { }); }); +describe('openMpvPlayer reusable request isolation', () => { + const ipcCommands: unknown[][] = []; + + beforeEach(() => { + ipcCommands.length = 0; + mockGetStalkerPlaybackContextHeaders.mockReset(); + mockGetStalkerPlaybackContextHeaders.mockReturnValue(undefined); + mockCreateConnection.mockReset(); + mockCreateConnection.mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + destroy: jest.fn(), + destroyed: false, + end: jest.fn(), + write: jest.fn((request: string) => { + const payload = JSON.parse(request) as { + command: unknown[]; + }; + ipcCommands.push(payload.command); + return true; + }), + }); + + setImmediate(() => socket.emit('connect')); + return socket; + }); + (spawn as unknown as jest.Mock).mockReset(); + (spawn as unknown as jest.Mock).mockReturnValue( + createMockChildProcess() + ); + (store.get as unknown as jest.Mock).mockImplementation( + (key: string, fallback?: unknown) => + key === MPV_REUSE_INSTANCE ? true : fallback + ); + }); + + afterEach(() => { + shutdownMpvSession(); + }); + + it('clears managed UA, referrer, Authorization, and Cookie before loading a plain source', async () => { + await openMpvPlayer({ + title: '', + url: 'https://seed.example/stream.m3u8', + }); + + await openMpvPlayer({ + mainOwnedHeaders: { + Authorization: 'Bearer managed-token', + Cookie: 'session=managed-cookie', + Origin: 'https://portal.example', + Referer: 'https://portal.example/player/', + 'User-Agent': 'ManagedAgent/1.0', + }, + title: '', + url: 'https://portal.example/managed.m3u8', + }); + + 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', + ], + [ + 'loadfile', + 'https://portal.example/managed.m3u8', + 'replace', + ], + ]); + + ipcCommands.length = 0; + + await openMpvPlayer({ + title: '', + url: 'https://plain.example/public.m3u8', + }); + + expect(ipcCommands).toEqual([ + ['set_property', 'user-agent', ''], + ['set_property', 'referrer', ''], + ['set_property', 'http-header-fields', ''], + [ + 'loadfile', + 'https://plain.example/public.m3u8', + 'replace', + ], + ]); + expect(JSON.stringify(ipcCommands)).not.toContain('managed-token'); + expect(JSON.stringify(ipcCommands)).not.toContain('managed-cookie'); + }); + + it('serializes concurrent managed and plain loads so their request properties cannot interleave', async () => { + await openMpvPlayer({ + title: '', + url: 'https://seed.example/stream.m3u8', + }); + + const managedOpen = openMpvPlayer({ + mainOwnedHeaders: { + Authorization: 'Bearer concurrent-token', + Cookie: 'session=concurrent-cookie', + Referer: 'https://portal.example/player/', + 'User-Agent': 'ConcurrentAgent/1.0', + }, + title: '', + url: 'https://portal.example/concurrent.m3u8', + }); + const plainOpen = openMpvPlayer({ + title: '', + url: 'https://plain.example/concurrent.m3u8', + }); + + 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', + ], + [ + 'loadfile', + 'https://portal.example/concurrent.m3u8', + 'replace', + ], + ['set_property', 'user-agent', ''], + ['set_property', 'referrer', ''], + ['set_property', 'http-header-fields', ''], + [ + 'loadfile', + 'https://plain.example/concurrent.m3u8', + 'replace', + ], + ]); + }); +}); + describe('openVlcPlayer', () => { beforeEach(() => { (spawn as unknown as jest.Mock).mockReset();