diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a0cbd559b..20113f933 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -540,6 +540,9 @@ const electronApi = { ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId), dbRemoveRecentItem: (contentId: number, playlistId: string) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId), + dbRemoveRecentItemsBatch: ( + items: { contentId: number; playlistId: string }[] + ) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEMS_BATCH', items), dbGetContentByXtreamId: ( xtreamId: number, playlistId: string, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index 854153ca6..f85f4c4fa 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -150,3 +150,36 @@ export async function removeRecentItem( return { success: true }; } + +export async function removeRecentItemsBatch( + db: AppDatabase, + items: { contentId: number; playlistId: string }[] +): Promise<{ success: boolean; count: number }> { + if (!Array.isArray(items) || items.length === 0) { + return { success: true, count: 0 }; + } + + const stmt = db + .delete(schema.recentlyViewed) + .where( + and( + eq( + schema.recentlyViewed.contentId, + sql.placeholder('contentId') + ), + eq( + schema.recentlyViewed.playlistId, + sql.placeholder('playlistId') + ) + ) + ) + .prepare(); + + await db.transaction(async () => { + for (const { contentId, playlistId } of items) { + await stmt.execute({ contentId, playlistId }); + } + }); + + return { success: true, count: items.length }; +} diff --git a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts index 9a871f2f4..041989d65 100644 --- a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts +++ b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts @@ -35,3 +35,10 @@ handleWorkerRequest( playlistId, }) ); + +handleWorkerRequest( + 'DB_REMOVE_RECENT_ITEMS_BATCH', + (items: { contentId: number; playlistId: string }[]) => ({ + items, + }) +); diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index f8fe3a9f3..e7a86da44 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -39,6 +39,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_ADD_RECENT_ITEM', 'DB_CLEAR_PLAYLIST_RECENT_ITEMS', 'DB_REMOVE_RECENT_ITEM', + 'DB_REMOVE_RECENT_ITEMS_BATCH', 'DB_SAVE_PLAYBACK_POSITION', 'DB_GET_PLAYBACK_POSITION', 'DB_GET_SERIES_PLAYBACK_POSITIONS', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index c5b53c84b..4649e5167 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -73,6 +73,7 @@ import { getRecentItems, getRecentlyViewed, removeRecentItem, + removeRecentItemsBatch, } from '../database/operations/recently-viewed.operations'; import { deleteXtreamContent, @@ -761,6 +762,13 @@ async function executeRequest(message: DbWorkerRequestMessage) { return removeRecentItem(db, payload.contentId, payload.playlistId); } + case 'DB_REMOVE_RECENT_ITEMS_BATCH': { + const payload = message.payload as { + items: { contentId: number; playlistId: string }[]; + }; + return removeRecentItemsBatch(db, payload.items); + } + case 'DB_SAVE_PLAYBACK_POSITION': { const payload = message.payload as { playlistId: string; diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 5c84ec15e..d4d328e96 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -273,6 +273,9 @@ declare global { contentId: number, playlistId: string ) => Promise<{ success: boolean }>; + dbRemoveRecentItemsBatch: ( + items: { contentId: number; playlistId: string }[] + ) => Promise<{ success: boolean; count: number }>; dbGetContentByXtreamId: ( xtreamId: number, playlistId: string, diff --git a/global.d.ts b/global.d.ts index 80b615bb3..635339785 100644 --- a/global.d.ts +++ b/global.d.ts @@ -266,6 +266,9 @@ declare global { contentId: number, playlistId: string ) => Promise<{ success: boolean }>; + dbRemoveRecentItemsBatch: ( + items: { contentId: number; playlistId: string }[] + ) => Promise<{ success: boolean; count: number }>; dbGetContentByXtreamId: ( xtreamId: number, playlistId: string, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 713759d85..ec627409b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -132,6 +132,7 @@ describe('UnifiedCollectionPageComponent', () => { const recentData = { getRecentItems: jest.fn().mockResolvedValue([]), removeRecentItem: jest.fn(), + removeRecentItemsBatch: jest.fn(), clearRecentItems: jest.fn(), }; const dialogService = { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index bbc564505..28c7df6e3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -556,11 +556,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { if (remaining.length > 0) { this.selectedContentType.set(remaining[0]); } - void Promise.all( - itemsToRemove.map((item) => - this.recentData.removeRecentItem(item) - ) - ); + void this.recentData.removeRecentItemsBatch(itemsToRemove); }, }); } 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 a0160b878..33af73374 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 @@ -26,6 +26,7 @@ describe('UnifiedRecentDataService', () => { getGlobalRecentlyViewed: jest.Mock; getRecentItems: jest.Mock; removeRecentItem: jest.Mock; + removeRecentItemsBatch: jest.Mock; clearPlaylistRecentItems: jest.Mock; clearGlobalRecentlyViewed: jest.Mock; addRecentItem: jest.Mock; @@ -119,6 +120,7 @@ describe('UnifiedRecentDataService', () => { getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]), getRecentItems: jest.fn().mockResolvedValue([]), removeRecentItem: jest.fn().mockResolvedValue(true), + removeRecentItemsBatch: jest.fn().mockResolvedValue(true), clearPlaylistRecentItems: jest.fn().mockResolvedValue(true), clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined), addRecentItem: jest.fn().mockResolvedValue(true), 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 b8bac3b17..fd1f22d5f 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 @@ -81,6 +81,40 @@ export class UnifiedRecentDataService { this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist); } + /** + * 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). + */ + async removeRecentItemsBatch( + items: UnifiedCollectionItem[] + ): Promise { + if (items.length === 0) { + return; + } + + const xtreamBatch: { contentId: number; playlistId: string }[] = []; + const nonXtreamItems: UnifiedCollectionItem[] = []; + + 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); + } + } + + await Promise.all([ + xtreamBatch.length > 0 + ? this.dbService.removeRecentItemsBatch(xtreamBatch) + : Promise.resolve(), + ...nonXtreamItems.map((item) => this.removeRecentItem(item)), + ]); + } + async clearRecentItems( scope: CollectionScope, playlistId?: string diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 6905855aa..abda4dbdb 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -838,6 +838,21 @@ export class DatabaseService { } } + async removeRecentItemsBatch( + items: { contentId: number; playlistId: string }[] + ): Promise { + if (items.length === 0) { + return true; + } + try { + await window.electron.dbRemoveRecentItemsBatch(items); + return true; + } catch (error) { + console.error('Error removing recent items batch:', error); + return false; + } + } + /** * Get content by xtream ID */