mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user