diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 5d641014b..fa18f83ea 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -13,6 +13,7 @@ describe('PlaylistsService', () => { afterEach(() => { testWindow.electron = originalElectron; localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG); + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -25,6 +26,8 @@ describe('PlaylistsService', () => { dbService: { clear: jest.fn(() => of(undefined)), delete: jest.fn(() => of(undefined)), + // IndexedDB operations always run the Stalker metadata migration first, + // and that migration reads all playlists before the requested operation. getAll: jest.fn(() => of([])), getByID: jest.fn(() => of(undefined)), update: jest.fn(() => of(undefined)), @@ -87,6 +90,109 @@ describe('PlaylistsService', () => { expect(cleanup).toHaveBeenCalledWith('playlist-1'); }); + it('returns browser playlist summaries without embedded playlist payloads', async () => { + const dbService = { + getAll: jest.fn(() => + of([ + { + _id: 'playlist-summary', + title: 'Summary Playlist', + count: 2, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-02T00:00:00.000Z', + autoRefresh: false, + favorites: ['channel-1'], + playlist: { + header: { raw: '#EXTM3U' }, + items: [{ id: 'channel-1' }], + }, + header: { raw: '#EXTM3U' }, + items: [{ id: 'legacy-item' }], + } as Playlist, + ]) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + const playlists = await firstValueFrom(service.getAllPlaylists()); + + expect(playlists).toEqual([ + expect.objectContaining({ + _id: 'playlist-summary', + title: 'Summary Playlist', + favorites: ['channel-1'], + }), + ]); + expect(playlists[0]).not.toHaveProperty('playlist'); + expect(playlists[0]).not.toHaveProperty('header'); + expect(playlists[0]).not.toHaveProperty('items'); + }); + + it('normalizes partial SQLite playlists when loading by id', async () => { + const electron = { + dbGetAppPlaylist: jest.fn(async () => ({ + id: 42, + count: '3', + url: 'https://example.com/list.m3u', + autoRefresh: '', + })), + 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(), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + const service = createService(); + + await expect( + firstValueFrom(service.getPlaylistById('42')) + ).resolves.toEqual( + expect.objectContaining({ + _id: '42', + title: '', + count: 3, + favorites: [], + recentlyViewed: [], + autoRefresh: false, + url: 'https://example.com/list.m3u', + }) + ); + }); + + it('adds browser playlists through IndexedDB and returns the original playlist', async () => { + const playlist = { + _id: 'playlist-add', + title: 'Add Me', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + } as Playlist; + const dbService = { + add: jest.fn(() => of('generated-key')), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.addPlaylist(playlist)) + ).resolves.toBe(playlist); + expect(dbService.add).toHaveBeenCalledWith( + DbStores.Playlists, + playlist + ); + }); + it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => { let storedPlaylists: Playlist[] = [ { @@ -258,6 +364,384 @@ describe('PlaylistsService', () => { ); }); + it('updates many browser playlists with refresh metadata', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1770000000000); + const playlists = [ + { + _id: 'playlist-a', + title: 'A', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + }, + { + _id: 'playlist-b', + title: 'B', + count: 2, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + }, + ] as Playlist[]; + const dbService = { + getAll: jest.fn(() => of([])), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.updateManyPlaylists(playlists)) + ).resolves.toHaveLength(2); + + expect(dbService.update).toHaveBeenNthCalledWith( + 1, + DbStores.Playlists, + expect.objectContaining({ + _id: 'playlist-a', + updateDate: 1770000000000, + autoRefresh: true, + }) + ); + expect(dbService.update).toHaveBeenNthCalledWith( + 2, + DbStores.Playlists, + expect.objectContaining({ + _id: 'playlist-b', + updateDate: 1770000000000, + autoRefresh: true, + }) + ); + }); + + it('short-circuits updateManyPlaylists when no playlists are provided', async () => { + const dbService = { + update: jest.fn(), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.updateManyPlaylists([])) + ).resolves.toEqual([]); + expect(dbService.update).not.toHaveBeenCalled(); + }); + + it('filters favorite channels from a playlist payload', async () => { + const playlist = { + _id: 'playlist-favorites', + title: 'Favorites', + count: 3, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + favorites: ['channel-2', 'channel-3'], + playlist: { + items: [ + { id: 'channel-1', name: 'One' }, + { id: 'channel-2', name: 'Two' }, + { id: 'channel-3', name: 'Three' }, + ], + }, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.getFavoriteChannels('playlist-favorites')) + ).resolves.toEqual([ + expect.objectContaining({ id: 'channel-2' }), + expect.objectContaining({ id: 'channel-3' }), + ]); + }); + + it('sorts portal favorites newest first', async () => { + const playlist = { + _id: 'portal-1', + title: 'Portal', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + favorites: [ + { id: 'old', title: 'Old', added_at: '2026-04-01T10:00:00Z' }, + { id: 'new', title: 'New', added_at: '2026-04-01T11:00:00Z' }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.getPortalFavorites('portal-1')) + ).resolves.toEqual([ + expect.objectContaining({ id: 'new' }), + expect.objectContaining({ id: 'old' }), + ]); + }); + + it('filters portal live stream favorites', async () => { + const playlist = { + _id: 'portal-live', + title: 'Portal Live', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + favorites: [ + { stream_id: 1, title: 'Live', stream_type: 'live' }, + { movie_id: 2, title: 'Movie', stream_type: 'movie' }, + { id: 3, title: 'No Type' }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.getPortalLiveStreamFavorites('portal-live')) + ).resolves.toEqual([expect.objectContaining({ stream_id: 1 })]); + }); + + it('removes portal favorites by any supported identity field', async () => { + const playlist = { + _id: 'portal-remove-favorite', + title: 'Portal Remove', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + favorites: [ + { stream_id: 10, title: 'Live' }, + { series_id: 20, title: 'Series' }, + { movie_id: 30, title: 'Movie' }, + { id: 40, title: 'Other' }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + update: jest.fn((_storeName: string, updated: Playlist) => + of(updated) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.removeFromPortalFavorites('portal-remove-favorite', 20) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + favorites: [ + expect.objectContaining({ stream_id: 10 }), + expect.objectContaining({ movie_id: 30 }), + expect.objectContaining({ id: 40 }), + ], + }) + ); + }); + + it('updates browser playlist positions through individual reads and writes', async () => { + const playlistsById = new Map([ + [ + 'playlist-position-a', + { + _id: 'playlist-position-a', + title: 'Position A', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + position: 1, + } as Playlist, + ], + [ + 'playlist-position-b', + { + _id: 'playlist-position-b', + title: 'Position B', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + position: 2, + } as Playlist, + ], + ]); + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn((_storeName: string, id: string) => + of(playlistsById.get(id)) + ), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom( + service.updatePlaylistPositions([ + { id: 'playlist-position-a', changes: { position: 2 } }, + { id: 'playlist-position-b', changes: { position: 1 } }, + ]) + ) + ).resolves.toEqual([ + expect.objectContaining({ + _id: 'playlist-position-a', + position: 2, + }), + expect.objectContaining({ + _id: 'playlist-position-b', + position: 1, + }), + ]); + }); + + it('returns only auto-refresh playlists as lightweight metadata', async () => { + const dbService = { + getAll: jest.fn(() => + of([ + { + _id: 'manual', + title: 'Manual', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + } as Playlist, + { + _id: 'auto', + title: 'Auto', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: true, + favorites: ['channel-1'], + playlist: { + header: { raw: '#EXTM3U' }, + items: [{ id: 'channel-1' }], + }, + } as Playlist, + ]) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + const autoUpdatePlaylists = await firstValueFrom( + service.getPlaylistsForAutoUpdate() + ); + + expect(autoUpdatePlaylists).toEqual([ + expect.objectContaining({ + _id: 'auto', + autoRefresh: true, + }), + ]); + expect(autoUpdatePlaylists[0]).not.toHaveProperty('playlist'); + expect(autoUpdatePlaylists[0]).not.toHaveProperty('favorites'); + }); + + it('exports raw playlist text from header and item raw lines', async () => { + const playlist = { + _id: 'playlist-raw', + title: 'Raw', + count: 2, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + playlist: { + header: { raw: '#EXTM3U' }, + items: [ + { raw: '#EXTINF:-1,One\nhttps://example.com/one.m3u8' }, + { raw: '#EXTINF:-1,Two\nhttps://example.com/two.m3u8' }, + ], + }, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.getRawPlaylistById('playlist-raw')) + ).resolves.toBe( + '#EXTM3U\n' + + '#EXTINF:-1,One\nhttps://example.com/one.m3u8\n' + + '#EXTINF:-1,Two\nhttps://example.com/two.m3u8' + ); + }); + + it('clears all browser playlists', async () => { + const dbService = { + clear: jest.fn(() => of('cleared')), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.removeAll()) + ).resolves.toBeUndefined(); + expect(dbService.clear).toHaveBeenCalledWith(DbStores.Playlists); + }); + + it('adds many browser playlists through bulkAdd', async () => { + const playlists = [ + { + _id: 'bulk-a', + title: 'Bulk A', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + }, + ] as Playlist[]; + const dbService = { + bulkAdd: jest.fn(() => of(['bulk-a'])), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom(service.addManyPlaylists(playlists)) + ).resolves.toEqual(['bulk-a']); + expect(dbService.bulkAdd).toHaveBeenCalledWith( + DbStores.Playlists, + playlists + ); + }); + it('removes multiple recently-viewed identities in a single PWA write', async () => { const existingPlaylist: Playlist = { _id: 'playlist-3', @@ -410,6 +894,156 @@ describe('PlaylistsService', () => { expect(dbService.update).not.toHaveBeenCalled(); }); + it('sorts recently viewed entries by normalized date', async () => { + const playlist = { + _id: 'playlist-recent-sort', + title: 'Recent Sort', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + recentlyViewed: [ + { + source: 'm3u', + id: 'old', + url: 'https://example.com/old.m3u8', + title: 'Old', + category_id: 'live', + added_at: '2026-04-01T10:00:00.000Z', + }, + { + id: 'new', + title: 'New', + added_at: '2026-04-01 11:00:00', + }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await expect( + firstValueFrom( + service.getPlaylistRecentlyViewed('playlist-recent-sort') + ) + ).resolves.toEqual([ + expect.objectContaining({ id: 'new' }), + expect.objectContaining({ id: 'old' }), + ]); + }); + + it('updates an existing M3U recently viewed item instead of duplicating it', async () => { + jest.useFakeTimers().setSystemTime( + new Date('2026-04-02T12:00:00.000Z') + ); + const playlist = { + _id: 'playlist-recent-m3u', + title: 'Recent M3U', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + recentlyViewed: [ + { + source: 'm3u', + id: 'channel-old-id', + url: 'https://example.com/channel.m3u8', + title: 'Old Title', + group_title: 'News', + category_id: 'live', + added_at: '2026-04-01T10:00:00.000Z', + }, + { + source: 'm3u', + id: 'other', + url: 'https://example.com/other.m3u8', + title: 'Other', + category_id: 'live', + added_at: '2026-04-01T09:00:00.000Z', + }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + update: jest.fn((_storeName: string, updated: Playlist) => + of(updated) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.addM3uRecentlyViewed('playlist-recent-m3u', { + source: 'm3u', + id: 'channel-new-id', + url: 'https://example.com/channel.m3u8', + title: 'New Title', + channel_id: 'channel-new-id', + category_id: 'live', + added_at: 'ignored', + }) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + recentlyViewed: [ + expect.objectContaining({ + id: 'channel-new-id', + title: 'New Title', + group_title: 'News', + added_at: '2026-04-02T12:00:00.000Z', + }), + expect.objectContaining({ + url: 'https://example.com/other.m3u8', + }), + ], + }) + ); + }); + + it('removes portal recently viewed items using normalized colon identities', async () => { + const playlist = { + _id: 'portal-recent-remove', + title: 'Portal Recent Remove', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + recentlyViewed: [ + { id: '100:episode', title: 'Remove Me' }, + { movie_id: '200', title: 'Keep Me' }, + ], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(playlist)), + update: jest.fn((_storeName: string, updated: Playlist) => + of(updated) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.removeFromPortalRecentlyViewed('portal-recent-remove', 100) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + recentlyViewed: [expect.objectContaining({ movie_id: '200' })], + }) + ); + }); + it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => { const existingPlaylist: Playlist = { _id: 'playlist-2', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 2e8de0e4e..33fddbabd 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -61,6 +61,8 @@ type PlaylistStorageWindow = Window & { electron?: PlaylistStorageElectronApi; }; +type AddManyPlaylistsResult = Playlist[] | IDBValidKey[]; + type PlaylistParserModule = Partial & { default?: Partial; }; @@ -846,7 +848,9 @@ export class PlaylistsService { ); } - addManyPlaylists(playlists: Playlist[]) { + addManyPlaylists( + playlists: Playlist[] + ): Observable { if (this.isElectronStorageAvailable) { return this.upsertManySqlitePlaylists(playlists); }