diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index c3d9d1c36..ede3bcbd5 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { firstValueFrom, from, of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { Channel, @@ -49,8 +49,7 @@ describe('UnifiedFavoritesDataService', () => { let playlistsService: { addPortalFavorite: jest.Mock; getPlaylistById: jest.Mock; - setFavorites: jest.Mock; - setPortalFavorites: jest.Mock; + transformPlaylistFavorites: jest.Mock; }; const m3uChannels: Channel[] = [ @@ -134,8 +133,31 @@ describe('UnifiedFavoritesDataService', () => { playlistsService = { addPortalFavorite: jest.fn().mockReturnValue(of({})), getPlaylistById: jest.fn(), - setFavorites: jest.fn().mockReturnValue(of({})), - setPortalFavorites: jest.fn().mockReturnValue(of({})), + // Faithful default: read the playlist through the configured + // getPlaylistById mock and apply the transform to its favorites, + // like the real queued PlaylistsService implementation. + transformPlaylistFavorites: jest.fn( + ( + playlistId: string, + transform: (current: unknown[]) => unknown[] + ) => + from( + (async () => { + const playlist = (await firstValueFrom( + playlistsService.getPlaylistById(playlistId) ?? + of(undefined) + )) as Partial | undefined; + const current = Array.isArray(playlist?.favorites) + ? playlist.favorites + : []; + return { + ...playlist, + _id: playlistId, + favorites: transform(current), + }; + })() + ) + ), }; databaseService = { getAllGlobalFavorites: jest.fn().mockResolvedValue([]), @@ -408,7 +430,7 @@ describe('UnifiedFavoritesDataService', () => { ]); }); - it('persists M3U playlist reorders through setFavorites', async () => { + it('persists M3U playlist reorders through an atomic favorites transform', async () => { const reorderedItems = [ { uid: 'm3u::m3u-1::https://example.com/2.m3u8', @@ -438,13 +460,27 @@ describe('UnifiedFavoritesDataService', () => { portalType: 'm3u', }); - expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [ + expect( + playlistsService.transformPlaylistFavorites + ).toHaveBeenCalledWith('m3u-1', expect.any(Function)); + const [, transform] = + playlistsService.transformPlaylistFavorites.mock.calls[0]; + // The reordered ids come first; favorites added concurrently (not part + // of the drag list) are preserved at the end instead of being dropped. + expect( + transform([ + 'https://example.com/1.m3u8', + 'https://example.com/2.m3u8', + 'https://example.com/concurrent.m3u8', + ]) + ).toEqual([ 'https://example.com/2.m3u8', 'https://example.com/1.m3u8', + 'https://example.com/concurrent.m3u8', ]); }); - it('adds M3U favorites through setFavorites without duplicating existing entries', async () => { + it('adds M3U favorites through an atomic transform without duplicating existing entries', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'm3u-1', @@ -463,10 +499,9 @@ describe('UnifiedFavoritesDataService', () => { channelId: 'new-channel', } satisfies UnifiedCollectionItem); - expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [ - 'https://example.com/existing.m3u8', - 'https://example.com/new.m3u8', - ]); + expect( + playlistsService.transformPlaylistFavorites + ).toHaveBeenCalledWith('m3u-1', expect.any(Function)); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: '[Playlists] Update Playlist Meta', @@ -490,7 +525,69 @@ describe('UnifiedFavoritesDataService', () => { streamUrl: 'https://example.com/existing.m3u8', } satisfies UnifiedCollectionItem); - expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1); + // The duplicate add still runs atomically, but its transform leaves + // the favorites unchanged. + const [, duplicateTransform] = + playlistsService.transformPlaylistFavorites.mock.calls[1]; + expect( + duplicateTransform(['https://example.com/existing.m3u8']) + ).toEqual(['https://example.com/existing.m3u8']); + }); + + it('does not lose a rapid favorite toggle through the public M3U add flow', async () => { + const backingStore = { + favorites: ['https://example.com/existing.m3u8'], + }; + let queue: Promise = Promise.resolve(); + playlistsService.transformPlaylistFavorites.mockImplementation( + ( + playlistId: string, + transform: (current: string[]) => string[] + ) => { + // Emulate the real queued read-transform-write semantics. + const run = queue.then(async () => { + await Promise.resolve(); + backingStore.favorites = transform(backingStore.favorites); + return { + _id: playlistId, + favorites: backingStore.favorites, + }; + }); + queue = run.then( + () => undefined, + () => undefined + ); + return from(run); + } + ); + + const baseItem = { + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U List', + } as const; + + await Promise.all([ + service.addFavorite({ + ...baseItem, + uid: 'm3u::m3u-1::https://example.com/a.m3u8', + name: 'Channel A', + streamUrl: 'https://example.com/a.m3u8', + } satisfies UnifiedCollectionItem), + service.addFavorite({ + ...baseItem, + uid: 'm3u::m3u-1::https://example.com/b.m3u8', + name: 'Channel B', + streamUrl: 'https://example.com/b.m3u8', + } satisfies UnifiedCollectionItem), + ]); + + expect(backingStore.favorites).toEqual([ + 'https://example.com/existing.m3u8', + 'https://example.com/a.m3u8', + 'https://example.com/b.m3u8', + ]); }); it('adds Xtream favorites after resolving the content id', async () => { @@ -605,7 +702,7 @@ describe('UnifiedFavoritesDataService', () => { ); }); - it('persists Stalker playlist reorders through setPortalFavorites', async () => { + it('persists Stalker playlist reorders through an atomic favorites transform', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'stalker-1', @@ -642,10 +739,15 @@ describe('UnifiedFavoritesDataService', () => { } ); - expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith( - 'stalker-1', - [stalkerFavorites[1], stalkerFavorites[0]] - ); + expect( + playlistsService.transformPlaylistFavorites + ).toHaveBeenCalledWith('stalker-1', expect.any(Function)); + const [, transform] = + playlistsService.transformPlaylistFavorites.mock.calls[0]; + expect(transform(stalkerFavorites)).toEqual([ + stalkerFavorites[1], + stalkerFavorites[0], + ]); }); it('clears M3U favorites once per playlist with the remaining favorites preserved', async () => { @@ -683,10 +785,21 @@ describe('UnifiedFavoritesDataService', () => { }, ] satisfies UnifiedCollectionItem[]); - expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1); - expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [ - 'https://example.com/3.m3u8', - ]); + expect( + playlistsService.transformPlaylistFavorites + ).toHaveBeenCalledTimes(1); + expect( + playlistsService.transformPlaylistFavorites + ).toHaveBeenCalledWith('m3u-1', expect.any(Function)); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: '[Playlists] Update Playlist Meta', + playlist: expect.objectContaining({ + _id: 'm3u-1', + favorites: ['https://example.com/3.m3u8'], + }), + }) + ); }); it('clears Stalker favorites once per playlist with the remaining favorites preserved', async () => { @@ -727,11 +840,15 @@ describe('UnifiedFavoritesDataService', () => { }, ] satisfies UnifiedCollectionItem[]); - expect(playlistsService.setPortalFavorites).toHaveBeenCalledTimes(1); - expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith( - 'stalker-1', - [remainingFavorite] - ); + expect( + playlistsService.transformPlaylistFavorites + ).toHaveBeenCalledTimes(1); + const [transformedPlaylistId, transform] = + playlistsService.transformPlaylistFavorites.mock.calls[0]; + expect(transformedPlaylistId).toBe('stalker-1'); + expect(transform([...stalkerFavorites, remainingFavorite])).toEqual([ + remainingFavorite, + ]); }); it('clears Xtream favorites through the bulk removal path', async () => { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index cdac59396..3a8c7bdf8 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -82,17 +82,13 @@ export class UnifiedFavoritesDataService { async removeFavorite(item: UnifiedCollectionItem): Promise { switch (item.sourceType) { case 'm3u': { - const playlist = await firstValueFrom( - this.playlistsService.getPlaylistById(item.playlistId) + await this.transformM3uFavorites(item.playlistId, (current) => + current.filter( + (favoriteId) => + favoriteId !== item.streamUrl && + favoriteId !== item.channelId + ) ); - const filtered = ( - (playlist.favorites as string[]) ?? [] - ).filter( - (favoriteId) => - favoriteId !== item.streamUrl && - favoriteId !== item.channelId - ); - await this.setM3uFavorites(item.playlistId, filtered); break; } case 'xtream': { @@ -132,23 +128,9 @@ export class UnifiedFavoritesDataService { return; } - const playlist = (await firstValueFrom( - this.playlistsService.getPlaylistById(item.playlistId) - )) as Playlist | undefined; - const currentFavorites = Array.isArray(playlist?.favorites) - ? playlist.favorites.filter( - (favorite): favorite is string => typeof favorite === 'string' - ) - : []; - - if (currentFavorites.includes(favoriteId)) { - return; - } - - await this.setM3uFavorites(item.playlistId, [ - ...currentFavorites, - favoriteId, - ]); + await this.transformM3uFavorites(item.playlistId, (current) => + current.includes(favoriteId) ? current : [...current, favoriteId] + ); } private async addXtreamFavorite( @@ -249,12 +231,14 @@ export class UnifiedFavoritesDataService { options.playlistId && options.portalType === 'm3u' ) { - await this.setM3uFavorites( - options.playlistId, - items - .map((item) => item.streamUrl ?? item.channelId ?? '') - .filter((value) => value.length > 0) - ); + const orderedIds = items + .map((item) => item.streamUrl ?? item.channelId ?? '') + .filter((value) => value.length > 0); + const orderedIdSet = new Set(orderedIds); + await this.transformM3uFavorites(options.playlistId, (current) => [ + ...orderedIds, + ...current.filter((favorite) => !orderedIdSet.has(favorite)), + ]); return; } @@ -263,32 +247,43 @@ export class UnifiedFavoritesDataService { options.playlistId && options.portalType === 'stalker' ) { - const playlist = (await firstValueFrom( - this.playlistsService.getPlaylistById(options.playlistId) - )) as Playlist | undefined; - const currentFavorites = Array.isArray(playlist?.favorites) - ? playlist.favorites.filter(isStalkerItem) - : []; - const favoritesById = new Map( - currentFavorites.map((favorite) => [ - this.getStalkerFavoriteId(favorite), - favorite, - ]) - ); - const reorderedFavorites = items - .map( - (item) => - favoritesById.get(this.getStalkerFavoriteId(item)) ?? - null - ) - .filter( - (favorite): favorite is StalkerPortalItem => - favorite !== null - ); await firstValueFrom( - this.playlistsService.setPortalFavorites( + this.playlistsService.transformPlaylistFavorites( options.playlistId, - reorderedFavorites + (current) => { + const currentFavorites = current.filter(isStalkerItem); + const favoritesById = new Map( + currentFavorites.map((favorite) => [ + this.getStalkerFavoriteId(favorite), + favorite, + ]) + ); + const reorderedFavorites = items + .map( + (item) => + favoritesById.get( + this.getStalkerFavoriteId(item) + ) ?? null + ) + .filter( + (favorite): favorite is StalkerPortalItem => + favorite !== null + ); + const reorderedIds = new Set( + reorderedFavorites.map((favorite) => + this.getStalkerFavoriteId(favorite) + ) + ); + return [ + ...reorderedFavorites, + ...currentFavorites.filter( + (favorite) => + !reorderedIds.has( + this.getStalkerFavoriteId(favorite) + ) + ), + ]; + } ) ); return; @@ -334,9 +329,6 @@ export class UnifiedFavoritesDataService { await Promise.all( Array.from(groupedItems.entries()).map( async ([playlistId, playlistItems]) => { - const playlist = (await firstValueFrom( - this.playlistsService.getPlaylistById(playlistId) - )) as Playlist | undefined; const targetIds = new Set(); playlistItems.forEach((item) => { [item.streamUrl, item.channelId].forEach((value) => { @@ -346,17 +338,12 @@ export class UnifiedFavoritesDataService { } }); }); - const currentFavorites = Array.isArray(playlist?.favorites) - ? playlist.favorites.filter( - (favorite): favorite is string => - typeof favorite === 'string' - ) - : []; - const nextFavorites = currentFavorites.filter( - (favorite) => !targetIds.has(favorite.trim()) - ); - await this.setM3uFavorites(playlistId, nextFavorites); + await this.transformM3uFavorites(playlistId, (current) => + current.filter( + (favorite) => !targetIds.has(favorite.trim()) + ) + ); } ) ); @@ -409,26 +396,26 @@ export class UnifiedFavoritesDataService { await Promise.all( Array.from(groupedItems.entries()).map( async ([playlistId, playlistItems]) => { - const playlist = (await firstValueFrom( - this.playlistsService.getPlaylistById(playlistId) - )) as Playlist | undefined; const targetIds = new Set( playlistItems.map((item) => this.getStalkerFavoriteId(item) ) ); - const currentFavorites = Array.isArray(playlist?.favorites) - ? playlist.favorites.filter(isStalkerItem) - : []; - const nextFavorites = currentFavorites.filter( - (favorite) => - !targetIds.has(this.getStalkerFavoriteId(favorite)) - ); await firstValueFrom( - this.playlistsService.setPortalFavorites( + this.playlistsService.transformPlaylistFavorites( playlistId, - nextFavorites + (current) => + current + .filter(isStalkerItem) + .filter( + (favorite) => + !targetIds.has( + this.getStalkerFavoriteId( + favorite + ) + ) + ) ) ); } @@ -758,18 +745,27 @@ export class UnifiedFavoritesDataService { } } - private async setM3uFavorites( + private async transformM3uFavorites( playlistId: string, - favorites: string[] + transform: (currentFavorites: string[]) => string[] ): Promise { - await firstValueFrom( - this.playlistsService.setFavorites(playlistId, favorites) + const updatedPlaylist = await firstValueFrom( + this.playlistsService.transformPlaylistFavorites( + playlistId, + (current) => + transform( + current.filter( + (favorite): favorite is string => + typeof favorite === 'string' + ) + ) + ) ); this.store.dispatch( PlaylistActions.updatePlaylistMeta({ playlist: { _id: playlistId, - favorites, + favorites: updatedPlaylist.favorites, } as PlaylistMeta, }) ); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index f1701fdca..fa269989d 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -55,6 +55,7 @@ describe('PlaylistsService', () => { }, electronMigrationPromise: null, indexedDbMigrationPromise: null, + playlistWriteQueues: new Map(), playlistDeleteCleanups: [], }); @@ -274,7 +275,9 @@ describe('PlaylistsService', () => { await expect( firstValueFrom(service.getM3uFavoriteChannels('playlist-1')) ).resolves.toBeNull(); - expect(electron.dbGetAppPlaylistFavoriteChannels).not.toHaveBeenCalled(); + expect( + electron.dbGetAppPlaylistFavoriteChannels + ).not.toHaveBeenCalled(); expect(electron.dbGetAppPlaylist).not.toHaveBeenCalled(); expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled(); }); @@ -477,6 +480,10 @@ describe('PlaylistsService', () => { it('updates many browser playlists with refresh metadata', async () => { jest.spyOn(Date, 'now').mockReturnValue(1770000000000); + // Auto-refresh snapshots always come from playlists that had + // autoRefresh enabled; the batch write preserves that flag from the + // current row (or the snapshot when the row is missing) instead of + // force-enabling it. const playlists = [ { _id: 'playlist-a', @@ -484,7 +491,7 @@ describe('PlaylistsService', () => { count: 1, importDate: '2026-04-01T00:00:00.000Z', lastUsage: '2026-04-01T00:00:00.000Z', - autoRefresh: false, + autoRefresh: true, }, { _id: 'playlist-b', @@ -492,7 +499,7 @@ describe('PlaylistsService', () => { count: 2, importDate: '2026-04-01T00:00:00.000Z', lastUsage: '2026-04-01T00:00:00.000Z', - autoRefresh: false, + autoRefresh: true, }, ] as Playlist[]; const dbService = { @@ -1240,4 +1247,428 @@ describe('PlaylistsService', () => { }) ); }); + + describe('per-playlist write serialization', () => { + function createBasePlaylist(id: string): Playlist { + return { + _id: id, + title: 'Race Playlist', + count: 0, + importDate: '2026-07-01T00:00:00.000Z', + lastUsage: '2026-07-01T00:00:00.000Z', + autoRefresh: false, + favorites: [{ stream_id: 1, title: 'Existing Favorite' }], + recentlyViewed: [], + } as Playlist; + } + + function createStatefulElectronStore(initialPlaylist: Playlist) { + const store = { current: initialPlaylist }; + const electron = { + dbGetAppPlaylist: jest.fn(async () => { + // Yield a microtask so overlapping reads interleave the + // same way real async IPC reads do. + await Promise.resolve(); + return store.current; + }), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async (key: string) => + key === SQLITE_PLAYLIST_MIGRATION_FLAG || + key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG + ? '1' + : null + ), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(async (playlist: Playlist) => { + store.current = playlist; + }), + dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => { + const target = playlists.find( + (playlist) => playlist._id === store.current._id + ); + if (target) { + store.current = target; + } + }), + }; + return { store, electron }; + } + + it('keeps both changes when a favorite add overlaps a recently-viewed add (SQLite)', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-race-cross-field') + ); + testWindow.electron = electron; + + const service = createService(); + + await Promise.all([ + firstValueFrom( + service.addPortalFavorite('portal-race-cross-field', { + stream_id: 2, + title: 'New Favorite', + } as never) + ), + firstValueFrom( + service.addPlaylistRecentlyViewed( + 'portal-race-cross-field', + { + source: 'm3u', + id: 'https://example.com/recent.m3u8', + url: 'https://example.com/recent.m3u8', + title: 'Recent Channel', + category_id: 'live', + added_at: '2026-07-25T10:00:00.000Z', + } as never + ) + ), + ]); + + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 2 }), + ]); + expect(store.current.recentlyViewed).toEqual([ + expect.objectContaining({ + url: 'https://example.com/recent.m3u8', + }), + ]); + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); + }); + + it('keeps both favorites when two rapid favorite adds overlap (SQLite)', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-race-two-favorites') + ); + testWindow.electron = electron; + + const service = createService(); + + await Promise.all([ + firstValueFrom( + service.addPortalFavorite('portal-race-two-favorites', { + stream_id: 2, + title: 'Second Favorite', + } as never) + ), + firstValueFrom( + service.addPortalFavorite('portal-race-two-favorites', { + stream_id: 3, + title: 'Third Favorite', + } as never) + ), + ]); + + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 2 }), + expect.objectContaining({ stream_id: 3 }), + ]); + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); + }); + + it('keeps both changes when mutations overlap in the IndexedDB path', async () => { + const store = { + current: createBasePlaylist('portal-race-indexeddb'), + }; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(store.current)), + update: jest.fn((_storeName: string, playlist: Playlist) => { + store.current = playlist; + return of(playlist); + }), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await Promise.all([ + firstValueFrom( + service.addPortalFavorite('portal-race-indexeddb', { + stream_id: 2, + title: 'New Favorite', + } as never) + ), + firstValueFrom( + service.addPlaylistRecentlyViewed('portal-race-indexeddb', { + source: 'm3u', + id: 'https://example.com/recent.m3u8', + url: 'https://example.com/recent.m3u8', + title: 'Recent Channel', + category_id: 'live', + added_at: '2026-07-25T10:00:00.000Z', + } as never) + ), + ]); + + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 2 }), + ]); + expect(store.current.recentlyViewed).toEqual([ + expect.objectContaining({ + url: 'https://example.com/recent.m3u8', + }), + ]); + expect(dbService.update).toHaveBeenCalledTimes(2); + }); + + it('continues the per-playlist queue after a failed mutation', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-race-error') + ); + electron.dbGetAppPlaylist.mockRejectedValueOnce( + new Error('read failed') + ); + testWindow.electron = electron; + + const service = createService(); + + const failing = firstValueFrom( + service.addPortalFavorite('portal-race-error', { + stream_id: 2, + title: 'Dropped Favorite', + } as never) + ); + const following = firstValueFrom( + service.addPortalFavorite('portal-race-error', { + stream_id: 3, + title: 'Surviving Favorite', + } as never) + ); + + await expect(failing).rejects.toThrow('read failed'); + await following; + + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 3 }), + ]); + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1); + }); + + it('applies overlapping favorites transforms atomically', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-transform-race') + ); + testWindow.electron = electron; + + const service = createService(); + + await Promise.all([ + firstValueFrom( + service.transformPlaylistFavorites( + 'portal-transform-race', + (current) => [ + ...current, + { stream_id: 2, title: 'Second' } as never, + ] + ) + ), + firstValueFrom( + service.transformPlaylistFavorites( + 'portal-transform-race', + (current) => [ + ...current, + { stream_id: 3, title: 'Third' } as never, + ] + ) + ), + ]); + + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 2 }), + expect.objectContaining({ stream_id: 3 }), + ]); + }); + + it('keeps a queued favorite add when an auto-refresh batch write overlaps', async () => { + const { store, electron } = createStatefulElectronStore({ + ...createBasePlaylist('portal-refresh-race'), + autoRefresh: true, + } as Playlist); + testWindow.electron = electron; + + const service = createService(); + const staleRefreshSnapshot = { + ...createBasePlaylist('portal-refresh-race'), + autoRefresh: true, + title: 'Refreshed Title', + count: 42, + recentlyViewed: [], + } as Playlist; + + await Promise.all([ + firstValueFrom( + service.addPortalFavorite('portal-refresh-race', { + stream_id: 2, + title: 'New Favorite', + } as never) + ), + firstValueFrom( + service.updateManyPlaylists([staleRefreshSnapshot]) + ), + ]); + + expect(store.current.title).toBe('Refreshed Title'); + expect(store.current.autoRefresh).toBe(true); + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 2 }), + ]); + }); + + it('does not re-enable auto-refresh disabled while a batch refresh is in flight', async () => { + const { store, electron } = createStatefulElectronStore({ + ...createBasePlaylist('portal-autorefresh-race'), + autoRefresh: true, + } as Playlist); + testWindow.electron = electron; + + const service = createService(); + const staleRefreshSnapshot = { + ...createBasePlaylist('portal-autorefresh-race'), + autoRefresh: true, + title: 'Refreshed Title', + } as Playlist; + + await Promise.all([ + firstValueFrom( + service.updatePlaylistMeta({ + _id: 'portal-autorefresh-race', + autoRefresh: false, + } as PlaylistMeta) + ), + firstValueFrom( + service.updateManyPlaylists([staleRefreshSnapshot]) + ), + ]); + + expect(store.current.autoRefresh).toBe(false); + expect(store.current.title).toBe('Refreshed Title'); + }); + + it('keeps queued metadata changes when an auto-refresh batch write overlaps', async () => { + const { store, electron } = createStatefulElectronStore({ + ...createBasePlaylist('portal-meta-race'), + hiddenGroupTitles: ['News'], + manualEpgUrls: ['https://example.com/manual.xml'], + } as Playlist); + testWindow.electron = electron; + + const service = createService(); + const staleRefreshSnapshot = { + ...createBasePlaylist('portal-meta-race'), + title: 'Refreshed Title', + playlist: { items: [{ id: 'channel-1' }] }, + } as Playlist; + + await Promise.all([ + firstValueFrom( + service.updatePlaylistMeta({ + _id: 'portal-meta-race', + hiddenGroupTitles: ['News', 'Movies'], + } as PlaylistMeta) + ), + firstValueFrom( + service.updateManyPlaylists([staleRefreshSnapshot]) + ), + ]); + + expect(store.current.hiddenGroupTitles).toEqual(['News', 'Movies']); + expect(store.current.manualEpgUrls).toEqual([ + 'https://example.com/manual.xml', + ]); + expect(store.current.title).toBe('Refreshed Title'); + expect(store.current.count).toBe(1); + }); + + it('keeps a queued favorite add when a position update overlaps', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-position-race') + ); + testWindow.electron = electron; + + const service = createService(); + + await Promise.all([ + firstValueFrom( + service.addPortalFavorite('portal-position-race', { + stream_id: 2, + title: 'New Favorite', + } as never) + ), + firstValueFrom( + service.updatePlaylistPositions([ + { + id: 'portal-position-race', + changes: { position: 7 }, + }, + ]) + ), + ]); + + expect(store.current.position).toBe(7); + expect(store.current.favorites).toEqual([ + expect.objectContaining({ stream_id: 1 }), + expect.objectContaining({ stream_id: 2 }), + ]); + }); + + it('does not serialize mutations across different playlists', async () => { + const playlists = new Map([ + ['portal-a', createBasePlaylist('portal-a')], + ['portal-b', createBasePlaylist('portal-b')], + ]); + let resolvePortalARead: (() => void) | undefined; + const portalARead = new Promise((resolve) => { + resolvePortalARead = resolve; + }); + const electron = { + dbGetAppPlaylist: jest.fn(async (playlistId: string) => { + if (playlistId === 'portal-a') { + await portalARead; + } + return playlists.get(playlistId); + }), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async (key: string) => + key === SQLITE_PLAYLIST_MIGRATION_FLAG || + key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG + ? '1' + : null + ), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(async (playlist: Playlist) => { + playlists.set(playlist._id, playlist); + }), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + const service = createService(); + + const blockedPortalA = firstValueFrom( + service.addPortalFavorite('portal-a', { + stream_id: 2, + title: 'Portal A Favorite', + } as never) + ); + + // Portal B completes although portal A's read is still blocked. + await firstValueFrom( + service.addPortalFavorite('portal-b', { + stream_id: 5, + title: 'Portal B Favorite', + } as never) + ); + expect(playlists.get('portal-b')?.favorites).toHaveLength(2); + + resolvePortalARead?.(); + await blockedPortalA; + expect(playlists.get('portal-a')?.favorites).toHaveLength(2); + }); + }); }); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index cc6938fc7..9233d8d3c 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -10,6 +10,7 @@ import { import { NgxIndexedDBService } from 'ngx-indexed-db'; import { combineLatest, + defer, firstValueFrom, from, map, @@ -95,6 +96,7 @@ export class PlaylistsService { inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? []; private electronMigrationPromise: Promise | null = null; private indexedDbMigrationPromise: Promise | null = null; + private readonly playlistWriteQueues = new Map>(); private get electronApi(): PlaylistStorageElectronApi | null { if (typeof window === 'undefined') { @@ -384,6 +386,41 @@ export class PlaylistsService { }); } + // Every playlist mutation reads the row, patches it in memory, and writes + // the whole row back. Overlapping mutations on the same playlist would be + // last-write-wins, so all of them are chained per playlist id here. + private serializePlaylistWrite( + playlistId: string, + operation: () => Promise + ): Observable { + return defer(() => { + const previous = + this.playlistWriteQueues.get(playlistId) ?? Promise.resolve(); + const next = previous.then(() => operation()); + const tail = next.then( + () => undefined, + () => undefined + ); + this.playlistWriteQueues.set(playlistId, tail); + void tail.then(() => { + if (this.playlistWriteQueues.get(playlistId) === tail) { + this.playlistWriteQueues.delete(playlistId); + } + }); + return next; + }); + } + + private persistPlaylistMutation(nextPlaylist: Playlist) { + if (this.isElectronStorageAvailable) { + return firstValueFrom(this.upsertSqlitePlaylist(nextPlaylist)); + } + + return firstValueFrom( + this.dbService.update(DbStores.Playlists, nextPlaylist) + ); + } + getAllPlaylists() { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { @@ -467,51 +504,69 @@ export class PlaylistsService { } } + /** + * Canonical refresh merge shared by the single-playlist update flow and + * the auto-refresh batch. The refreshed payload only contributes + * refresh-owned data (parsed content, count, EPG detection); user-owned + * state on the freshly read row — favorites, recently viewed, ordering, + * hidden groups, curated EPG sources — must survive the refresh write. + */ + private mergeRefreshedPlaylist( + currentPlaylist: Playlist | undefined, + updatedPlaylist: Playlist, + playlistId: string + ): Playlist { + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls: + updatedPlaylist.detectedEpgUrls ?? + currentPlaylist?.detectedEpgUrls, + enabledEpgUrls: updatedPlaylist.epgUrls ?? currentPlaylist?.epgUrls, + manualEpgUrls: + updatedPlaylist.manualEpgUrls ?? currentPlaylist?.manualEpgUrls, + disabledEpgUrls: + updatedPlaylist.disabledEpgUrls ?? + currentPlaylist?.disabledEpgUrls, + }); + + return { + ...currentPlaylist, + ...updatedPlaylist, + _id: playlistId, + count: + updatedPlaylist.playlist?.items?.length ?? + currentPlaylist?.count ?? + updatedPlaylist.count, + updateDate: Date.now(), + updateState: PlaylistUpdateState.UPDATED, + ...(currentPlaylist + ? { + favorites: currentPlaylist.favorites, + recentlyViewed: currentPlaylist.recentlyViewed, + position: currentPlaylist.position, + } + : {}), + epgUrls: epgSourceState.epgUrls, + detectedEpgUrls: epgSourceState.detectedEpgUrls, + manualEpgUrls: epgSourceState.manualEpgUrls, + disabledEpgUrls: epgSourceState.disabledEpgUrls, + autoRefresh: + currentPlaylist?.autoRefresh ?? updatedPlaylist.autoRefresh, + }; + } + updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { - return this.getPlaylistById(playlistId).pipe( - switchMap((currentPlaylist: Playlist) => { - const epgSourceState = resolvePlaylistEpgSourceState({ - detectedEpgUrls: - updatedPlaylist.detectedEpgUrls ?? - currentPlaylist.detectedEpgUrls, - enabledEpgUrls: - updatedPlaylist.epgUrls ?? currentPlaylist.epgUrls, - manualEpgUrls: - updatedPlaylist.manualEpgUrls ?? - currentPlaylist.manualEpgUrls, - disabledEpgUrls: - updatedPlaylist.disabledEpgUrls ?? - currentPlaylist.disabledEpgUrls, - }); - const mergedPlaylist: Playlist = { - ...currentPlaylist, - ...updatedPlaylist, - _id: playlistId, - count: - updatedPlaylist.playlist?.items?.length ?? - currentPlaylist.count, - updateDate: Date.now(), - updateState: PlaylistUpdateState.UPDATED, - favorites: currentPlaylist.favorites, - epgUrls: epgSourceState.epgUrls, - detectedEpgUrls: epgSourceState.detectedEpgUrls, - manualEpgUrls: epgSourceState.manualEpgUrls, - disabledEpgUrls: epgSourceState.disabledEpgUrls, - autoRefresh: - currentPlaylist.autoRefresh ?? - updatedPlaylist.autoRefresh, - }; + return this.serializePlaylistWrite(playlistId, async () => { + const currentPlaylist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); + const mergedPlaylist = this.mergeRefreshedPlaylist( + currentPlaylist, + updatedPlaylist, + playlistId + ); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(mergedPlaylist); - } - - return this.dbService.update( - DbStores.Playlists, - mergedPlaylist - ); - }) - ); + return this.persistPlaylistMutation(mergedPlaylist); + }); } getPlaylistById(id: string) { @@ -535,138 +590,160 @@ export class PlaylistsService { } updatePlaylistMeta(updatedPlaylist: PlaylistMeta) { - return this.getPlaylistById(updatedPlaylist._id).pipe( - switchMap((playlist) => { - const epgSourceState = resolvePlaylistEpgSourceState({ - detectedEpgUrls: - updatedPlaylist.detectedEpgUrls ?? - playlist.detectedEpgUrls, - enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls, - manualEpgUrls: - updatedPlaylist.manualEpgUrls ?? - playlist.manualEpgUrls, - disabledEpgUrls: - updatedPlaylist.disabledEpgUrls ?? - playlist.disabledEpgUrls, - }); - const nextPlaylist: Playlist = { - ...playlist, - ...(updatedPlaylist.title != null - ? { title: updatedPlaylist.title } - : {}), - ...(updatedPlaylist.autoRefresh != null - ? { autoRefresh: updatedPlaylist.autoRefresh } - : {}), - ...(updatedPlaylist.userAgent != null - ? { userAgent: updatedPlaylist.userAgent } - : {}), - ...(updatedPlaylist.referrer !== undefined - ? { referrer: updatedPlaylist.referrer } - : {}), - ...(updatedPlaylist.origin !== undefined - ? { origin: updatedPlaylist.origin } - : {}), - ...(updatedPlaylist.serverUrl != null - ? { serverUrl: updatedPlaylist.serverUrl } - : {}), - ...(updatedPlaylist.portalUrl != null - ? { portalUrl: updatedPlaylist.portalUrl } - : {}), - ...(updatedPlaylist.isFullStalkerPortal !== undefined - ? { - isFullStalkerPortal: - updatedPlaylist.isFullStalkerPortal, - } - : {}), - ...(updatedPlaylist.macAddress != null - ? { macAddress: updatedPlaylist.macAddress } - : {}), - ...(updatedPlaylist.username != null - ? { username: updatedPlaylist.username } - : {}), - ...(updatedPlaylist.password != null - ? { password: updatedPlaylist.password } - : {}), - ...(updatedPlaylist.favorites != null - ? { favorites: updatedPlaylist.favorites } - : {}), - ...(updatedPlaylist.recentlyViewed != null - ? { recentlyViewed: updatedPlaylist.recentlyViewed } - : {}), - ...(updatedPlaylist.hiddenGroupTitles != null - ? { - hiddenGroupTitles: - updatedPlaylist.hiddenGroupTitles, - } - : {}), - ...(updatedPlaylist.detectedEpgUrls !== undefined - ? { detectedEpgUrls: epgSourceState.detectedEpgUrls } - : {}), - ...(updatedPlaylist.manualEpgUrls !== undefined - ? { manualEpgUrls: epgSourceState.manualEpgUrls } - : {}), - ...(updatedPlaylist.disabledEpgUrls !== undefined - ? { disabledEpgUrls: epgSourceState.disabledEpgUrls } - : {}), - ...(updatedPlaylist.epgUrls !== undefined || - updatedPlaylist.detectedEpgUrls !== undefined || - updatedPlaylist.manualEpgUrls !== undefined || - updatedPlaylist.disabledEpgUrls !== undefined - ? { epgUrls: epgSourceState.epgUrls } - : {}), - ...(updatedPlaylist.updateDate !== undefined - ? { updateDate: updatedPlaylist.updateDate } - : {}), - ...(updatedPlaylist.stalkerSerialNumber !== undefined - ? { - stalkerSerialNumber: - updatedPlaylist.stalkerSerialNumber, - } - : {}), - ...(updatedPlaylist.stalkerDeviceId1 !== undefined - ? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 } - : {}), - ...(updatedPlaylist.stalkerDeviceId2 !== undefined - ? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 } - : {}), - ...(updatedPlaylist.stalkerSignature1 !== undefined - ? { - stalkerSignature1: - updatedPlaylist.stalkerSignature1, - } - : {}), - ...(updatedPlaylist.stalkerSignature2 !== undefined - ? { - stalkerSignature2: - updatedPlaylist.stalkerSignature2, - } - : {}), - }; + return this.serializePlaylistWrite(updatedPlaylist._id, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(updatedPlaylist._id) + ); + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls: + updatedPlaylist.detectedEpgUrls ?? playlist.detectedEpgUrls, + enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls, + manualEpgUrls: + updatedPlaylist.manualEpgUrls ?? playlist.manualEpgUrls, + disabledEpgUrls: + updatedPlaylist.disabledEpgUrls ?? playlist.disabledEpgUrls, + }); + const nextPlaylist: Playlist = { + ...playlist, + ...(updatedPlaylist.title != null + ? { title: updatedPlaylist.title } + : {}), + ...(updatedPlaylist.autoRefresh != null + ? { autoRefresh: updatedPlaylist.autoRefresh } + : {}), + ...(updatedPlaylist.userAgent != null + ? { userAgent: updatedPlaylist.userAgent } + : {}), + ...(updatedPlaylist.referrer !== undefined + ? { referrer: updatedPlaylist.referrer } + : {}), + ...(updatedPlaylist.origin !== undefined + ? { origin: updatedPlaylist.origin } + : {}), + ...(updatedPlaylist.serverUrl != null + ? { serverUrl: updatedPlaylist.serverUrl } + : {}), + ...(updatedPlaylist.portalUrl != null + ? { portalUrl: updatedPlaylist.portalUrl } + : {}), + ...(updatedPlaylist.isFullStalkerPortal !== undefined + ? { + isFullStalkerPortal: + updatedPlaylist.isFullStalkerPortal, + } + : {}), + ...(updatedPlaylist.macAddress != null + ? { macAddress: updatedPlaylist.macAddress } + : {}), + ...(updatedPlaylist.username != null + ? { username: updatedPlaylist.username } + : {}), + ...(updatedPlaylist.password != null + ? { password: updatedPlaylist.password } + : {}), + ...(updatedPlaylist.favorites != null + ? { favorites: updatedPlaylist.favorites } + : {}), + ...(updatedPlaylist.recentlyViewed != null + ? { recentlyViewed: updatedPlaylist.recentlyViewed } + : {}), + ...(updatedPlaylist.hiddenGroupTitles != null + ? { + hiddenGroupTitles: updatedPlaylist.hiddenGroupTitles, + } + : {}), + ...(updatedPlaylist.detectedEpgUrls !== undefined + ? { detectedEpgUrls: epgSourceState.detectedEpgUrls } + : {}), + ...(updatedPlaylist.manualEpgUrls !== undefined + ? { manualEpgUrls: epgSourceState.manualEpgUrls } + : {}), + ...(updatedPlaylist.disabledEpgUrls !== undefined + ? { disabledEpgUrls: epgSourceState.disabledEpgUrls } + : {}), + ...(updatedPlaylist.epgUrls !== undefined || + updatedPlaylist.detectedEpgUrls !== undefined || + updatedPlaylist.manualEpgUrls !== undefined || + updatedPlaylist.disabledEpgUrls !== undefined + ? { epgUrls: epgSourceState.epgUrls } + : {}), + ...(updatedPlaylist.updateDate !== undefined + ? { updateDate: updatedPlaylist.updateDate } + : {}), + ...(updatedPlaylist.stalkerSerialNumber !== undefined + ? { + stalkerSerialNumber: + updatedPlaylist.stalkerSerialNumber, + } + : {}), + ...(updatedPlaylist.stalkerDeviceId1 !== undefined + ? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 } + : {}), + ...(updatedPlaylist.stalkerDeviceId2 !== undefined + ? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 } + : {}), + ...(updatedPlaylist.stalkerSignature1 !== undefined + ? { + stalkerSignature1: updatedPlaylist.stalkerSignature1, + } + : {}), + ...(updatedPlaylist.stalkerSignature2 !== undefined + ? { + stalkerSignature2: updatedPlaylist.stalkerSignature2, + } + : {}), + }; - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation(nextPlaylist); + }); } updateFavorites(id: string, favorites: string[]) { - return this.getPlaylistById(id).pipe( - switchMap((playlist) => { - const nextPlaylist: Playlist = { - ...playlist, - favorites, - }; + return this.serializePlaylistWrite(id, async () => { + const playlist = await firstValueFrom(this.getPlaylistById(id)); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } + return this.persistPlaylistMutation({ + ...playlist, + favorites, + }); + }); + } - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + /** + * Applies an atomic favorites update: the current favorites are read + * inside the per-playlist write queue, so overlapping calls cannot work + * from stale snapshots. Callers should pass a pure transform instead of + * precomputing the next favorites array from an earlier read. + */ + transformPlaylistFavorites( + playlistId: string, + transform: ( + currentFavorites: NonNullable + ) => NonNullable + ): Observable { + if (!playlistId) { + throw new Error('Playlist ID is required'); + } + + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); + if (!playlist) { + throw new Error(`Playlist not found: ${playlistId}`); + } + + const currentFavorites = Array.isArray(playlist.favorites) + ? playlist.favorites + : []; + const nextPlaylist: Playlist = { + ...playlist, + favorites: transform(currentFavorites), + }; + + await this.persistPlaylistMutation(nextPlaylist); + return nextPlaylist; + }); } updateManyPlaylists(playlists: Playlist[]) { @@ -674,23 +751,29 @@ export class PlaylistsService { return of([]); } - if (this.isElectronStorageAvailable) { - const updatedPlaylists = playlists.map((playlist) => ({ - ...playlist, - updateDate: Date.now(), - autoRefresh: true, - })); - return this.upsertManySqlitePlaylists(updatedPlaylists); - } - + // Auto-refresh payloads are snapshots taken before the refresh ran, + // so each row goes through the same per-playlist queue and canonical + // refresh merge as the single-playlist update flow: a batch write + // cannot clobber a concurrent collection or metadata mutation. return combineLatest( - playlists.map((playlist) => { - return this.dbService.update(DbStores.Playlists, { - ...playlist, - updateDate: Date.now(), - autoRefresh: true, - }); - }) + playlists.map((playlist) => + this.serializePlaylistWrite(playlist._id, async () => { + const current = await firstValueFrom( + this.getPlaylistById(playlist._id) + ); + // The merge takes autoRefresh from the current row first, + // so disabling auto-refresh while a refresh is in flight + // is not reverted by the completing batch write. + const nextPlaylist = this.mergeRefreshedPlaylist( + current, + playlist, + playlist._id + ); + + await this.persistPlaylistMutation(nextPlaylist); + return nextPlaylist; + }) + ) ); } @@ -767,20 +850,14 @@ export class PlaylistsService { if (!portalId) { throw new Error('Portal ID is required'); } - return this.getPlaylistById(portalId).pipe( - switchMap((portal) => { - const nextPlaylist: Playlist = { - ...portal, - favorites: [...(portal.favorites ?? []), item], - }; + return this.serializePlaylistWrite(portalId, async () => { + const portal = await firstValueFrom(this.getPlaylistById(portalId)); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...portal, + favorites: [...(portal.favorites ?? []), item], + }); + }); } setPortalFavorites(portalId: string, favorites: StalkerPortalItem[]) { @@ -788,54 +865,42 @@ export class PlaylistsService { throw new Error('Portal ID is required'); } - return this.getPlaylistById(portalId).pipe( - switchMap((portal) => { - const nextPlaylist: Playlist = { - ...portal, - favorites, - }; + return this.serializePlaylistWrite(portalId, async () => { + const portal = await firstValueFrom(this.getPlaylistById(portalId)); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...portal, + favorites, + }); + }); } removeFromPortalFavorites(portalId: string, favoriteId: number | string) { if (!portalId) { throw new Error('Portal ID is required'); } - return this.getPlaylistById(portalId).pipe( - switchMap((portal) => { - const nextPlaylist: Playlist = { - ...portal, - favorites: portal.favorites?.filter((i) => { - const expectedId = String(favoriteId); - const favorite = i as PortalFavoriteItem; - const streamId = String(favorite.stream_id ?? ''); - const seriesId = String(favorite.series_id ?? ''); - const movieId = String(favorite.movie_id ?? ''); - const itemId = String(favorite.id ?? ''); + return this.serializePlaylistWrite(portalId, async () => { + const portal = await firstValueFrom(this.getPlaylistById(portalId)); - return ( - streamId !== expectedId && - seriesId !== expectedId && - movieId !== expectedId && - itemId !== expectedId - ); - }), - }; + return this.persistPlaylistMutation({ + ...portal, + favorites: portal.favorites?.filter((i) => { + const expectedId = String(favoriteId); + const favorite = i as PortalFavoriteItem; + const streamId = String(favorite.stream_id ?? ''); + const seriesId = String(favorite.series_id ?? ''); + const movieId = String(favorite.movie_id ?? ''); + const itemId = String(favorite.id ?? ''); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return ( + streamId !== expectedId && + seriesId !== expectedId && + movieId !== expectedId && + itemId !== expectedId + ); + }), + }); + }); } updatePlaylistPositions( @@ -848,46 +913,25 @@ export class PlaylistsService { return of([]); } - if (this.isElectronStorageAvailable) { - return this.runOnSqlite(async () => { - const electron = this.electronApi; - const playlists = electron - ? ((await electron.dbGetAppPlaylists()) as Playlist[]) - : []; - const positionsById = new Map( - positionUpdates.map((item) => [ - item.id, - item.changes.position, - ]) - ); - - const updatedPlaylists = playlists - .filter((playlist) => positionsById.has(playlist._id)) - .map((playlist) => ({ - ...playlist, - position: positionsById.get(playlist._id), - })); - - if (electron) { - await electron.dbUpsertAppPlaylists(updatedPlaylists); - } - return updatedPlaylists; - }); - } - return combineLatest( - positionUpdates.map((item) => { - return this.dbService - .getByID(DbStores.Playlists, item.id) - .pipe( - switchMap((playlist: Playlist) => - this.dbService.update(DbStores.Playlists, { - ...playlist, - position: item.changes.position, - }) - ) + positionUpdates.map((item) => + this.serializePlaylistWrite(item.id, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(item.id) ); - }) + if (!playlist) { + return null; + } + + const nextPlaylist: Playlist = { + ...playlist, + position: item.changes.position, + }; + + await this.persistPlaylistMutation(nextPlaylist); + return nextPlaylist; + }) + ) ); } @@ -953,20 +997,16 @@ export class PlaylistsService { } setFavorites(playlistId: string, favorites: string[]) { - return this.getPlaylistById(playlistId).pipe( - switchMap((playlist) => { - const nextPlaylist: Playlist = { - ...playlist, - favorites, - }; + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...playlist, + favorites, + }); + }); } getRawPlaylistById(id: string) { @@ -1087,40 +1127,36 @@ export class PlaylistsService { throw new Error('Playlist ID is required'); } - return this.getPlaylistById(playlistId).pipe( - switchMap((playlist) => { - const nowIso = new Date().toISOString(); - const recentItems = Array.isArray(playlist.recentlyViewed) - ? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[]) - : []; - const existingIndex = recentItems.findIndex((recentItem) => - this.matchesPlaylistRecentIdentity( - recentItem, - this.getPlaylistRecentIdentity(item) - ) - ); - const existingItem = - existingIndex >= 0 ? recentItems[existingIndex] : null; - const nextItem: PlaylistRecentlyViewedItem = { - ...(existingItem ?? {}), - ...item, - added_at: nowIso, - }; - const remainingItems = recentItems.filter( - (_, index) => index !== existingIndex - ); - const nextPlaylist: Playlist = { - ...playlist, - recentlyViewed: [nextItem, ...remainingItems], - }; + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); + const nowIso = new Date().toISOString(); + const recentItems = Array.isArray(playlist.recentlyViewed) + ? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[]) + : []; + const existingIndex = recentItems.findIndex((recentItem) => + this.matchesPlaylistRecentIdentity( + recentItem, + this.getPlaylistRecentIdentity(item) + ) + ); + const existingItem = + existingIndex >= 0 ? recentItems[existingIndex] : null; + const nextItem: PlaylistRecentlyViewedItem = { + ...(existingItem ?? {}), + ...item, + added_at: nowIso, + }; + const remainingItems = recentItems.filter( + (_, index) => index !== existingIndex + ); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...playlist, + recentlyViewed: [nextItem, ...remainingItems], + }); + }); } removeFromPlaylistRecentlyViewed( @@ -1131,25 +1167,21 @@ export class PlaylistsService { throw new Error('Playlist ID is required'); } - return this.getPlaylistById(playlistId).pipe( - switchMap((playlist) => { - const nextPlaylist: Playlist = { - ...playlist, - recentlyViewed: ( - playlist.recentlyViewed as PlaylistRecentlyViewedItem[] - )?.filter( - (item) => - !this.matchesPlaylistRecentIdentity(item, identity) - ), - }; + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...playlist, + recentlyViewed: ( + playlist.recentlyViewed as PlaylistRecentlyViewedItem[] + )?.filter( + (item) => + !this.matchesPlaylistRecentIdentity(item, identity) + ), + }); + }); } removeFromPlaylistRecentlyViewedBatch( @@ -1164,30 +1196,23 @@ export class PlaylistsService { 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 - ) - ) - ), - }; + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...playlist, + recentlyViewed: ( + playlist.recentlyViewed as PlaylistRecentlyViewedItem[] + )?.filter( + (item) => + !identities.some((identity) => + this.matchesPlaylistRecentIdentity(item, identity) + ) + ), + }); + }); } clearPlaylistRecentlyViewed(playlistId: string) { @@ -1195,20 +1220,16 @@ export class PlaylistsService { throw new Error('Playlist ID is required'); } - return this.getPlaylistById(playlistId).pipe( - switchMap((playlist) => { - const nextPlaylist: Playlist = { - ...playlist, - recentlyViewed: [], - }; + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); + return this.persistPlaylistMutation({ + ...playlist, + recentlyViewed: [], + }); + }); } getPortalRecentlyViewed(portalId: string) { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 09cbe4c5d..acbd1ed89 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -148,7 +148,7 @@ describe('DashboardDataService', () => { const playlistsServiceMock = { getM3uFavoriteChannels: jest.fn().mockReturnValue(of(null)), getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)), - setFavorites: jest.fn().mockReturnValue(of(undefined)), + transformPlaylistFavorites: jest.fn().mockReturnValue(of(playlistMock)), removeFromM3uRecentlyViewed: jest.fn().mockReturnValue( of({ ...playlistMock, @@ -193,8 +193,10 @@ describe('DashboardDataService', () => { playlistsServiceMock.getM3uFavoriteChannels.mockReturnValue(of(null)); playlistsServiceMock.getPlaylistById.mockClear(); playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock)); - playlistsServiceMock.setFavorites.mockClear(); - playlistsServiceMock.setFavorites.mockReturnValue(of(undefined)); + playlistsServiceMock.transformPlaylistFavorites.mockClear(); + playlistsServiceMock.transformPlaylistFavorites.mockReturnValue( + of(playlistMock) + ); playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear(); playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue( of({ @@ -751,10 +753,14 @@ describe('DashboardDataService', () => { await service.removeGlobalFavorite(m3uItem!); - expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith( - 'm3u-1', - ['https://example.com/stream-2.m3u8'] - ); + expect( + playlistsServiceMock.transformPlaylistFavorites + ).toHaveBeenCalledWith('m3u-1', expect.any(Function)); + const [, transform] = + playlistsServiceMock.transformPlaylistFavorites.mock.calls[0]; + expect( + transform(['channel-1', 'https://example.com/stream-2.m3u8']) + ).toEqual(['https://example.com/stream-2.m3u8']); }); it('removes PWA Xtream favorites through the active data source', async () => { @@ -1288,9 +1294,7 @@ describe('DashboardDataService', () => { // Crucially, the movie favorite is excluded — the rail source must // never leak VOD posters into the channel layout. - expect(liveOnly.map((it) => it.title)).not.toContain( - 'Favorite Movie' - ); + expect(liveOnly.map((it) => it.title)).not.toContain('Favorite Movie'); expect( service.globalFavoriteLiveItems().every((it) => it.type === 'live') ).toBe(true); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 5b399effd..71cb19fe5 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -1065,23 +1065,15 @@ export class DashboardDataService { } if (item.source === 'm3u') { - const playlist = await firstValueFrom( - this.playlistsService.getPlaylistById(item.playlist_id) - ); - const currentFavorites = Array.isArray(playlist?.favorites) - ? playlist.favorites.filter( - (favorite): favorite is string => - typeof favorite === 'string' - ) - : []; - const filteredFavorites = currentFavorites.filter( - (favorite) => favorite !== String(item.id) - ); - await firstValueFrom( - this.playlistsService.setFavorites( + this.playlistsService.transformPlaylistFavorites( item.playlist_id, - filteredFavorites + (current) => + current.filter( + (favorite) => + typeof favorite !== 'string' || + favorite !== String(item.id) + ) ) ); await this.reloadGlobalFavorites(); @@ -1221,14 +1213,16 @@ export class DashboardDataService { const fallbackTimestamp = this.getM3uFavoriteTimestamp(playlistMeta) ?? new Date(0).toISOString(); - const items = resolvedChannels.slice().map((favorite) => - this.createM3uFavoriteItem( - playlistMeta, - favorite.favoriteId, - favorite.channel, - fallbackTimestamp - ) - ); + const items = resolvedChannels + .slice() + .map((favorite) => + this.createM3uFavoriteItem( + playlistMeta, + favorite.favoriteId, + favorite.channel, + fallbackTimestamp + ) + ); this.m3uFavoritesCache.set(playlistMeta._id, { fingerprint, diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index 83506948e..47a390da1 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -86,17 +86,11 @@ export class GlobalFavoritesService { async removeFavorite(channel: UnifiedFavoriteChannel): Promise { switch (channel.sourceType) { case 'm3u': { - const playlist = await firstValueFrom( - this.playlistsService.getPlaylistById(channel.playlistId) - ); - const currentFavs = (playlist.favorites as string[]) ?? []; - const filtered = currentFavs.filter( - (f) => f !== channel.streamUrl - ); await firstValueFrom( - this.playlistsService.setFavorites( + this.playlistsService.transformPlaylistFavorites( channel.playlistId, - filtered + (current) => + current.filter((f) => f !== channel.streamUrl) ) ); break; @@ -134,9 +128,7 @@ export class GlobalFavoritesService { // Persist xtream positions to DB const xtreamUpdates = channels .filter( - ( - ch - ): ch is UnifiedFavoriteChannel & { contentId: number } => + (ch): ch is UnifiedFavoriteChannel & { contentId: number } => ch.sourceType === 'xtream' && ch.contentId != null ) .map((ch, index) => ({