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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL
This commit is contained in:
Claude committed 2026-08-09 18:50:31 +00:00
1 parent 297733ed42
commit 18db154d3f
6 files changed
+104 -19

No files matched your search

+2 -1
View File
@@ -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.
@@ -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);
@@ -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}`);
@@ -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',
]);
@@ -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}`);
+6 -1
View File
@@ -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