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 213d1793c..10e7f9fed 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 @@ -42,6 +42,20 @@ describe('PwaXtreamDataSource', () => { dataSource = TestBed.inject(PwaXtreamDataSource); }); + function createFreshDataSource(): PwaXtreamDataSource { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + return TestBed.inject(PwaXtreamDataSource); + } + it('finds VOD cached under vod when callers request movie content', async () => { await dataSource.getContent('playlist-1', credentials, 'vod'); @@ -73,17 +87,63 @@ describe('PwaXtreamDataSource', () => { await dataSource.addFavorite(20203, 'playlist-1'); await dataSource.addRecentItem(20203, 'playlist-1'); - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [ - PwaXtreamDataSource, - { - provide: XtreamApiService, - useValue: apiService, - }, - ], - }); - const freshDataSource = TestBed.inject(PwaXtreamDataSource); + const freshDataSource = createFreshDataSource(); + + await expect( + freshDataSource.getFavorites('playlist-1') + ).resolves.toEqual([ + expect.objectContaining({ + stream_id: 20203, + title: 'Movie', + }), + ]); + await expect( + freshDataSource.getRecentItems('playlist-1') + ).resolves.toEqual([ + expect.objectContaining({ + stream_id: 20203, + title: 'Movie', + viewed_at: expect.any(String), + }), + ]); + }); + + it('keeps PWA favorites in restore state when only stored snapshots are available', async () => { + await dataSource.getContent('playlist-1', credentials, 'vod'); + + await dataSource.addFavorite(20203, 'playlist-1'); + await dataSource.addRecentItem(20203, 'playlist-1'); + + const freshDataSource = createFreshDataSource(); + const restoreState = + await freshDataSource.clearPlaylistContent('playlist-1'); + + expect(restoreState.favorites).toEqual([ + expect.objectContaining({ + contentType: 'movie', + xtreamId: 20203, + }), + ]); + expect(restoreState.recentlyViewed).toEqual([ + expect.objectContaining({ + contentType: 'movie', + xtreamId: 20203, + viewedAt: expect.any(String), + }), + ]); + }); + + it('restores PWA favorites and recent items with snapshots after content refresh', async () => { + await dataSource.getContent('playlist-1', credentials, 'vod'); + + await dataSource.addFavorite(20203, 'playlist-1'); + await dataSource.addRecentItem(20203, 'playlist-1'); + + const restoreState = + await dataSource.clearPlaylistContent('playlist-1'); + await dataSource.restoreUserData('playlist-1', restoreState); + + const freshDataSource = createFreshDataSource(); await expect( freshDataSource.getFavorites('playlist-1') 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 6f9602430..443bccae3 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 @@ -7,7 +7,6 @@ import { XtreamSerieItem, XtreamVodStream, } from '@iptvnator/shared/interfaces'; -import { createLogger } from '@iptvnator/portal/shared/util'; import { CategoryType, StreamType, @@ -47,6 +46,7 @@ interface XtreamCachedContentItem { readonly stream_display_name?: string; readonly stream_id?: number; readonly stream_icon?: string; + readonly stream_type?: string; readonly title?: string; readonly type?: string; readonly viewed_at?: string; @@ -69,6 +69,15 @@ interface StoredRecentItem { } type PwaContentCacheType = 'live' | 'movie' | 'vod' | 'series'; +type PwaRestoreFavoriteItem = XtreamPendingRestoreState['favorites'][number] & { + readonly backdropUrl?: string; + readonly content?: XtreamCachedContentItem; +}; +type PwaRestoreRecentItem = + XtreamPendingRestoreState['recentlyViewed'][number] & { + readonly backdropUrl?: string; + readonly content?: XtreamCachedContentItem; + }; /** * PWA implementation of the Xtream data source. @@ -78,7 +87,6 @@ type PwaContentCacheType = 'live' | 'movie' | 'vod' | 'series'; @Injectable({ providedIn: 'root' }) export class PwaXtreamDataSource implements IXtreamDataSource { private readonly apiService = inject(XtreamApiService); - private readonly logger = createLogger('PwaXtreamDataSource'); // In-memory cache for the current session private categoryCache = new Map(); @@ -227,7 +235,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { void categoryIds; void hidden; // Category visibility is not supported in PWA mode - this.logger.warn('Category visibility not supported in PWA mode'); + console.warn( + '[PwaXtreamDataSource]', + 'Category visibility not supported in PWA mode' + ); } // ========================================================================= @@ -770,11 +781,26 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return null; } + private findStoredContentIdentity( + content: XtreamCachedContentItem | undefined, + fallbackId: number + ): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null { + if (!content) { + return null; + } + + const xtreamId = this.resolveContentId(content) ?? fallbackId; + const contentType = this.resolveContentType(content); + + return contentType ? { contentType, xtreamId } : null; + } + private findCachedContentById( playlistId: string, - contentId: number + contentId: number, + contentType?: 'live' | 'movie' | 'series' ): XtreamCachedContentItem | undefined { - for (const type of this.getContentCacheLookupTypes()) { + for (const type of this.getContentCacheLookupTypes(contentType)) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; const found = content.find( @@ -789,6 +815,25 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return undefined; } + private resolveContentType( + item: XtreamCachedContentItem + ): 'live' | 'movie' | 'series' | null { + if (item.series_id != null) { + return 'series'; + } + + const type = String(item.type ?? item.stream_type ?? '').toLowerCase(); + if (type === 'live' || type === 'series') { + return type; + } + + if (type === 'movie' || type === 'vod') { + return 'movie'; + } + + return null; + } + private resolveContentId(item: XtreamCachedContentItem): number | null { const id = Number(item.stream_id ?? item.series_id ?? item.id); return Number.isFinite(id) && id > 0 ? id : null; @@ -808,24 +853,39 @@ export class PwaXtreamDataSource implements IXtreamDataSource { await this.getAllPlaybackPositions(playlistId); const typedFavorites = (favorites[playlistId] || []) - .map((favorite) => - this.findContentIdentity( - playlistId, - this.getStoredFavoriteId(favorite) - ) - ) - .filter( - ( - value - ): value is { - contentType: 'live' | 'movie' | 'series'; - xtreamId: number; - } => value !== null - ); + .map((favorite) => { + const favoriteId = this.getStoredFavoriteId(favorite); + const content = + this.getStoredFavoriteContent(favorite) ?? + this.findCachedContentById(playlistId, favoriteId); + const identity = + this.findStoredContentIdentity(content, favoriteId) ?? + this.findContentIdentity(playlistId, favoriteId); + + if (!identity) { + return null; + } + + return { + ...identity, + addedAt: this.getStoredFavoriteAddedAt(favorite), + backdropUrl: + this.getStoredFavoriteBackdropUrl(favorite) ?? + content?.backdrop_url ?? + undefined, + content, + }; + }) + .filter((value): value is PwaRestoreFavoriteItem => value !== null); const typedRecentlyViewed = (recentItems[playlistId] || []) - .map((item) => { - const identity = this.findContentIdentity(playlistId, item.id); + .map((item) => { + const content = + item.content ?? + this.findCachedContentById(playlistId, item.id); + const identity = + this.findStoredContentIdentity(content, item.id) ?? + this.findContentIdentity(playlistId, item.id); if (!identity) { return null; @@ -834,17 +894,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return { ...identity, viewedAt: item.viewedAt, + backdropUrl: item.backdropUrl ?? content?.backdrop_url, + content, }; }) - .filter( - ( - value - ): value is { - contentType: 'live' | 'movie' | 'series'; - xtreamId: number; - viewedAt: string; - } => value !== null - ); + .filter((value): value is PwaRestoreRecentItem => value !== null); // Clear in-memory cache this.clearCacheForPlaylist(playlistId); @@ -865,17 +919,46 @@ export class PwaXtreamDataSource implements IXtreamDataSource { void options; // Restore favorites const favorites = this.getFavoritesFromStorage(); - favorites[playlistId] = restoreState.favorites.map( - (item) => item.xtreamId - ); + favorites[playlistId] = restoreState.favorites.map((item) => { + const pwaItem = item as PwaRestoreFavoriteItem; + const content = + pwaItem.content ?? + this.findCachedContentById( + playlistId, + item.xtreamId, + item.contentType + ); + + return { + id: item.xtreamId, + addedAt: item.addedAt ?? new Date().toISOString(), + backdropUrl: + pwaItem.backdropUrl ?? content?.backdrop_url ?? undefined, + content, + }; + }); this.saveFavoritesToStorage(favorites); // Restore recent items const recentItems = this.getRecentItemsFromStorage(); - recentItems[playlistId] = restoreState.recentlyViewed.map((item) => ({ - id: item.xtreamId, - viewedAt: item.viewedAt, - })); + recentItems[playlistId] = restoreState.recentlyViewed.map((item) => { + const pwaItem = item as PwaRestoreRecentItem; + const content = + pwaItem.content ?? + this.findCachedContentById( + playlistId, + item.xtreamId, + item.contentType + ); + + return { + id: item.xtreamId, + viewedAt: item.viewedAt, + backdropUrl: + pwaItem.backdropUrl ?? content?.backdrop_url ?? undefined, + content, + }; + }); this.saveRecentItemsToStorage(recentItems); // Restore playback positions