mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(playback): escape commas in mpv header fields on the TS paths (#1323)
* fix(playback): escape commas in mpv header fields on the TS paths PR #1321 fixed the comma truncation of --http-header-fields inside the native embedded-mpv addon, but the same OPT_STRINGLIST parsing bites three TypeScript call sites that join header fields with ',': - external MPV CLI launch (--http-header-fields=...) - external MPV instance reuse (set_property http-header-fields) - embedded MPV frame-copy loadfile options (opt.http-header-fields); the helper's %len% quoting protects only the option-list level, mpv still stringlist-parses the value afterwards The Stalker MAG user agent contains "(KHTML, like Gecko)", so strict portals received a truncated X-User-Agent and rejected live streams with HTTP 400 (#910). Escape backslashes and commas per field with the same scheme as the native fix, via a shared util. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(playback): cover the reused-instance IPC header escaping Review follow-up (Greptile on #1323): the regression coverage only exercised the CLI spawn path. Capture the JSON IPC traffic of a second, reused mpv launch and assert the escaped MAG user agent survives the set_property http-header-fields transport, so later serialization changes cannot silently reintroduce truncated headers. net is mocked with a passthrough default because the VLC specs bind a real ephemeral port via createServer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
814161274b
commit
9bb1811984
7 files changed
+216
-5
No files matched your search
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [910]
|
||||
---
|
||||
|
||||
External MPV and the embedded MPV frame-copy engine no longer truncate HTTP
|
||||
headers that contain commas — most notably the Stalker MAG user agent. Strict
|
||||
portals validated that header and rejected live streams with HTTP 400, so
|
||||
channels that only worked in VLC now also play through MPV. Completes the same
|
||||
fix that landed for the native embedded MPV view.
|
||||
@@ -8,6 +8,17 @@ jest.mock('child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
|
||||
// Passthrough by default (the VLC specs bind a real ephemeral port via
|
||||
// createServer); individual tests override createConnection to capture the
|
||||
// JSON IPC traffic of a reused mpv instance.
|
||||
jest.mock('net', () => ({
|
||||
...jest.requireActual('net'),
|
||||
createConnection: jest.fn((...args: unknown[]) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
jest.requireActual('net').createConnection(...(args as [any]))
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
@@ -30,6 +41,7 @@ jest.mock('../services/store.service', () => ({
|
||||
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import { createConnection } from 'net';
|
||||
import {
|
||||
MPV_PLAYER_PATH,
|
||||
MPV_REUSE_INSTANCE,
|
||||
@@ -101,6 +113,95 @@ describe('external player shutdown on app quit', () => {
|
||||
expect(proc.kill).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('escapes commas in http header fields passed to mpv', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
||||
mockStoreValues({
|
||||
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
||||
[MPV_REUSE_INSTANCE]: false,
|
||||
});
|
||||
|
||||
await openMpvPlayer({
|
||||
title: 'Stalker live stream',
|
||||
url: 'https://portal.example/ch/1234',
|
||||
headers: {
|
||||
'X-User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
},
|
||||
});
|
||||
|
||||
const args = (spawn as unknown as jest.Mock).mock
|
||||
.calls[0][1] as string[];
|
||||
const headerArg = args.find((arg) =>
|
||||
arg.startsWith('--http-header-fields=')
|
||||
);
|
||||
|
||||
// mpv parses the option as a comma-separated list; the comma inside
|
||||
// the MAG user agent must arrive escaped or the header is truncated
|
||||
// and Stalker portals reject the stream with HTTP 400.
|
||||
expect(headerArg).toContain('(KHTML\\, like Gecko) MAG250');
|
||||
expect(headerArg).not.toContain('(KHTML, like Gecko)');
|
||||
});
|
||||
|
||||
it('escapes commas in http header fields on the reused-instance IPC path', async () => {
|
||||
// Reset any reusable instance a previous test may have left behind so
|
||||
// the first launch below spawns rather than reuses.
|
||||
shutdownMpvSession();
|
||||
|
||||
const proc = createMockChildProcess();
|
||||
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
||||
mockStoreValues({
|
||||
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
||||
[MPV_REUSE_INSTANCE]: true,
|
||||
});
|
||||
|
||||
// First launch spawns the reusable instance and records its IPC socket.
|
||||
await openMpvPlayer({
|
||||
title: 'First stream',
|
||||
url: 'https://portal.example/ch/1',
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
await openMpvPlayer({
|
||||
title: 'Stalker live stream',
|
||||
url: 'https://portal.example/ch/1234',
|
||||
headers: {
|
||||
'X-User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
},
|
||||
});
|
||||
|
||||
const commands = written.map(
|
||||
(chunk) =>
|
||||
JSON.parse(chunk.trim()).command as Array<string | number>
|
||||
);
|
||||
const setHeaderFields = commands.find(
|
||||
(command) =>
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'http-header-fields'
|
||||
);
|
||||
|
||||
// The value must survive the JSON IPC transport with the mpv escape
|
||||
// intact: mpv re-parses the property as a comma-separated stringlist
|
||||
// on its side, so an unescaped comma truncates the MAG user agent.
|
||||
expect(setHeaderFields).toBeDefined();
|
||||
expect(setHeaderFields?.[2]).toContain('(KHTML\\, like Gecko) MAG250');
|
||||
expect(setHeaderFields?.[2]).not.toContain('(KHTML, like Gecko)');
|
||||
|
||||
shutdownMpvSession();
|
||||
});
|
||||
|
||||
it('does not track non-reusable MPV processes for shutdown', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
shouldReuseMpvInstance,
|
||||
shouldUseMpvSocketBridge,
|
||||
} from './external-player-launch-context';
|
||||
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
|
||||
import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request';
|
||||
import {
|
||||
buildPlayerStartError,
|
||||
@@ -295,7 +296,7 @@ export async function openMpvPlayer({
|
||||
if (headerFields.length > 0) {
|
||||
await sendMpvCommand('set_property', [
|
||||
'http-header-fields',
|
||||
headerFields.join(','),
|
||||
joinMpvHeaderFields(headerFields),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -372,7 +373,7 @@ export async function openMpvPlayer({
|
||||
}
|
||||
|
||||
if (headerFields.length > 0) {
|
||||
args.push(`--http-header-fields=${headerFields.join(',')}`);
|
||||
args.push(`--http-header-fields=${joinMpvHeaderFields(headerFields)}`);
|
||||
}
|
||||
|
||||
if (title) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { buildLoadPlaybackCommand } from './embedded-mpv-frame-copy-protocol';
|
||||
|
||||
describe('buildLoadPlaybackCommand', () => {
|
||||
const basePlayback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://portal.example/ch/1234',
|
||||
} as ResolvedPortalPlayback;
|
||||
|
||||
it('escapes commas inside header values for the mpv stringlist parser', () => {
|
||||
const command = buildLoadPlaybackCommand({
|
||||
...basePlayback,
|
||||
headers: {
|
||||
'X-User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
Referer: 'https://portal.example/c/',
|
||||
},
|
||||
});
|
||||
|
||||
const headerField = command
|
||||
.split('\t')
|
||||
.find((field) => field.startsWith('opt.http-header-fields='));
|
||||
|
||||
expect(headerField).toBeDefined();
|
||||
// The comma inside the MAG user agent is mpv-escaped; the comma
|
||||
// separating the two header fields is not.
|
||||
expect(headerField).toContain('(KHTML\\, like Gecko) MAG250');
|
||||
expect(headerField).toContain(',Referer: https://portal.example/c/');
|
||||
});
|
||||
|
||||
it('omits the header option when no headers are present', () => {
|
||||
const command = buildLoadPlaybackCommand(basePlayback);
|
||||
|
||||
expect(command).not.toContain('opt.http-header-fields=');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
EmbeddedMpvFrameSource,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
|
||||
import type { NativeEmbeddedMpvSessionSnapshot } from './embedded-mpv-native.service';
|
||||
|
||||
/**
|
||||
@@ -65,9 +66,14 @@ export function buildLoadPlaybackCommand(
|
||||
fields.push(`opt.start=${playback.startTime}`);
|
||||
}
|
||||
if (playback.headers && Object.keys(playback.headers).length > 0) {
|
||||
const headerFields = Object.entries(playback.headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(',');
|
||||
// The helper %len%-quotes the value at the loadfile-options level, but
|
||||
// mpv still parses it as a comma-separated stringlist afterwards, so
|
||||
// commas inside header values must be mpv-escaped here.
|
||||
const headerFields = joinMpvHeaderFields(
|
||||
Object.entries(playback.headers).map(
|
||||
([key, value]) => `${key}: ${value}`
|
||||
)
|
||||
);
|
||||
fields.push(
|
||||
`opt.http-header-fields=${encodeProtocolValue(headerFields)}`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
escapeMpvStringListValue,
|
||||
joinMpvHeaderFields,
|
||||
} from './mpv-string-list.util';
|
||||
|
||||
const MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
|
||||
describe('escapeMpvStringListValue', () => {
|
||||
it('escapes commas so mpv keeps them inside the value', () => {
|
||||
expect(escapeMpvStringListValue(`X-User-Agent: ${MAG_USER_AGENT}`)).toBe(
|
||||
'X-User-Agent: Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML\\, like Gecko) MAG250'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes backslashes before commas so escaping round-trips', () => {
|
||||
expect(escapeMpvStringListValue('a\\b,c')).toBe('a\\\\b\\,c');
|
||||
});
|
||||
|
||||
it('leaves comma-free values untouched', () => {
|
||||
expect(escapeMpvStringListValue('Referer: http://portal.example/c/')).toBe(
|
||||
'Referer: http://portal.example/c/'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinMpvHeaderFields', () => {
|
||||
it('joins fields with commas while protecting in-value commas', () => {
|
||||
const joined = joinMpvHeaderFields([
|
||||
`X-User-Agent: ${MAG_USER_AGENT}`,
|
||||
'Cookie: mac=00:1A:79:AA:BB:CC; stb_lang=en_US@rg=dezzzz',
|
||||
]);
|
||||
|
||||
expect(joined).toBe(
|
||||
'X-User-Agent: Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML\\, like Gecko) MAG250,' +
|
||||
'Cookie: mac=00:1A:79:AA:BB:CC; stb_lang=en_US@rg=dezzzz'
|
||||
);
|
||||
// Exactly one unescaped separator between the two fields.
|
||||
expect(joined.split(/(?<!\\),/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* mpv's `http-header-fields` option is a comma-separated list
|
||||
* (OPT_STRINGLIST). Header values that themselves contain commas — e.g. the
|
||||
* Stalker MAG250 user agent "...(KHTML, like Gecko) MAG250" — must be escaped
|
||||
* or mpv splits them into bogus fields and the server rejects the request
|
||||
* (HTTP 400). mpv strips the backslash and keeps the comma inside the value.
|
||||
* Mirrors the escaping of the native embedded-mpv addon (PR #1321).
|
||||
*/
|
||||
export function escapeMpvStringListValue(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,');
|
||||
}
|
||||
|
||||
/** Join `Name: value` header fields into one mpv stringlist option value. */
|
||||
export function joinMpvHeaderFields(fields: readonly string[]): string {
|
||||
return fields.map(escapeMpvStringListValue).join(',');
|
||||
}
|
||||
Reference in new issue
Block a user