mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
* 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>
307 lines
9.3 KiB
TypeScript
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);
|
|
});
|
|
});
|