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 cc220c222..7810b8a53 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 @@ -365,6 +365,9 @@ describe('PwaXtreamDataSource', () => { const stored = JSON.parse( localStorage.getItem('xtream-recent-items') || '{}' )['playlist-1'][0]; + const storedSnapshot = JSON.parse( + localStorage.getItem('xtream-collection-items') || '{}' + )['playlist-1']['202']; expect(stored).toEqual( expect.objectContaining({ id: 202, @@ -372,6 +375,13 @@ describe('PwaXtreamDataSource', () => { viewedAt: before, }) ); + expect(storedSnapshot).toEqual( + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }) + ); await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ expect.objectContaining({ backdrop_url: 'https://example.com/backdrop.png', @@ -380,4 +390,71 @@ describe('PwaXtreamDataSource', () => { }), ]); }); + + it('uses stored collection snapshots for fresh-session favorites and recent items', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + await dataSource.addFavorite(202, 'playlist-1'); + await dataSource.addRecentItem( + 202, + 'playlist-1', + 'https://example.com/backdrop.png' + ); + + const storedCollectionItems = JSON.parse( + localStorage.getItem('xtream-collection-items') || '{}' + ); + expect(storedCollectionItems['playlist-1']['202']).toEqual( + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }) + ); + + TestBed.resetTestingModule(); + apiService = { + getStreams: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + dataSource = TestBed.inject(PwaXtreamDataSource); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }), + ]); + expect(apiService.getStreams).not.toHaveBeenCalled(); + }); }); 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 768d9f2ab..3457ea212 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 @@ -28,6 +28,7 @@ import { * LocalStorage keys for PWA persistence */ const STORAGE_KEYS = { + COLLECTION_ITEMS: 'xtream-collection-items', FAVORITES: 'xtream-favorites', RECENT_ITEMS: 'xtream-recent-items', PLAYLISTS: 'xtream-playlists', @@ -122,6 +123,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.savePlaylistsToStorage(filtered); // Also clear favorites and recent items for this playlist + this.clearCollectionItemsForPlaylist(playlistId); this.clearFavoritesForPlaylist(playlistId); this.clearRecentItemsForPlaylist(playlistId); this.clearPlaybackPositionsForPlaylist(playlistId); @@ -471,31 +473,16 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistFavorites ); - // Match favorites with cached content - const results: XtreamCachedContentItem[] = []; - for (const type of this.contentTypes) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - for (const item of content) { - const itemId = this.getItemIdentity(item); - if (playlistFavorites.includes(itemId)) { - results.push(item); - } - } - } - - return results as XtreamContentItem[]; + return Array.from( + this.getCollectionItemsById(playlistId, playlistFavorites).values() + ); } async addFavorite( contentId: number, playlistId: string, - // PWA uses localStorage with no content table, so backdrop persistence - // is electron-only. Accept the param for interface parity. - _backdropUrl?: string + backdropUrl?: string ): Promise { - void _backdropUrl; const normalizedContentId = this.normalizeStoredId(contentId); if (normalizedContentId == null) { return; @@ -509,6 +496,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allFavorites[playlistId].push(normalizedContentId); } this.saveFavoritesToStorage(allFavorites); + this.saveCollectionItemSnapshot( + playlistId, + normalizedContentId, + backdropUrl + ); } async removeFavorite(contentId: number, playlistId: string): Promise { @@ -698,24 +690,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistRecent.map((item) => item.id) ); - // Match recent items with cached content - const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; - for (const type of this.contentTypes) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - for (const item of content) { - const itemId = this.getItemIdentity(item); - const recentEntry = playlistRecent.find((r) => r.id === itemId); - if (recentEntry) { - results.push({ - ...item, - backdrop_url: - recentEntry.backdropUrl ?? item.backdrop_url, - viewed_at: recentEntry.viewedAt, - }); - } + const contentById = this.getCollectionItemsById( + playlistId, + playlistRecent.map((item) => item.id) + ); + const results: (XtreamContentItem & { viewed_at: string })[] = []; + for (const recentEntry of playlistRecent) { + const item = contentById.get(recentEntry.id); + if (!item) { + continue; } + + results.push({ + ...item, + backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url, + viewed_at: recentEntry.viewedAt, + }); } // Sort by viewed_at descending @@ -762,6 +752,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allRecent[playlistId] = allRecent[playlistId].slice(0, 50); this.saveRecentItemsToStorage(allRecent); + this.saveCollectionItemSnapshot( + playlistId, + normalizedContentId, + normalizedBackdropUrl + ); } async removeRecentItem( @@ -892,6 +887,130 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return backdropUrl ? { backdropUrl } : {}; } + private getCollectionItemsFromStorage(): Record< + string, + Record + > { + try { + const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS); + const parsed = data ? JSON.parse(data) : {}; + if (!parsed || typeof parsed !== 'object') { + return {}; + } + return parsed as Record>; + } catch { + return {}; + } + } + + private saveCollectionItemsToStorage( + items: Record> + ): void { + localStorage.setItem( + STORAGE_KEYS.COLLECTION_ITEMS, + JSON.stringify(items) + ); + } + + private clearCollectionItemsForPlaylist(playlistId: string): void { + const allItems = this.getCollectionItemsFromStorage(); + delete allItems[playlistId]; + this.saveCollectionItemsToStorage(allItems); + } + + private saveCollectionItemSnapshot( + playlistId: string, + contentId: number, + backdropUrl?: string + ): void { + const item = this.findCachedContentItemById(playlistId, contentId); + if (!item) { + return; + } + + const normalizedBackdropUrl = backdropUrl?.trim(); + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId] ?? {}; + playlistItems[String(contentId)] = { + ...item, + ...(normalizedBackdropUrl && !item.backdrop_url + ? { backdrop_url: normalizedBackdropUrl } + : {}), + }; + this.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: playlistItems, + }); + } + + private setCollectionItemBackdropIfMissing( + playlistId: string, + contentId: number, + backdropUrl: string + ): void { + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId]; + const item = playlistItems?.[String(contentId)]; + if (!item || item.backdrop_url) { + return; + } + + this.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: { + ...playlistItems, + [String(contentId)]: { + ...item, + backdrop_url: backdropUrl, + }, + }, + }); + } + + private getCollectionItemsById( + playlistId: string, + ids: readonly number[] + ): Map { + const idSet = new Set(ids); + const results = new Map(); + + for (const type of this.contentTypes) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey) || []; + + for (const item of content) { + const itemId = this.getItemIdentity(item, type); + if (idSet.has(itemId)) { + results.set(itemId, item as XtreamContentItem); + } + } + } + + const storedItems = this.getCollectionItemsFromStorage()[playlistId]; + if (!storedItems) { + return results; + } + + for (const id of ids) { + if (!results.has(id) && storedItems[String(id)]) { + results.set(id, storedItems[String(id)]); + } + } + + return results; + } + + private findCachedContentItemById( + playlistId: string, + contentId: number + ): XtreamContentItem | null { + return ( + this.getCollectionItemsById(playlistId, [contentId]).get( + contentId + ) ?? null + ); + } + private async hydrateStoredCollectionContent( playlistId: string, ids: readonly number[] @@ -996,6 +1115,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ); } + this.setCollectionItemBackdropIfMissing( + playlistId, + normalizedContentId, + normalizedBackdropUrl + ); + const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId]; if (!playlistRecent) { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 5bce017be..d94547821 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -388,6 +388,33 @@ describe('DashboardDataService', () => { ); }); + it('does not load Stalker playlists through the PWA Xtream favorites path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream Playlist', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Playlist', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -660,6 +687,33 @@ describe('DashboardDataService', () => { ); }); + it('does not load Stalker playlists through the PWA Xtream recent path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream Playlist', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Playlist', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('falls back to playlist metadata ordering when sources have no recent activity', async () => { playlistsSignal.set([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index b2c300c4f..4555f6346 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -464,7 +464,9 @@ export class DashboardDataService { } private getXtreamPlaylists(): PlaylistMeta[] { - return this.playlists().filter((playlist) => !!playlist.serverUrl); + return this.playlists().filter( + (playlist) => !!playlist.serverUrl && !playlist.macAddress + ); } private mapPwaXtreamRecentItem(