Files
iptvnator/libs/m3u-state/src/lib/effects.spec.ts
T
4grayandClaude Fable 5 9aeb83e515 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>
2026-08-09 23:34:03 +02:00

307 lines
9.3 KiB
TypeScript

import { Channel, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
import { EpgActions } from './actions';
import {
buildExternalPlayerPayload,
shouldAutoLaunchExternalPlayer,
} from './external-player-payload.util';
import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util';
describe('buildExternalPlayerPayload', () => {
const activeChannel: Channel = {
id: 'channel-1',
name: 'Sample TV',
url: 'https://streams.example.com/live.m3u8',
group: { title: 'News' },
tvg: {
id: 'sample-tv',
name: 'Sample TV',
url: '',
logo: '',
rec: '3',
},
catchup: {
type: 'shift',
days: '3',
},
timeshift: '3',
http: {
referrer: 'https://referrer.example.com',
'user-agent': 'Codex Test Agent',
origin: 'https://origin.example.com',
},
radio: 'false',
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(
activeChannel,
'https://archive.example.com/replay.m3u8?utc=123&lutc=456'
)
).toEqual({
url: 'https://archive.example.com/replay.m3u8?utc=123&lutc=456',
title: 'Sample TV',
'user-agent': 'Codex Test Agent',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
});
});
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 };
it('launches the configured external player for regular channels', () => {
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(true);
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
activeChannel,
VideoPlayer.VLC
)
).toBe(false);
});
it('never launches external players for DASH (.mpd) channels', () => {
const dashChannel: Channel = {
...activeChannel,
url: 'https://streams.example.com/live.mpd',
};
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
dashChannel,
VideoPlayer.MPV
)
).toBe(false);
expect(
shouldAutoLaunchExternalPlayer(
{ player: VideoPlayer.VLC },
true,
dashChannel,
VideoPlayer.VLC
)
).toBe(false);
});
it('never launches external players for radio channels', () => {
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
{ ...activeChannel, radio: 'true' },
VideoPlayer.MPV
)
).toBe(false);
});
it('respects the double-click setting and missing settings', () => {
expect(
shouldAutoLaunchExternalPlayer(
{ ...mpvSettings, openStreamOnDoubleClick: true },
undefined,
activeChannel,
VideoPlayer.MPV
)
).toBe(false);
expect(
shouldAutoLaunchExternalPlayer(
{ ...mpvSettings, openStreamOnDoubleClick: true },
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(true);
expect(
shouldAutoLaunchExternalPlayer(
null,
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(false);
expect(
shouldAutoLaunchExternalPlayer(
{},
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(false);
});
});
});
describe('resolveActiveEpgProgramAction', () => {
const program: EpgProgram = {
start: '2026-07-05T09:00:00+03:00',
stop: '2026-07-05T10:50:00+03:00',
channel: 'sample-tv',
title: 'Archived show',
desc: null,
category: null,
};
const catchupChannel: Channel = {
id: 'channel-1',
name: 'Sample TV',
url: 'https://streams.example.com/live.m3u8',
group: { title: 'News' },
tvg: {
id: 'sample-tv',
name: 'Sample TV',
url: '',
logo: '',
rec: '3',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
} as Channel;
it('resolves a catch-up url for a channel with archive support', () => {
const onUnavailable = jest.fn();
const action = resolveActiveEpgProgramAction(
program,
catchupChannel,
onUnavailable
) as ReturnType<typeof EpgActions.setActivePlaybackUrl>;
expect(action.type).toBe(EpgActions.setActivePlaybackUrl.type);
expect(action.playbackUrl).toContain(
'https://streams.example.com/live.m3u8?utc='
);
expect(action.program).toEqual(program);
expect(onUnavailable).not.toHaveBeenCalled();
});
it('notifies and resets when the channel exposes no catch-up window', () => {
const onUnavailable = jest.fn();
const action = resolveActiveEpgProgramAction(
program,
{
...catchupChannel,
tvg: { ...catchupChannel.tvg, rec: '' },
} as Channel,
onUnavailable
);
expect(action.type).toBe(EpgActions.resetActiveEpgProgram.type);
expect(onUnavailable).toHaveBeenCalledTimes(1);
});
it('notifies and resets when there is no active channel', () => {
const onUnavailable = jest.fn();
const action = resolveActiveEpgProgramAction(
program,
undefined,
onUnavailable
);
expect(action.type).toBe(EpgActions.resetActiveEpgProgram.type);
expect(onUnavailable).toHaveBeenCalledTimes(1);
});
});