mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(player): bind reused mpv headers to source
This commit is contained in:
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': '',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user