diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts index 0bfe9473f..47c48ed1a 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -1,5 +1,5 @@ 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'; @@ -33,6 +33,9 @@ describe('PlaylistInfoComponent', () => { let store: { dispatch: jest.Mock; }; + let dialogRef: { + close: jest.Mock; + }; const originalElectron = window.electron; const playlist = { @@ -63,6 +66,9 @@ describe('PlaylistInfoComponent', () => { store = { dispatch: jest.fn(), }; + dialogRef = { + close: jest.fn(), + }; await TestBed.configureTestingModule({ imports: [PlaylistInfoComponent], @@ -87,6 +93,10 @@ describe('PlaylistInfoComponent', () => { provide: MatSnackBar, useValue: snackBar, }, + { + provide: MatDialogRef, + useValue: dialogRef, + }, { provide: TranslateService, useValue: { @@ -146,6 +156,7 @@ describe('PlaylistInfoComponent', () => { 'CLOSE', { duration: 3000 } ); + expect(dialogRef.close).toHaveBeenCalledTimes(1); }); it('uses the Electron save dialog when desktop file saving is available', async () => { diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index d149d691b..52cec9325 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -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); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 1530ff910..0570ce2db 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -1,5 +1,6 @@ import { inject, Injectable } 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 & { @Injectable({ providedIn: 'root' }) export class PwaXtreamDataSource implements IXtreamDataSource { private readonly apiService = inject(XtreamApiService); + private readonly playlistsService = inject(PlaylistsService); 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 { + 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,67 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ); } + private async getPlaylistFromCurrentMetadata( + playlistId: string + ): Promise { + try { + const playlist = await firstValueFrom( + this.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 {