mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user