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 79f3a90d8..744f9c8e9 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 @@ -17,8 +17,19 @@ describe('UnifiedFavoritesDataService', () => { let service: UnifiedFavoritesDataService; let electronApi: { dbAddFavorite: jest.Mock; + dbAddRecentItem: jest.Mock; + dbClearPlaylistRecentItems: jest.Mock; + dbClearRecentlyViewed: jest.Mock; dbGetAllGlobalFavorites: jest.Mock; + dbGetContentByXtreamId: jest.Mock; + dbGetFavorites: jest.Mock; + dbGetGlobalRecentlyAdded: jest.Mock; + dbGetRecentItems: jest.Mock; + dbGetRecentlyViewed: jest.Mock; + dbReorderGlobalFavorites: jest.Mock; dbRemoveFavorite: jest.Mock; + dbRemoveRecentItem: jest.Mock; + dbRemoveRecentItemsBatch: jest.Mock; }; let databaseService: { getAllGlobalFavorites: jest.Mock; @@ -99,8 +110,21 @@ describe('UnifiedFavoritesDataService', () => { beforeEach(() => { electronApi = { dbAddFavorite: jest.fn().mockResolvedValue({ success: true }), + dbAddRecentItem: jest.fn(), + dbClearPlaylistRecentItems: jest.fn(), + dbClearRecentlyViewed: jest.fn(), dbGetAllGlobalFavorites: jest.fn(), + dbGetContentByXtreamId: jest.fn(), + dbGetFavorites: jest.fn(), + dbGetGlobalRecentlyAdded: jest.fn(), + dbGetRecentItems: jest.fn(), + dbGetRecentlyViewed: jest.fn(), + dbReorderGlobalFavorites: jest.fn().mockResolvedValue({ + success: true, + }), dbRemoveFavorite: jest.fn().mockResolvedValue(undefined), + dbRemoveRecentItem: jest.fn(), + dbRemoveRecentItemsBatch: jest.fn(), }; Object.defineProperty(window, 'electron', { value: electronApi as Window['electron'], @@ -522,6 +546,31 @@ describe('UnifiedFavoritesDataService', () => { expect(electronApi.dbAddFavorite).not.toHaveBeenCalled(); }); + it('uses the active Xtream data source when the Electron bridge lacks activity storage methods', async () => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + + await service.removeFavorite({ + uid: 'xtream::xtream-1::movie:101', + name: 'Partial Bridge Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + posterUrl: 'movie.png', + xtreamId: 101, + contentId: 1010, + } satisfies UnifiedCollectionItem); + + expect(xtreamDataSource.removeFavorite).toHaveBeenCalledWith( + 1010, + 'xtream-1' + ); + expect(electronApi.dbRemoveFavorite).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 2ab10870e..1db243c50 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 @@ -3,7 +3,11 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { firstValueFrom, map } from 'rxjs'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { Channel, extractStalkerItemId, @@ -47,6 +51,7 @@ export class UnifiedFavoritesDataService { private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); private readonly translate = inject(TranslateService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getFavorites( @@ -90,12 +95,13 @@ export class UnifiedFavoritesDataService { await this.setM3uFavorites(item.playlistId, filtered); break; } - case 'xtream': + case 'xtream': { if (item.contentId == null) { return; } - if (window.electron) { - await window.electron.dbRemoveFavorite( + const electron = this.electronActivityBridge; + if (electron) { + await electron.dbRemoveFavorite( item.contentId, item.playlistId ); @@ -106,6 +112,7 @@ export class UnifiedFavoritesDataService { item.playlistId ); break; + } case 'stalker': { const sourceItemId = item.uid.split('::')[2]; await firstValueFrom( @@ -157,7 +164,8 @@ export class UnifiedFavoritesDataService { return; } - if (!window.electron) { + const electron = this.electronActivityBridge; + if (!electron) { await this.xtreamDataSource.addFavorite( contentId, item.playlistId, @@ -166,7 +174,7 @@ export class UnifiedFavoritesDataService { return; } - await window.electron.dbAddFavorite( + await electron.dbAddFavorite( contentId, item.playlistId, item.posterUrl ?? item.logo ?? undefined @@ -287,21 +295,22 @@ export class UnifiedFavoritesDataService { } const xtreamUpdates = this.buildXtreamPositionUpdates(items); + const electron = this.electronActivityBridge; if ( options?.scope === 'playlist' && options.playlistId && options.portalType === 'xtream' ) { - if (xtreamUpdates.length > 0 && window.electron) { - await window.electron.dbReorderGlobalFavorites(xtreamUpdates); + if (xtreamUpdates.length > 0 && electron) { + await electron.dbReorderGlobalFavorites(xtreamUpdates); } return; } await Promise.all([ - xtreamUpdates.length > 0 && window.electron - ? window.electron.dbReorderGlobalFavorites(xtreamUpdates) + xtreamUpdates.length > 0 && electron + ? electron.dbReorderGlobalFavorites(xtreamUpdates) : Promise.resolve(), this.saveOrder(items.map((item) => item.uid)), ]); @@ -356,7 +365,7 @@ export class UnifiedFavoritesDataService { private async clearXtreamFavorites( items: UnifiedCollectionItem[] ): Promise { - const electron = window.electron; + const electron = this.electronActivityBridge; if (!electron) { await Promise.all( items @@ -514,7 +523,7 @@ export class UnifiedFavoritesDataService { } private async getXtreamAllFavorites(): Promise { - if (!window.electron) { + if (!this.electronActivityBridge) { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; for (const meta of allMeta.filter( @@ -527,7 +536,6 @@ export class UnifiedFavoritesDataService { return results; } - if (!window.electron?.dbGetAllGlobalFavorites) return []; try { const rows = (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[]; @@ -542,7 +550,7 @@ export class UnifiedFavoritesDataService { ): Promise { try { const meta = await this.getPlaylistMeta(playlistId); - if (!window.electron) { + if (!this.electronActivityBridge) { const rows = await this.xtreamDataSource.getFavorites(playlistId); return rows.map((row) => @@ -568,7 +576,7 @@ export class UnifiedFavoritesDataService { return null; } - if (!window.electron) { + if (!this.electronActivityBridge) { const content = await this.xtreamDataSource.getContentByXtreamId( item.xtreamId, item.playlistId, @@ -721,9 +729,9 @@ export class UnifiedFavoritesDataService { } private async getSavedOrder(): Promise { - if (!window.electron?.dbGetAppState) return []; + if (!this.runtime.hasElectronMethod('dbGetAppState')) return []; try { - const raw = await window.electron.dbGetAppState( + const raw = await window.electron?.dbGetAppState( GLOBAL_FAVORITES_ORDER_KEY ); return raw ? (JSON.parse(raw) as string[]) : []; @@ -733,9 +741,9 @@ export class UnifiedFavoritesDataService { } private async saveOrder(uidOrder: string[]): Promise { - if (!window.electron?.dbSetAppState) return; + if (!this.runtime.hasElectronMethod('dbSetAppState')) return; try { - await window.electron.dbSetAppState( + await window.electron?.dbSetAppState( GLOBAL_FAVORITES_ORDER_KEY, JSON.stringify(uidOrder) ); @@ -845,4 +853,10 @@ export class UnifiedFavoritesDataService { return extractStalkerItemId(favorite); } + + private get electronActivityBridge(): Window['electron'] | undefined { + return this.runtime.supportsPortalActivityStorage + ? window.electron + : undefined; + } } 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 017b3fc8a..72037c787 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 @@ -94,9 +94,27 @@ describe('UnifiedRecentDataService', () => { }, ]; + const createPortalActivityElectronApi = (): Window['electron'] => + ({ + dbGetRecentlyViewed: jest.fn(), + dbClearRecentlyViewed: jest.fn(), + dbGetAllGlobalFavorites: jest.fn(), + dbGetGlobalRecentlyAdded: jest.fn(), + dbAddFavorite: jest.fn(), + dbRemoveFavorite: jest.fn(), + dbGetFavorites: jest.fn(), + dbReorderGlobalFavorites: jest.fn(), + dbGetRecentItems: jest.fn(), + dbAddRecentItem: jest.fn(), + dbClearPlaylistRecentItems: jest.fn(), + dbRemoveRecentItem: jest.fn(), + dbRemoveRecentItemsBatch: jest.fn(), + dbGetContentByXtreamId: jest.fn(), + }) as unknown as Window['electron']; + beforeEach(() => { Object.defineProperty(window, 'electron', { - value: {} as Window['electron'], + value: createPortalActivityElectronApi(), configurable: true, }); store = { @@ -295,6 +313,30 @@ describe('UnifiedRecentDataService', () => { ); }); + it('uses the active Xtream data source when the Electron bridge lacks activity storage methods', async () => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + + await service.removeRecentItem({ + uid: 'xtream::xtream-1::movie:290', + name: 'Partial Bridge Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 290, + contentId: 444, + } satisfies UnifiedCollectionItem); + + expect(xtreamDataSource.removeRecentItem).toHaveBeenCalledWith( + 444, + 'xtream-1' + ); + expect(dbService.removeRecentItem).not.toHaveBeenCalled(); + }); + it('builds distinct Xtream recent UIDs when live and series share an xtream id', 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 cb743c8e4..3da7dedd2 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 @@ -2,7 +2,11 @@ import { inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { firstValueFrom, map } from 'rxjs'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { Channel, extractStalkerItemId, @@ -40,6 +44,7 @@ export class UnifiedRecentDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getRecentItems( @@ -60,15 +65,15 @@ export class UnifiedRecentDataService { return; } - if (!window.electron) { - await this.xtreamDataSource.removeRecentItem( + if (this.hasPortalActivityStorage) { + await this.dbService.removeRecentItem( item.contentId, item.playlistId ); return; } - await this.dbService.removeRecentItem( + await this.xtreamDataSource.removeRecentItem( item.contentId, item.playlistId ); @@ -139,7 +144,7 @@ export class UnifiedRecentDataService { const tasks: Promise[] = []; if (xtreamBatch.length > 0) { - if (window.electron) { + if (this.hasPortalActivityStorage) { tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); } else { tasks.push( @@ -177,7 +182,7 @@ export class UnifiedRecentDataService { playlistId?: string ): Promise { if (scope === 'playlist' && playlistId) { - if (window.electron) { + if (this.hasPortalActivityStorage) { await this.dbService.clearPlaylistRecentItems(playlistId); } else { await this.xtreamDataSource.clearRecentItems(playlistId); @@ -189,7 +194,7 @@ export class UnifiedRecentDataService { return; } - if (window.electron) { + if (this.hasPortalActivityStorage) { await this.dbService.clearGlobalRecentlyViewed(); } else { const allMeta = await this.getAllMeta(); @@ -264,7 +269,7 @@ export class UnifiedRecentDataService { : null); if (contentId != null) { - if (window.electron) { + if (this.hasPortalActivityStorage) { await this.dbService.addRecentItem( contentId, item.playlistId @@ -349,8 +354,12 @@ export class UnifiedRecentDataService { return this.getM3uPlaylistRecent(playlistId); } + private get hasPortalActivityStorage(): boolean { + return this.runtime.supportsPortalActivityStorage; + } + private async getXtreamGlobalRecent(): Promise { - if (!window.electron) { + if (!this.hasPortalActivityStorage) { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; for (const meta of allMeta.filter( @@ -393,7 +402,7 @@ export class UnifiedRecentDataService { ): Promise { try { const meta = await this.getPlaylistMeta(playlistId); - if (!window.electron) { + if (!this.hasPortalActivityStorage) { const rows = await this.xtreamDataSource.getRecentItems(playlistId); return rows.map((row) => @@ -434,7 +443,7 @@ export class UnifiedRecentDataService { return null; } - if (!window.electron) { + if (!this.hasPortalActivityStorage) { const content = await this.xtreamDataSource.getContentByXtreamId( item.xtreamId, item.playlistId, diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index a9d8ec726..206751d0d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -39,10 +39,19 @@ describe('RuntimeCapabilitiesService', () => { dbGetAppState: jest.fn(), dbSetAppState: jest.fn(), dbGetRecentlyViewed: jest.fn(), + dbClearRecentlyViewed: jest.fn(), dbGetAllGlobalFavorites: jest.fn(), dbGetGlobalRecentlyAdded: jest.fn(), + dbAddFavorite: jest.fn(), dbRemoveRecentItem: jest.fn(), dbRemoveFavorite: jest.fn(), + dbGetFavorites: jest.fn(), + dbReorderGlobalFavorites: jest.fn(), + dbGetRecentItems: jest.fn(), + dbAddRecentItem: jest.fn(), + dbClearPlaylistRecentItems: jest.fn(), + dbRemoveRecentItemsBatch: jest.fn(), + dbGetContentByXtreamId: jest.fn(), downloadsGetList: jest.fn(), prepareEmbeddedMpv: jest.fn(), saveFileDialog: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index ec6d9bcca..d1676ffc1 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -49,13 +49,22 @@ export class RuntimeCapabilitiesService { } get supportsPortalActivityStorage(): boolean { - return ( - this.hasElectronMethod('dbGetRecentlyViewed') && - this.hasElectronMethod('dbGetAllGlobalFavorites') && - this.hasElectronMethod('dbGetGlobalRecentlyAdded') && - this.hasElectronMethod('dbRemoveRecentItem') && - this.hasElectronMethod('dbRemoveFavorite') - ); + return [ + 'dbGetRecentlyViewed', + 'dbClearRecentlyViewed', + 'dbGetAllGlobalFavorites', + 'dbGetGlobalRecentlyAdded', + 'dbAddFavorite', + 'dbRemoveFavorite', + 'dbGetFavorites', + 'dbReorderGlobalFavorites', + 'dbGetRecentItems', + 'dbAddRecentItem', + 'dbClearPlaylistRecentItems', + 'dbRemoveRecentItem', + 'dbRemoveRecentItemsBatch', + 'dbGetContentByXtreamId', + ].every((methodName) => this.hasElectronMethod(methodName)); } get supportsManagedExternalPlayers(): boolean { 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 11907b848..d5cc41009 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 @@ -159,10 +159,19 @@ describe('DashboardDataService', () => { Object.defineProperty(window, 'electron', { value: { dbGetRecentlyViewed: jest.fn(), + dbClearRecentlyViewed: jest.fn(), dbGetAllGlobalFavorites: jest.fn(), dbGetGlobalRecentlyAdded: jest.fn(), + dbAddFavorite: jest.fn(), dbRemoveRecentItem: jest.fn(), dbRemoveFavorite: jest.fn(), + dbGetFavorites: jest.fn(), + dbReorderGlobalFavorites: jest.fn(), + dbGetRecentItems: jest.fn(), + dbAddRecentItem: jest.fn(), + dbClearPlaylistRecentItems: jest.fn(), + dbRemoveRecentItemsBatch: jest.fn(), + dbGetContentByXtreamId: jest.fn(), } as unknown as Window['electron'], configurable: true, });