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

@@ -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 }
: {}),
},
})
)
);