diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 91826b2af..66c182087 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -10,6 +10,7 @@ import { StalkerPortalItem, } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; describe('UnifiedFavoritesDataService', () => { @@ -24,6 +25,12 @@ describe('UnifiedFavoritesDataService', () => { getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; }; + let xtreamDataSource: { + addFavorite: jest.Mock; + getContentByXtreamId: jest.Mock; + getFavorites: jest.Mock; + removeFavorite: jest.Mock; + }; let store: { dispatch: jest.Mock; select: jest.Mock; @@ -111,6 +118,12 @@ describe('UnifiedFavoritesDataService', () => { getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), }; + xtreamDataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + getFavorites: jest.fn().mockResolvedValue([]), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; store = { dispatch: jest.fn(), select: jest.fn(() => @@ -141,6 +154,10 @@ describe('UnifiedFavoritesDataService', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSource, + }, { provide: PlaylistsService, useValue: playlistsService, @@ -223,6 +240,52 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('loads Xtream playlist favorites through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getFavorites.mockResolvedValue([ + { + id: 202, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + xtream_id: 202, + added_at: '2026-05-21T12:00:00.000Z', + }, + ]); + + const items = await service.getFavorites( + 'playlist', + 'xtream-1', + 'xtream' + ); + + expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1'); + expect(databaseService.getFavorites).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + }), + ]); + }); + it('preserves persisted M3U favorites order when extracting playlist favorites', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -400,6 +463,37 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + + await service.addFavorite({ + uid: 'xtream::xtream-1::movie:101', + name: 'Xtream Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + posterUrl: 'movie.png', + xtreamId: 101, + } satisfies UnifiedCollectionItem); + + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + 101, + 'xtream-1', + 'movie' + ); + expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith( + 101, + 'xtream-1', + 'movie.png' + ); + expect(electronApi.dbAddFavorite).not.toHaveBeenCalled(); + }); + it('adds Stalker favorites through portal favorites', async () => { await service.addFavorite({ uid: 'stalker::stalker-1::101', diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index cb53d8c00..a623ffbb9 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -25,6 +25,10 @@ import { xtreamContentType, XtreamFavoriteRow, } from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; @@ -43,6 +47,7 @@ export class UnifiedFavoritesDataService { private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); private readonly translate = inject(TranslateService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getFavorites( scope: CollectionScope, @@ -86,12 +91,20 @@ export class UnifiedFavoritesDataService { break; } case 'xtream': - if (window.electron && item.contentId != null) { + if (item.contentId == null) { + return; + } + if (window.electron) { await window.electron.dbRemoveFavorite( item.contentId, item.playlistId ); + return; } + await this.xtreamDataSource.removeFavorite( + item.contentId, + item.playlistId + ); break; case 'stalker': { const sourceItemId = item.uid.split('::')[2]; @@ -134,26 +147,25 @@ export class UnifiedFavoritesDataService { private async addXtreamFavorite( item: UnifiedCollectionItem ): Promise { - if (!window.electron) { - return; - } - const contentId = item.contentId ?? (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id + ? await this.resolveXtreamContentId(item) : null); if (contentId == null) { return; } + if (!window.electron) { + await this.xtreamDataSource.addFavorite( + contentId, + item.playlistId, + item.posterUrl ?? item.logo ?? undefined + ); + return; + } + await window.electron.dbAddFavorite( contentId, item.playlistId, @@ -346,6 +358,22 @@ export class UnifiedFavoritesDataService { ): Promise { const electron = window.electron; if (!electron) { + await Promise.all( + items + .filter( + ( + item + ): item is UnifiedCollectionItem & { + readonly contentId: number; + } => item.contentId != null + ) + .map((item) => + this.xtreamDataSource.removeFavorite( + item.contentId, + item.playlistId + ) + ) + ); return; } @@ -486,6 +514,17 @@ export class UnifiedFavoritesDataService { } private async getXtreamAllFavorites(): Promise { + if (!window.electron) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + for (const meta of allMeta.filter((p) => p._id && p.serverUrl)) { + results.push( + ...(await this.getXtreamPlaylistFavorites(meta._id)) + ); + } + return results; + } + if (!window.electron?.dbGetAllGlobalFavorites) return []; try { const rows = @@ -499,10 +538,17 @@ export class UnifiedFavoritesDataService { private async getXtreamPlaylistFavorites( playlistId: string ): Promise { - if (!window.electron) return []; try { - const rows = await this.dbService.getFavorites(playlistId); const meta = await this.getPlaylistMeta(playlistId); + if (!window.electron) { + const rows = + await this.xtreamDataSource.getFavorites(playlistId); + return rows.map((row) => + this.mapXtreamContentItem(row, playlistId, meta?.title) + ); + } + + const rows = await this.dbService.getFavorites(playlistId); return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({ ...this.mapXtreamRow(r), playlistId, @@ -513,6 +559,31 @@ export class UnifiedFavoritesDataService { } } + private async resolveXtreamContentId( + item: UnifiedCollectionItem + ): Promise { + if (item.xtreamId == null) { + return null; + } + + if (!window.electron) { + const content = await this.xtreamDataSource.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + return content?.id ?? item.xtreamId; + } + + const content = await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + + return content?.id ?? null; + } + private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem { const ct = xtreamContentType(row.type); return { @@ -535,6 +606,33 @@ export class UnifiedFavoritesDataService { }; } + private mapXtreamContentItem( + item: XtreamContentItem, + playlistId: string, + playlistName?: string + ): UnifiedCollectionItem { + const ct = xtreamContentType(item.type); + return { + uid: buildXtreamCollectionUid(playlistId, ct, item.xtream_id), + name: item.title, + contentType: ct, + sourceType: 'xtream', + playlistId, + playlistName: playlistName ?? item.playlist_name ?? 'Xtream', + logo: ct === 'live' ? (item.poster_url ?? null) : null, + posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null, + xtreamId: item.xtream_id, + categoryId: item.category_id, + tvgId: ct === 'live' ? String(item.xtream_id) : undefined, + rating: item.rating ?? undefined, + addedAt: + normalizeStalkerDate(item.added_at ?? item.added) || + new Date(0).toISOString(), + position: item.position ?? 0, + contentId: item.id, + }; + } + private async getStalkerAllFavorites(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; 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 2557e7dd9..418f1fd61 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 @@ -9,6 +9,7 @@ import { PlaylistMeta, } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedRecentDataService } from './unified-recent-data.service'; describe('UnifiedRecentDataService', () => { @@ -33,6 +34,12 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.Mock; getContentByXtreamId: jest.Mock; }; + let xtreamDataSource: { + addRecentItem: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; const playlistMeta = { _id: 'm3u-1', @@ -87,6 +94,10 @@ describe('UnifiedRecentDataService', () => { ]; beforeEach(() => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); store = { select: jest.fn(() => of([playlistMeta])), dispatch: jest.fn(), @@ -136,6 +147,12 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.fn().mockResolvedValue(true), getContentByXtreamId: jest.fn().mockResolvedValue(null), }; + xtreamDataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + getRecentItems: jest.fn().mockResolvedValue([]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -143,6 +160,10 @@ describe('UnifiedRecentDataService', () => { { provide: Store, useValue: store }, { provide: PlaylistsService, useValue: playlistsService }, { provide: DatabaseService, useValue: dbService }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSource, + }, ], }); @@ -235,6 +256,43 @@ describe('UnifiedRecentDataService', () => { ); }); + it('uses the Xtream id as the PWA recent key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + + const item = { + uid: 'xtream::xtream-1::movie:290', + name: 'PWA Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 290, + } satisfies UnifiedCollectionItem; + + const recorded = await service.recordLivePlayback(item); + + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + 290, + 'xtream-1', + 'movie' + ); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + 290, + 'xtream-1' + ); + expect(dbService.addRecentItem).not.toHaveBeenCalled(); + expect(recorded).toEqual( + expect.objectContaining({ + contentId: 290, + viewedAt: expect.any(String), + }) + ); + }); + it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => { store.select.mockReturnValue( of([ @@ -288,6 +346,56 @@ describe('UnifiedRecentDataService', () => { ); }); + it('loads Xtream playlist recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getRecentItems.mockResolvedValue([ + { + id: 202, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + backdrop_url: 'backdrop.png', + xtream_id: 202, + viewed_at: '2026-05-21T12:00:00.000Z', + }, + ]); + + const items = await service.getRecentItems( + 'playlist', + 'xtream-1', + 'xtream' + ); + + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbService.getRecentItems).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + viewedAt: '2026-05-21T12:00:00.000Z', + }), + ]); + }); + 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 bb1f657dc..6c45392d0 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 @@ -26,6 +26,10 @@ import { UnifiedCollectionItem, xtreamContentType, } from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; type PlaylistWithChannels = Playlist & { readonly playlist?: { readonly items?: Channel[] }; @@ -36,6 +40,7 @@ export class UnifiedRecentDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getRecentItems( scope: CollectionScope, @@ -55,6 +60,14 @@ export class UnifiedRecentDataService { return; } + if (!window.electron) { + await this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ); + return; + } + await this.dbService.removeRecentItem( item.contentId, item.playlistId @@ -126,7 +139,18 @@ export class UnifiedRecentDataService { const tasks: Promise[] = []; if (xtreamBatch.length > 0) { - tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + if (window.electron) { + tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + } else { + tasks.push( + ...xtreamBatch.map((item) => + this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ) + ) + ); + } } for (const [playlistId, identities] of groupedByPlaylist) { @@ -153,7 +177,11 @@ export class UnifiedRecentDataService { playlistId?: string ): Promise { if (scope === 'playlist' && playlistId) { - await this.dbService.clearPlaylistRecentItems(playlistId); + if (window.electron) { + await this.dbService.clearPlaylistRecentItems(playlistId); + } else { + await this.xtreamDataSource.clearRecentItems(playlistId); + } const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed(playlistId) ); @@ -161,7 +189,9 @@ export class UnifiedRecentDataService { return; } - await this.dbService.clearGlobalRecentlyViewed(); + if (window.electron) { + await this.dbService.clearGlobalRecentlyViewed(); + } const playlists = (await firstValueFrom( this.playlistsService.getAllPlaylists() )) as Playlist[]; @@ -223,17 +253,21 @@ export class UnifiedRecentDataService { const contentId = item.contentId ?? (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id + ? await this.resolveXtreamContentId(item) : null); if (contentId != null) { - await this.dbService.addRecentItem(contentId, item.playlistId); + if (window.electron) { + await this.dbService.addRecentItem( + contentId, + item.playlistId + ); + } else { + await this.xtreamDataSource.addRecentItem( + contentId, + item.playlistId + ); + } } return { @@ -309,6 +343,17 @@ export class UnifiedRecentDataService { } private async getXtreamGlobalRecent(): Promise { + if (!window.electron) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + for (const meta of allMeta.filter( + (playlist) => playlist._id && playlist.serverUrl + )) { + results.push(...(await this.getXtreamPlaylistRecent(meta._id))); + } + return results; + } + try { const rows = await this.dbService.getGlobalRecentlyViewed(); return (rows || []).map((row) => ({ @@ -340,9 +385,16 @@ export class UnifiedRecentDataService { playlistId: string ): Promise { try { - const rows = await this.dbService.getRecentItems(playlistId); const meta = await this.getPlaylistMeta(playlistId); + if (!window.electron) { + const rows = + await this.xtreamDataSource.getRecentItems(playlistId); + return rows.map((row) => + this.mapXtreamContentItem(row, playlistId, meta?.title) + ); + } + const rows = await this.dbService.getRecentItems(playlistId); return (rows || []).map((row) => ({ uid: buildXtreamCollectionUid( playlistId, @@ -368,6 +420,60 @@ export class UnifiedRecentDataService { } } + private async resolveXtreamContentId( + item: UnifiedCollectionItem + ): Promise { + if (item.xtreamId == null) { + return null; + } + + if (!window.electron) { + const content = await this.xtreamDataSource.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + return content?.id ?? item.xtreamId; + } + + const content = await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + + return content?.id ?? null; + } + + private mapXtreamContentItem( + item: XtreamContentItem, + playlistId: string, + playlistName?: string + ): UnifiedCollectionItem { + const contentType = xtreamContentType(item.type); + + return { + uid: buildXtreamCollectionUid( + playlistId, + contentType, + item.xtream_id + ), + name: item.title, + contentType, + sourceType: 'xtream', + playlistId, + playlistName: playlistName ?? item.playlist_name ?? 'Xtream', + logo: contentType === 'live' ? (item.poster_url ?? null) : null, + posterUrl: + contentType !== 'live' ? (item.poster_url ?? null) : null, + xtreamId: item.xtream_id, + categoryId: item.category_id, + tvgId: contentType === 'live' ? String(item.xtream_id) : undefined, + contentId: item.id, + viewedAt: normalizeStalkerDate(item.viewed_at), + }; + } + private async getM3uGlobalRecent(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; 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 32ad75e03..0b12daf62 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 @@ -138,4 +138,50 @@ describe('PwaXtreamDataSource', () => { }) ); }); + + it('matches legacy string favorite and recent ids against numeric content identities', async () => { + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + added: '2', + }, + ]); + 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 dataSource.getContent('playlist-1', credentials, 'movie'); + + await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe( + true + ); + 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, + }), + ]); + }); }); 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 476ad009b..8ffbdba90 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 @@ -417,35 +417,50 @@ export class PwaXtreamDataSource implements IXtreamDataSource { _backdropUrl?: string ): Promise { void _backdropUrl; + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allFavorites = this.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; } - if (!allFavorites[playlistId].includes(contentId)) { - allFavorites[playlistId].push(contentId); + if (!allFavorites[playlistId].includes(normalizedContentId)) { + allFavorites[playlistId].push(normalizedContentId); } this.saveFavoritesToStorage(allFavorites); } async removeFavorite(contentId: number, playlistId: string): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allFavorites = this.getFavoritesFromStorage(); if (allFavorites[playlistId]) { allFavorites[playlistId] = allFavorites[playlistId].filter( - (id: number) => id !== contentId + (id: number) => id !== normalizedContentId ); } this.saveFavoritesToStorage(allFavorites); } async isFavorite(contentId: number, playlistId: string): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return false; + } + const allFavorites = this.getFavoritesFromStorage(); - return (allFavorites[playlistId] || []).includes(contentId); + return (allFavorites[playlistId] || []).includes(normalizedContentId); } private getFavoritesFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); - return data ? JSON.parse(data) : {}; + return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {}); } catch { return {}; } @@ -634,6 +649,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { _backdropUrl?: string ): Promise { void _backdropUrl; + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; @@ -641,12 +661,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Remove existing entry if present allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== contentId + (r) => r.id !== normalizedContentId ); // Add new entry at the beginning allRecent[playlistId].unshift({ - id: contentId, + id: normalizedContentId, viewedAt: new Date().toISOString(), }); @@ -660,10 +680,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource { contentId: number, playlistId: string ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allRecent = this.getRecentItemsFromStorage(); if (allRecent[playlistId]) { allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== contentId + (r) => r.id !== normalizedContentId ); } this.saveRecentItemsToStorage(allRecent); @@ -676,7 +701,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getRecentItemsFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); - return data ? JSON.parse(data) : {}; + return this.normalizeRecentStorage(data ? JSON.parse(data) : {}); } catch { return {}; } @@ -697,6 +722,70 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.saveRecentItemsToStorage(allRecent); } + private normalizeStoredId(value: unknown): number | null { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; + } + + private normalizeFavoriteStorage(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + const normalized: Record = {}; + Object.entries(value as Record).forEach( + ([playlistId, ids]) => { + if (!Array.isArray(ids)) { + return; + } + + normalized[playlistId] = ids + .map((id) => this.normalizeStoredId(id)) + .filter((id): id is number => id !== null); + } + ); + return normalized; + } + + private normalizeRecentStorage( + value: unknown + ): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + const normalized: Record = {}; + Object.entries(value as Record).forEach( + ([playlistId, items]) => { + if (!Array.isArray(items)) { + return; + } + + normalized[playlistId] = items + .map((item) => { + const rawItem = item as { + readonly id?: unknown; + readonly viewedAt?: unknown; + }; + const id = this.normalizeStoredId(rawItem.id); + if ( + id == null || + typeof rawItem.viewedAt !== 'string' + ) { + return null; + } + + return { + id, + viewedAt: rawItem.viewedAt, + }; + }) + .filter((item): item is StoredRecentItem => item !== null); + } + ); + return normalized; + } + // ========================================================================= // Content Lookup // ========================================================================= 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 af2ee954c..1978b39cf 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 @@ -47,6 +47,7 @@ export interface XtreamContentItem { rating: string; added: string; poster_url: string; + backdrop_url?: string | null; epg_channel_id?: string | null; tv_archive?: number | null; tv_archive_duration?: number | null; diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts new file mode 100644 index 000000000..db9f2ffe6 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; +import { FavoritesService } from './favorites.service'; + +describe('FavoritesService', () => { + let service: FavoritesService; + let dataSource: { + addFavorite: jest.Mock; + getFavorites: jest.Mock; + isFavorite: jest.Mock; + removeFavorite: jest.Mock; + }; + + beforeEach(() => { + dataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getFavorites: jest.fn().mockResolvedValue([ + { + id: 202, + type: 'movie', + title: 'Movie One', + poster_url: 'movie.png', + added_at: '2026-05-21T12:00:00.000Z', + category_id: '20', + xtream_id: 202, + }, + ]), + isFavorite: jest.fn().mockResolvedValue(true), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [ + FavoritesService, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, + ], + }); + + service = TestBed.inject(FavoritesService); + }); + + it('uses the active Xtream data source for favorite mutations and reads', async () => { + await service.addToFavorites({ + content_id: 202, + playlist_id: 'playlist-1', + backdrop_url: 'backdrop.jpg', + }); + await service.removeFromFavorites(202, 'playlist-1'); + await expect(service.isFavorite(202, 'playlist-1')).resolves.toBe(true); + await expect( + firstValueFrom(service.getFavorites('playlist-1')) + ).resolves.toEqual([ + expect.objectContaining({ + content_id: 202, + playlist_id: 'playlist-1', + type: 'movie', + title: 'Movie One', + poster_url: 'movie.png', + category_id: 20, + xtream_id: 202, + }), + ]); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 202, + 'playlist-1', + 'backdrop.jpg' + ); + expect(dataSource.removeFavorite).toHaveBeenCalledWith( + 202, + 'playlist-1' + ); + expect(dataSource.isFavorite).toHaveBeenCalledWith(202, 'playlist-1'); + expect(dataSource.getFavorites).toHaveBeenCalledWith('playlist-1'); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts index 7d6ea7ff5..4929d4471 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts @@ -1,20 +1,25 @@ import { inject, Injectable } from '@angular/core'; import { from, map, Observable } from 'rxjs'; -import { DatabaseService } from '@iptvnator/services'; +import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; import { FavoriteItem } from './favorite-item.interface'; +function normalizeCategoryId(categoryId: string | number): number { + const numericCategoryId = Number(categoryId); + return Number.isFinite(numericCategoryId) ? numericCategoryId : 0; +} + @Injectable({ providedIn: 'root', }) export class FavoritesService { - private dbService = inject(DatabaseService); + private dataSource = inject(XTREAM_DATA_SOURCE); async addToFavorites(item: { content_id: number; playlist_id: string; backdrop_url?: string; }): Promise { - await this.dbService.addToFavorites( + await this.dataSource.addFavorite( item.content_id, item.playlist_id, item.backdrop_url @@ -25,15 +30,15 @@ export class FavoritesService { contentId: number, playlistId: string ): Promise { - await this.dbService.removeFromFavorites(contentId, playlistId); + await this.dataSource.removeFavorite(contentId, playlistId); } async isFavorite(contentId: number, playlistId: string): Promise { - return await this.dbService.isFavorite(contentId, playlistId); + return await this.dataSource.isFavorite(contentId, playlistId); } getFavorites(playlistId: string): Observable { - return from(this.dbService.getFavorites(playlistId)).pipe( + return from(this.dataSource.getFavorites(playlistId)).pipe( map((items) => items.map((item) => ({ content_id: item.id, @@ -42,7 +47,7 @@ export class FavoritesService { title: item.title, poster_url: item.poster_url, added_at: item.added_at, - category_id: item.category_id, + category_id: normalizeCategoryId(item.category_id), xtream_id: item.xtream_id, })) ) 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..3580e67ec 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 { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withFavorites } from './with-favorites.feature'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -16,36 +15,29 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ const TestFavoritesStore = signalStore(withFavorites()); describe('withFavorites', () => { + const originalElectron = window.electron; 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, }, ], }); @@ -53,8 +45,16 @@ describe('withFavorites', () => { store = TestBed.inject(TestFavoritesStore); }); + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + 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 +63,72 @@ 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 the Xtream ID as the PWA favorite key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite( + 1767451, + 'playlist-1', + 'movie' + ); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + expect(store.isFavorite()).toBe(true); + }); + + it('normalizes route-param Xtream IDs before using the PWA favorite fallback', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite( + '1767451', + 'playlist-1', + 'movie' + ); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + 'movie' + ); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 1767451, + '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 +138,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,25 +152,43 @@ 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' ); expect(store.isFavorite()).toBe(true); }); + + it('checks PWA favorite state against the Xtream ID when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + dataSource.isFavorite.mockResolvedValue(true); + + await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie'); + + expect(dataSource.isFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1' + ); + expect(store.isFavorite()).toBe(true); + }); }); 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..49c3168f9 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,8 @@ 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'; export const withFavorites = function () { const logger = createLogger('withFavorites'); @@ -15,85 +14,94 @@ 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 normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - return false; - } + return false; + } - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron ? normalizedXtreamId : null); + + if (contentId == null) { + logger.error( + 'Content not found for xtream ID', + normalizedXtreamId ); - if (!content) { - logger.error( - 'Content not found for xtream ID', - xtreamId - ); - return false; - } + 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 normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - patchState(store, { isFavorite: false }); - return; - } + patchState(store, { isFavorite: false }); + return; + } - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType - ); - if (!content) { - patchState(store, { isFavorite: false }); - return; - } + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron ? normalizedXtreamId : null); - const isFavorite = await favoritesService.isFavorite( - content.id, - playlistId - ); + if (contentId == null) { + patchState(store, { isFavorite: false }); + return; + } - patchState(store, { isFavorite }); - }, - }) - ) + const isFavorite = await dataSource.isFavorite( + contentId, + playlistId + ); + + 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..9efc794de 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 @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { signalStore } 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', () => ({ @@ -17,13 +18,22 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ const TestRecentItemsStore = signalStore(withRecentItems()); describe('withRecentItems', () => { + const originalElectron = window.electron; let store: InstanceType; let databaseService: { - addRecentItem: jest.Mock; + clearPlaylistRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; setContentBackdropIfMissing: jest.Mock; }; + let dataSource: { + addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; beforeEach(() => { Object.defineProperty(window, 'electron', { @@ -32,7 +42,7 @@ describe('withRecentItems', () => { }); databaseService = { - addRecentItem: jest.fn().mockResolvedValue(undefined), + clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), getRecentItems: jest.fn().mockResolvedValue([ { @@ -46,8 +56,27 @@ describe('withRecentItems', () => { category_id: 17, }, ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), }; + dataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn(), + getRecentItems: jest.fn().mockResolvedValue([ + { + id: 3941697, + title: 'Krypton', + type: 'series', + poster_url: 'https://example.com/krypton.png', + backdrop_url: 'https://example.com/krypton-backdrop.png', + viewed_at: '2026-04-21T20:42:27.000Z', + xtream_id: 290, + category_id: 17, + }, + ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -56,12 +85,16 @@ describe('withRecentItems', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, { provide: PlaylistsService, useValue: { - clearPlaylistRecentlyViewed: jest.fn().mockReturnValue( - of(undefined) - ), + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), getAllPlaylists: jest.fn().mockReturnValue(of([])), }, }, @@ -71,8 +104,16 @@ describe('withRecentItems', () => { store = TestBed.inject(TestRecentItemsStore); }); + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + 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 +127,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 +148,56 @@ describe('withRecentItems', () => { ]); }); + it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + store.addRecentItem({ + xtreamId: 1767451, + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(databaseService.getRecentItems).not.toHaveBeenCalled(); + }); + + it('normalizes route-param Xtream IDs before using the PWA recent fallback', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + store.addRecentItem({ + xtreamId: '1767451', + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + 'movie' + ); + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 1767451, + '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 +212,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 +245,42 @@ describe('withRecentItems', () => { 3941697, 'https://example.com/krypton-backdrop.png' ); - expect(databaseService.addRecentItem).not.toHaveBeenCalled(); + expect(dataSource.addRecentItem).not.toHaveBeenCalled(); + }); + + it('clears recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + + store.clearRecentItems({ id: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.clearRecentItems).toHaveBeenCalledWith('playlist-1'); + expect(databaseService.clearPlaylistRecentItems).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([]); + }); + + it('removes recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + + store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.removeRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1' + ); + expect(databaseService.removeRecentItem).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([ + expect.objectContaining({ + id: 3941697, + title: 'Krypton', + }), + ]); }); }); 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..71eb926eb 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,6 +14,7 @@ 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. */ @@ -29,7 +30,7 @@ function mapDbRecentItem( backdrop_url?: string | null; viewed_at?: string; xtream_id: number; - category_id: number; + category_id: number | string; }, playlistId: string ): RecentlyViewedItem { @@ -53,11 +54,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) => @@ -74,10 +75,11 @@ export const withRecentItems = function () { ( store, dbService = inject(DatabaseService), - playlistsService = inject(PlaylistsService) + playlistsService = inject(PlaylistsService), + dataSource = inject(XTREAM_DATA_SOURCE) ) => ({ addRecentItem: rxMethod<{ - xtreamId: number; + xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; backdropUrl?: string; @@ -90,30 +92,49 @@ 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 normalizedXtreamId = Number(xtreamId); + if ( + !playlistId || + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 + ) { + return; + } - // 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) - ), - }); + const content = + await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron + ? normalizedXtreamId + : null); + + if (contentId != null) { + 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 dataSource.getRecentItems( + playlistId + ); + patchState(store, { + recentItems: items.map((item) => + mapDbRecentItem(item, playlistId) + ), + }); + } } - }) + ) ) ), async backfillContentBackdrop({ @@ -122,7 +143,7 @@ export const withRecentItems = function () { playlist, backdropUrl, }: { - xtreamId: number; + xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; backdropUrl?: string; @@ -132,13 +153,19 @@ export const withRecentItems = function () { } const playlistId = playlist().id; + const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); - if (!playlistId || !normalizedBackdropUrl) { + if ( + !playlistId || + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !normalizedBackdropUrl + ) { return; } const content = await dbService.getContentByXtreamId( - xtreamId, + normalizedXtreamId, playlistId, contentType ); @@ -154,9 +181,13 @@ export const withRecentItems = function () { clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - await dbService.clearPlaylistRecentItems( - playlist.id - ); + if (window.electron) { + await dbService.clearPlaylistRecentItems( + playlist.id + ); + } else { + await dataSource.clearRecentItems(playlist.id); + } patchState(store, { recentItems: [] }); }) ) @@ -167,13 +198,20 @@ export const withRecentItems = function () { }>( pipe( switchMap(async ({ itemId, playlistId }) => { - await dbService.removeRecentItem( - itemId, - playlistId - ); + if (window.electron) { + await dbService.removeRecentItem( + itemId, + playlistId + ); + } else { + 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 +227,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/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index 06effcad6..3c04e76d4 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -41,6 +41,37 @@ describe('DatabaseService browser guards', () => { await expect( service.getContentByXtreamId(20229, 'playlist-1', 'movie') ).resolves.toBeNull(); + await expect(service.getGlobalRecentlyViewed()).resolves.toEqual([]); + await expect(service.getGlobalFavorites()).resolves.toEqual([]); + await expect(service.getAllGlobalFavorites()).resolves.toEqual([]); + await expect( + service.clearGlobalRecentlyViewed() + ).resolves.toBeUndefined(); + await expect(service.addToFavorites(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.removeFromFavorites(20229, 'playlist-1') + ).resolves.toBe(false); + await expect(service.isFavorite(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect(service.getFavorites('playlist-1')).resolves.toEqual([]); + await expect(service.getRecentItems('playlist-1')).resolves.toEqual([]); + await expect(service.addRecentItem(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.clearPlaylistRecentItems('playlist-1') + ).resolves.toBe(false); + await expect( + service.removeRecentItem(20229, 'playlist-1') + ).resolves.toBe(false); + await expect( + service.removeRecentItemsBatch([ + { contentId: 20229, playlistId: 'playlist-1' }, + ]) + ).resolves.toBe(false); expect(consoleErrorSpy).not.toHaveBeenCalled(); }); diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 8b0da609d..e03a9489c 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -627,6 +627,10 @@ export class DatabaseService { * Get recently viewed items */ async getGlobalRecentlyViewed(): Promise { + if (typeof window.electron?.dbGetRecentlyViewed !== 'function') { + return []; + } + try { const items = await window.electron.dbGetRecentlyViewed(); return items || []; @@ -640,6 +644,10 @@ export class DatabaseService { * Get global favorites across all playlists */ async getGlobalFavorites(): Promise { + if (typeof window.electron?.dbGetGlobalFavorites !== 'function') { + return []; + } + try { const items = await window.electron.dbGetGlobalFavorites(); return items || []; @@ -653,6 +661,10 @@ export class DatabaseService { * Get global favorites across all playlists (all content types) */ async getAllGlobalFavorites(): Promise { + if (typeof window.electron?.dbGetAllGlobalFavorites !== 'function') { + return []; + } + try { const items = await window.electron.dbGetAllGlobalFavorites(); return items || []; @@ -666,6 +678,10 @@ export class DatabaseService { * Clear recently viewed items */ async clearGlobalRecentlyViewed(): Promise { + if (typeof window.electron?.dbClearRecentlyViewed !== 'function') { + return; + } + try { await window.electron.dbClearRecentlyViewed(); } catch (error) { @@ -706,6 +722,10 @@ export class DatabaseService { playlistId: string, backdropUrl?: string ): Promise { + if (typeof window.electron?.dbAddFavorite !== 'function') { + return false; + } + try { await window.electron.dbAddFavorite( contentId, @@ -726,6 +746,10 @@ export class DatabaseService { contentId: number, playlistId: string ): Promise { + if (typeof window.electron?.dbRemoveFavorite !== 'function') { + return false; + } + try { await window.electron.dbRemoveFavorite(contentId, playlistId); return true; @@ -739,6 +763,10 @@ export class DatabaseService { * Check if content is favorited */ async isFavorite(contentId: number, playlistId: string): Promise { + if (typeof window.electron?.dbIsFavorite !== 'function') { + return false; + } + try { return await window.electron.dbIsFavorite(contentId, playlistId); } catch (error) { @@ -751,6 +779,10 @@ export class DatabaseService { * Get all favorites for a playlist */ async getFavorites(playlistId: string): Promise { + if (typeof window.electron?.dbGetFavorites !== 'function') { + return []; + } + try { return await window.electron.dbGetFavorites(playlistId); } catch (error) { @@ -763,6 +795,10 @@ export class DatabaseService { * Get recently viewed items for a specific playlist */ async getRecentItems(playlistId: string): Promise { + if (typeof window.electron?.dbGetRecentItems !== 'function') { + return []; + } + try { return await window.electron.dbGetRecentItems(playlistId); } catch (error) { @@ -779,6 +815,10 @@ export class DatabaseService { playlistId: string, backdropUrl?: string ): Promise { + if (typeof window.electron?.dbAddRecentItem !== 'function') { + return false; + } + try { await window.electron.dbAddRecentItem( contentId, @@ -825,6 +865,10 @@ export class DatabaseService { * Clear recently viewed for a specific playlist */ async clearPlaylistRecentItems(playlistId: string): Promise { + if (typeof window.electron?.dbClearPlaylistRecentItems !== 'function') { + return false; + } + try { await window.electron.dbClearPlaylistRecentItems(playlistId); return true; @@ -841,6 +885,10 @@ export class DatabaseService { contentId: number, playlistId: string ): Promise { + if (typeof window.electron?.dbRemoveRecentItem !== 'function') { + return false; + } + try { await window.electron.dbRemoveRecentItem(contentId, playlistId); return true; @@ -856,6 +904,10 @@ export class DatabaseService { if (items.length === 0) { return true; } + if (typeof window.electron?.dbRemoveRecentItemsBatch !== 'function') { + return false; + } + try { await window.electron.dbRemoveRecentItemsBatch(items); return true; 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 350f89d1e..52ccab9ba 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 @@ -2,10 +2,14 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { DashboardDataService } from './dashboard-data.service'; describe('DashboardDataService', () => { @@ -68,6 +72,10 @@ describe('DashboardDataService', () => { removeFromFavorites: jest.fn().mockResolvedValue(undefined), removeRecentItem: jest.fn().mockResolvedValue(undefined), }; + const xtreamDataSourceMock = { + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + }; const playlistMock: Playlist = { _id: 'm3u-1', title: 'M3U Playlist', @@ -138,6 +146,10 @@ describe('DashboardDataService', () => { dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]); dbServiceMock.removeFromFavorites.mockClear(); dbServiceMock.removeRecentItem.mockClear(); + xtreamDataSourceMock.getFavorites.mockClear(); + xtreamDataSourceMock.getFavorites.mockResolvedValue([]); + xtreamDataSourceMock.getRecentItems.mockClear(); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([]); storeMock.dispatch.mockClear(); TestBed.configureTestingModule({ @@ -145,6 +157,10 @@ describe('DashboardDataService', () => { DashboardDataService, { provide: Store, useValue: storeMock }, { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSourceMock, + }, { provide: PlaylistsService, useValue: playlistsServiceMock, @@ -324,6 +340,44 @@ describe('DashboardDataService', () => { ).toHaveLength(1); }); + it('includes PWA Xtream favorites from the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getFavorites.mockResolvedValue([ + { + id: 51, + category_id: 12, + title: 'Action Movie', + rating: '8.0', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled(); + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 51, + title: 'Action Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + }), + ]) + ); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -528,6 +582,45 @@ describe('DashboardDataService', () => { ).toEqual(['xtream-1', 'stalker-1', 'm3u-1']); }); + it('includes PWA Xtream recent items from the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([ + { + id: 91, + category_id: 18, + title: 'Recent Movie', + rating: '7.8', + viewed_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(service.globalRecentItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 91, + title: 'Recent Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + viewed_at: '2026-04-21T10:00:00.000Z', + }), + ]) + ); + }); + 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 c06a46ca8..0dc8c58be 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 @@ -20,6 +20,10 @@ import { GlobalRecentlyAddedKind, PlaylistsService, } from '@iptvnator/services'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; import { buildPlaylistRecentItems, Channel, @@ -64,6 +68,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind; export class DashboardDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); private readonly playlistsService = inject(PlaylistsService); private readonly ngZone = inject(NgZone); private readonly translate = inject(TranslateService); @@ -298,7 +303,10 @@ export class DashboardDataService { } if (!window.electron) { - this.xtreamGlobalRecentItems.set([]); + const recentItems = await this.loadPwaXtreamGlobalRecentItems(); + this.ngZone.run(() => + this.xtreamGlobalRecentItems.set(recentItems) + ); this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); return; @@ -400,7 +408,8 @@ export class DashboardDataService { private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { - this.xtreamGlobalFavorites.set([]); + const favorites = await this.loadPwaXtreamGlobalFavorites(); + this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites)); this.finishInitialGlobalFavoritesLoadIfReady(); return; } @@ -422,6 +431,87 @@ export class DashboardDataService { } } + private async loadPwaXtreamGlobalRecentItems(): Promise< + GlobalRecentItem[] + > { + const results: GlobalRecentItem[] = []; + for (const playlist of this.getXtreamPlaylists()) { + const rows = await this.xtreamDataSource.getRecentItems( + playlist._id + ); + results.push( + ...rows.map((item) => + this.mapPwaXtreamRecentItem(item, playlist) + ) + ); + } + return results; + } + + private async loadPwaXtreamGlobalFavorites(): Promise< + DashboardFavoriteItem[] + > { + const results: DashboardFavoriteItem[] = []; + for (const playlist of this.getXtreamPlaylists()) { + const rows = await this.xtreamDataSource.getFavorites(playlist._id); + results.push( + ...rows.map((item) => + this.mapPwaXtreamFavoriteItem(item, playlist) + ) + ); + } + return results; + } + + private getXtreamPlaylists(): PlaylistMeta[] { + return this.playlists().filter((playlist) => !!playlist.serverUrl); + } + + private mapPwaXtreamRecentItem( + item: XtreamContentItem, + playlist: PlaylistMeta + ): GlobalRecentItem { + return { + id: item.id, + title: item.title, + type: this.normalizeXtreamActivityType(item.type), + playlist_id: playlist._id, + playlist_name: playlist.title || 'Xtream', + viewed_at: item.viewed_at ?? '', + category_id: item.category_id, + xtream_id: item.xtream_id, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, + source: 'xtream', + }; + } + + private mapPwaXtreamFavoriteItem( + item: XtreamContentItem, + playlist: PlaylistMeta + ): DashboardFavoriteItem { + return { + id: item.id, + title: item.title, + type: this.normalizeXtreamActivityType(item.type), + playlist_id: playlist._id, + playlist_name: playlist.title || 'Xtream', + added_at: item.added_at ?? item.added ?? new Date(0).toISOString(), + category_id: item.category_id, + xtream_id: item.xtream_id, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, + source: 'xtream', + }; + } + + private normalizeXtreamActivityType(type: string): PortalActivityType { + if (type === 'live' || type === 'series') { + return type; + } + return 'movie'; + } + private async refreshPlaylistBackedGlobalFavorites(): Promise { await this.reloadM3uGlobalFavorites(); this.finishInitialGlobalFavoritesLoadIfReady(); @@ -875,8 +965,7 @@ export class DashboardDataService { const matchedFavoriteId = channelIdFavoritePosition !== undefined && (channelUrlFavoritePosition === undefined || - channelIdFavoritePosition <= - channelUrlFavoritePosition) + channelIdFavoritePosition <= channelUrlFavoritePosition) ? channelId : channelUrlFavoritePosition !== undefined ? channelUrl