From ec098f98b1ca69205108773fced0ffc07425fa32 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:35:36 +0300 Subject: [PATCH] fix(pwa): hydrate xtream user collections --- .../unified-recent-data.service.spec.ts | 44 ++++++ .../collection/unified-recent-data.service.ts | 27 +++- .../electron-xtream-data-source.ts | 12 ++ .../pwa-xtream-data-source.spec.ts | 100 +++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 133 +++++++++++++++++- .../xtream-data-source.interface.ts | 10 ++ .../src/lib/with-recent-items.feature.spec.ts | 74 ++++++++-- .../data-access/src/lib/with-recent-items.ts | 42 +++--- 8 files changed, 404 insertions(+), 38 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 418f1fd61..b570432a6 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -36,6 +36,7 @@ describe('UnifiedRecentDataService', () => { }; let xtreamDataSource: { addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; removeRecentItem: jest.Mock; @@ -149,6 +150,7 @@ describe('UnifiedRecentDataService', () => { }; xtreamDataSource = { addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn().mockResolvedValue(null), getRecentItems: jest.fn().mockResolvedValue([]), removeRecentItem: jest.fn().mockResolvedValue(undefined), @@ -396,6 +398,48 @@ describe('UnifiedRecentDataService', () => { ]); }); + it('clears Xtream recent localStorage during global PWA clear', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsService.getAllPlaylists.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'm3u-1', + title: 'M3U List', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ] satisfies Partial[]) + ); + + await service.clearRecentItems('all'); + + expect(dbService.clearGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(xtreamDataSource.clearRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('m3u-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('stalker-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).not.toHaveBeenCalledWith('xtream-1'); + }); + it('keeps Stalker radio recent items in the live collection with radio metadata', async () => { store.select.mockReturnValue( of([ diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 6c45392d0..de428b516 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -197,12 +197,12 @@ export class UnifiedRecentDataService { )) as Playlist[]; await Promise.all( - playlists - .filter( - (playlist) => - Boolean(playlist.macAddress) || !playlist.serverUrl - ) - .map(async (playlist) => { + playlists.map(async (playlist) => { + if (!window.electron && this.isXtreamPlaylist(playlist)) { + await this.xtreamDataSource.clearRecentItems(playlist._id); + } + + if (this.isPlaylistBackedRecentPlaylist(playlist)) { const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed( playlist._id @@ -212,7 +212,8 @@ export class UnifiedRecentDataService { playlist._id, updatedPlaylist ); - }) + } + }) ); } @@ -695,4 +696,16 @@ export class UnifiedRecentDataService { ): boolean { return !playlist.serverUrl && !playlist.macAddress; } + + private isXtreamPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.serverUrl) && !playlist.macAddress; + } + + private isPlaylistBackedRecentPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.macAddress) || this.isM3uPlaylist(playlist); + } } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 111180599..e2eabcedc 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -452,6 +452,18 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ); } + async setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise { + void playlistId; + await this.dbService.setContentBackdropIfMissing( + contentId, + backdropUrl + ); + } + // ========================================================================= // Playback Position Operations // ========================================================================= 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 2b7bcc5f0..12f5bb943 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 @@ -255,4 +255,104 @@ describe('PwaXtreamDataSource', () => { [] ); }); + + it('hydrates stored favorites and recent items from the API when content cache is cold', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + apiService.getStreams.mockImplementation( + (_credentials: XtreamCredentials, type: string) => + Promise.resolve( + type === 'movie' + ? [ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ] + : [] + ) + ); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': [202] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: 202, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + 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({ + title: 'Movie One', + viewed_at: '2026-05-21T12:00:00.000Z', + xtream_id: 202, + }), + ]); + expect(apiService.getStreams).toHaveBeenCalledWith( + credentials, + 'movie' + ); + }); + + it('backfills PWA recent-item backdrop metadata without rewriting recency', async () => { + 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.addRecentItem(202, 'playlist-1'); + const before = JSON.parse( + localStorage.getItem('xtream-recent-items') || '{}' + )['playlist-1'][0].viewedAt; + + await dataSource.setContentBackdropIfMissing( + 202, + 'playlist-1', + ' https://example.com/backdrop.png ' + ); + + const stored = JSON.parse( + localStorage.getItem('xtream-recent-items') || '{}' + )['playlist-1'][0]; + expect(stored).toEqual( + expect.objectContaining({ + id: 202, + backdropUrl: 'https://example.com/backdrop.png', + viewedAt: before, + }) + ); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }), + ]); + }); }); 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 102cb6fbd..687a1b720 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 @@ -36,6 +36,7 @@ const STORAGE_KEYS = { interface XtreamCachedContentItem { readonly added?: string; + readonly backdrop_url?: string | null; readonly category_id?: string | number; readonly cover?: string; readonly cover_big?: string; @@ -59,6 +60,7 @@ interface XtreamCachedContentItem { interface StoredRecentItem { readonly id: number; readonly viewedAt: string; + readonly backdropUrl?: string; } /** @@ -70,6 +72,7 @@ interface StoredRecentItem { export class PwaXtreamDataSource implements IXtreamDataSource { private readonly apiService = inject(XtreamApiService); private readonly logger = createLogger('PwaXtreamDataSource'); + private readonly contentTypes = ['live', 'movie', 'series'] as const; // In-memory cache for the current session private categoryCache = new Map(); @@ -391,10 +394,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getFavorites(playlistId: string): Promise { const allFavorites = this.getFavoritesFromStorage(); const playlistFavorites = allFavorites[playlistId] || []; + await this.hydrateStoredCollectionContent( + playlistId, + playlistFavorites + ); // Match favorites with cached content const results: XtreamCachedContentItem[] = []; - for (const type of ['live', 'movie', 'series']) { + for (const type of this.contentTypes) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; @@ -614,10 +621,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getRecentItems(playlistId: string): Promise { const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId] || []; + await this.hydrateStoredCollectionContent( + playlistId, + playlistRecent.map((item) => item.id) + ); // Match recent items with cached content const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; - for (const type of ['live', 'movie', 'series']) { + for (const type of this.contentTypes) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; @@ -627,6 +638,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { if (recentEntry) { results.push({ ...item, + backdrop_url: + recentEntry.backdropUrl ?? item.backdrop_url, viewed_at: recentEntry.viewedAt, }); } @@ -648,11 +661,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, _backdropUrl?: string ): Promise { - void _backdropUrl; const normalizedContentId = this.normalizeStoredId(contentId); if (normalizedContentId == null) { return; } + const normalizedBackdropUrl = _backdropUrl?.trim(); const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { @@ -668,6 +681,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allRecent[playlistId].unshift({ id: normalizedContentId, viewedAt: new Date().toISOString(), + ...(normalizedBackdropUrl + ? { backdropUrl: normalizedBackdropUrl } + : {}), }); // Keep only last 50 items @@ -768,6 +784,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const rawItem = item as { readonly id?: unknown; readonly viewedAt?: unknown; + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; }; const id = this.normalizeStoredId(rawItem.id); if ( @@ -780,6 +798,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return { id, viewedAt: rawItem.viewedAt, + ...this.normalizeStoredBackdrop(rawItem), }; }) .filter((item): item is StoredRecentItem => item !== null); @@ -788,6 +807,59 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return normalized; } + private normalizeStoredBackdrop(item: { + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; + }): Pick | Record { + const value = item.backdropUrl ?? item.backdrop_url; + if (typeof value !== 'string') { + return {}; + } + + const backdropUrl = value.trim(); + return backdropUrl ? { backdropUrl } : {}; + } + + private async hydrateStoredCollectionContent( + playlistId: string, + ids: readonly number[] + ): Promise { + if (ids.length === 0) { + return; + } + + const missingTypes = this.contentTypes.filter( + (type) => !this.contentCache.has(`${playlistId}-${type}-content`) + ); + if (missingTypes.length === 0) { + return; + } + + const playlist = await this.getPlaylist(playlistId); + if (!playlist) { + return; + } + + const credentials: XtreamCredentials = { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }; + + await Promise.all( + missingTypes.map(async (type) => { + try { + await this.getContent(playlistId, credentials, type); + } catch (error) { + this.logger.warn( + 'Failed to hydrate stored PWA Xtream collection content', + { playlistId, type, error } + ); + } + }) + ); + } + // ========================================================================= // Content Lookup // ========================================================================= @@ -818,6 +890,61 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return null; } + async setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedBackdropUrl = backdropUrl.trim(); + if (normalizedContentId == null || !normalizedBackdropUrl) { + return; + } + + for (const type of this.contentTypes) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey); + if (!content) { + continue; + } + + this.contentCache.set( + cacheKey, + content.map((item) => { + const itemId = this.getItemIdentity(item, type); + if (itemId !== normalizedContentId || item.backdrop_url) { + return item; + } + + return { + ...item, + backdrop_url: normalizedBackdropUrl, + }; + }) + ); + } + + const allRecent = this.getRecentItemsFromStorage(); + const playlistRecent = allRecent[playlistId]; + if (!playlistRecent) { + return; + } + + this.saveRecentItemsToStorage({ + ...allRecent, + [playlistId]: playlistRecent.map((item) => { + if (item.id !== normalizedContentId || item.backdropUrl) { + return item; + } + + return { + ...item, + backdropUrl: normalizedBackdropUrl, + }; + }), + }); + } + private findContentIdentity( playlistId: string, xtreamId: number, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 1978b39cf..831ead825 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -359,6 +359,16 @@ export interface IXtreamDataSource { contentType?: 'live' | 'movie' | 'series' ): Promise; + /** + * Persist a backdrop URL for an already-known content item without changing + * favorites or recent ordering. + */ + setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise; + // ========================================================================= // Playback Position Operations // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index 9efc794de..d1d63d17a 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -21,6 +21,7 @@ describe('withRecentItems', () => { const originalElectron = window.electron; let store: InstanceType; let databaseService: { + clearGlobalRecentlyViewed: jest.Mock; clearPlaylistRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; @@ -33,6 +34,11 @@ describe('withRecentItems', () => { getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; removeRecentItem: jest.Mock; + setContentBackdropIfMissing: jest.Mock; + }; + let playlistsService: { + clearPlaylistRecentlyViewed: jest.Mock; + getAllPlaylists: jest.Mock; }; beforeEach(() => { @@ -42,6 +48,7 @@ describe('withRecentItems', () => { }); databaseService = { + clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined), clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), getRecentItems: jest.fn().mockResolvedValue([ @@ -76,6 +83,13 @@ describe('withRecentItems', () => { }, ]), removeRecentItem: jest.fn().mockResolvedValue(undefined), + setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), + }; + playlistsService = { + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), + getAllPlaylists: jest.fn().mockReturnValue(of([])), }; TestBed.configureTestingModule({ @@ -91,12 +105,7 @@ describe('withRecentItems', () => { }, { provide: PlaylistsService, - useValue: { - clearPlaylistRecentlyViewed: jest - .fn() - .mockReturnValue(of(undefined)), - getAllPlaylists: jest.fn().mockReturnValue(of([])), - }, + useValue: playlistsService, }, ], }); @@ -220,7 +229,7 @@ describe('withRecentItems', () => { }); it('backfills a backdrop without rewriting recent ordering', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -234,18 +243,21 @@ describe('withRecentItems', () => { backdropUrl: ' https://example.com/krypton-backdrop.png ', }); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect( - databaseService.setContentBackdropIfMissing - ).toHaveBeenCalledWith( + expect(dataSource.setContentBackdropIfMissing).toHaveBeenCalledWith( 3941697, + 'playlist-1', 'https://example.com/krypton-backdrop.png' ); expect(dataSource.addRecentItem).not.toHaveBeenCalled(); + expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled(); + expect( + databaseService.setContentBackdropIfMissing + ).not.toHaveBeenCalled(); }); it('clears recent items through the active data source in PWA', async () => { @@ -262,6 +274,46 @@ describe('withRecentItems', () => { expect(store.recentItems()).toEqual([]); }); + it('clears Xtream recent items during global PWA clear', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsService.getAllPlaylists.mockReturnValue( + of([ + { + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'm3u-1', + }, + { + _id: 'stalker-1', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]) + ); + + await store.clearGlobalRecentlyViewed(); + + expect( + databaseService.clearGlobalRecentlyViewed + ).not.toHaveBeenCalled(); + expect(dataSource.clearRecentItems).toHaveBeenCalledWith('xtream-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('m3u-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('stalker-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).not.toHaveBeenCalledWith('xtream-1'); + expect(store.recentItems()).toEqual([]); + }); + it('removes recent items through the active data source in PWA', async () => { Object.defineProperty(window, 'electron', { value: undefined, diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 71eb926eb..5e7ed411b 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -148,10 +148,6 @@ export const withRecentItems = function () { playlist: Signal<{ id: string }>; backdropUrl?: string; }): Promise { - if (!window.electron) { - return; - } - const playlistId = playlist().id; const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); @@ -164,7 +160,7 @@ export const withRecentItems = function () { return; } - const content = await dbService.getContentByXtreamId( + const content = await dataSource.getContentByXtreamId( normalizedXtreamId, playlistId, contentType @@ -173,8 +169,9 @@ export const withRecentItems = function () { return; } - await dbService.setContentBackdropIfMissing( + await dataSource.setContentBackdropIfMissing( content.id, + playlistId, normalizedBackdropUrl ); }, @@ -276,24 +273,35 @@ export const withRecentItems = function () { }, async clearGlobalRecentlyViewed() { try { - await dbService.clearGlobalRecentlyViewed(); + if (window.electron) { + await dbService.clearGlobalRecentlyViewed(); + } const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; await Promise.all( - playlists - .filter( - (playlist) => - Boolean(playlist.macAddress) || - !playlist.serverUrl - ) - .map((playlist) => - firstValueFrom( + playlists.map(async (playlist) => { + if ( + !window.electron && + playlist.serverUrl && + !playlist.macAddress + ) { + await dataSource.clearRecentItems( + playlist._id + ); + } + + if ( + Boolean(playlist.macAddress) || + !playlist.serverUrl + ) { + await firstValueFrom( playlistsService.clearPlaylistRecentlyViewed( playlist._id ) - ) - ) + ); + } + }) ); patchState(store, { recentItems: [] }); } catch (error) {