mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(m3u): use custom User-Agent for URL import and refresh (#1535)
This commit is contained in:
1 parent
79f3f6c897
commit
0ba5107561
34 files changed
+508
-27
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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"?>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
@@ -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',
|
||||
}),
|
||||
},
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
+1
-1
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user