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

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

No files matched your search

@@ -217,6 +217,36 @@ describe('playlistReducers', () => {
expect(nextState.channelsLoading).toBe(false);
});
it.each([undefined, '', 'Replacement/2.0'])(
'preserves the saved User-Agent unless refresh explicitly replaces it: %s',
(userAgent) => {
const existing = {
_id: 'playlist-1',
userAgent: 'IPTVnator-Test/1.0',
} as PlaylistMeta;
const state = {
...initialState,
playlists: playlistsAdapter.addOne(
existing,
initialState.playlists
),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlistId: existing._id,
playlist: {
playlist: { items: [] },
...(userAgent !== undefined ? { userAgent } : {}),
} as Playlist,
})
);
expect(nextState.playlists.entities[existing._id]?.userAgent).toBe(
userAgent ?? existing.userAgent
);
}
);
it('keeps hiddenGroupTitles on playlist refresh when the refreshed payload omits them', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
@@ -60,7 +60,9 @@ export const playlistReducers = [
_id: action.playlistId,
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
userAgent:
action.playlist.userAgent ??
currentPlaylist?.userAgent,
favorites: currentPlaylist?.favorites ?? [],
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
@@ -72,6 +72,7 @@ describe('AddPlaylistDialogComponent', () => {
form: {
getRawValue: () => ({
playlistName: ' My Playlist ',
userAgent: ' IPTVnator-Test/1.0 ',
playlistUrl: ' https://example.com/list.m3u ',
}),
},
@@ -83,6 +84,7 @@ describe('AddPlaylistDialogComponent', () => {
PLAYLIST_PARSE_BY_URL,
{
title: 'My Playlist',
userAgent: 'IPTVnator-Test/1.0',
url: 'https://example.com/list.m3u',
}
);
@@ -94,6 +96,7 @@ describe('AddPlaylistDialogComponent', () => {
form: {
getRawValue: () => ({
playlistName: ' ',
userAgent: ' ',
playlistUrl: 'https://example.com/list.m3u',
}),
},
@@ -278,9 +278,12 @@ export class AddPlaylistDialogComponent {
formValue?.playlistName
);
const userAgent = this.normalizeOptionalValue(formValue?.userAgent);
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
url: playlistUrl,
...(playlistName ? { title: playlistName } : {}),
...(userAgent ? { userAgent } : {}),
});
this.closeDialog();
}
@@ -7,6 +7,15 @@
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
<input type="text" matInput formControlName="playlistName" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input type="text" matInput formControlName="userAgent" />
<mat-hint>{{
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
}}</mat-hint>
</mat-form-field>
@if (!isDesktop) {
<p class="cors-note">
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
@@ -67,6 +67,7 @@ describe('UrlUploadComponent', () => {
const testUrl = 'http://example.org/playlist.m3u';
component.form.setValue({
userAgent: '',
playlistName: ' Custom Playlist ',
playlistUrl: 'wrong url here',
});
@@ -74,6 +75,7 @@ describe('UrlUploadComponent', () => {
expect(component.form.valid).toBeFalsy();
component.form.setValue({
userAgent: '',
playlistName: '',
playlistUrl: testUrl,
});
@@ -81,6 +83,7 @@ describe('UrlUploadComponent', () => {
expect(component.form.valid).toBeTruthy();
component.form.setValue({
userAgent: '',
playlistName: ' ',
playlistUrl: testUrl,
});
@@ -90,6 +93,7 @@ describe('UrlUploadComponent', () => {
it('clears the url playlist form', () => {
component.form.setValue({
userAgent: 'IPTVnator-Test/1.0',
playlistName: 'News',
playlistUrl: 'http://example.org/playlist.m3u',
});
@@ -100,6 +104,7 @@ describe('UrlUploadComponent', () => {
expect(component.form.getRawValue()).toEqual({
playlistName: '',
playlistUrl: '',
userAgent: '',
});
expect(component.form.pristine).toBeTruthy();
});
@@ -28,6 +28,7 @@ export class UrlUploadComponent implements OnInit {
form!: FormGroup<{
playlistName: FormControl<string>;
playlistUrl: FormControl<string>;
userAgent: FormControl<string>;
}>;
readonly isDesktop = this.runtime.isElectron;
@@ -39,6 +40,7 @@ export class UrlUploadComponent implements OnInit {
[Validators.required, Validators.pattern(urlRegex)],
],
playlistName: [''],
userAgent: [''],
});
}
@@ -46,6 +48,7 @@ export class UrlUploadComponent implements OnInit {
this.form.reset({
playlistName: '',
playlistUrl: '',
userAgent: '',
});
}
}
@@ -306,6 +306,7 @@ describe('PlaylistRefreshActionService', () => {
username: undefined,
password: undefined,
url: 'https://example.com/playlist.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
service.refresh(playlist);
@@ -314,6 +315,7 @@ describe('PlaylistRefreshActionService', () => {
id: 'playlist-url',
title: 'URL playlist',
url: 'https://example.com/playlist.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
@@ -326,6 +328,7 @@ describe('PlaylistRefreshActionService', () => {
username: undefined,
password: undefined,
url: 'https://playlist.local/list.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
playlistRefreshService.refreshPlaylist.mockResolvedValue({
_id: playlist._id,
@@ -338,6 +341,7 @@ describe('PlaylistRefreshActionService', () => {
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://playlist.local/list.m3u',
userAgent: 'IPTVnator-Test/1.0',
trustedInsecureTlsHosts: ['playlist.local'],
})
);
@@ -89,6 +89,9 @@ export class PlaylistRefreshActionService {
id: playlist._id,
title: playlist.title,
url: playlist.url,
...(playlist.userAgent
? { userAgent: playlist.userAgent }
: {}),
});
}
}
@@ -150,6 +153,7 @@ export class PlaylistRefreshActionService {
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
filePath: item.filePath,
trustedInsecureTlsHosts:
this.settingsStore.getTrustOptions()
@@ -152,7 +152,7 @@
<input matInput formControlName="count" />
</mat-form-field>
}
@if (isDesktop) {
@if (isDesktop || playlist.url) {
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
@@ -619,6 +619,7 @@ describe('RecentPlaylistsComponent busy state', () => {
password: undefined,
filePath: undefined,
url: 'https://example.com/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
component.refreshPlaylist(item);
@@ -627,6 +628,7 @@ describe('RecentPlaylistsComponent busy state', () => {
id: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
});
});
@@ -658,6 +660,7 @@ describe('RecentPlaylistsComponent busy state', () => {
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
lateComponent.refreshPlaylist(item);
@@ -668,6 +671,7 @@ describe('RecentPlaylistsComponent busy state', () => {
playlistId: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
filePath: item.filePath,
},
{
@@ -331,6 +331,7 @@ export class RecentPlaylistsComponent {
id: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
}
}
@@ -385,6 +386,9 @@ export class RecentPlaylistsComponent {
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent
? { userAgent: item.userAgent }
: {}),
filePath: item.filePath,
},
{
@@ -340,6 +340,11 @@ export interface ElectronBridgeTrustOptions {
trustedInsecureTlsHosts?: string[];
}
export interface ElectronBridgePlaylistFetchOptions
extends ElectronBridgeTrustOptions {
userAgent?: string;
}
export interface ElectronBridgeEpgFreshnessResult {
staleUrls: string[];
freshUrls: string[];
@@ -718,7 +723,7 @@ export interface ElectronBridgeApi {
fetchPlaylistByUrl: (
url: string,
title?: string,
options?: ElectronBridgeTrustOptions
options?: ElectronBridgePlaylistFetchOptions
) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
@@ -25,6 +25,7 @@ export interface PlaylistRefreshPayload {
title: string;
filePath?: string;
url?: string;
userAgent?: string;
trustedInsecureTlsHosts?: string[];
}