diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts new file mode 100644 index 000000000..497f41362 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts @@ -0,0 +1,40 @@ +import { TestBed } from '@angular/core/testing'; +import { XtreamApiService } from '../services/xtream-api.service'; +import { + provideXtreamDataSource, + PwaXtreamDataSource, + XTREAM_DATA_SOURCE, +} from './index'; + +describe('provideXtreamDataSource', () => { + let originalElectron: typeof window.electron; + + beforeEach(() => { + originalElectron = window.electron; + }); + + afterEach(() => { + (window as unknown as { electron?: typeof window.electron }).electron = + originalElectron; + TestBed.resetTestingModule(); + }); + + it('uses the PWA data source when the web shim exposes an empty electron object', () => { + (window as unknown as { electron?: typeof window.electron }).electron = + {} as typeof window.electron; + + TestBed.configureTestingModule({ + providers: [ + ...provideXtreamDataSource(), + { + provide: XtreamApiService, + useValue: {}, + }, + ], + }); + + expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBeInstanceOf( + PwaXtreamDataSource + ); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index 7f20fe0af..cfd67ba83 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -1,13 +1,24 @@ import { inject, Provider } from '@angular/core'; import { ElectronXtreamDataSource } from './electron-xtream-data-source'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; -import { IXtreamDataSource, XTREAM_DATA_SOURCE } from './xtream-data-source.interface'; +import { + IXtreamDataSource, + XTREAM_DATA_SOURCE, +} from './xtream-data-source.interface'; // Re-export all types and interfaces export * from './xtream-data-source.interface'; export { ElectronXtreamDataSource } from './electron-xtream-data-source'; export { PwaXtreamDataSource } from './pwa-xtream-data-source'; +function hasElectronDatabasePreload(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.electron?.dbGetPlaylist === 'function' && + typeof window.electron?.dbGetContent === 'function' + ); +} + /** * Factory function that returns the appropriate data source based on environment. * - Electron: Uses DatabaseService for DB-first caching @@ -15,7 +26,7 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source'; */ export function xtreamDataSourceFactory(): IXtreamDataSource { // Check if we're in Electron environment - if (typeof window !== 'undefined' && window.electron) { + if (hasElectronDatabasePreload()) { return inject(ElectronXtreamDataSource); } 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 new file mode 100644 index 000000000..213d1793c --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -0,0 +1,106 @@ +import { TestBed } from '@angular/core/testing'; +import { XtreamApiService } from '../services/xtream-api.service'; +import { PwaXtreamDataSource } from './pwa-xtream-data-source'; + +describe('PwaXtreamDataSource', () => { + let dataSource: PwaXtreamDataSource; + let apiService: { + getStreams: jest.Mock; + }; + + const credentials = { + serverUrl: 'http://localhost:3211', + username: 'user1', + password: 'pass1', + }; + + beforeEach(() => { + localStorage.clear(); + apiService = { + getStreams: jest.fn().mockResolvedValue([ + { + stream_id: 20203, + category_id: '206', + name: 'Movie', + title: 'Movie', + stream_type: 'movie', + type: 'movie', + }, + ]), + }; + + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + + dataSource = TestBed.inject(PwaXtreamDataSource); + }); + + it('finds VOD cached under vod when callers request movie content', async () => { + await dataSource.getContent('playlist-1', credentials, 'vod'); + + await expect( + dataSource.getContentByXtreamId(20203, 'playlist-1', 'movie') + ).resolves.toMatchObject({ + stream_id: 20203, + title: 'Movie', + }); + }); + + it('matches PWA favorites and recent items against VOD cache entries', async () => { + await dataSource.getContent('playlist-1', credentials, 'vod'); + + await dataSource.addFavorite(20203, 'playlist-1'); + await dataSource.addRecentItem(20203, 'playlist-1'); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ stream_id: 20203 }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ stream_id: 20203 }), + ]); + }); + + it('restores PWA favorites and recent items from persisted content snapshots after reload', async () => { + await dataSource.getContent('playlist-1', credentials, 'vod'); + + 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); + + 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), + }), + ]); + }); +}); 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 26d3b3e70..6f9602430 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 @@ -35,8 +35,11 @@ const STORAGE_KEYS = { }; interface XtreamCachedContentItem { - readonly added?: string; + readonly added?: string | number; + readonly added_at?: string | number; + readonly backdrop_url?: string | null; readonly category_id?: string | number; + readonly cover?: string; readonly id?: number; readonly name?: string; readonly poster_url?: string; @@ -49,11 +52,24 @@ interface XtreamCachedContentItem { readonly viewed_at?: string; } +interface StoredFavoriteItem { + readonly id: number; + readonly addedAt: string; + readonly backdropUrl?: string; + readonly content?: XtreamCachedContentItem; +} + +type StoredFavoriteEntry = number | StoredFavoriteItem; + interface StoredRecentItem { readonly id: number; readonly viewedAt: string; + readonly backdropUrl?: string; + readonly content?: XtreamCachedContentItem; } +type PwaContentCacheType = 'live' | 'movie' | 'vod' | 'series'; + /** * PWA implementation of the Xtream data source. * Uses API-only strategy: always fetch from API, no database caching. @@ -68,6 +84,20 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private categoryCache = new Map(); private contentCache = new Map(); + private getContentCacheLookupTypes( + contentType?: 'live' | 'movie' | 'series' + ): PwaContentCacheType[] { + if (contentType === 'movie') { + return ['movie', 'vod']; + } + + if (contentType) { + return [contentType]; + } + + return ['live', 'movie', 'vod', 'series']; + } + // ========================================================================= // Playlist Operations (localStorage) // ========================================================================= @@ -319,17 +349,27 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const allFavorites = this.getFavoritesFromStorage(); const playlistFavorites = allFavorites[playlistId] || []; - // Match favorites with cached content const results: XtreamCachedContentItem[] = []; - for (const type of ['live', 'movie', 'series']) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; + const seenIds = new Set(); - for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; - if (playlistFavorites.includes(itemId)) { - results.push(item); - } + for (const favorite of playlistFavorites) { + const favoriteId = this.getStoredFavoriteId(favorite); + if (seenIds.has(favoriteId)) { + continue; + } + + const content = + this.getStoredFavoriteContent(favorite) ?? + this.findCachedContentById(playlistId, favoriteId); + if (content) { + results.push({ + ...content, + added_at: this.getStoredFavoriteAddedAt(favorite), + backdrop_url: + this.getStoredFavoriteBackdropUrl(favorite) ?? + content.backdrop_url, + }); + seenIds.add(favoriteId); } } @@ -347,8 +387,35 @@ export class PwaXtreamDataSource implements IXtreamDataSource { if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; } - if (!allFavorites[playlistId].includes(contentId)) { - allFavorites[playlistId].push(contentId); + + const existingIndex = allFavorites[playlistId].findIndex( + (entry) => this.getStoredFavoriteId(entry) === contentId + ); + const existing = + existingIndex >= 0 ? allFavorites[playlistId][existingIndex] : null; + const cachedContent = this.findCachedContentById(playlistId, contentId); + const nextFavorite: StoredFavoriteItem = { + id: contentId, + addedAt: + existing && typeof existing !== 'number' + ? existing.addedAt + : new Date().toISOString(), + backdropUrl: + _backdropUrl ?? + (existing && typeof existing !== 'number' + ? existing.backdropUrl + : undefined), + content: + cachedContent ?? + (existing && typeof existing !== 'number' + ? existing.content + : undefined), + }; + + if (existingIndex >= 0) { + allFavorites[playlistId][existingIndex] = nextFavorite; + } else { + allFavorites[playlistId].push(nextFavorite); } this.saveFavoritesToStorage(allFavorites); } @@ -357,7 +424,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const allFavorites = this.getFavoritesFromStorage(); if (allFavorites[playlistId]) { allFavorites[playlistId] = allFavorites[playlistId].filter( - (id: number) => id !== contentId + (entry) => this.getStoredFavoriteId(entry) !== contentId ); } this.saveFavoritesToStorage(allFavorites); @@ -365,10 +432,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async isFavorite(contentId: number, playlistId: string): Promise { const allFavorites = this.getFavoritesFromStorage(); - return (allFavorites[playlistId] || []).includes(contentId); + return (allFavorites[playlistId] || []).some( + (entry) => this.getStoredFavoriteId(entry) === contentId + ); } - private getFavoritesFromStorage(): Record { + private getFavoritesFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); return data ? JSON.parse(data) : {}; @@ -377,10 +446,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } } - private saveFavoritesToStorage(favorites: Record): void { + private saveFavoritesToStorage( + favorites: Record + ): void { localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites)); } + private getStoredFavoriteId(favorite: StoredFavoriteEntry): number { + return typeof favorite === 'number' ? favorite : favorite.id; + } + + private getStoredFavoriteContent( + favorite: StoredFavoriteEntry + ): XtreamCachedContentItem | undefined { + return typeof favorite === 'number' ? undefined : favorite.content; + } + + private getStoredFavoriteAddedAt(favorite: StoredFavoriteEntry): string { + return typeof favorite === 'number' + ? new Date(0).toISOString() + : favorite.addedAt; + } + + private getStoredFavoriteBackdropUrl( + favorite: StoredFavoriteEntry + ): string | undefined { + return typeof favorite === 'number' ? undefined : favorite.backdropUrl; + } + private clearFavoritesForPlaylist(playlistId: string): void { const allFavorites = this.getFavoritesFromStorage(); delete allFavorites[playlistId]; @@ -526,21 +619,25 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId] || []; - // Match recent items with cached content const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; - for (const type of ['live', 'movie', 'series']) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; + const seenIds = new Set(); - for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; - const recentEntry = playlistRecent.find((r) => r.id === itemId); - if (recentEntry) { - results.push({ - ...item, - viewed_at: recentEntry.viewedAt, - }); - } + for (const recentEntry of playlistRecent) { + if (seenIds.has(recentEntry.id)) { + continue; + } + + const content = + recentEntry.content ?? + this.findCachedContentById(playlistId, recentEntry.id); + if (content) { + results.push({ + ...content, + viewed_at: recentEntry.viewedAt, + backdrop_url: + recentEntry.backdropUrl ?? content.backdrop_url, + }); + seenIds.add(recentEntry.id); } } @@ -573,6 +670,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allRecent[playlistId].unshift({ id: contentId, viewedAt: new Date().toISOString(), + backdropUrl: _backdropUrl, + content: this.findCachedContentById(playlistId, contentId), }); // Keep only last 50 items @@ -631,19 +730,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, contentType?: 'live' | 'movie' | 'series' ): Promise { - const types = contentType - ? [contentType] - : (['live', 'movie', 'series'] as const); - - for (const type of types) { + for (const type of this.getContentCacheLookupTypes(contentType)) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; - const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; - return itemXtreamId === xtreamId; - }); + const found = content.find( + (item) => this.resolveContentId(item) === xtreamId + ); if (found) { return found as XtreamContentItem; @@ -658,23 +751,17 @@ export class PwaXtreamDataSource implements IXtreamDataSource { xtreamId: number, contentType?: 'live' | 'movie' | 'series' ): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null { - const types = contentType - ? [contentType] - : (['live', 'movie', 'series'] as const); - - for (const type of types) { + for (const type of this.getContentCacheLookupTypes(contentType)) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; - const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; - return itemXtreamId === xtreamId; - }); + const found = content.find( + (item) => this.resolveContentId(item) === xtreamId + ); if (found) { return { - contentType: type, + contentType: type === 'vod' ? 'movie' : type, xtreamId, }; } @@ -683,6 +770,30 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return null; } + private findCachedContentById( + playlistId: string, + contentId: number + ): XtreamCachedContentItem | undefined { + for (const type of this.getContentCacheLookupTypes()) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey) || []; + const found = content.find( + (item) => this.resolveContentId(item) === contentId + ); + + if (found) { + return found; + } + } + + return undefined; + } + + 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; + } + // ========================================================================= // Cleanup Operations // ========================================================================= @@ -697,7 +808,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { await this.getAllPlaybackPositions(playlistId); const typedFavorites = (favorites[playlistId] || []) - .map((xtreamId) => this.findContentIdentity(playlistId, xtreamId)) + .map((favorite) => + this.findContentIdentity( + playlistId, + this.getStoredFavoriteId(favorite) + ) + ) .filter( ( value diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts index 8ebc10e40..d7b8dcba4 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -1,7 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { patchState, signalStore } from '@ngrx/signals'; -import { DatabaseService } from '@iptvnator/services'; -import { FavoritesService } from './services/favorites.service'; +import { patchState, signalStore, withState } from '@ngrx/signals'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withFavorites } from './with-favorites.feature'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -14,38 +13,42 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ })); const TestFavoritesStore = signalStore(withFavorites()); +const TestLoadedContentFavoritesStore = signalStore( + withState({ + vodStreams: [ + { + stream_id: 20203, + title: 'Movie', + type: 'movie', + }, + ], + }), + withFavorites() +); describe('withFavorites', () => { let store: InstanceType; - let databaseService: { + let dataSource: { + addFavorite: jest.Mock; getContentByXtreamId: jest.Mock; - }; - let favoritesService: { - addToFavorites: jest.Mock; isFavorite: jest.Mock; - removeFromFavorites: jest.Mock; + removeFavorite: jest.Mock; }; beforeEach(() => { - databaseService = { + dataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), - }; - favoritesService = { - addToFavorites: jest.fn().mockResolvedValue(undefined), isFavorite: jest.fn().mockResolvedValue(false), - removeFromFavorites: jest.fn().mockResolvedValue(undefined), + removeFavorite: jest.fn().mockResolvedValue(undefined), }; TestBed.configureTestingModule({ providers: [ TestFavoritesStore, { - provide: DatabaseService, - useValue: databaseService, - }, - { - provide: FavoritesService, - useValue: favoritesService, + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, }, ], }); @@ -54,7 +57,7 @@ describe('withFavorites', () => { }); it('looks favorites up with the requested content type before adding one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -63,21 +66,122 @@ describe('withFavorites', () => { const result = await store.toggleFavorite(290, 'playlist-1', 'series'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect(favoritesService.addToFavorites).toHaveBeenCalledWith({ - content_id: 3941697, - playlist_id: 'playlist-1', - }); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 3941697, + 'playlist-1', + undefined + ); expect(result).toBe(true); expect(store.isFavorite()).toBe(true); }); + it('uses stream_id as the PWA favorite id when no database id exists', async () => { + dataSource.getContentByXtreamId.mockResolvedValue({ + stream_id: 20203, + title: 'Movie', + type: 'movie', + }); + + const result = await store.toggleFavorite(20203, 'playlist-1', 'movie'); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 20203, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + }); + + it('normalizes route string ids before looking up PWA content', async () => { + dataSource.getContentByXtreamId.mockImplementation( + async (xtreamId: number) => + xtreamId === 20203 + ? { + stream_id: 20203, + title: 'Movie', + type: 'movie', + } + : null + ); + + const result = await store.toggleFavorite( + '20203', + 'playlist-1', + 'movie' + ); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 20203, + 'playlist-1', + 'movie' + ); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 20203, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + }); + + it('normalizes route string ids before checking PWA favorite status', async () => { + dataSource.getContentByXtreamId.mockImplementation( + async (xtreamId: number) => + xtreamId === 20203 + ? { + stream_id: 20203, + title: 'Movie', + type: 'movie', + } + : null + ); + dataSource.isFavorite.mockResolvedValue(true); + + await store.checkFavoriteStatus('20203', 'playlist-1', 'movie'); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 20203, + 'playlist-1', + 'movie' + ); + expect(dataSource.isFavorite).toHaveBeenCalledWith(20203, 'playlist-1'); + expect(store.isFavorite()).toBe(true); + }); + + it('falls back to loaded store content when the PWA source cache misses', async () => { + dataSource.getContentByXtreamId.mockResolvedValue(null); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + TestLoadedContentFavoritesStore, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, + ], + }); + const loadedStore = TestBed.inject(TestLoadedContentFavoritesStore); + + const result = await loadedStore.toggleFavorite( + 20203, + 'playlist-1', + 'movie' + ); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 20203, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + }); + it('looks favorites up with the requested content type before removing one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3867578, title: 'SE: V Film Premiere FHD', type: 'live', @@ -87,12 +191,12 @@ describe('withFavorites', () => { const result = await store.toggleFavorite(290, 'playlist-1', 'live'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'live' ); - expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith( + expect(dataSource.removeFavorite).toHaveBeenCalledWith( 3867578, 'playlist-1' ); @@ -101,22 +205,22 @@ describe('withFavorites', () => { }); it('checks favorite state against the matching content type', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3829429, title: 'Dragon Ball Heroes', type: 'series', xtream_id: 31, }); - favoritesService.isFavorite.mockResolvedValue(true); + dataSource.isFavorite.mockResolvedValue(true); await store.checkFavoriteStatus(31, 'playlist-1', 'series'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 31, 'playlist-1', 'series' ); - expect(favoritesService.isFavorite).toHaveBeenCalledWith( + expect(dataSource.isFavorite).toHaveBeenCalledWith( 3829429, 'playlist-1' ); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts index 19dad0f54..9b12f0df4 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts @@ -5,9 +5,43 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { DatabaseService } from '@iptvnator/services'; -import { FavoritesService } from './services/favorites.service'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; + +function resolveContentId(content: Record): number | null { + const rawId = + content['id'] ?? + content['stream_id'] ?? + content['series_id'] ?? + content['xtream_id']; + const id = Number(rawId); + return Number.isFinite(id) && id > 0 ? id : null; +} + +function findLoadedContent( + store: unknown, + xtreamId: number, + contentType: 'live' | 'movie' | 'series' +): Record | null { + const storeWithContent = store as { + liveStreams?: () => Record[]; + vodStreams?: () => Record[]; + serialStreams?: () => Record[]; + }; + const items = + contentType === 'live' + ? storeWithContent.liveStreams?.() + : contentType === 'movie' + ? storeWithContent.vodStreams?.() + : storeWithContent.serialStreams?.(); + + return ( + items?.find((item) => { + const itemId = resolveContentId(item); + return itemId === xtreamId; + }) ?? null + ); +} export const withFavorites = function () { const logger = createLogger('withFavorites'); @@ -15,85 +49,104 @@ export const withFavorites = function () { withState({ isFavorite: false, }), - withMethods( - ( - store, - dbService = inject(DatabaseService), - favoritesService = inject(FavoritesService) - ) => ({ - async toggleFavorite( - xtreamId: number, - playlistId: string, - contentType: 'live' | 'movie' | 'series', - backdropUrl?: string + withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ + async toggleFavorite( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series', + backdropUrl?: string + ) { + const numericXtreamId = Number(xtreamId); + if ( + !Number.isFinite(numericXtreamId) || + numericXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - return false; - } + return false; + } - const content = await dbService.getContentByXtreamId( - xtreamId, + const content = + (await dataSource.getContentByXtreamId( + numericXtreamId, playlistId, contentType + )) ?? + findLoadedContent(store, numericXtreamId, contentType); + if (!content) { + logger.error( + 'Content not found for xtream ID', + numericXtreamId ); - if (!content) { - logger.error( - 'Content not found for xtream ID', - xtreamId - ); - return false; - } + return false; + } + const contentId = resolveContentId( + content as unknown as Record + ); + if (!contentId) { + logger.error('Content has no favorite id', content); + return false; + } - const currentStatus = store.isFavorite(); + const currentStatus = store.isFavorite(); - if (currentStatus) { - // Remove from favorites - await favoritesService.removeFromFavorites( - content.id, - playlistId - ); - patchState(store, { isFavorite: false }); - return false; - } else { - // Add to favorites - await favoritesService.addToFavorites({ - content_id: content.id, - playlist_id: playlistId, - backdrop_url: backdropUrl, - }); - patchState(store, { isFavorite: true }); - return true; - } - }, + if (currentStatus) { + // Remove from favorites + await dataSource.removeFavorite(contentId, playlistId); + patchState(store, { isFavorite: false }); + return false; + } else { + // Add to favorites + await dataSource.addFavorite( + contentId, + playlistId, + backdropUrl + ); + patchState(store, { isFavorite: true }); + return true; + } + }, - async checkFavoriteStatus( - xtreamId: number, - playlistId: string, - contentType: 'live' | 'movie' | 'series' + async checkFavoriteStatus( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series' + ) { + const numericXtreamId = Number(xtreamId); + if ( + !Number.isFinite(numericXtreamId) || + numericXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - patchState(store, { isFavorite: false }); - return; - } + patchState(store, { isFavorite: false }); + return; + } - const content = await dbService.getContentByXtreamId( - xtreamId, + const content = + (await dataSource.getContentByXtreamId( + numericXtreamId, playlistId, contentType - ); - if (!content) { - patchState(store, { isFavorite: false }); - return; - } + )) ?? + findLoadedContent(store, numericXtreamId, contentType); + if (!content) { + patchState(store, { isFavorite: false }); + return; + } + const contentId = resolveContentId( + content as unknown as Record + ); + if (!contentId) { + patchState(store, { isFavorite: false }); + return; + } - const isFavorite = await favoritesService.isFavorite( - content.id, - playlistId - ); + const isFavorite = await dataSource.isFavorite( + contentId, + playlistId + ); - patchState(store, { isFavorite }); - }, - }) - ) + patchState(store, { isFavorite }); + }, + })) ); }; 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 c3b4e0bd3..3c2b0a128 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 @@ -1,8 +1,9 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { signalStore } from '@ngrx/signals'; +import { signalStore, withState } from '@ngrx/signals'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withRecentItems } from './with-recent-items'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -15,11 +16,30 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ })); const TestRecentItemsStore = signalStore(withRecentItems()); +const TestLoadedContentRecentItemsStore = signalStore( + withState({ + vodStreams: [ + { + stream_id: 20203, + title: 'Movie', + type: 'movie', + category_id: 206, + }, + ], + }), + withRecentItems() +); describe('withRecentItems', () => { let store: InstanceType; - let databaseService: { + let dataSource: { addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; + let databaseService: { getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; setContentBackdropIfMissing: jest.Mock; @@ -31,8 +51,9 @@ describe('withRecentItems', () => { configurable: true, }); - databaseService = { + dataSource = { addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), getRecentItems: jest.fn().mockResolvedValue([ { @@ -46,12 +67,22 @@ describe('withRecentItems', () => { category_id: 17, }, ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; + + databaseService = { + getContentByXtreamId: jest.fn(), + getRecentItems: jest.fn().mockResolvedValue([]), setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), }; TestBed.configureTestingModule({ providers: [ TestRecentItemsStore, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, { provide: DatabaseService, useValue: databaseService, @@ -59,9 +90,9 @@ describe('withRecentItems', () => { { provide: PlaylistsService, useValue: { - clearPlaylistRecentlyViewed: jest.fn().mockReturnValue( - of(undefined) - ), + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), getAllPlaylists: jest.fn().mockReturnValue(of([])), }, }, @@ -72,7 +103,7 @@ describe('withRecentItems', () => { }); it('looks recent items up with the requested content type before saving one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -86,12 +117,12 @@ describe('withRecentItems', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect(databaseService.addRecentItem).toHaveBeenCalledWith( + expect(dataSource.addRecentItem).toHaveBeenCalledWith( 3941697, 'playlist-1', undefined @@ -107,8 +138,71 @@ describe('withRecentItems', () => { ]); }); + it('uses stream_id as the PWA recent id when no database id exists', async () => { + dataSource.getContentByXtreamId.mockResolvedValue({ + stream_id: 20203, + title: 'Movie', + type: 'movie', + category_id: 206, + }); + + store.addRecentItem({ + xtreamId: 20203, + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 20203, + 'playlist-1', + undefined + ); + }); + + it('falls back to loaded store content when the PWA source cache misses', async () => { + dataSource.getContentByXtreamId.mockResolvedValue(null); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + TestLoadedContentRecentItemsStore, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, + { + provide: DatabaseService, + useValue: databaseService, + }, + { + provide: PlaylistsService, + useValue: { + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), + getAllPlaylists: jest.fn().mockReturnValue(of([])), + }, + }, + ], + }); + const loadedStore = TestBed.inject(TestLoadedContentRecentItemsStore); + + loadedStore.addRecentItem({ + xtreamId: 20203, + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 20203, + 'playlist-1', + undefined + ); + }); + it('forwards backdrop urls on recent-item saves', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -123,7 +217,7 @@ describe('withRecentItems', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(databaseService.addRecentItem).toHaveBeenCalledWith( + expect(dataSource.addRecentItem).toHaveBeenCalledWith( 3941697, 'playlist-1', 'https://example.com/krypton-backdrop.png' @@ -156,6 +250,6 @@ describe('withRecentItems', () => { 3941697, 'https://example.com/krypton-backdrop.png' ); - expect(databaseService.addRecentItem).not.toHaveBeenCalled(); + expect(dataSource.addRecentItem).not.toHaveBeenCalled(); }); }); 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 a56bd5723..dc4cc648b 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 @@ -14,35 +14,80 @@ import { PortalRecentItem, } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; export interface RecentlyViewedItem extends PortalRecentItem { /** @deprecated Redundant — always equals `id`. Retained for compat. */ content_id: number | string; } +function resolveContentId(item: Record): number | null { + const rawId = + item['id'] ?? + item['stream_id'] ?? + item['series_id'] ?? + item['xtream_id']; + const id = Number(rawId); + return Number.isFinite(id) && id > 0 ? id : null; +} + +function findLoadedContent( + store: unknown, + xtreamId: number, + contentType: 'live' | 'movie' | 'series' +): Record | null { + const storeWithContent = store as { + liveStreams?: () => Record[]; + vodStreams?: () => Record[]; + serialStreams?: () => Record[]; + }; + const items = + contentType === 'live' + ? storeWithContent.liveStreams?.() + : contentType === 'movie' + ? storeWithContent.vodStreams?.() + : storeWithContent.serialStreams?.(); + + return ( + items?.find((item) => { + const itemId = resolveContentId(item); + return itemId === xtreamId; + }) ?? null + ); +} + function mapDbRecentItem( item: { - id: number; - title: string; + id?: number | string; + title?: string; + name?: string; type: string; - poster_url: string; + poster_url?: string; + stream_icon?: string; + cover?: string; backdrop_url?: string | null; viewed_at?: string; - xtream_id: number; - category_id: number; + xtream_id?: number | string; + stream_id?: number | string; + series_id?: number | string; + category_id: number | string; }, playlistId: string ): RecentlyViewedItem { + const id = resolveContentId(item) ?? 0; + return { - id: item.id, - title: item.title, + id, + title: item.title ?? item.name ?? 'Unknown', type: item.type as 'live' | 'movie' | 'series', - poster_url: item.poster_url, + poster_url: item.poster_url ?? item.stream_icon ?? item.cover ?? '', backdrop_url: item.backdrop_url ?? undefined, - content_id: item.id, + content_id: id, playlist_id: playlistId, viewed_at: item.viewed_at || '', - xtream_id: item.xtream_id, + xtream_id: Number( + item.xtream_id ?? item.stream_id ?? item.series_id ?? id + ), category_id: item.category_id, }; } @@ -53,11 +98,11 @@ export const withRecentItems = function () { withState({ recentItems: [], }), - withMethods((store, dbService = inject(DatabaseService)) => ({ + withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ loadRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - const items = await dbService.getRecentItems( + const items = await dataSource.getRecentItems( playlist.id ); return items.map((item) => @@ -73,6 +118,7 @@ export const withRecentItems = function () { withMethods( ( store, + dataSource = inject(XTREAM_DATA_SOURCE), dbService = inject(DatabaseService), playlistsService = inject(PlaylistsService) ) => ({ @@ -90,30 +136,47 @@ export const withRecentItems = function () { playlist, backdropUrl, }) => { - const playlistId = playlist().id; - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType - ); - if (content) { - await dbService.addRecentItem( - content.id, - playlistId, - backdropUrl - ); + const playlistId = playlist().id; + const content = + (await dataSource.getContentByXtreamId( + xtreamId, + playlistId, + contentType + )) ?? + findLoadedContent( + store, + xtreamId, + contentType + ); + const contentId = content + ? resolveContentId( + content as unknown as Record< + string, + unknown + > + ) + : null; + if (contentId) { + await dataSource.addRecentItem( + contentId, + playlistId, + backdropUrl + ); - // Reload after add/update so re-watched items - // immediately move to the top in recently-viewed. - const items = - await dbService.getRecentItems(playlistId); - patchState(store, { - recentItems: items.map((item) => - mapDbRecentItem(item, playlistId) - ), - }); + // Reload after add/update so re-watched items + // immediately move to the top in recently-viewed. + const items = + await dataSource.getRecentItems( + playlistId + ); + patchState(store, { + recentItems: items.map((item) => + mapDbRecentItem(item, playlistId) + ), + }); + } } - }) + ) ) ), async backfillContentBackdrop({ @@ -154,9 +217,7 @@ export const withRecentItems = function () { clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - await dbService.clearPlaylistRecentItems( - playlist.id - ); + await dataSource.clearRecentItems(playlist.id); patchState(store, { recentItems: [] }); }) ) @@ -167,13 +228,13 @@ export const withRecentItems = function () { }>( pipe( switchMap(async ({ itemId, playlistId }) => { - await dbService.removeRecentItem( + await dataSource.removeRecentItem( itemId, playlistId ); // Reload recent items to update UI const items = - await dbService.getRecentItems(playlistId); + await dataSource.getRecentItems(playlistId); patchState(store, { recentItems: items.map((item) => mapDbRecentItem(item, playlistId) @@ -189,14 +250,16 @@ export const withRecentItems = function () { const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; - const playlistBackedItems = - buildPlaylistRecentItems(playlists, { + const playlistBackedItems = buildPlaylistRecentItems( + playlists, + { stalker: 'Stalker Portal', m3u: 'M3U', - }).map((item) => ({ - ...item, - content_id: item.id, - })) as RecentlyViewedItem[]; + } + ).map((item) => ({ + ...item, + content_id: item.id, + })) as RecentlyViewedItem[]; const normalizedXtream: RecentlyViewedItem[] = ( xtreamItems || [] diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts index 5492aaba8..235419489 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -1,8 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { - PortalCatalogSortMode, -} from '@iptvnator/portal/shared/util'; +import { PortalCatalogSortMode } from '@iptvnator/portal/shared/util'; import { XtreamPlaylistData, XtreamStore, @@ -180,6 +178,43 @@ describe('XtreamCatalogFacadeService', () => { ); }); + it('routes raw Xtream VOD stream items by stream_id inside a category', () => { + selectedCategoryId.set(201); + + expect( + service.selectItem({ + stream_id: 650020, + category_id: '201', + title: 'Movie', + }) + ).toEqual(['650020']); + }); + + it('routes raw Xtream VOD stream items from the all-items view with their category', () => { + selectedCategoryId.set(null); + + expect( + service.selectItem({ + stream_id: 650020, + category_id: '201', + title: 'Movie', + }) + ).toEqual(['201', '650020']); + }); + + it('routes raw Xtream series items by series_id', () => { + contentType.set('series'); + selectedCategoryId.set(301); + + expect( + service.selectItem({ + series_id: 103, + category_id: '301', + title: 'Series', + }) + ).toEqual(['103']); + }); + it('persists sort mode changes and delegates them to the store', () => { service.setContentSortMode('name-desc'); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 79f7c40ba..011040d00 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -98,14 +98,14 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< } selectItem(item: Record): string[] | null { - const xtreamId = item['xtream_id']; - if (xtreamId === undefined || xtreamId === null) { + const itemId = this.getCatalogItemId(item); + if (itemId === undefined || itemId === null) { return null; } const selectedCategoryId = this.xtreamStore.selectedCategoryId(); if (selectedCategoryId !== null && selectedCategoryId !== undefined) { - return [String(xtreamId)]; + return [String(itemId)]; } const categoryId = item['category_id']; @@ -113,14 +113,12 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< return null; } - return [String(categoryId), String(xtreamId)]; + return [String(categoryId), String(itemId)]; } getItemProgress(item: Record): PortalCatalogItemProgress { const isSeries = this.contentType() === 'series'; - const itemId = Number( - item['xtream_id'] ?? item['series_id'] ?? item['stream_id'] - ); + const itemId = Number(this.getCatalogItemId(item)); if (Number.isNaN(itemId)) { return {}; } @@ -136,6 +134,17 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< isWatched: this.xtreamStore.isWatched(itemId, 'vod'), }; } + + private getCatalogItemId( + item: Record + ): string | number | null | undefined { + const itemId = + item['xtream_id'] ?? item['series_id'] ?? item['stream_id']; + + return typeof itemId === 'string' || typeof itemId === 'number' + ? itemId + : null; + } } export function provideXtreamCatalogFacade(): Provider[] {