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:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-01 16:19:38 +02:00
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(',');
}