fix(playlist): save xtream details in pwa

This commit is contained in:
4gray committed 2026-06-13 11:34:44 +02:00
1 parent 8d672f8b81
commit 4303496d63
4 files changed
+135 -18

No files matched your search

+69 -3
View File
@@ -80,9 +80,7 @@ async function addXtreamPortal(
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(MOCK_SERVER);
@@ -94,6 +92,35 @@ async function addXtreamPortal(
await page.waitForURL(/xtreams.*vod/);
}
async function openPlaylistDetailsDialog(page: Page, title: string) {
const playlistSettingsButton = page.getByRole('button', {
name: 'Playlist Settings',
});
if (await playlistSettingsButton.isVisible()) {
await playlistSettingsButton.click();
} else {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
const switcherMenu = page.getByRole('menu').filter({ hasText: title });
const sourceRow = switcherMenu
.locator('.playlist-item')
.filter({ hasText: title })
.first();
await expect(sourceRow).toBeVisible();
await sourceRow
.getByRole('button', { name: 'Playlist actions' })
.click();
await page.getByRole('menuitem', { name: 'Playlist info' }).click();
}
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
return dialog;
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
@@ -439,6 +466,45 @@ test('@xtream add portal and see it in the playlist list', async ({ page }) => {
).toBeVisible();
});
test('@xtream playlist details edit is retained in the PWA browser context', async ({
page,
}) => {
await addXtreamPortal(page, { name: 'Editable PWA Xtream Portal' });
let dialog = await openPlaylistDetailsDialog(
page,
'Editable PWA Xtream Portal'
);
await dialog
.locator('input[formcontrolname="title"]')
.fill('Edited PWA Xtream Portal');
await dialog
.locator('input[formcontrolname="serverUrl"]')
.fill(MOCK_SERVER);
await dialog.locator('input[formcontrolname="username"]').fill('minimal');
await dialog.locator('input[formcontrolname="password"]').fill('minimal');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(
page.getByText('Edited PWA Xtream Portal', { exact: false })
).toBeVisible();
dialog = await openPlaylistDetailsDialog(page, 'Edited PWA Xtream Portal');
await expect(dialog.locator('input[formcontrolname="title"]')).toHaveValue(
'Edited PWA Xtream Portal'
);
await expect(
dialog.locator('input[formcontrolname="serverUrl"]')
).toHaveValue(MOCK_SERVER);
await expect(
dialog.locator('input[formcontrolname="username"]')
).toHaveValue('minimal');
await expect(
dialog.locator('input[formcontrolname="password"]')
).toHaveValue('minimal');
});
test('@xtream minimal scenario — reduced item count', async ({ request }) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_categories`
@@ -182,7 +182,6 @@
</button>
<button
mat-flat-button
mat-dialog-close
color="primary"
type="submit"
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
@@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
DatabaseService,
PlaylistsService,
@@ -18,13 +19,20 @@ describe('PlaylistInfoComponent', () => {
let playlistsService: {
getRawPlaylistById: jest.Mock;
};
let databaseService: {
updateXtreamPlaylistDetails: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsDesktopFileSave: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
const originalElectron = window.electron;
const playlist = {
@@ -41,13 +49,20 @@ describe('PlaylistInfoComponent', () => {
playlistsService = {
getRawPlaylistById: jest.fn(() => of('#EXTM3U\n')),
};
databaseService = {
updateXtreamPlaylistDetails: jest.fn(),
};
runtime = {
isElectron: false,
supportsDesktopFileSave: false,
supportsXtreamSqliteDataSource: false,
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [PlaylistInfoComponent],
@@ -62,15 +77,11 @@ describe('PlaylistInfoComponent', () => {
},
{
provide: DatabaseService,
useValue: {
updateXtreamPlaylistDetails: jest.fn(),
},
useValue: databaseService,
},
{
provide: Store,
useValue: {
dispatch: jest.fn(),
},
useValue: store,
},
{
provide: MatSnackBar,
@@ -100,6 +111,43 @@ describe('PlaylistInfoComponent', () => {
component = fixture.componentInstance;
}
it('saves Xtream playlist details through playlist metadata in the browser context', async () => {
const xtreamPlaylist = {
...playlist,
title: 'Old Xtream',
serverUrl: 'http://old.example:8080',
username: 'old-user',
password: 'old-pass',
url: undefined,
} as Playlist & { id: string };
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: xtreamPlaylist,
});
createComponent();
const updatedPlaylist = {
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl: 'http://new.example:8080',
username: 'new-user',
password: 'new-pass',
};
await component.saveChanges(updatedPlaylist);
expect(
databaseService.updateXtreamPlaylistDetails
).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({ playlist: updatedPlaylist })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS',
'CLOSE',
{ duration: 3000 }
);
});
it('uses the Electron save dialog when desktop file saving is available', async () => {
runtime.isElectron = true;
runtime.supportsDesktopFileSave = true;
@@ -10,7 +10,11 @@ import {
} from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -90,6 +94,9 @@ export class PlaylistInfoComponent {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private runtime = inject(RuntimeCapabilitiesService);
private dialogRef = inject(MatDialogRef<PlaylistInfoComponent>, {
optional: true,
});
public playlistData = inject<Playlist & { id: string }>(MAT_DIALOG_DATA);
get isDesktop(): boolean {
@@ -159,7 +166,7 @@ export class PlaylistInfoComponent {
this.playlist.password &&
this.playlist.serverUrl;
if (isXtream) {
if (isXtream && this.runtime.supportsXtreamSqliteDataSource) {
await this.updateXtreamPlaylist(playlist);
}
@@ -175,6 +182,7 @@ export class PlaylistInfoComponent {
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
this.dialogRef?.close();
} catch (error) {
console.error('Error updating playlist:', error);
this.snackBar.open(
@@ -215,8 +223,7 @@ export class PlaylistInfoComponent {
);
if (this.runtime.supportsDesktopFileSave) {
const desktopFileBridge =
window.electron as DesktopFileSaveBridge;
const desktopFileBridge = window.electron as DesktopFileSaveBridge;
try {
const savePath = await desktopFileBridge.saveFileDialog(
@@ -269,10 +276,7 @@ export class PlaylistInfoComponent {
'data:text/plain;charset=utf-8,' +
encodeURIComponent(playlistAsString)
);
element.setAttribute(
'download',
this.playlist.title || 'exported.m3u'
);
element.setAttribute('download', this.playlist.title || 'exported.m3u');
element.style.display = 'none';
document.body.appendChild(element);
element.click();