fix(m3u): use custom User-Agent for URL import and refresh (#1535)

This commit is contained in:
4gray authored and GitHub committed 2026-09-05 14:49:23 +02:00
1 parent 79f3f6c897
commit 0ba5107561
34 files changed
+508 -27

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: m3u
issues: [465, 1120]
---
M3U URL imports now accept a custom User-Agent for providers that require it before downloading the playlist. The value is saved and reused for playlist refreshes in the desktop app and self-hosted web app.
+11
View File
@@ -207,6 +207,17 @@ Key files:
- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video
- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface
## M3U URL User-Agent
- The URL import form accepts an optional User-Agent and stores it as
`Playlist.userAgent`. Electron sends it on initial download, manual refresh,
and startup auto-update. The self-hosted PWA sends it through the registered
target `/parse` backend proxy for import and refresh; a matching backend is
required, and browser playback-header restrictions still apply.
- Reuse the existing source editor and channel-over-playlist playback header
precedence. Contract: `docs/architecture/m3u-playlist-module.md`
("User-Agent for URL sources").
## Shared Player Controls
- `libs/ui/playback/src/lib/player-controls/` contains the additive,
+12
View File
@@ -771,6 +771,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m
### Key Features
#### M3U URL User-Agent
- The URL import form accepts an optional User-Agent and stores it as
`Playlist.userAgent`. Electron sends it on initial download, manual refresh,
and startup auto-update. The self-hosted PWA sends it through the registered
target `/parse` backend proxy for import and refresh; a matching backend is
required, and browser playback-header restrictions still apply.
- Reuse the existing source editor and channel-over-playlist playback header
precedence. Contract: `docs/architecture/m3u-playlist-module.md`
("User-Agent for URL sources").
**Playlist Support**:
- M3U/M3U8 files (local or URL)
@@ -1079,7 +1079,8 @@ export async function restartElectronApp(
export async function importM3uPlaylistFromUrl(
page: Page,
playlistUrl: string
playlistUrl: string,
userAgent?: string
): Promise<void> {
await openAddPlaylistDialog(page);
const dialog = await getActiveDialog(page);
@@ -1094,6 +1095,9 @@ export async function importM3uPlaylistFromUrl(
dialog.locator('input[formcontrolname="playlistUrl"]'),
playlistUrl
);
if (userAgent !== undefined) {
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
}
await dialog.getByRole('button', { name: /Add playlist/i }).click();
await dialog.waitFor({ state: 'detached' });
}
@@ -1186,6 +1190,7 @@ export async function createMutableTextServer(
options: {
contentType?: string;
resourcePath?: string;
requiredUserAgent?: string;
} = {}
): Promise<MutableTextServer> {
const {
@@ -1205,6 +1210,12 @@ export async function createMutableTextServer(
return;
}
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
res.writeHead(403);
res.end('User-Agent required');
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(body);
});
@@ -8,6 +8,7 @@ import {
openSourceEditor,
openSources,
restartElectronApp,
refreshSource,
saveSourceDialog,
sourceRowByTitle,
test,
@@ -38,6 +39,83 @@ async function openAutoRefreshPlaylistCatalog(page: Page): Promise<void> {
}
test.describe('Electron startup playlist auto-refresh', () => {
test('imports a protected URL with User-Agent and reuses it for manual and startup refresh', async ({
dataDir,
}) => {
const userAgent = 'IPTVnator-Test/1.0';
const urlServer = await createMutableTextServer(
buildAutoRefreshM3u('Original UA Channel'),
{
resourcePath: `/${autoRefreshSourceName}`,
requiredUserAgent: userAgent,
}
);
const app = await launchElectronApp(dataDir);
try {
expect((await fetch(urlServer.resourceUrl)).status).toBe(403);
await importM3uPlaylistFromUrl(
app.mainWindow,
urlServer.resourceUrl,
` ${userAgent} `
);
await openSources(app.mainWindow);
const dialog = await openSourceEditor(
app.mainWindow,
autoRefreshSourceName
);
await expect(
dialog.getByRole('textbox', { name: 'User agent', exact: true })
).toHaveValue(userAgent);
await updateSourceDialog(dialog, { autoRefresh: true });
await saveSourceDialog(app.mainWindow, dialog);
urlServer.setBody(buildAutoRefreshM3u('Manual UA Channel'));
await refreshSource(app.mainWindow, autoRefreshSourceName);
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await expect(
app.mainWindow
.getByTestId('channel-item')
.filter({ hasText: 'Manual UA Channel' })
).toHaveCount(1);
// A refresh result must keep the in-memory default as well as SQLite metadata.
await openSources(app.mainWindow);
urlServer.setBody(buildAutoRefreshM3u('Second UA Channel'));
await refreshSource(app.mainWindow, autoRefreshSourceName);
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await expect(
app.mainWindow
.getByTestId('channel-item')
.filter({ hasText: 'Second UA Channel' })
).toHaveCount(1);
urlServer.setBody(buildAutoRefreshM3u('Startup UA Channel'));
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await expect(
app.mainWindow
.getByTestId('channel-item')
.filter({ hasText: 'Startup UA Channel' })
).toHaveCount(1);
await openSources(app.mainWindow);
const savedDialog = await openSourceEditor(
app.mainWindow,
autoRefreshSourceName
);
await expect(
savedDialog.getByRole('textbox', {
name: 'User agent',
exact: true,
})
).toHaveValue(userAgent);
} finally {
await closeElectronApp(app);
await urlServer.close();
}
});
test('reports failed playlists instead of an unconditional success toast', async ({
dataDir,
}) => {
@@ -46,6 +46,7 @@ import type {
ElectronBridgeRemoteControlCommand,
ElectronBridgeRemoteControlStatus,
ElectronBridgeTrustOptions,
ElectronBridgePlaylistFetchOptions,
ElectronBridgeWindowState,
ElectronBridgeXtreamContentStream,
ExternalPlayerSession,
@@ -447,7 +448,7 @@ const electronApi: ElectronBridgeApi = {
fetchPlaylistByUrl: (
url: string,
title?: string,
options?: ElectronBridgeTrustOptions
options?: ElectronBridgePlaylistFetchOptions
) => ipcRenderer.invoke('fetch-playlist-by-url', url, title, options),
updatePlaylistFromFilePath: (filePath: string, title: string) =>
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
@@ -217,6 +217,20 @@ describe('autoUpdatePlaylists', () => {
]);
});
it('downloads startup refreshes with each playlist’s own User-Agent', async () => {
mockFetchPlaylistFromUrl.mockResolvedValue(createPlaylist());
const playlist = createPlaylist({
url: 'https://example.test/list.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
await autoUpdatePlaylists([playlist]);
expect(mockFetchPlaylistFromUrl).toHaveBeenCalledWith(
playlist.url,
playlist.title,
{ userAgent: playlist.userAgent }
);
});
it('skips playlists without a usable source and forwards trust options', async () => {
mockFetchPlaylistFromUrl.mockResolvedValue(createPlaylist());
@@ -36,7 +36,7 @@ async function refreshPlaylist(
playlistObject = await fetchPlaylistFromUrl(
playlist.url,
playlist.title,
options
{ ...options, userAgent: playlist.userAgent }
);
} else if (playlist.filePath) {
console.log(
@@ -91,7 +91,7 @@ export async function autoUpdatePlaylists(
} catch (error) {
console.error(
`Failed to update playlist "${playlist.title}":`,
error
redactSensitiveData(error)
);
statuses[index] = 'failed';
}
@@ -17,6 +17,7 @@ import type { M3uImportPerformanceCapture } from './playlist-import-performance'
import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
export interface PlaylistFetchOptions {
userAgent?: string;
performanceCapture?: M3uImportPerformanceCapture | null;
trustedInsecureTlsHosts?: readonly string[];
}
@@ -88,6 +89,7 @@ export async function fetchPlaylistFromUrl(
options: PlaylistFetchOptions = {}
): Promise<Playlist> {
const performanceCapture = options.performanceCapture;
const userAgent = options.userAgent?.trim() || undefined;
const request = () =>
requestWithValidatedRedirects<string>(
url,
@@ -96,6 +98,7 @@ export async function fetchPlaylistFromUrl(
trustedInsecureTlsHosts: options.trustedInsecureTlsHosts,
}),
method: 'GET',
...(userAgent ? { headers: { 'User-Agent': userAgent } } : {}),
timeout: PLAYLIST_FETCH_TIMEOUT_MS,
},
{ allowPrivateNetworks: true }
@@ -135,12 +138,16 @@ export async function fetchPlaylistFromUrl(
? 'Imported from URL'
: extractedName;
return createPlaylistObject(
const playlist = createPlaylistObject(
title ?? playlistName,
parsedPlaylist,
url,
'URL'
);
if (userAgent) {
playlist.userAgent = userAgent;
}
return playlist;
};
return performanceCapture
@@ -220,6 +220,27 @@ describe('playlist IPC events', () => {
expect(result).toEqual(playlist);
});
it.each([' IPTVnator-Test/1.0 ', '', ' '])(
'uses and persists the optional initial download User-Agent: %s',
async (userAgent) => {
mockAxiosGet.mockResolvedValue({ data: '#EXTM3U' });
mockParse.mockReturnValue({ items: [] });
mockCreatePlaylistObject.mockReturnValue(createPlaylist());
const result = await getHandler('fetch-playlist-by-url')(
createIpcEvent(),
'https://example.test/remote.m3u',
undefined,
{ userAgent }
);
const expected = userAgent.trim() || undefined;
const request = mockAxiosGet.mock.calls[0][0];
expect(request.headers?.['User-Agent']).toBe(expected);
expect((result as Playlist).userAgent).toBe(expected);
}
);
it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => {
process.env[PERF_CAPTURE_ENV] = '1';
const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news';
@@ -4,6 +4,7 @@
*/
import { app, dialog, ipcMain, WebContents } from 'electron';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
@@ -14,6 +15,7 @@ import {
PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE,
PLAYLIST_REFRESH_EVENT,
ElectronBridgeTrustOptions,
ElectronBridgePlaylistFetchOptions,
Playlist,
PlaylistRefreshCancelledResult,
PlaylistRefreshEvent,
@@ -94,7 +96,7 @@ ipcMain.handle(
event,
url,
title?: string,
options?: ElectronBridgeTrustOptions
options?: ElectronBridgePlaylistFetchOptions
) => {
try {
const performanceCapture = createM3uImportPerformanceCapture({
@@ -102,10 +104,14 @@ ipcMain.handle(
});
return await fetchPlaylistFromUrl(url, title, {
performanceCapture,
userAgent: options?.userAgent,
trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts,
});
} catch (error) {
console.error('Error fetching playlist:', error);
console.error(
'Error fetching playlist:',
redactSensitiveData(error)
);
throw error;
}
}
@@ -112,6 +112,9 @@ async function fetchPlaylistFromUrl(
trustedInsecureTlsHosts: payload.trustedInsecureTlsHosts,
}),
method: 'GET',
...(payload.userAgent?.trim()
? { headers: { 'User-Agent': payload.userAgent.trim() } }
: {}),
signal: controller.signal,
timeout: PLAYLIST_FETCH_TIMEOUT_MS,
},
@@ -99,6 +99,37 @@ https://stream.example/news.m3u8`);
);
});
it.each([' IPTVnator-Test/1.0 ', '', ' '])(
'forwards and persists the optional playlist User-Agent: %s',
async (userAgent) => {
const httpClient = new StubHttpClient();
httpClient.queueResponse('#EXTM3U');
await withServer(
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'https://provider.example/list.m3u'
);
const params = new URLSearchParams({ targetId, userAgent });
const response = await fetch(`${baseUrl}/parse?${params}`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
userAgent?: string;
};
const expected = userAgent.trim() || undefined;
expect(httpClient.requests[0].headers?.['User-Agent']).toBe(
expected
);
expect(body.userAgent).toBe(expected);
}
);
}
);
it('parses XMLTV metadata into the current EPG shape', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse(`<?xml version="1.0"?>
+18 -7
View File
@@ -231,6 +231,10 @@ export function createWebBackendApp(
httpClient,
now,
url: url.href,
userAgent:
typeof req.query.userAgent === 'string'
? req.query.userAgent.trim() || undefined
: undefined,
});
if (isPlaylistParseError(result)) {
@@ -656,22 +660,29 @@ async function handlePlaylistParse(options: {
readonly httpClient: WebBackendHttpClient;
readonly now: () => Date;
readonly url: string;
readonly userAgent?: string;
}): Promise<Record<string, unknown> | PlaylistParseError> {
try {
// Provider URLs are validated by /provider-targets before playlist parsing.
// codeql[js/request-forgery]
const response = await options.httpClient.get<string>(options.url, {
timeout: PROVIDER_REQUEST_TIMEOUT_MS.playlist,
...(options.userAgent
? { headers: { 'User-Agent': options.userAgent } }
: {}),
});
const parsedPlaylist = parsePlaylist(response.data);
const title = getLastUrlSegment(options.url);
return createPlaylistObject({
guid: options.guid,
now: options.now,
playlist: parsedPlaylist,
title,
url: options.url,
});
return {
...createPlaylistObject({
guid: options.guid,
now: options.now,
playlist: parsedPlaylist,
title,
url: options.url,
}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
};
} catch (error) {
logProviderRequestFailure({ error, route: '/parse', url: options.url });
const providerError = error as ProviderError;
+75
View File
@@ -1,4 +1,6 @@
import type { Page } from '@playwright/test';
import { createServer } from 'node:http';
import { openSourceEditor, sourceRowByTitle } from './sources-pwa.helpers';
import { postWithRetry, setInputValue } from './e2e-helpers';
import { expect, test } from './fixtures';
@@ -193,3 +195,76 @@ test('@self-hosted Stalker portal loads through web-backend proxy', async ({
await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 });
expectRequestsUseTargetId(stalkerRequests, '/stalker');
});
test('@self-hosted M3U User-Agent reaches the provider on import and refresh', async ({
page,
}) => {
const userAgent = 'IPTVnator-Test/1.0';
const received: (string | undefined)[] = [];
let channel = 'Initial UA Channel';
const server = createServer((req, res) => {
received.push(req.headers['user-agent']);
if (req.headers['user-agent'] !== userAgent) {
res.writeHead(403);
res.end('User-Agent required');
return;
}
res.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' });
res.end(
`#EXTM3U\n#EXTINF:-1,${channel}\nhttps://streams.example.test/news.m3u8\n`
);
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Missing test server port');
const url = `http://127.0.0.1:${address.port}/protected.m3u`;
try {
expect((await fetch(url)).status).toBe(403);
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await setInputValue(
dialog.getByRole('textbox', { name: /Playlist URL/ }),
url
);
await setInputValue(
dialog.getByRole('textbox', { name: 'Playlist title' }),
'Protected M3U'
);
await setInputValue(
dialog.getByRole('textbox', { name: 'User agent', exact: true }),
` ${userAgent} `
);
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await page.waitForURL(/playlists.*all/);
await expect(page.getByText('1. Initial UA Channel')).toBeVisible();
const catalogUrl = page.url();
await page.reload();
await page.goto('/workspace/sources');
const editor = await openSourceEditor(page, 'Protected M3U');
await expect(
editor.getByRole('textbox', { name: 'User agent', exact: true })
).toHaveValue(userAgent);
await editor
.getByRole('button', { name: 'Close', exact: true })
.click();
channel = 'Refreshed UA Channel';
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await expect(page.locator('.mat-mdc-snack-bar-label').last()).toContainText(
'updated'
);
await page.goto(catalogUrl);
await expect(page.getByText('1. Refreshed UA Channel')).toBeVisible();
expect(received.slice(1)).toEqual([userAgent, userAgent]);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
});
@@ -111,6 +111,34 @@ describe('ElectronService', () => {
expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled();
});
it('forwards URL import User-Agent alongside trust options and dispatches the persisted value', async () => {
const playlist = {
_id: 'ua-playlist',
userAgent: 'IPTVnator-Test/1.0',
};
electronBridge.fetchPlaylistByUrl.mockResolvedValue(playlist);
service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
url: 'https://example.test/list.m3u',
userAgent: playlist.userAgent,
});
await Promise.resolve();
expect(electronBridge.fetchPlaylistByUrl).toHaveBeenCalledWith(
'https://example.test/list.m3u',
undefined,
{
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
userAgent: playlist.userAgent,
}
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.handleAddingPlaylistByUrl({
isTemporary: false,
playlist: playlist as Playlist,
})
);
});
it('redacts credentials from backend player errors before logging', () => {
const secret = 'player-error-token-secret';
const listener = electronBridge.onPlayerError.mock.calls[0][0];
+10 -6
View File
@@ -145,6 +145,7 @@ export class ElectronService extends DataService {
id: string;
filePath?: string;
url?: string;
userAgent?: string;
title: string;
}
);
@@ -329,11 +330,10 @@ export class ElectronService extends DataService {
const title = payload.title?.trim() || undefined;
window.electron
.fetchPlaylistByUrl(
payload.url,
title,
this.settingsStore.getTrustOptions()
)
.fetchPlaylistByUrl(payload.url, title, {
...this.settingsStore.getTrustOptions(),
userAgent: payload.userAgent,
})
.then((result) => {
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH,
@@ -393,6 +393,7 @@ export class ElectronService extends DataService {
id: string;
url?: string;
filePath?: string;
userAgent?: string;
title: string;
}) {
try {
@@ -401,7 +402,10 @@ export class ElectronService extends DataService {
playlistObject = await window.electron.fetchPlaylistByUrl(
data.url,
data.title,
this.settingsStore.getTrustOptions()
{
...this.settingsStore.getTrustOptions(),
userAgent: data.userAgent,
}
);
} else if (data.filePath && !data.url) {
playlistObject =
@@ -85,6 +85,54 @@ describe('PwaService', () => {
expect(http.match(() => true)).toHaveLength(0);
});
it.each([PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE])(
'passes the saved User-Agent through the proxy for %s',
async (event) => {
service.sendIpcEvent(event, {
id: 'playlist-1',
url: 'https://provider.example/list.m3u',
userAgent: ' IPTVnator-Test/1.0 ',
});
http.expectOne((req) =>
req.url.endsWith('/provider-targets')
).flush({ targetId: 'target-ua' });
await new Promise((resolve) => setTimeout(resolve));
const parse = http.expectOne((req) => req.url.endsWith('/parse'));
expect(parse.request.params.get('userAgent')).toBe(
'IPTVnator-Test/1.0'
);
expect(parse.request.headers.has('User-Agent')).toBe(false);
parse.flush({ _id: 'playlist-1', userAgent: 'IPTVnator-Test/1.0' });
expect(TestBed.inject(Store).dispatch).toHaveBeenCalledWith(
expect.objectContaining({
playlist: expect.objectContaining({
userAgent: 'IPTVnator-Test/1.0',
}),
})
);
}
);
it.each([undefined, '', ' '])(
'omits a blank User-Agent from proxy requests: %s',
async (userAgent) => {
service
.getPlaylistFromUrl(
'https://provider.example/list.m3u',
userAgent
)
.subscribe();
http.expectOne((req) =>
req.url.endsWith('/provider-targets')
).flush({ targetId: 'target-default' });
await new Promise((resolve) => setTimeout(resolve));
const parse = http.expectOne((req) => req.url.endsWith('/parse'));
expect(parse.request.params.keys()).toEqual(['targetId']);
expect(parse.request.headers.has('User-Agent')).toBe(false);
parse.flush({});
}
);
it('appends the proxy network code to the URL-import failure toast', async () => {
// The /parse proxy reports connection-level failures as HTTP 500 with
// a `code` field in the body (#1400). The toast must carry that code —
+10 -4
View File
@@ -222,7 +222,7 @@ export class PwaService extends DataService {
const playlistId = payload.id;
this.getPlaylistFromUrl(payload.url)
this.getPlaylistFromUrl(payload.url, payload.userAgent)
.pipe(
catchError((error) => {
this.snackBar.open(
@@ -287,7 +287,7 @@ export class PwaService extends DataService {
const title = payload.title?.trim() || undefined;
this.getPlaylistFromUrl(payload.url)
this.getPlaylistFromUrl(payload.url, payload.userAgent)
.pipe(
catchError((error) => {
this.snackBar.open(
@@ -700,11 +700,17 @@ export class PwaService extends DataService {
}
}
getPlaylistFromUrl(url: string): Observable<Playlist> {
getPlaylistFromUrl(url: string, userAgent?: string): Observable<Playlist> {
const normalizedUserAgent = userAgent?.trim();
return from(this.getProviderTargetId(url)).pipe(
switchMap((targetId) =>
this.http.get<Playlist>(`${this.corsProxyUrl}/parse`, {
params: { targetId },
params: {
targetId,
...(normalizedUserAgent
? { userAgent: normalizedUserAgent }
: {}),
},
})
)
);
+30
View File
@@ -56,6 +56,36 @@ There is intentionally **no URL validation** (upstream removed it in 0.15.0): an
The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite.
## User-Agent for URL sources
The M3U URL form accepts an optional User-Agent, including after an Auto-detect
handoff. It reuses the playlist editor's translated label and hint. Import
trims the value; an empty or whitespace-only value leaves download defaults
unchanged. A successful import stores it as `Playlist.userAgent`, so the
existing source editor can update it without a schema migration. The editor
also exposes this field for URL sources in the PWA.
Electron carries it in `ElectronBridgePlaylistFetchOptions` alongside TLS
trust options. `playlist-source.ts` sets the HTTP header on the first fetch,
startup auto-update supplies each saved playlist's UA, and explicit refresh
passes it through `PlaylistRefreshPayload` to the refresh worker. The reducer
retains the current UA when a refresh result omits it, preserving repeated
refreshes and playback defaults within the same session. Requests
retain their validated redirect, private-network, TLS, timeout, and cancellation
policies. Download errors use the shared redactor before logging.
The self-hosted PWA passes the optional `userAgent` parameter to `/parse` using
the existing registered `targetId`; the backend sets the upstream header and
returns the value in the playlist. Refresh uses the saved value through the
same proxy. This requires the matching web backend. The browser never sets a
User-Agent header itself, and this does not add custom browser playback-header
support. Electron playback retains existing per-header precedence: channel
headers override playlist defaults. File, text, and portal imports are unchanged.
Regression coverage uses synthetic UA-gated HTTP endpoints in
`playlist-auto-refresh.e2e.ts` (Electron) and `self-hosted.e2e.ts` (PWA), plus
form, renderer, download, proxy, and refresh-action unit tests.
## Initial URL Import Performance Benchmark (Electron)
The Electron E2E project has a deterministic initial-import benchmark for
@@ -217,6 +217,36 @@ describe('playlistReducers', () => {
expect(nextState.channelsLoading).toBe(false);
});
it.each([undefined, '', 'Replacement/2.0'])(
'preserves the saved User-Agent unless refresh explicitly replaces it: %s',
(userAgent) => {
const existing = {
_id: 'playlist-1',
userAgent: 'IPTVnator-Test/1.0',
} as PlaylistMeta;
const state = {
...initialState,
playlists: playlistsAdapter.addOne(
existing,
initialState.playlists
),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlistId: existing._id,
playlist: {
playlist: { items: [] },
...(userAgent !== undefined ? { userAgent } : {}),
} as Playlist,
})
);
expect(nextState.playlists.entities[existing._id]?.userAgent).toBe(
userAgent ?? existing.userAgent
);
}
);
it('keeps hiddenGroupTitles on playlist refresh when the refreshed payload omits them', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
@@ -60,7 +60,9 @@ export const playlistReducers = [
_id: action.playlistId,
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
userAgent:
action.playlist.userAgent ??
currentPlaylist?.userAgent,
favorites: currentPlaylist?.favorites ?? [],
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
@@ -72,6 +72,7 @@ describe('AddPlaylistDialogComponent', () => {
form: {
getRawValue: () => ({
playlistName: ' My Playlist ',
userAgent: ' IPTVnator-Test/1.0 ',
playlistUrl: ' https://example.com/list.m3u ',
}),
},
@@ -83,6 +84,7 @@ describe('AddPlaylistDialogComponent', () => {
PLAYLIST_PARSE_BY_URL,
{
title: 'My Playlist',
userAgent: 'IPTVnator-Test/1.0',
url: 'https://example.com/list.m3u',
}
);
@@ -94,6 +96,7 @@ describe('AddPlaylistDialogComponent', () => {
form: {
getRawValue: () => ({
playlistName: ' ',
userAgent: ' ',
playlistUrl: 'https://example.com/list.m3u',
}),
},
@@ -278,9 +278,12 @@ export class AddPlaylistDialogComponent {
formValue?.playlistName
);
const userAgent = this.normalizeOptionalValue(formValue?.userAgent);
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
url: playlistUrl,
...(playlistName ? { title: playlistName } : {}),
...(userAgent ? { userAgent } : {}),
});
this.closeDialog();
}
@@ -7,6 +7,15 @@
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
<input type="text" matInput formControlName="playlistName" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input type="text" matInput formControlName="userAgent" />
<mat-hint>{{
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
}}</mat-hint>
</mat-form-field>
@if (!isDesktop) {
<p class="cors-note">
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
@@ -67,6 +67,7 @@ describe('UrlUploadComponent', () => {
const testUrl = 'http://example.org/playlist.m3u';
component.form.setValue({
userAgent: '',
playlistName: ' Custom Playlist ',
playlistUrl: 'wrong url here',
});
@@ -74,6 +75,7 @@ describe('UrlUploadComponent', () => {
expect(component.form.valid).toBeFalsy();
component.form.setValue({
userAgent: '',
playlistName: '',
playlistUrl: testUrl,
});
@@ -81,6 +83,7 @@ describe('UrlUploadComponent', () => {
expect(component.form.valid).toBeTruthy();
component.form.setValue({
userAgent: '',
playlistName: ' ',
playlistUrl: testUrl,
});
@@ -90,6 +93,7 @@ describe('UrlUploadComponent', () => {
it('clears the url playlist form', () => {
component.form.setValue({
userAgent: 'IPTVnator-Test/1.0',
playlistName: 'News',
playlistUrl: 'http://example.org/playlist.m3u',
});
@@ -100,6 +104,7 @@ describe('UrlUploadComponent', () => {
expect(component.form.getRawValue()).toEqual({
playlistName: '',
playlistUrl: '',
userAgent: '',
});
expect(component.form.pristine).toBeTruthy();
});
@@ -28,6 +28,7 @@ export class UrlUploadComponent implements OnInit {
form!: FormGroup<{
playlistName: FormControl<string>;
playlistUrl: FormControl<string>;
userAgent: FormControl<string>;
}>;
readonly isDesktop = this.runtime.isElectron;
@@ -39,6 +40,7 @@ export class UrlUploadComponent implements OnInit {
[Validators.required, Validators.pattern(urlRegex)],
],
playlistName: [''],
userAgent: [''],
});
}
@@ -46,6 +48,7 @@ export class UrlUploadComponent implements OnInit {
this.form.reset({
playlistName: '',
playlistUrl: '',
userAgent: '',
});
}
}
@@ -306,6 +306,7 @@ describe('PlaylistRefreshActionService', () => {
username: undefined,
password: undefined,
url: 'https://example.com/playlist.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
service.refresh(playlist);
@@ -314,6 +315,7 @@ describe('PlaylistRefreshActionService', () => {
id: 'playlist-url',
title: 'URL playlist',
url: 'https://example.com/playlist.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
@@ -326,6 +328,7 @@ describe('PlaylistRefreshActionService', () => {
username: undefined,
password: undefined,
url: 'https://playlist.local/list.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
playlistRefreshService.refreshPlaylist.mockResolvedValue({
_id: playlist._id,
@@ -338,6 +341,7 @@ describe('PlaylistRefreshActionService', () => {
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://playlist.local/list.m3u',
userAgent: 'IPTVnator-Test/1.0',
trustedInsecureTlsHosts: ['playlist.local'],
})
);
@@ -89,6 +89,9 @@ export class PlaylistRefreshActionService {
id: playlist._id,
title: playlist.title,
url: playlist.url,
...(playlist.userAgent
? { userAgent: playlist.userAgent }
: {}),
});
}
}
@@ -150,6 +153,7 @@ export class PlaylistRefreshActionService {
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
filePath: item.filePath,
trustedInsecureTlsHosts:
this.settingsStore.getTrustOptions()
@@ -152,7 +152,7 @@
<input matInput formControlName="count" />
</mat-form-field>
}
@if (isDesktop) {
@if (isDesktop || playlist.url) {
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
@@ -619,6 +619,7 @@ describe('RecentPlaylistsComponent busy state', () => {
password: undefined,
filePath: undefined,
url: 'https://example.com/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
component.refreshPlaylist(item);
@@ -627,6 +628,7 @@ describe('RecentPlaylistsComponent busy state', () => {
id: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
});
});
@@ -658,6 +660,7 @@ describe('RecentPlaylistsComponent busy state', () => {
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
lateComponent.refreshPlaylist(item);
@@ -668,6 +671,7 @@ describe('RecentPlaylistsComponent busy state', () => {
playlistId: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
filePath: item.filePath,
},
{
@@ -331,6 +331,7 @@ export class RecentPlaylistsComponent {
id: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
}
}
@@ -385,6 +386,9 @@ export class RecentPlaylistsComponent {
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent
? { userAgent: item.userAgent }
: {}),
filePath: item.filePath,
},
{
@@ -340,6 +340,11 @@ export interface ElectronBridgeTrustOptions {
trustedInsecureTlsHosts?: string[];
}
export interface ElectronBridgePlaylistFetchOptions
extends ElectronBridgeTrustOptions {
userAgent?: string;
}
export interface ElectronBridgeEpgFreshnessResult {
staleUrls: string[];
freshUrls: string[];
@@ -718,7 +723,7 @@ export interface ElectronBridgeApi {
fetchPlaylistByUrl: (
url: string,
title?: string,
options?: ElectronBridgeTrustOptions
options?: ElectronBridgePlaylistFetchOptions
) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
@@ -25,6 +25,7 @@ export interface PlaylistRefreshPayload {
title: string;
filePath?: string;
url?: string;
userAgent?: string;
trustedInsecureTlsHosts?: string[];
}