mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playlist): save xtream details in pwa
- save Xtream playlist details through browser-safe metadata persistence in PWA\n- keep PWA Xtream data source cache in sync with current playlist metadata\n- cover dialog close timing, stale cache, and PWA data-source bootstrap regression
This commit is contained in:
1 parent
8d672f8b81
commit
dfdb5bb8ad
7 files changed
+322
-20
No files matched your search
@@ -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`
|
||||
|
||||
-1
@@ -182,7 +182,6 @@
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
mat-dialog-close
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
|
||||
|
||||
+66
-7
@@ -1,9 +1,10 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
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,23 @@ 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;
|
||||
};
|
||||
let dialogRef: {
|
||||
close: jest.Mock;
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
const playlist = {
|
||||
@@ -41,13 +52,23 @@ 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(),
|
||||
};
|
||||
dialogRef = {
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlaylistInfoComponent],
|
||||
@@ -62,20 +83,20 @@ describe('PlaylistInfoComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: {
|
||||
updateXtreamPlaylistDetails: jest.fn(),
|
||||
},
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
dispatch: jest.fn(),
|
||||
},
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: dialogRef,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
@@ -100,6 +121,44 @@ 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 }
|
||||
);
|
||||
expect(dialogRef.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the Electron save dialog when desktop file saving is available', async () => {
|
||||
runtime.isElectron = true;
|
||||
runtime.supportsDesktopFileSave = true;
|
||||
|
||||
+12
-8
@@ -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();
|
||||
|
||||
@@ -2,8 +2,10 @@ import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
PLAYLIST_DELETE_CLEANUP,
|
||||
PlaylistDeleteCleanup,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import { XtreamApiService } from '../services/xtream-api.service';
|
||||
import {
|
||||
ElectronXtreamDataSource,
|
||||
PwaXtreamDataSource,
|
||||
@@ -65,6 +67,34 @@ describe('provideXtreamDataSource', () => {
|
||||
expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBe(pwaSource);
|
||||
});
|
||||
|
||||
it('does not resolve playlist metadata services while constructing the PWA data source', () => {
|
||||
runtime = {
|
||||
supportsXtreamSqliteDataSource: false,
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
...provideXtreamDataSource(),
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: runtime,
|
||||
},
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useFactory: () => {
|
||||
throw new Error('PlaylistsService should be lazy');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => TestBed.inject(XTREAM_DATA_SOURCE)).not.toThrow();
|
||||
});
|
||||
|
||||
it('skips browser sidecar cleanup for SQLite-backed Xtream storage', async () => {
|
||||
configure(true);
|
||||
const [cleanup] = TestBed.inject(
|
||||
|
||||
@@ -4,12 +4,17 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../services/xtream-api.service';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('PwaXtreamDataSource', () => {
|
||||
let dataSource: PwaXtreamDataSource;
|
||||
let apiService: {
|
||||
getStreams: jest.Mock;
|
||||
};
|
||||
let playlistsService: {
|
||||
getPlaylistById: jest.Mock;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://localhost:3211',
|
||||
@@ -23,6 +28,9 @@ describe('PwaXtreamDataSource', () => {
|
||||
apiService = {
|
||||
getStreams: jest.fn(),
|
||||
};
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn(() => of(undefined)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -31,6 +39,10 @@ describe('PwaXtreamDataSource', () => {
|
||||
provide: XtreamApiService,
|
||||
useValue: apiService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -66,6 +78,62 @@ describe('PwaXtreamDataSource', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses current playlist metadata before stale PWA storage when fetching playlist details', async () => {
|
||||
await dataSource.createPlaylist({
|
||||
id: 'playlist-1',
|
||||
name: 'Old Xtream',
|
||||
title: 'Old Xtream',
|
||||
serverUrl: 'http://old.example:8080',
|
||||
username: 'old-user',
|
||||
password: 'old-pass',
|
||||
type: 'xtream',
|
||||
});
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
importDate: '2026-04-01T00:00:00.000Z',
|
||||
lastUsage: '2026-04-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
updateDate: 123,
|
||||
serverUrl: 'http://new.example:8080',
|
||||
username: 'new-user',
|
||||
password: 'new-pass',
|
||||
userAgent: 'new-agent',
|
||||
referrer: 'https://referrer.example',
|
||||
origin: 'https://origin.example',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'playlist-1',
|
||||
name: 'Updated Xtream',
|
||||
title: 'Updated Xtream',
|
||||
updateDate: 123,
|
||||
serverUrl: 'http://new.example:8080',
|
||||
username: 'new-user',
|
||||
password: 'new-pass',
|
||||
userAgent: 'new-agent',
|
||||
referrer: 'https://referrer.example',
|
||||
origin: 'https://origin.example',
|
||||
})
|
||||
);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem('xtream-playlists') || '[]')
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'playlist-1',
|
||||
name: 'Updated Xtream',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl: 'http://new.example:8080',
|
||||
username: 'new-user',
|
||||
type: 'xtream',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => {
|
||||
apiService.getStreams.mockImplementation(
|
||||
(_credentials: XtreamCredentials, type: string) => {
|
||||
@@ -481,6 +549,10 @@ describe('PwaXtreamDataSource', () => {
|
||||
provide: XtreamApiService,
|
||||
useValue: apiService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
],
|
||||
});
|
||||
dataSource = TestBed.inject(PwaXtreamDataSource);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { inject, Injectable, Injector } from '@angular/core';
|
||||
import {
|
||||
Playlist,
|
||||
PlaybackPositionData,
|
||||
XtreamPendingRestoreState,
|
||||
XtreamCategory,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../services/xtream-api.service';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DbCategoryType,
|
||||
IXtreamDataSource,
|
||||
@@ -76,6 +79,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private readonly apiService = inject(XtreamApiService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly logger = createLogger('PwaXtreamDataSource');
|
||||
private readonly contentTypes = ['live', 'movie', 'series'] as const;
|
||||
|
||||
@@ -89,6 +93,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// =========================================================================
|
||||
|
||||
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
|
||||
const currentPlaylist =
|
||||
await this.getPlaylistFromCurrentMetadata(playlistId);
|
||||
if (currentPlaylist) {
|
||||
return currentPlaylist;
|
||||
}
|
||||
|
||||
const playlists = this.getPlaylistsFromStorage();
|
||||
const playlist = playlists.find((p) => p.id === playlistId);
|
||||
return playlist?.password ? playlist : null;
|
||||
@@ -157,6 +167,68 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistFromCurrentMetadata(
|
||||
playlistId: string
|
||||
): Promise<XtreamPlaylistData | null> {
|
||||
try {
|
||||
const playlistsService = this.injector.get(PlaylistsService);
|
||||
const playlist = await firstValueFrom(
|
||||
playlistsService.getPlaylistById(playlistId)
|
||||
);
|
||||
const xtreamPlaylist = this.toXtreamPlaylistData(playlist);
|
||||
|
||||
if (xtreamPlaylist) {
|
||||
this.upsertPlaylistInStorage(xtreamPlaylist);
|
||||
}
|
||||
|
||||
return xtreamPlaylist;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toXtreamPlaylistData(
|
||||
playlist: Playlist | null | undefined
|
||||
): XtreamPlaylistData | null {
|
||||
if (
|
||||
!playlist?._id ||
|
||||
!playlist.serverUrl ||
|
||||
!playlist.username ||
|
||||
!playlist.password
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: playlist._id,
|
||||
name: playlist.title,
|
||||
title: playlist.title,
|
||||
updateDate: playlist.updateDate,
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
type: 'xtream',
|
||||
userAgent: playlist.userAgent,
|
||||
referrer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
};
|
||||
}
|
||||
|
||||
private upsertPlaylistInStorage(playlist: XtreamPlaylistData): void {
|
||||
this.rememberPlaylistPassword(playlist);
|
||||
|
||||
const playlists = this.getPlaylistsFromStorage();
|
||||
const index = playlists.findIndex((item) => item.id === playlist.id);
|
||||
|
||||
if (index === -1) {
|
||||
this.savePlaylistsToStorage([...playlists, playlist]);
|
||||
return;
|
||||
}
|
||||
|
||||
playlists[index] = playlist;
|
||||
this.savePlaylistsToStorage(playlists);
|
||||
}
|
||||
|
||||
private toStoredPlaylist(
|
||||
playlist: XtreamPlaylistData
|
||||
): StoredXtreamPlaylistData {
|
||||
|
||||
Reference in new issue
Block a user