From 18db154d3f2bf7ed3eb2fbdef802d630505dd3c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 18:50:31 +0000 Subject: [PATCH] fix(playback): send Origin as a real VLC header and cover header IPC in E2E Review follow-ups: VLC only used the Origin value as an :http-referrer fallback while MPV already sent it via --http-header-fields; both VLC paths (fresh spawn and RC enqueue) now emit the same buildHttpHeaderFields list, so a real `Origin: ...` header reaches the provider, deduplicated against an explicit headers-map Origin. The legacy origin-as-Referer fallback stays. The dash-clearkey Electron E2E now asserts the new IPC contract (blank channel-level headers arrive as undefined, not empty strings) and gains a scenario that sets a playlist-level User-Agent through the source editor and verifies the captured MPV fallback launch carries it across the renderer/main IPC boundary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL --- .changes/m3u-external-player-user-agent.md | 3 +- .../src/dash-clearkey.e2e.ts | 67 ++++++++++++++++++- .../electron-backend/src/app/events/vlc-rc.ts | 10 +-- .../app/events/vlc-session.service.spec.ts | 21 +++++- .../src/app/events/vlc-session.service.ts | 15 ++--- docs/architecture/m3u-playlist-module.md | 7 +- 6 files changed, 104 insertions(+), 19 deletions(-) diff --git a/.changes/m3u-external-player-user-agent.md b/.changes/m3u-external-player-user-agent.md index 0044d21c1..d0ec019d3 100644 --- a/.changes/m3u-external-player-user-agent.md +++ b/.changes/m3u-external-player-user-agent.md @@ -7,4 +7,5 @@ issues: [1221] When a channel plays in MPV, VLC or the embedded MPV player, the custom User-Agent, Referer and Origin saved on the M3U playlist now reach the player. Per-channel `#EXTVLCOPT` headers still win; the playlist values only fill the -gaps. +gaps. VLC now also sends the Origin value as a real HTTP header, as MPV +already did. diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index dae1e6a32..5df10698c 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -13,7 +13,12 @@ import { launchElectronApp, LaunchedElectronApp, openAddPlaylistDialog, + openSourceEditor, + openSources, + saveSourceDialog, + sourceRowByTitle, test, + updateSourceDialog, waitForM3uCatalog, workspaceRoot, } from './electron-test-fixtures'; @@ -479,14 +484,17 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la /web-player-diagnostic__player-card--primary/ ); + // Blank channel-level #EXTVLCOPT values resolve to `undefined` (not + // empty strings) since the playlist-level header fallback landed — + // absent means absent on the IPC boundary. const expectedLaunches = [ { args: [ `${fixtureServer.origin}/unsupported.mkv`, 'Unsupported MKV', '', - '', - '', + undefined, + undefined, undefined, undefined, undefined, @@ -628,6 +636,61 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la ).toBeVisible(); await dock.getByRole('button', { name: 'Dismiss' }).click(); await expect(dock).toBeHidden(); + + // Playlist-level custom headers must cross the IPC boundary when the + // channel itself carries no #EXTVLCOPT values (#1221): set a + // User-Agent on the source, relaunch the MPV fallback, and expect the + // captured launch to carry it. + await openSources(app.mainWindow); + const sourceDialog = await openSourceEditor( + app.mainWindow, + 'Imported as text' + ); + await updateSourceDialog(sourceDialog, { + userAgent: 'Playlist Agent E2E/1.0', + }); + await saveSourceDialog(app.mainWindow, sourceDialog); + await sourceRowByTitle(app.mainWindow, 'Imported as text') + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + + await channelItemByTitle(app.mainWindow, 'Unsupported MKV') + .first() + .click(); + await expect(banner).toContainText( + /container is likely unsupported by the browser player/i, + { timeout: 15_000 } + ); + await expect(mpvFallback).toBeVisible(); + await mpvFallback.click(); + const expectedPlaylistHeaderLaunch = { + args: [ + `${fixtureServer.origin}/unsupported.mkv`, + 'Unsupported MKV', + '', + 'Playlist Agent E2E/1.0', + undefined, + undefined, + undefined, + undefined, + undefined, + ], + player: 'mpv', + } satisfies CapturedExternalPlayerLaunch; + await expect + .poll(() => getPlaybackRecommendationCapture(app), { + timeout: 10_000, + }) + .toEqual({ + closed: ['e2e-recommended-mpv-1'], + completed: 3, + launches: [ + ...expectedBothLaunches, + expectedPlaylistHeaderLaunch, + ], + released: true, + }); } finally { await releasePlaybackRecommendationCapture(app).catch(() => undefined); await closeElectronApp(app); diff --git a/apps/electron-backend/src/app/events/vlc-rc.ts b/apps/electron-backend/src/app/events/vlc-rc.ts index 5ab4340e1..efa3963fe 100644 --- a/apps/electron-backend/src/app/events/vlc-rc.ts +++ b/apps/electron-backend/src/app/events/vlc-rc.ts @@ -1,4 +1,5 @@ import { createConnection } from 'net'; +import { buildHttpHeaderFields } from './external-player-playback-request'; import { ExternalPlaybackSnapshot } from './external-player-runtime'; export function buildVlcEnqueueCommands(options: { @@ -20,11 +21,10 @@ export function buildVlcEnqueueCommands(options: { } else if (options.origin) { inputOptions.push(`:http-referrer=${options.origin}`); } - Object.entries(options.headers ?? {}).forEach(([name, value]) => { - if (!name || value === undefined || value === null) return; - const trimmedValue = String(value).trim(); - if (!trimmedValue) return; - inputOptions.push(`:http-header=${name}: ${trimmedValue}`); + // Same field list MPV sends: a real `Origin: ...` header (unless the + // headers map already carries one) plus every non-empty custom header. + buildHttpHeaderFields(options.origin, options.headers).forEach((field) => { + inputOptions.push(`:http-header=${field}`); }); if (options.title) { inputOptions.push(`:meta-title=${options.title}`); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts index 1c80160ea..0d7fb8208 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts @@ -134,13 +134,29 @@ describe('vlc-session.service helpers and launch args', () => { }) ).toEqual([ 'clear', - 'add http://srv/2 :http-referrer=https://origin.example', + 'add http://srv/2 :http-referrer=https://origin.example ' + + ':http-header=Origin: https://origin.example', ]); expect(buildVlcEnqueueCommands({ url: 'http://srv/3' })).toEqual([ 'clear', 'add http://srv/3', ]); }); + + it('does not duplicate an Origin already present in the headers map', () => { + expect( + buildVlcEnqueueCommands({ + url: 'http://srv/4', + referer: 'https://ref.example', + origin: 'https://origin.example', + headers: { Origin: 'https://explicit.example' }, + }) + ).toEqual([ + 'clear', + 'add http://srv/4 :http-referrer=https://ref.example ' + + ':http-header=Origin: https://explicit.example', + ]); + }); }); describe('RC response parsing', () => { @@ -193,7 +209,7 @@ describe('vlc-session.service helpers and launch args', () => { expect(session.status).toBe('opened'); }); - it('uses the origin as referrer fallback when no referer is given', async () => { + it('uses the origin as referrer fallback and sends it as a real header', async () => { const proc = createMockChildProcess(); spawnMock.mockReturnValueOnce(proc); @@ -208,6 +224,7 @@ describe('vlc-session.service helpers and launch args', () => { expect(spawnMock.mock.calls[0][1]).toEqual([ ':http-referrer=https://origin.example', + ':http-header=Origin: https://origin.example', streamUrl, ':meta-title=Origin Stream', ]); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index 99cef348d..3ec688255 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -192,6 +192,7 @@ export async function openVlcPlayer({ effectiveOrigin, effectiveReferer, effectiveUserAgent, + headerFields, } = resolveEffectiveExternalPlaybackRequest({ url, userAgent, @@ -262,14 +263,12 @@ export async function openVlcPlayer({ args.push(`:http-referrer=${effectiveOrigin}`); } - if (Object.keys(mergedHeaders).length > 0) { - Object.entries(mergedHeaders).forEach(([name, value]) => { - if (!name || value === undefined || value === null) return; - const trimmedValue = String(value).trim(); - if (!trimmedValue) return; - args.push(`:http-header=${name}: ${trimmedValue}`); - }); - } + // Same field list MPV sends via --http-header-fields: a real + // `Origin: ...` header (deduplicated against the merged headers) + // plus every non-empty custom header. + headerFields.forEach((field) => { + args.push(`:http-header=${field}`); + }); if (startTime) { args.push(`--start-time=${startTime}`); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 4fd923e92..8d5d0b501 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -969,7 +969,12 @@ wins, the playlist-level value (import dialog / playlist settings) is the fallback, and blank values count as absent. Every M3U external launch path goes through it: the auto-launch and catch-up effects in `m3u-state`, the manual MPV/VLC fallback in `VideoPlayerComponent`, and the embedded MPV -payload (`embeddedPlayback`). +payload (`embeddedPlayback`). In the main process both players emit the same +header field list (`buildHttpHeaderFields`): a real `Origin: ...` header +(deduplicated against the custom headers map) plus every non-empty custom +header — MPV via `--http-header-fields`, VLC via per-input `:http-header=` +options in both the fresh-spawn and RC-enqueue paths. VLC additionally keeps +its legacy origin-as-Referer fallback when no Referer is set. ### DASH + ClearKey Playback