fix(recently-viewed): persist clear-all for M3U and Stalker playlists

Batch removal fanned out per-item read-modify-write calls against the
playlist's recentlyViewed JSON column via Promise.all, racing each other so
only the last write persisted. Group non-Xtream items by playlistId and use
a new removeFromPlaylistRecentlyViewedBatch helper that filters all
identities in a single read-filter-write per playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-04 23:59:58 +02:00
1 parent a74b8f1b81
commit 008bafbaae
4 files changed
+349 -17

No files matched your search

@@ -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([
@@ -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<string, (string | number)[]>();
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<unknown>[] = [];
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(
@@ -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',
@@ -1041,6 +1041,44 @@ export class PlaylistsService {
);
}
removeFromPlaylistRecentlyViewedBatch(
playlistId: string,
identities: ReadonlyArray<string | number>
) {
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');