perf(recents): batch Xtream remove-recent-item into a single IPC call

Clearing all recents of a given content type fired N concurrent IPC calls,
each opening its own implicit transaction in the recently_viewed table.
For users with hundreds of recently-watched VOD/series rows this added
real overhead even though the UI updates optimistically.

Add a new DB_REMOVE_RECENT_ITEMS_BATCH path end-to-end:
- removeRecentItemsBatch() Drizzle op: one transaction, one prepared
  statement reused per row
- Wire through worker → IPC handler → preload → window.electron typings
  → DatabaseService
- UnifiedRecentDataService.removeRecentItemsBatch() groups items by
  source. Xtream items go through the new batch IPC. M3U/Stalker items
  still go per-playlist because they update a JSON column on the
  playlist row, not the recently_viewed table — but they now run in
  parallel with the Xtream batch via a single Promise.all.
- Single call site updated: unified-collection-page "Clear all of type"
  confirmation handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4bfb62f988b7
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-01 23:00:32 +02:00
1 parent 21fe51e73b
commit 39ddaa2f9c
12 files changed
+111 -5

No files matched your search

@@ -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,
@@ -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 };
}
@@ -35,3 +35,10 @@ handleWorkerRequest(
playlistId,
})
);
handleWorkerRequest(
'DB_REMOVE_RECENT_ITEMS_BATCH',
(items: { contentId: number; playlistId: string }[]) => ({
items,
})
);
@@ -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',
@@ -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;
+3
View File
@@ -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,
Vendored
+3
View File
@@ -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,
@@ -132,6 +132,7 @@ describe('UnifiedCollectionPageComponent', () => {
const recentData = {
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn(),
removeRecentItemsBatch: jest.fn(),
clearRecentItems: jest.fn(),
};
const dialogService = {
@@ -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);
},
});
}
@@ -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),
@@ -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<void> {
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
@@ -838,6 +838,21 @@ export class DatabaseService {
}
}
async removeRecentItemsBatch(
items: { contentId: number; playlistId: string }[]
): Promise<boolean> {
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
*/