diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts index 33af73374..8d7b2a686 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts @@ -19,6 +19,7 @@ describe('UnifiedRecentDataService', () => { addM3uRecentlyViewed: jest.Mock; removeFromM3uRecentlyViewed: jest.Mock; removeFromPortalRecentlyViewed: jest.Mock; + removeFromPlaylistRecentlyViewedBatch: jest.Mock; clearPlaylistRecentlyViewed: jest.Mock; getAllPlaylists: jest.Mock; }; @@ -113,6 +114,9 @@ describe('UnifiedRecentDataService', () => { ), removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })), removeFromPortalRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })), + removeFromPlaylistRecentlyViewedBatch: jest + .fn() + .mockReturnValue(of({ recentlyViewed: [] })), clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })), getAllPlaylists: jest.fn().mockReturnValue(of([])), }; @@ -273,6 +277,112 @@ describe('UnifiedRecentDataService', () => { ); }); + it('coalesces multiple M3U items from one playlist into a single batched removal', async () => { + const items: UnifiedCollectionItem[] = [ + { + uid: 'm3u::m3u-1::https://example.com/1.m3u8', + name: 'Channel One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U List', + streamUrl: 'https://example.com/1.m3u8', + }, + { + uid: 'm3u::m3u-1::https://example.com/2.m3u8', + name: 'Channel Two', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U List', + streamUrl: 'https://example.com/2.m3u8', + }, + ]; + + await service.removeRecentItemsBatch(items); + + expect( + playlistsService.removeFromPlaylistRecentlyViewedBatch + ).toHaveBeenCalledTimes(1); + expect( + playlistsService.removeFromPlaylistRecentlyViewedBatch + ).toHaveBeenCalledWith('m3u-1', [ + 'https://example.com/1.m3u8', + 'https://example.com/2.m3u8', + ]); + expect(playlistsService.removeFromM3uRecentlyViewed).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + it('groups Stalker items by their stalker id and skips entries without an identity', async () => { + const items: UnifiedCollectionItem[] = [ + { + uid: 'stalker::stalker-1::42', + name: 'Stalker Live A', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '42', + }, + { + uid: 'stalker::stalker-1::43', + name: 'Stalker Live B', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '43', + }, + ]; + + await service.removeRecentItemsBatch(items); + + expect( + playlistsService.removeFromPlaylistRecentlyViewedBatch + ).toHaveBeenCalledTimes(1); + expect( + playlistsService.removeFromPlaylistRecentlyViewedBatch + ).toHaveBeenCalledWith('stalker-1', ['42', '43']); + }); + + it('routes Xtream items through dbService and m3u items through the batch helper', async () => { + const items: UnifiedCollectionItem[] = [ + { + uid: 'xtream::xtream-1::live:290', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + contentId: 3867578, + xtreamId: 290, + }, + { + uid: 'm3u::m3u-1::https://example.com/1.m3u8', + name: 'Channel One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U List', + streamUrl: 'https://example.com/1.m3u8', + }, + ]; + + await service.removeRecentItemsBatch(items); + + expect(dbService.removeRecentItemsBatch).toHaveBeenCalledTimes(1); + expect(dbService.removeRecentItemsBatch).toHaveBeenCalledWith([ + { contentId: 3867578, playlistId: 'xtream-1' }, + ]); + expect( + playlistsService.removeFromPlaylistRecentlyViewedBatch + ).toHaveBeenCalledTimes(1); + expect( + playlistsService.removeFromPlaylistRecentlyViewedBatch + ).toHaveBeenCalledWith('m3u-1', ['https://example.com/1.m3u8']); + }); + it('normalizes SQLite-style Xtream recent timestamps to ISO before exposing them', async () => { store.select.mockReturnValue( of([ diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts index fd1f22d5f..8de09882b 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts @@ -82,9 +82,10 @@ export class UnifiedRecentDataService { } /** - * Bulk remove. Xtream items are batched into a single IPC call; - * m3u/stalker items still go per-playlist because they update a JSON - * column on the playlist row (not the recently_viewed SQL table). + * Bulk remove. Xtream items are batched into a single IPC call. m3u/stalker + * items live in the playlist row's `recentlyViewed` JSON column, so we + * group them by `playlistId` and do one read-filter-write per playlist — + * a per-item Promise.all would race and clobber sibling deletions. */ async removeRecentItemsBatch( items: UnifiedCollectionItem[] @@ -94,25 +95,56 @@ export class UnifiedRecentDataService { } const xtreamBatch: { contentId: number; playlistId: string }[] = []; - const nonXtreamItems: UnifiedCollectionItem[] = []; + const groupedByPlaylist = new Map(); for (const item of items) { - if (item.sourceType === 'xtream' && item.contentId != null) { - xtreamBatch.push({ - contentId: item.contentId, - playlistId: item.playlistId, - }); - } else if (item.sourceType !== 'xtream') { - nonXtreamItems.push(item); + if (item.sourceType === 'xtream') { + if (item.contentId != null) { + xtreamBatch.push({ + contentId: item.contentId, + playlistId: item.playlistId, + }); + } + continue; } + + const identity = + item.sourceType === 'm3u' + ? (item.streamUrl ?? item.uid.split('::')[2]) + : (item.stalkerId ?? item.uid.split('::')[2]); + + if (!identity) { + continue; + } + + const existing = groupedByPlaylist.get(item.playlistId) ?? []; + existing.push(identity); + groupedByPlaylist.set(item.playlistId, existing); } - await Promise.all([ - xtreamBatch.length > 0 - ? this.dbService.removeRecentItemsBatch(xtreamBatch) - : Promise.resolve(), - ...nonXtreamItems.map((item) => this.removeRecentItem(item)), - ]); + const tasks: Promise[] = []; + + if (xtreamBatch.length > 0) { + tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + } + + for (const [playlistId, identities] of groupedByPlaylist) { + tasks.push( + firstValueFrom( + this.playlistsService.removeFromPlaylistRecentlyViewedBatch( + playlistId, + identities + ) + ).then((updatedPlaylist) => + this.dispatchPlaylistRecentUpdate( + playlistId, + updatedPlaylist + ) + ) + ); + } + + await Promise.all(tasks); } async clearRecentItems( diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 41a52cc24..e21456a19 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -217,6 +217,158 @@ describe('PlaylistsService', () => { ); }); + it('removes multiple recently-viewed identities in a single PWA write', async () => { + const existingPlaylist: Playlist = { + _id: 'playlist-3', + title: 'Playlist Three', + count: 3, + importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), + autoRefresh: false, + recentlyViewed: [ + { + source: 'm3u', + id: 'https://example.com/1.m3u8', + url: 'https://example.com/1.m3u8', + title: 'A', + category_id: 'live', + added_at: '2026-05-04T10:00:00.000Z', + }, + { + source: 'm3u', + id: 'https://example.com/2.m3u8', + url: 'https://example.com/2.m3u8', + title: 'B', + category_id: 'live', + added_at: '2026-05-04T10:01:00.000Z', + }, + { + source: 'm3u', + id: 'https://example.com/3.m3u8', + url: 'https://example.com/3.m3u8', + title: 'C', + category_id: 'live', + added_at: '2026-05-04T10:02:00.000Z', + }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.removeFromPlaylistRecentlyViewedBatch('playlist-3', [ + 'https://example.com/1.m3u8', + 'https://example.com/2.m3u8', + ]) + ); + + expect(dbService.update).toHaveBeenCalledTimes(1); + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + _id: 'playlist-3', + recentlyViewed: [ + expect.objectContaining({ + url: 'https://example.com/3.m3u8', + }), + ], + }) + ); + }); + + it('removes multiple recently-viewed identities in a single Electron upsert', async () => { + const existingPlaylist: Playlist = { + _id: 'playlist-4', + title: 'Playlist Four', + count: 0, + importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), + autoRefresh: false, + recentlyViewed: [ + { + source: 'm3u', + id: 'https://example.com/a.m3u8', + url: 'https://example.com/a.m3u8', + title: 'A', + category_id: 'live', + added_at: '2026-05-04T10:00:00.000Z', + }, + { + source: 'm3u', + id: 'https://example.com/b.m3u8', + url: 'https://example.com/b.m3u8', + title: 'B', + category_id: 'live', + added_at: '2026-05-04T10:01:00.000Z', + }, + ], + } as Playlist; + const electron = { + dbGetAppPlaylist: jest.fn(async () => existingPlaylist), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async (key: string) => + key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null + ), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(async () => undefined), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + const service = createService(); + + await firstValueFrom( + service.removeFromPlaylistRecentlyViewedBatch('playlist-4', [ + 'https://example.com/a.m3u8', + 'https://example.com/b.m3u8', + ]) + ); + + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1); + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'playlist-4', + recentlyViewed: [], + }) + ); + }); + + it('short-circuits removeFromPlaylistRecentlyViewedBatch when identities is empty', async () => { + const existingPlaylist: Playlist = { + _id: 'playlist-5', + title: 'Playlist Five', + count: 0, + importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), + autoRefresh: false, + recentlyViewed: [], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.removeFromPlaylistRecentlyViewedBatch('playlist-5', []) + ); + + expect(dbService.update).not.toHaveBeenCalled(); + }); + it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => { const existingPlaylist: Playlist = { _id: 'playlist-2', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 0be38a512..d5bdf13c1 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -1041,6 +1041,44 @@ export class PlaylistsService { ); } + removeFromPlaylistRecentlyViewedBatch( + playlistId: string, + identities: ReadonlyArray + ) { + if (!playlistId) { + throw new Error('Playlist ID is required'); + } + + if (identities.length === 0) { + return this.getPlaylistById(playlistId); + } + + return this.getPlaylistById(playlistId).pipe( + switchMap((playlist) => { + const nextPlaylist: Playlist = { + ...playlist, + recentlyViewed: ( + playlist.recentlyViewed as PlaylistRecentlyViewedItem[] + )?.filter( + (item) => + !identities.some((identity) => + this.matchesPlaylistRecentIdentity( + item, + identity + ) + ) + ), + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) + ); + } + clearPlaylistRecentlyViewed(playlistId: string) { if (!playlistId) { throw new Error('Playlist ID is required');