diff --git a/.changes/m3u-external-player-user-agent.md b/.changes/m3u-external-player-user-agent.md new file mode 100644 index 000000000..d0ec019d3 --- /dev/null +++ b/.changes/m3u-external-player-user-agent.md @@ -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. 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 76a6ec298..8d5d0b501 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/m3u-state/src/lib/effects.spec.ts b/libs/m3u-state/src/lib/effects.spec.ts index 553cd1f20..8c682053f 100644 --- a/libs/m3u-state/src/lib/effects.spec.ts +++ b/libs/m3u-state/src/lib/effects.spec.ts @@ -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 }; diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index f82bda40e..40ab30ead 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -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 { if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) { return; } - const payload = buildExternalPlayerPayload(activeChannel, playbackUrl); + const payload = buildExternalPlayerPayload( + activeChannel, + playbackUrl, + activePlaylist + ); if (!payload) { return; } diff --git a/libs/m3u-state/src/lib/external-player-payload.util.ts b/libs/m3u-state/src/lib/external-player-payload.util.ts index 082770e2a..3ed1d592a 100644 --- a/libs/m3u-state/src/lib/external-player-payload.util.ts +++ b/libs/m3u-state/src/lib/external-player-payload.util.ts @@ -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 | 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), }; } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index fcc6a0d16..3db5cf5f7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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 = 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 = {}; - 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;