mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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');
|
||||
|
||||
Reference in new issue
Block a user