diff --git a/.changes/playback-mpv-header-comma-escape.md b/.changes/playback-mpv-header-comma-escape.md new file mode 100644 index 000000000..ca79c7d6b --- /dev/null +++ b/.changes/playback-mpv-header-comma-escape.md @@ -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. 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..3535289a4 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 @@ -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 + ); + 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); 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 fb6052f45..8492a94ac 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -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) { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.spec.ts new file mode 100644 index 000000000..c7e307a41 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.spec.ts @@ -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='); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts index 51d6146b1..d5fecefbb 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts @@ -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)}` ); diff --git a/apps/electron-backend/src/app/util/mpv-string-list.util.spec.ts b/apps/electron-backend/src/app/util/mpv-string-list.util.spec.ts new file mode 100644 index 000000000..52cc3c9c1 --- /dev/null +++ b/apps/electron-backend/src/app/util/mpv-string-list.util.spec.ts @@ -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(/(?