fix(m3u): forward playlist-level custom headers to external players (#1397)

* fix(m3u): forward playlist-level custom headers to external players

The custom User-Agent/Referer/Origin stored on an M3U playlist only
reached the built-in web players (via the Electron webRequest override).
MPV/VLC and the embedded MPV player make their own HTTP requests and
received only the per-channel #EXTVLCOPT values, so a playlist-wide
custom User-Agent was silently dropped for UA-locked providers (#1221).

External launch payloads now resolve each header independently: the
channel-level #EXTVLCOPT value wins, the playlist-level value is the
fallback, blank values count as absent — matching the semantics the
unified favorites/recent stream resolver already had. Covers the
auto-launch and catch-up effects in m3u-state, the manual MPV/VLC
fallback and the embedded MPV payload in VideoPlayerComponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL

* 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-09 23:34:03 +02:00
1 parent e5bb127ede
commit 9aeb83e515
10 files changed
+336 -67

No files matched your search

@@ -0,0 +1,11 @@
---
type: fix
area: m3u
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. 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}`);
+22
View File
@@ -954,6 +954,28 @@ class EpgService {
for a programme the user clicked, both hosts surface a
`EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing.
### External Player Request Headers
The built-in web players inherit the playlist-level custom
User-Agent/Referer through the Electron `webRequest` header override
(`set-user-agent` IPC: the playlist values form the session-wide override, the
active channel's `#EXTVLCOPT` values a stream-scoped one on top). External
MPV/VLC and the embedded MPV player make their own HTTP requests, so those
launches carry the headers in the payload instead:
`resolveExternalPlayerHttpHeaders()` in
`libs/m3u-state/src/lib/external-player-payload.util.ts` resolves each of
User-Agent/Referer/Origin independently — the channel's `#EXTVLCOPT` value
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`). 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
MPEG-DASH (`.mpd`) channels play through a Shaka Player _source engine_ inside
+89
View File
@@ -33,6 +33,12 @@ describe('buildExternalPlayerPayload', () => {
epgParams: '',
};
const playlistMeta = {
userAgent: 'Playlist Agent/2.0',
referrer: 'https://playlist-referrer.example.com',
origin: 'https://playlist-origin.example.com',
};
it('uses the resolved archive url while preserving headers and title', () => {
expect(
buildExternalPlayerPayload(
@@ -48,6 +54,89 @@ describe('buildExternalPlayerPayload', () => {
});
});
it('falls back to the playlist headers when the channel has none', () => {
const bareChannel: Channel = {
...activeChannel,
http: { referrer: '', 'user-agent': '', origin: '' },
};
expect(
buildExternalPlayerPayload(
bareChannel,
bareChannel.url,
playlistMeta
)
).toEqual({
url: bareChannel.url,
title: 'Sample TV',
'user-agent': 'Playlist Agent/2.0',
referer: 'https://playlist-referrer.example.com',
origin: 'https://playlist-origin.example.com',
});
});
it('keeps channel-level #EXTVLCOPT headers over the playlist fallback', () => {
expect(
buildExternalPlayerPayload(
activeChannel,
activeChannel.url,
playlistMeta
)
).toEqual({
url: activeChannel.url,
title: 'Sample TV',
'user-agent': 'Codex Test Agent',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
});
});
it('treats whitespace-only values as absent on both levels', () => {
const bareChannel: Channel = {
...activeChannel,
http: { referrer: ' ', 'user-agent': ' ', origin: '' },
};
expect(
buildExternalPlayerPayload(bareChannel, bareChannel.url, {
userAgent: ' ',
referrer: undefined,
origin: 'https://playlist-origin.example.com',
})
).toEqual({
url: bareChannel.url,
title: 'Sample TV',
'user-agent': undefined,
referer: undefined,
origin: 'https://playlist-origin.example.com',
});
});
it('mixes per-header: each header falls back independently', () => {
const partialChannel: Channel = {
...activeChannel,
http: {
'user-agent': 'Codex Test Agent',
referrer: '',
origin: '',
},
};
expect(
buildExternalPlayerPayload(
partialChannel,
partialChannel.url,
playlistMeta
)
).toEqual({
url: partialChannel.url,
title: 'Sample TV',
'user-agent': 'Codex Test Agent',
referer: 'https://playlist-referrer.example.com',
origin: 'https://playlist-origin.example.com',
});
});
describe('shouldAutoLaunchExternalPlayer', () => {
const mpvSettings = { player: VideoPlayer.MPV };
+42 -23
View File
@@ -45,6 +45,7 @@ import {
} from './actions';
import {
selectActive,
selectActivePlaylist,
selectActivePlaylistId,
selectChannels,
selectFavorites,
@@ -52,6 +53,7 @@ import {
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import {
buildExternalPlayerPayload,
type ExternalPlayerHeaderFallback,
shouldAutoLaunchExternalPlayer,
} from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
@@ -140,11 +142,15 @@ export class PlaylistEffects {
() => {
return this.actions$.pipe(
ofType(EpgActions.setActivePlaybackUrl),
withLatestFrom(this.store.select(selectActive)),
tap(([action, activeChannel]) => {
withLatestFrom(
this.store.select(selectActive),
this.store.select(selectActivePlaylist)
),
tap(([action, activeChannel, activePlaylist]) => {
void this.openWithConfiguredExternalPlayer(
action.playbackUrl,
activeChannel
activeChannel,
activePlaylist
);
})
);
@@ -156,12 +162,16 @@ export class PlaylistEffects {
() => {
return this.actions$.pipe(
ofType(EpgActions.returnToLivePlayback),
withLatestFrom(this.store.select(selectActive)),
withLatestFrom(
this.store.select(selectActive),
this.store.select(selectActivePlaylist)
),
filter(([, activeChannel]) => Boolean(activeChannel?.url)),
tap(([, activeChannel]) => {
tap(([, activeChannel, activePlaylist]) => {
void this.openWithConfiguredExternalPlayer(
activeChannel?.url ?? '',
activeChannel
activeChannel,
activePlaylist
);
})
);
@@ -174,7 +184,8 @@ export class PlaylistEffects {
ofType(ChannelActions.setActiveChannel),
// Skip the effect entirely when channel is falsy
filter((action) => !!action.channel),
map((action) => {
withLatestFrom(this.store.select(selectActivePlaylist)),
map(([action, activePlaylist]) => {
const { channel } = action;
// Use modern EPG service to get channel programs
@@ -198,6 +209,15 @@ export class PlaylistEffects {
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
const payload = buildExternalPlayerPayload(
channel,
channel.url,
activePlaylist
);
if (!payload) {
return;
}
if (
shouldAutoLaunchExternalPlayer(
settings,
@@ -206,13 +226,10 @@ export class PlaylistEffects {
VideoPlayer.MPV
)
) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: channel.url,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
this.dataService.sendIpcEvent(
OPEN_MPV_PLAYER,
payload
);
} else if (
shouldAutoLaunchExternalPlayer(
settings,
@@ -221,13 +238,10 @@ export class PlaylistEffects {
VideoPlayer.VLC
)
) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: channel.url,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
this.dataService.sendIpcEvent(
OPEN_VLC_PLAYER,
payload
);
}
}
);
@@ -259,13 +273,18 @@ export class PlaylistEffects {
private async openWithConfiguredExternalPlayer(
playbackUrl: string,
activeChannel: Channel | undefined | null
activeChannel: Channel | undefined | null,
activePlaylist?: ExternalPlayerHeaderFallback | null
): Promise<void> {
if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) {
return;
}
const payload = buildExternalPlayerPayload(activeChannel, playbackUrl);
const payload = buildExternalPlayerPayload(
activeChannel,
playbackUrl,
activePlaylist
);
if (!payload) {
return;
}
@@ -1,6 +1,15 @@
import { Channel, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
Channel,
PlaylistMeta,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { isDashChannel } from '@iptvnator/shared/m3u-utils';
export type ExternalPlayerHeaderFallback = Pick<
PlaylistMeta,
'userAgent' | 'referrer' | 'origin'
>;
/**
* Decides whether activating a channel should auto-launch the configured
* external player. Radio channels use the inline audio player; DASH (`.mpd`)
@@ -30,18 +39,52 @@ export function shouldAutoLaunchExternalPlayer(
);
}
function firstNonEmpty(...values: (string | undefined)[]): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
/**
* External players (MPV/VLC, embedded MPV) make their own HTTP requests, so
* the playlist-level custom User-Agent/Referer/Origin configured on import
* never reaches them through the Electron webRequest override that covers the
* built-in web players. Channel-level `#EXTVLCOPT` values stay authoritative;
* the playlist values only fill the gaps (#1221).
*/
export function resolveExternalPlayerHttpHeaders(
channel: Pick<Channel, 'http'> | undefined | null,
playlist?: ExternalPlayerHeaderFallback | null
): {
'user-agent': string | undefined;
referer: string | undefined;
origin: string | undefined;
} {
return {
'user-agent': firstNonEmpty(
channel?.http?.['user-agent'],
playlist?.userAgent
),
referer: firstNonEmpty(channel?.http?.referrer, playlist?.referrer),
origin: firstNonEmpty(channel?.http?.origin, playlist?.origin),
};
}
export function buildExternalPlayerPayload(
activeChannel: Channel | undefined | null,
playbackUrl: string
):
| {
url: string;
title: string;
'user-agent': string | undefined;
referer: string | undefined;
origin: string | undefined;
}
| null {
playbackUrl: string,
playlist?: ExternalPlayerHeaderFallback | null
): {
url: string;
title: string;
'user-agent': string | undefined;
referer: string | undefined;
origin: string | undefined;
} | null {
if (!playbackUrl || !activeChannel) {
return null;
}
@@ -49,8 +92,6 @@ export function buildExternalPlayerPayload(
return {
url: playbackUrl,
title: activeChannel.name ?? '',
'user-agent': activeChannel.http?.['user-agent'],
referer: activeChannel.http?.referrer,
origin: activeChannel.http?.origin,
...resolveExternalPlayerHttpHeaders(activeChannel, playlist),
};
}
@@ -48,6 +48,7 @@ import {
EpgActions,
PlaylistActions,
buildExternalPlayerPayload,
resolveExternalPlayerHttpHeaders,
resolveChannelEpgLookupKey,
selectActive,
selectActiveEpgProgram,
@@ -290,16 +291,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return null;
}
const http: Partial<Channel['http']> = playbackTarget.http ?? {};
// Embedded MPV requests bypass the Electron webRequest override, so
// the playlist-level custom headers must ride in the payload; channel
// #EXTVLCOPT values still win.
const effective = resolveExternalPlayerHttpHeaders(
playbackTarget,
this.activePlaylistMeta()
);
const headers: Record<string, string> = {};
if (http['user-agent']) {
headers['User-Agent'] = http['user-agent'];
if (effective['user-agent']) {
headers['User-Agent'] = effective['user-agent'];
}
if (http.referrer) {
headers['Referer'] = http.referrer;
if (effective.referer) {
headers['Referer'] = effective.referer;
}
if (http.origin) {
headers['Origin'] = http.origin;
if (effective.origin) {
headers['Origin'] = effective.origin;
}
return {
@@ -311,9 +318,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
thumbnail: activeChannel.tvg?.logo ?? null,
isLive: !this.activePlaybackUrl(),
headers: Object.keys(headers).length > 0 ? headers : undefined,
userAgent: http['user-agent'] || undefined,
referer: http.referrer || undefined,
origin: http.origin || undefined,
userAgent: effective['user-agent'],
referer: effective.referer,
origin: effective.origin,
// Playlists imported before the DRM feature carry no drm field
// yet, but their raw KODIPROP block survived in the stored items
// — extract lazily so they work without a re-import.
@@ -361,7 +368,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
);
private readonly activePlaylistForEpg =
private readonly activePlaylistMeta =
this.store.selectSignal(selectActivePlaylist);
/**
* Without a single configured XMLTV source the whole playlist has no EPG,
@@ -382,7 +389,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
? globalSources.some((url) => Boolean(url?.trim?.()))
: Boolean(globalSources);
const hasPlaylistSources = (
this.activePlaylistForEpg()?.epgUrls ?? []
this.activePlaylistMeta()?.epgUrls ?? []
).some((url) => Boolean(url?.trim?.()));
return hasGlobalSources || hasPlaylistSources
@@ -1078,7 +1085,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
const payload = buildExternalPlayerPayload(
this.activeChannel(),
request.playback.streamUrl
request.playback.streamUrl,
this.activePlaylistMeta()
);
if (!payload) {
return;