From 0ba5107561492a9a47f1d96b676cb1cc3475cdfd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:49:23 +0200 Subject: [PATCH] fix(m3u): use custom User-Agent for URL import and refresh (#1535) --- .changes/m3u-url-user-agent.md | 7 ++ AGENTS.md | 11 +++ CLAUDE.md | 12 +++ .../src/electron-test-fixtures.ts | 13 +++- .../src/playlist-auto-refresh.e2e.ts | 78 +++++++++++++++++++ .../src/app/api/main.preload.ts | 3 +- .../app/events/playlist-auto-update.spec.ts | 14 ++++ .../src/app/events/playlist-auto-update.ts | 4 +- .../src/app/events/playlist-source.ts | 9 ++- .../src/app/events/playlist.events.spec.ts | 21 +++++ .../src/app/events/playlist.events.ts | 10 ++- .../app/workers/playlist-refresh.worker.ts | 3 + .../src/app/web-backend-app.spec.ts | 31 ++++++++ apps/web-backend/src/app/web-backend-app.ts | 25 ++++-- apps/web-e2e/src/self-hosted.e2e.ts | 75 ++++++++++++++++++ .../src/app/services/electron.service.spec.ts | 28 +++++++ apps/web/src/app/services/electron.service.ts | 16 ++-- apps/web/src/app/services/pwa.service.spec.ts | 48 ++++++++++++ apps/web/src/app/services/pwa.service.ts | 14 +++- docs/architecture/m3u-playlist-module.md | 30 +++++++ .../lib/reducers/playlist.reducers.spec.ts | 30 +++++++ .../src/lib/reducers/playlist.reducers.ts | 4 +- .../add-playlist-dialog.component.spec.ts | 3 + .../add-playlist-dialog.component.ts | 3 + .../lib/url-upload/url-upload.component.html | 9 +++ .../url-upload/url-upload.component.spec.ts | 5 ++ .../lib/url-upload/url-upload.component.ts | 3 + .../playlist-refresh-action.service.spec.ts | 4 + .../lib/playlist-refresh-action.service.ts | 4 + .../playlist-info.component.html | 2 +- .../recent-playlists.component.spec.ts | 4 + .../recent-playlists.component.ts | 4 + .../src/lib/electron-api.interface.ts | 7 +- .../src/lib/playlist-refresh.interface.ts | 1 + 34 files changed, 508 insertions(+), 27 deletions(-) create mode 100644 .changes/m3u-url-user-agent.md diff --git a/.changes/m3u-url-user-agent.md b/.changes/m3u-url-user-agent.md new file mode 100644 index 000000000..bcc5e04b1 --- /dev/null +++ b/.changes/m3u-url-user-agent.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index a59e9b666..3c4602a9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, diff --git a/CLAUDE.md b/CLAUDE.md index 2d054500f..a71a9f679 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 87e41df35..0f666bcd8 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1079,7 +1079,8 @@ export async function restartElectronApp( export async function importM3uPlaylistFromUrl( page: Page, - playlistUrl: string + playlistUrl: string, + userAgent?: string ): Promise { 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 { 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); }); diff --git a/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts b/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts index f50fbf8b8..99cae5d2f 100644 --- a/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts @@ -8,6 +8,7 @@ import { openSourceEditor, openSources, restartElectronApp, + refreshSource, saveSourceDialog, sourceRowByTitle, test, @@ -38,6 +39,83 @@ async function openAutoRefreshPlaylistCatalog(page: Page): Promise { } 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, }) => { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index ac0987de1..b1720151b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), diff --git a/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts b/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts index d3d7c60e8..331a755f9 100644 --- a/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts +++ b/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts @@ -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()); diff --git a/apps/electron-backend/src/app/events/playlist-auto-update.ts b/apps/electron-backend/src/app/events/playlist-auto-update.ts index 6e3975cff..01d1369c6 100644 --- a/apps/electron-backend/src/app/events/playlist-auto-update.ts +++ b/apps/electron-backend/src/app/events/playlist-auto-update.ts @@ -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'; } diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index e147ae806..3ae0e6cb3 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -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 { const performanceCapture = options.performanceCapture; + const userAgent = options.userAgent?.trim() || undefined; const request = () => requestWithValidatedRedirects( 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 diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index 0b12a65b1..0cbfe708a 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -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'; diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index 71ea32af1..60b25eafe 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -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; } } diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index a541d8baf..4f6551634 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -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, }, diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index bd4ea7292..2a3080f82 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -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(` diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index f4994c63a..e30144e24 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -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 | PlaylistParseError> { try { // Provider URLs are validated by /provider-targets before playlist parsing. // codeql[js/request-forgery] const response = await options.httpClient.get(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; diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 2c1f2f8d9..e2f22da86 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -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((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((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + } +}); diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 8288a78cd..08e69427c 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -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]; diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index f495c2cb6..bced05a03 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -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 = diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index c81cdff1e..6653c9684 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -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 — diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 4f7fed12b..6b60d8a10 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -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 { + getPlaylistFromUrl(url: string, userAgent?: string): Observable { + const normalizedUserAgent = userAgent?.trim(); return from(this.getProviderTargetId(url)).pipe( switchMap((targetId) => this.http.get(`${this.corsProxyUrl}/parse`, { - params: { targetId }, + params: { + targetId, + ...(normalizedUserAgent + ? { userAgent: normalizedUserAgent } + : {}), + }, }) ) ); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 9f837783d..26f6c01a3 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts index 394182ed6..da5469cc8 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -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', diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index 1d92e88bb..0169c11e4 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -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, diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts index 2b3a42252..c47623f0a 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -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', }), }, diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 24065dea8..7db950b8e 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -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(); } diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html index 8153a6319..23efbc61a 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html @@ -7,6 +7,15 @@ {{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }} + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate + }} + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate + }} + @if (!isDesktop) {

{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }} diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts index db23e51f0..9f22e12a8 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts @@ -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(); }); diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 7f14d9cd3..15d756d6d 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -28,6 +28,7 @@ export class UrlUploadComponent implements OnInit { form!: FormGroup<{ playlistName: FormControl; playlistUrl: FormControl; + userAgent: FormControl; }>; 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: '', }); } } diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index e5fff96ee..de91415ca 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -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'], }) ); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 70e866fb5..925ed42e3 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -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() diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html index e87383744..cf6ccea15 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html @@ -152,7 +152,7 @@ } - @if (isDesktop) { + @if (isDesktop || playlist.url) { {{ 'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 8e6f24366..29e950e3c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -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, }, { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index a54fb5d9f..a4db12aea 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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, }, { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 7fe39e27c..2fcd2bfdb 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; updatePlaylistFromFilePath: ( filePath: string, diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index 6a153f952..94ba1b996 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -25,6 +25,7 @@ export interface PlaylistRefreshPayload { title: string; filePath?: string; url?: string; + userAgent?: string; trustedInsecureTlsHosts?: string[]; }