import { EMPTY, firstValueFrom, from, of } from 'rxjs'; import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { reservePlaylistAuthority } from './playlist-cross-context-lock'; import { PlaylistWriteConflict } from './playlist-storage-backend'; import { PlaylistsService, resolvePlaylistParser } from './playlists.service'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG = 'm3u-playlists-stalker-metadata-v1'; describe('PlaylistsService', () => { const testWindow = window as unknown as { electron?: unknown }; const originalElectron = testWindow.electron; const originalLockManager = globalThis.navigator?.locks; afterEach(() => { testWindow.electron = originalElectron; Object.defineProperty(globalThis.navigator, 'locks', { configurable: true, value: originalLockManager, }); localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG); jest.useRealTimers(); jest.restoreAllMocks(); }); function hasSqliteBridge(): boolean { const electron = testWindow.electron as Record | undefined; return ( !!electron && typeof electron['dbGetAppPlaylists'] === 'function' && typeof electron['dbUpsertAppPlaylist'] === 'function' && typeof electron['dbGetAppState'] === 'function' && typeof electron['dbSetAppState'] === 'function' ); } const testStorageBackend = { kind: () => (hasSqliteBridge() ? 'electron' : 'indexeddb'), documentStore: () => (hasSqliteBridge() ? testWindow.electron : null), }; function createService(overrides: Record = {}) { const service = Object.create( PlaylistsService.prototype ) as PlaylistsService; Object.assign(service as object, { dbService: { clear: jest.fn(() => of(undefined)), delete: jest.fn(() => of(undefined)), // IndexedDB operations run the Stalker metadata migration first; // the default cursor represents an empty playlist store. getAll: jest.fn(() => of([])), getByID: jest.fn(() => of(undefined)), openCursor: jest.fn(() => EMPTY), update: jest.fn(() => of(undefined)), ...overrides, }, snackBar: { open: jest.fn(), }, translateService: { instant: jest.fn((key: string) => key), }, storageBackend: testStorageBackend, electronMigrationPromise: null, indexedDbMigrationPromise: null, playlistWriteQueues: new Map(), deletingAll: Promise.resolve(), resets: 0, playlistDeleteCleanups: [], }); return service; } it('announces a committed SQLite connection edit', async () => { const playlist = { _id: 'source', url: 'https://old.test/list', } as Playlist; testWindow.electron = { dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(), dbGetAppPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(playlist), dbUpsertAppPlaylist: jest.fn().mockResolvedValue(undefined), }; const service = createService(); const next = jest.fn(); Object.assign(service, { healthEvidence: { connections: { next } } }); await firstValueFrom( service.updatePlaylistMeta({ ...playlist, title: 'Source', count: 0, importDate: '', autoRefresh: false, serverUrl: 'https://new.test', }) ); expect(next).toHaveBeenCalledWith({ id: 'source', playlist: expect.objectContaining({ serverUrl: 'https://new.test', }), }); }); it('announces committed deletion but does not retire health after a failed write', async () => { const service = createService(); const next = jest.fn(); Object.assign(service, { healthEvidence: { connections: { next } } }); await firstValueFrom(service.deletePlaylist('retired')); expect(next).toHaveBeenCalledWith({ id: 'retired' }); next.mockClear(); const failed = createService({ delete: jest.fn(() => { throw new Error('write failed'); }), }); Object.assign(failed, { healthEvidence: { connections: { next } } }); await expect( firstValueFrom(failed.deletePlaylist('kept')) ).rejects.toThrow(); expect(next).not.toHaveBeenCalled(); }); it('resolves the parser from a CommonJS default dynamic import shape', () => { const parse = jest.fn(); expect( resolvePlaylistParser({ default: { parse }, }) ).toBe(parse); }); it('runs registered cleanup hooks after deleting a browser playlist', async () => { const cleanup = jest.fn().mockResolvedValue(undefined); const deleteFromIndexedDb = jest.fn(() => of(undefined)); const service = createService({ delete: deleteFromIndexedDb }); Object.assign(service as object, { playlistDeleteCleanups: [cleanup], }); await expect( firstValueFrom(service.deletePlaylist('playlist-1')) ).resolves.toEqual({ success: true }); expect(deleteFromIndexedDb).toHaveBeenCalledWith( DbStores.Playlists, 'playlist-1' ); expect(cleanup).toHaveBeenCalledWith('playlist-1'); }); it('owns one worker deletion and waits for cleanup before completion', async () => { const service = createService(); const deleteWorker = jest.fn().mockResolvedValue(true); let finishCleanup!: () => void; const cleanup = jest.fn( () => new Promise((resolve) => { finishCleanup = resolve; }) ); Object.assign(service, { storageBackend: { kind: () => 'electron', documentStore: () => ({ dbDeletePlaylist: jest.fn() }), }, }); Object.assign(service, { ensureElectronPlaylistMigrations: jest .fn() .mockResolvedValue(undefined), databaseService: { deletePlaylist: deleteWorker }, playlistDeleteCleanups: [cleanup], }); let completed = false; const deletion = firstValueFrom( service.deletePlaylist('a', { operationId: 'delete-a' }) ).then((result) => { completed = true; return result; }); for (let i = 0; i < 20 && !finishCleanup; i++) await Promise.resolve(); expect(deleteWorker).toHaveBeenCalledTimes(1); expect(completed).toBe(false); finishCleanup(); await expect(deletion).resolves.toEqual({ success: true }); expect(cleanup).toHaveBeenCalledTimes(1); }); it('reports cleanup warnings without resurrecting an already deleted row', async () => { const service = createService(); Object.assign(service, { playlistDeleteCleanups: [ () => Promise.reject(new Error('cleanup failed')), ], }); const warning = jest .spyOn(console, 'warn') .mockImplementation(() => undefined); try { await expect( firstValueFrom(service.deletePlaylist('a')) ).resolves.toEqual({ success: true, cleanupWarnings: 1 }); } finally { warning.mockRestore(); } }); 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('loads Electron playlist summaries from the metadata-only bridge when available', async () => { const electron = { dbGetAppPlaylist: jest.fn(), dbGetAppPlaylistMetas: jest.fn(async () => [ { _id: 'playlist-meta', title: 'Metadata Playlist', count: 2, favorites: ['channel-1'], playlist: { items: [{ id: 'channel-1' }], }, }, ]), 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(); const playlists = await firstValueFrom(service.getAllPlaylists()); expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(1); expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled(); expect(playlists).toEqual([ expect.objectContaining({ _id: 'playlist-meta', title: 'Metadata Playlist', favorites: ['channel-1'], }), ]); expect(playlists[0]).not.toHaveProperty('playlist'); }); 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('loads resolved M3U favorite channels from Electron without fetching the full playlist payload', async () => { const resolvedFavorites = [ { favoriteId: 'channel-1', favoriteIndex: 0, channel: { id: 'channel-1', name: 'Channel One', url: 'https://example.com/stream-1.m3u8', }, }, ]; const electron = { dbGetAppPlaylist: jest.fn(), dbGetAppPlaylistFavoriteChannels: jest.fn( async () => resolvedFavorites ), 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.getM3uFavoriteChannels('playlist-1')) ).resolves.toBe(resolvedFavorites); expect(electron.dbGetAppState).toHaveBeenCalledWith( SQLITE_PLAYLIST_MIGRATION_FLAG ); expect(electron.dbGetAppPlaylistFavoriteChannels).toHaveBeenCalledWith( 'playlist-1' ); expect(electron.dbGetAppPlaylist).not.toHaveBeenCalled(); expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled(); }); it('reads resolved M3U favorites only after a queued favorites write lands', async () => { let storedFavorites: string[] = []; let releaseWrite!: () => void; const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); const electron = { dbGetAppPlaylist: jest.fn(async () => { await writeGate; return { _id: 'playlist-1', favorites: storedFavorites }; }), dbGetAppPlaylistFavoriteChannels: jest.fn(async () => storedFavorites.map((id) => ({ id })) ), 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) => { storedFavorites = playlist.favorites as string[]; }), dbUpsertAppPlaylists: jest.fn(), }; testWindow.electron = electron; const service = createService(); const write = firstValueFrom( service.updateFavorites('playlist-1', ['channel-1']) ); const read = firstValueFrom( service.getM3uFavoriteChannels('playlist-1') ); await new Promise((resolve) => setTimeout(resolve)); releaseWrite(); await write; await expect(read).resolves.toEqual([{ id: 'channel-1' }]); }); it('falls back from resolved M3U favorites when SQLite playlist migration is incomplete', async () => { const electron = { dbGetAppPlaylist: jest.fn(), dbGetAppPlaylistFavoriteChannels: jest.fn(), dbGetAppPlaylists: jest.fn(async () => []), dbGetAppState: jest.fn(async (key: string) => key === SQLITE_PLAYLIST_MIGRATION_FLAG ? null : '1' ), dbSetAppState: jest.fn(), dbUpsertAppPlaylist: jest.fn(), dbUpsertAppPlaylists: jest.fn(), }; testWindow.electron = electron; const service = createService(); await expect( firstValueFrom(service.getM3uFavoriteChannels('playlist-1')) ).resolves.toBeNull(); expect( electron.dbGetAppPlaylistFavoriteChannels ).not.toHaveBeenCalled(); expect(electron.dbGetAppPlaylist).not.toHaveBeenCalled(); expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled(); }); 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('coordinates browser row replacements with the cross-context edit reservation', async () => { const request = jest.fn( async ( name: string, options: LockOptions, callback: (lock: Lock | null) => Promise ) => callback({ name, mode: options.mode ?? 'exclusive', } as Lock) ); Object.defineProperty(globalThis.navigator, 'locks', { configurable: true, value: { request }, }); const playlist = { _id: 'playlist-replacement-lock', title: 'Replacement', 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')), delete: jest.fn(() => of(undefined)), }; testWindow.electron = undefined; const service = createService(dbService); await firstValueFrom(service.addPlaylist(playlist)); await firstValueFrom(service.deletePlaylist(playlist._id)); expect( request.mock.calls.map(([name, options]) => [name, options]) ).toEqual([ ['iptvnator:playlist-authority', { mode: 'shared' }], [ `iptvnator:playlist-authority:${playlist._id}`, { mode: 'exclusive' }, ], ['iptvnator:playlist-authority', { mode: 'shared' }], [ `iptvnator:playlist-authority:${playlist._id}`, { mode: 'exclusive' }, ], ]); }); it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => { let storedPlaylists: Playlist[] = [ { _id: 'stalker-1', title: 'Legacy Stalker', count: 0, importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(), autoRefresh: false, macAddress: '00:1A:79:AA:BB:CC', portalUrl: 'http://example.com/stalker_portal/c/', } as Playlist, ]; const appState = new Map([ [SQLITE_PLAYLIST_MIGRATION_FLAG, '1'], ]); const electron = { dbGetAppPlaylists: jest.fn(async () => storedPlaylists), dbGetAppState: jest.fn( async (key: string) => appState.get(key) ?? null ), dbSetAppState: jest.fn(async (key: string, value: string) => { appState.set(key, value); }), dbUpsertAppPlaylist: jest.fn(), dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => { const updates = new Map( playlists.map((playlist) => [playlist._id, playlist]) ); storedPlaylists = storedPlaylists.map( (playlist) => updates.get(playlist._id) ?? playlist ); }), }; testWindow.electron = electron; const service = createService(); await expect( firstValueFrom(service.getAllPlaylists()) ).resolves.toEqual([ expect.objectContaining({ _id: 'stalker-1', isFullStalkerPortal: true, }), ]); expect(electron.dbUpsertAppPlaylists).toHaveBeenCalledWith([ expect.objectContaining({ _id: 'stalker-1', isFullStalkerPortal: true, }), ]); expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe( '1' ); }); describe('legacy Stalker portal flags on the server', () => { // A desktop database copied to the server, from before the flag. const copied = { _id: 'stalker-1', title: 'Copied Stalker', count: 0, importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(), autoRefresh: false, macAddress: '00:1A:79:AA:BB:CC', portalUrl: 'http://example.com/stalker_portal/c/', } as Playlist; function serverService( fresh: Playlist, upsert: jest.Mock = jest.fn(async () => ({ success: true })) ) { const appState = new Map(); const server = { dbGetAppPlaylists: jest.fn(async () => [copied]), // Another device may have changed it since the list read. dbGetAppPlaylist: jest.fn(async () => fresh), dbGetAppState: jest.fn( async (key: string) => appState.get(key) ?? null ), dbSetAppState: jest.fn(async (key: string, value: string) => { appState.set(key, value); }), dbUpsertAppPlaylist: upsert, dbUpsertAppPlaylists: jest.fn(), dbRecoverLegacyPlaylists: jest.fn(), }; testWindow.electron = undefined; const service = createService(); Object.assign(service as object, { storageBackend: { kind: () => 'server', documentStore: () => server, }, }); return { service, server, appState }; } it('are written per row on a fresh read, as conditional mutations', async () => { const edited = { ...copied, title: 'Renamed elsewhere' }; const { service, server, appState } = serverService(edited); await firstValueFrom(service.getAllPlaylists()); // Never the stale list copy, never an unconditional bulk write. expect(server.dbUpsertAppPlaylists).not.toHaveBeenCalled(); expect(server.dbUpsertAppPlaylist).toHaveBeenCalledWith({ ...edited, isFullStalkerPortal: true, }); expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe( '1' ); // The desktop-only steps stay with the desktop. expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled(); }); it('stay unflagged for the next start after a conflict', async () => { const { service, appState } = serverService( copied, jest.fn(async () => { throw new PlaylistWriteConflict('stalker-1'); }) ); jest.spyOn(console, 'error').mockImplementation(() => undefined); await firstValueFrom(service.getAllPlaylists()); expect( appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) ).toBeUndefined(); }); }); it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => { const electron = { dbGetAppPlaylists: jest.fn(async () => []), dbGetAppState: jest.fn(async (key: string) => { if ( key === SQLITE_PLAYLIST_MIGRATION_FLAG || key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG ) { return '1'; } return null; }), dbSetAppState: jest.fn(), dbUpsertAppPlaylist: jest.fn(), dbUpsertAppPlaylists: jest.fn(), }; testWindow.electron = electron; const service = createService(); await firstValueFrom(service.getAllPlaylists()); await firstValueFrom(service.getAllPlaylists()); expect(electron.dbUpsertAppPlaylists).not.toHaveBeenCalled(); expect(electron.dbSetAppState).not.toHaveBeenCalledWith( STALKER_PLAYLIST_METADATA_MIGRATION_FLAG, '1' ); }); it('migrates legacy Stalker portal flags in one IndexedDB cursor transaction', async () => { let storedPlaylists: Playlist[] = [ { _id: 'stalker-2', title: 'IndexedDB Stalker', count: 0, importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(), autoRefresh: false, macAddress: '00:1A:79:11:22:33', portalUrl: 'http://example.com/portal/c/', } as Playlist, ]; const cursorUpdate = jest.fn((playlist: Playlist) => { storedPlaylists = [playlist]; }); const cursorContinue = jest.fn(); const dbService = { getAll: jest.fn(() => of(storedPlaylists)), openCursor: jest.fn(() => of({ value: storedPlaylists[0], update: cursorUpdate, continue: cursorContinue, }) ), }; testWindow.electron = undefined; const service = createService(dbService); await expect(firstValueFrom(service.getAllData())).resolves.toEqual([ expect.objectContaining({ _id: 'stalker-2', isFullStalkerPortal: false, }), ]); expect(dbService.openCursor).toHaveBeenCalledWith({ storeName: DbStores.Playlists, mode: 'readwrite', }); expect(cursorUpdate).toHaveBeenCalledWith( expect.objectContaining({ _id: 'stalker-2', isFullStalkerPortal: false, }) ); expect(cursorContinue).toHaveBeenCalledTimes(1); expect(dbService.getAll).toHaveBeenCalledTimes(1); expect( localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) ).toBe('1'); }); it('carries the Stalker session fields through the SQLite fallback read', async () => { // Without these, every cold Electron session loses the persisted // cadence AND the identity the token was negotiated for — so the // mismatch check cannot run and the watchdog falls back to default. const electron = { dbGetAppPlaylist: jest.fn(async () => ({ id: 'stalker-1', stalkerToken: 'TOKEN', stalkerSessionIdentity: 'fingerprint-1', stalkerWatchdogTimeout: 90, stalkerTimeslot: 7, })), dbGetAppPlaylists: jest.fn(async () => []), dbGetAppState: jest.fn(async () => '1'), dbSetAppState: jest.fn(), dbUpsertAppPlaylist: jest.fn(), dbUpsertAppPlaylists: jest.fn(), }; testWindow.electron = electron; const service = createService(); const playlist = await firstValueFrom( service.getPlaylistById('stalker-1') ); expect(playlist).toEqual( expect.objectContaining({ stalkerToken: 'TOKEN', stalkerSessionIdentity: 'fingerprint-1', stalkerWatchdogTimeout: 90, stalkerTimeslot: 7, }) ); }); it('clears a stale watchdog cadence when the portal stops advertising one', async () => { // Reusing the token skips the profile that would correct the cadence, // so leaving the old value on the row means the next restart applies // a cadence the portal no longer asks for. const existingPlaylist: Playlist = { _id: 'stalker-1', title: 'Stalker', count: 0, importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(), autoRefresh: false, stalkerToken: 'OLD', stalkerWatchdogTimeout: 45, stalkerTimeslot: 7, } 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.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' }) ); const written = dbService.update.mock.calls[0][1] as Playlist; expect(written.stalkerToken).toBe('NEW'); expect(written.stalkerWatchdogTimeout).toBeUndefined(); expect(written.stalkerTimeslot).toBeUndefined(); }); it('persists the watchdog cadence alongside the stalker token', async () => { const existingPlaylist: Playlist = { _id: 'stalker-1', title: 'Stalker', count: 0, importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(), autoRefresh: false, } 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.updateStalkerSession('stalker-1', { stalkerToken: 'TOKEN', stalkerWatchdogTimeout: 90, stalkerTimeslot: 12, }) ); expect(dbService.update).toHaveBeenCalledWith( DbStores.Playlists, expect.objectContaining({ stalkerToken: 'TOKEN', stalkerWatchdogTimeout: 90, stalkerTimeslot: 12, }) ); }); it('persists hiddenGroupTitles in playlist meta updates', async () => { const existingPlaylist: Playlist = { _id: 'playlist-1', title: 'Playlist One', count: 2, importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), autoRefresh: false, hiddenGroupTitles: ['News'], } 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.updatePlaylistMeta({ _id: 'playlist-1', hiddenGroupTitles: ['Movies', 'Sports'], } as PlaylistMeta) ); expect(dbService.update).toHaveBeenCalledWith( DbStores.Playlists, expect.objectContaining({ _id: 'playlist-1', hiddenGroupTitles: ['Movies', 'Sports'], }) ); }); it('drops the learned panel timezone when a metadata update points the source at another server (issue #1562)', async () => { const existingPlaylist = { _id: 'xtream-1', title: 'Portal', serverUrl: 'https://old.example.com', username: 'user', password: 'pass', serverTimezone: 'Europe/London', } 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.updatePlaylistMeta({ _id: 'xtream-1', title: 'Portal', serverUrl: 'https://new.example.com', } as PlaylistMeta) ); const [, moved] = dbService.update.mock.calls[0]; expect(moved.serverUrl).toBe('https://new.example.com'); expect(moved.serverTimezone).toBeUndefined(); dbService.update.mockClear(); await firstValueFrom( service.updatePlaylistMeta({ _id: 'xtream-1', title: 'Renamed portal', serverUrl: 'https://old.example.com', } as PlaylistMeta) ); const [, renamed] = dbService.update.mock.calls[0]; expect(renamed.serverTimezone).toBe('Europe/London'); dbService.update.mockClear(); await firstValueFrom( service.updatePlaylistMeta({ _id: 'xtream-1', title: 'Portal', serverUrl: 'https://new.example.com', serverTimezone: 'UTC+03:00', } as PlaylistMeta) ); const [, relearned] = dbService.update.mock.calls[0]; expect(relearned.serverTimezone).toBe('UTC+03:00'); }); it('aborts a guarded metadata update in one readwrite transaction when the row no longer matches', async () => { const replacementPlaylist = { _id: 'stalker-replaced', title: 'Restored Portal', portalUrl: 'https://restored.example.com/portal.php', } as Playlist; const cursorUpdate = jest.fn(); const dbService = { getAll: jest.fn(() => of([])), getByID: jest.fn(), update: jest.fn(), openCursor: jest.fn(() => of({ value: replacementPlaylist, update: cursorUpdate, }) ), }; testWindow.electron = undefined; const service = createService(dbService); await expect( firstValueFrom( service.updatePlaylistMetaIfCurrent( { _id: replacementPlaylist._id, portalUrl: 'https://late.example.com/server/load.php', } as PlaylistMeta, (current) => current.portalUrl === 'https://original.example.com/portal.php' ) ) ).resolves.toBeNull(); expect(dbService.openCursor).toHaveBeenCalledWith({ storeName: DbStores.Playlists, query: replacementPlaylist._id, mode: 'readwrite', }); expect(cursorUpdate).not.toHaveBeenCalled(); expect(dbService.getByID).not.toHaveBeenCalled(); expect(dbService.update).not.toHaveBeenCalled(); }); it('merges and commits a matching guarded browser update through its cursor', async () => { const currentPlaylist = { _id: 'stalker-guarded-write', title: 'Original Portal', portalUrl: 'https://original.example.com/portal.php', macAddress: '00:1A:79:00:00:01', } as Playlist; const cursorUpdate = jest.fn(); const dbService = { getAll: jest.fn(() => of([])), openCursor: jest.fn(() => of({ value: currentPlaylist, update: cursorUpdate, }) ), }; testWindow.electron = undefined; const service = createService(dbService); const result = await firstValueFrom( service.updatePlaylistMetaIfCurrent( { _id: currentPlaylist._id, portalUrl: 'https://resolved.example.com/server/load.php', stalkerSessionPatch: null, } as never, (current) => current.portalUrl === currentPlaylist.portalUrl ) ); expect(cursorUpdate).toHaveBeenCalledWith(result); expect(result).toEqual( expect.objectContaining({ title: currentPlaylist.title, portalUrl: 'https://resolved.example.com/server/load.php', macAddress: currentPlaylist.macAddress, stalkerToken: undefined, }) ); }); it('commits a browser conditional transform through the same readwrite cursor', async () => { const currentPlaylist = { _id: 'stalker-cursor-write', title: 'Original Portal', portalUrl: 'https://original.example.com/portal.php', } as Playlist; const cursorUpdate = jest.fn(); const dbService = { getAll: jest.fn(() => of([])), getByID: jest.fn(), update: jest.fn(), openCursor: jest.fn(() => of({ value: currentPlaylist, update: cursorUpdate, }) ), }; testWindow.electron = undefined; const service = createService(dbService); const result = await firstValueFrom( service.transformPlaylistMeta(currentPlaylist._id, (current) => ({ ...current, portalUrl: 'https://resolved.example.com/server/load.php', })) ); expect(dbService.openCursor).toHaveBeenCalledWith({ storeName: DbStores.Playlists, query: currentPlaylist._id, mode: 'readwrite', }); expect(cursorUpdate).toHaveBeenCalledWith(result); expect(result?.portalUrl).toBe( 'https://resolved.example.com/server/load.php' ); expect(dbService.getByID).not.toHaveBeenCalled(); expect(dbService.update).not.toHaveBeenCalled(); }); describe('Stalker session patches in playlist meta updates', () => { function createStalkerPlaylist(): Playlist { return { _id: 'stalker-session-patch', title: 'Stalker Portal', count: 0, importDate: '2026-08-08T00:00:00.000Z', lastUsage: '2026-08-08T00:00:00.000Z', autoRefresh: false, stalkerToken: 'OLD_TOKEN', stalkerSessionIdentity: 'old-fingerprint', stalkerWatchdogTimeout: 120, stalkerTimeslot: 15, stalkerAccountInfo: { login: 'old-login', expireDate: 1800000000, }, }; } it('preserves the current session when the transient patch is absent', async () => { const existingPlaylist = createStalkerPlaylist(); 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.updatePlaylistMeta({ _id: existingPlaylist._id, title: 'Renamed Portal', } as PlaylistMeta) ); expect(dbService.update.mock.calls[0][1]).toEqual( expect.objectContaining({ stalkerToken: 'OLD_TOKEN', stalkerSessionIdentity: 'old-fingerprint', stalkerWatchdogTimeout: 120, stalkerTimeslot: 15, stalkerAccountInfo: existingPlaylist.stalkerAccountInfo, }) ); }); it('clears every stored session field when the transient patch is null', async () => { const existingPlaylist = createStalkerPlaylist(); 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.updatePlaylistMeta({ _id: existingPlaylist._id, stalkerSessionPatch: null, } as never) ); const written = dbService.update.mock.calls[0][1]; expect(written.stalkerToken).toBeUndefined(); expect(written.stalkerSessionIdentity).toBeUndefined(); expect(written.stalkerWatchdogTimeout).toBeUndefined(); expect(written.stalkerTimeslot).toBeUndefined(); expect(written.stalkerAccountInfo).toBeUndefined(); expect(written).not.toHaveProperty('stalkerSessionPatch'); }); it('fully replaces session metadata without persisting the transient patch', async () => { const existingPlaylist = createStalkerPlaylist(); 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.updatePlaylistMeta({ _id: existingPlaylist._id, stalkerSessionPatch: { stalkerToken: 'NEW_TOKEN', stalkerSessionIdentity: 'new-fingerprint', stalkerWatchdogTimeout: 90, }, } as never) ); const written = dbService.update.mock.calls[0][1]; expect(written).toEqual( expect.objectContaining({ stalkerToken: 'NEW_TOKEN', stalkerSessionIdentity: 'new-fingerprint', stalkerWatchdogTimeout: 90, }) ); expect(written.stalkerTimeslot).toBeUndefined(); expect(written.stalkerAccountInfo).toBeUndefined(); expect(written).not.toHaveProperty('stalkerSessionPatch'); }); }); 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 instead of // force-enabling it. const playlists = [ { _id: 'playlist-a', title: 'A', count: 1, importDate: '2026-04-01T00:00:00.000Z', lastUsage: '2026-04-01T00:00:00.000Z', autoRefresh: true, }, { _id: 'playlist-b', title: 'B', count: 2, importDate: '2026-04-01T00:00:00.000Z', lastUsage: '2026-04-01T00:00:00.000Z', autoRefresh: true, }, ] as Playlist[]; const dbService = { getAll: jest.fn(() => of([])), getByID: jest.fn((_store: string, id: string) => of(playlists.find((p) => p._id === id)) ), 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.each([false, true])( 'does not resurrect a deleted source when startup refresh finishes (SQLite=%s)', async (sqlite) => { const upsert = jest.fn(); testWindow.electron = sqlite ? { dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(), dbGetAppPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(null), dbUpsertAppPlaylist: upsert, } : undefined; const update = jest.fn(() => of(undefined)); const service = createService({ update }); expect( await firstValueFrom( service.updateManyPlaylists([ { _id: 'deleted', autoRefresh: true } as Playlist, ]) ) ).toEqual([]); expect(update).not.toHaveBeenCalled(); expect(upsert).not.toHaveBeenCalled(); } ); 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('takes the exclusive cross-context authority barrier before clearing playlists', async () => { const request = jest.fn( async ( name: string, options: LockOptions, callback: (lock: Lock | null) => Promise ) => callback({ name, mode: options.mode ?? 'exclusive', } as Lock) ); Object.defineProperty(globalThis.navigator, 'locks', { configurable: true, value: { request }, }); const dbService = { clear: jest.fn(() => of('cleared')), }; testWindow.electron = undefined; const service = createService(dbService); await firstValueFrom(service.removeAll()); expect(request).toHaveBeenCalledWith( 'iptvnator:playlist-authority', { mode: 'exclusive' }, expect.any(Function) ); }); 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', 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('deletes everything only after queued writes, before later ones', async () => { const order: string[] = []; let finishWrite: () => void = () => undefined; const electron = { dbGetAppPlaylist: jest.fn(async () => { order.push('read'); return { _id: 'p9', title: 'P', favorites: [] }; }), dbGetAppPlaylists: jest.fn(async () => []), dbGetAppState: jest.fn(async (key: string) => key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null ), dbSetAppState: jest.fn(), dbUpsertAppPlaylist: jest.fn( () => new Promise((resolve) => { order.push('write'); finishWrite = () => resolve(); }) ), dbUpsertAppPlaylists: jest.fn(), dbDeleteAllPlaylists: jest.fn(async () => { order.push('delete all'); }), }; testWindow.electron = electron; const service = createService(); // Microtasks only: a real timer would let other tests' late errors in. const settle = async () => { for (let i = 0; i < 50; i += 1) { await Promise.resolve(); } }; const first = firstValueFrom( service.transformPlaylistFavorites('p9', (f) => [...f, 'a']) ); await settle(); const removed = firstValueFrom(service.removeAll()); const later = firstValueFrom( service.transformPlaylistFavorites('p9', (f) => [...f, 'b']) ); await settle(); expect(order).toEqual(['read', 'write']); finishWrite(); await first; await removed; await settle(); finishWrite(); await later; expect(order).toEqual(['read', 'write', 'delete all', 'read', 'write']); }); describe('Delete All with cross-context locks', () => { /** Web Locks as far as these tests need: the barrier and free rows. */ function fakeLocks() { let shared = 0; let exclusive = false; const waiting: (() => void)[] = []; const changed = () => waiting.splice(0).forEach((wake) => wake()); const until = async (free: () => boolean) => { while (!free()) { await new Promise((wake) => waiting.push(wake)); } }; const request = jest.fn( async ( name: string, options: LockOptions, callback: (lock: Lock | null) => Promise ) => { const lock = { name, mode: options.mode ?? 'exclusive', } as Lock; if (name !== 'iptvnator:playlist-authority') { return callback(lock); } if (options.mode === 'shared') { await until(() => !exclusive); shared += 1; try { return await callback(lock); } finally { shared -= 1; changed(); } } await until(() => !exclusive && shared === 0); exclusive = true; try { return await callback(lock); } finally { exclusive = false; changed(); } } ); Object.defineProperty(globalThis.navigator, 'locks', { configurable: true, value: { request }, }); } let finishWrite: () => void = () => undefined; function store(slowWrites = false) { const electron = { dbGetAppPlaylist: jest.fn(async () => ({ _id: 'p9', title: 'P', favorites: [], })), dbGetAppPlaylists: jest.fn(async () => []), dbGetAppState: jest.fn(async (key: string) => key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null ), dbSetAppState: jest.fn(), // Only the first write is slow. dbUpsertAppPlaylist: jest.fn(() => { if (!slowWrites) { return Promise.resolve(); } slowWrites = false; return new Promise( (resolve) => (finishWrite = resolve) ); }), dbUpsertAppPlaylists: jest.fn(), dbDeletePlaylist: jest.fn(async () => undefined), dbDeleteAllPlaylists: jest.fn(async () => undefined), }; testWindow.electron = electron; return electron; } const settle = async () => { for (let i = 0; i < 100; i += 1) { await Promise.resolve(); } }; it('lets a repair holding its reservation write first', async () => { fakeLocks(); const electron = store(); const service = createService(); const reservation = await reservePlaylistAuthority('p9'); if (reservation.status !== 'acquired') { throw new Error('expected a reservation'); } const removed = firstValueFrom(service.removeAll()); // The repair's conditional row write, under its reservation. let written = false; const repair = firstValueFrom( service.transformPlaylistFavorites('p9', (f) => [...f, 'a']) ).then(() => (written = true)); await settle(); expect(written).toBe(true); expect(electron.dbDeleteAllPlaylists).not.toHaveBeenCalled(); reservation.release(); await repair; await removed; expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled(); }); it('lets a repair write past later writes waiting for the reset', async () => { fakeLocks(); const electron = store(true); const service = createService(); // An earlier slow write that Delete All waits for. const slow = firstValueFrom( service.transformPlaylistFavorites('q1', (f) => [...f, 'a']) ); await settle(); const removed = firstValueFrom(service.removeAll()); // A later write for P, which waits for the reset. const later = firstValueFrom( service.transformPlaylistFavorites('p9', (f) => [...f, 'b']) ); // A repair reserves P before the reset takes its lock. const reservation = await reservePlaylistAuthority('p9'); if (reservation.status !== 'acquired') { throw new Error('expected a reservation'); } let repaired = false; const repair = firstValueFrom( service.transformPlaylistFavorites('p9', (f) => [...f, 'r']) ).then(() => { repaired = true; reservation.release(); }); await settle(); expect(repaired).toBe(true); finishWrite(); await Promise.all([slow, repair, removed, later]); expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled(); }); it('lets a deletion queued behind a slow write finish first', async () => { fakeLocks(); const electron = store(true); const service = createService(); const slow = firstValueFrom( service.transformPlaylistFavorites('p9', (f) => [...f, 'a']) ); await settle(); const deleted = firstValueFrom(service.deletePlaylist('p9')); let done = false; const removed = firstValueFrom(service.removeAll()).then( () => (done = true) ); await settle(); finishWrite(); await settle(); // The deletion needs the shared lock: it cannot wait for a // reset that waits for it. expect(done).toBe(true); await slow; await deleted; await removed; expect(electron.dbDeletePlaylist).toHaveBeenCalledWith('p9'); expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled(); }); }); it('runs a mutation again on a fresh read when the server refused it', async () => { const versions = [ { _id: 'p9', title: 'P', favorites: ['a'] }, { _id: 'p9', title: 'P', favorites: ['a', 'theirs'] }, ] as unknown as Playlist[]; let reads = 0; const electron = { dbGetAppPlaylist: jest.fn( async () => versions[Math.min(reads++, 1)] ), dbGetAppPlaylists: jest.fn(async () => []), dbGetAppState: jest.fn(async (key: string) => key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null ), dbSetAppState: jest.fn(), dbUpsertAppPlaylist: jest .fn() .mockRejectedValueOnce(new PlaylistWriteConflict('p9')) .mockResolvedValue(undefined), dbUpsertAppPlaylists: jest.fn(), }; testWindow.electron = electron; await firstValueFrom( createService().transformPlaylistFavorites('p9', (favorites) => [ ...favorites, 'mine', ]) ); expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); expect(electron.dbUpsertAppPlaylist).toHaveBeenLastCalledWith( expect.objectContaining({ favorites: ['a', 'theirs', 'mine'] }) ); }); 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('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.each([false, true])( 'does not recreate a removed source on singular refresh (SQLite: %s)', async (sqlite) => { const upsert = jest.fn(); testWindow.electron = sqlite ? ({ dbGetAllPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(null), dbUpsertAppPlaylist: upsert, } as unknown as typeof testWindow.electron) : undefined; const update = jest.fn(() => of(undefined)); const service = createService({ update }); await expect( firstValueFrom( service.updatePlaylist('deleted', { _id: 'deleted', } as Playlist) ) ).rejects.toThrow('Playlist no longer exists'); expect(update).not.toHaveBeenCalled(); expect(upsert).not.toHaveBeenCalled(); } ); it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => { const existingPlaylist: Playlist = { _id: 'playlist-2', title: 'Playlist Two', count: 1, importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), autoRefresh: false, hiddenGroupTitles: ['Radio-de'], playlist: { items: [{ id: 'channel-1' }], }, } 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.updatePlaylist('playlist-2', { _id: 'playlist-2', playlist: { items: [], }, } as Playlist) ); expect(dbService.update).toHaveBeenCalledWith( DbStores.Playlists, expect.objectContaining({ _id: 'playlist-2', hiddenGroupTitles: ['Radio-de'], count: 0, }) ); }); it('keeps autoRefresh enabled when refreshing a playlist payload with the parser default disabled', async () => { const existingPlaylist: Playlist = { _id: 'playlist-3', title: 'Playlist Three', count: 1, importDate: new Date('2026-04-12T00:00:00.000Z').toISOString(), lastUsage: new Date('2026-04-12T00:00:00.000Z').toISOString(), autoRefresh: true, playlist: { items: [{ id: 'channel-1' }], }, } 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.updatePlaylist('playlist-3', { _id: 'playlist-3', autoRefresh: false, playlist: { items: [], }, } as Playlist) ); expect(dbService.update).toHaveBeenCalledWith( DbStores.Playlists, expect.objectContaining({ _id: 'playlist-3', autoRefresh: true, count: 0, }) ); }); 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; } }), dbDeletePlaylist: jest.fn(async () => { store.current = undefined as unknown as Playlist; }), }; return { store, electron }; } it.each([false, true])( 'reads the catalog after a queued refresh settles (write fails: %s)', async (fails) => { const original = createBasePlaylist('catalog-refresh'); const { store, electron } = createStatefulElectronStore(original); testWindow.electron = electron; let release!: () => void; let started!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const writing = new Promise((resolve) => { started = resolve; }); electron.dbUpsertAppPlaylist.mockImplementationOnce( async (playlist) => { started(); await gate; if (fails) throw new Error('Write failed'); store.current = playlist; } ); const service = createService(); const updated = { ...original, title: 'Refreshed catalog' }; const write = firstValueFrom( service.updatePlaylist(original._id, updated) ); const outcome = write.catch((error: Error) => error); await writing; // A database read captures the snapshot at request time, // even if its IPC response arrives after the write completes. jest.spyOn(service, 'getPlaylistById').mockImplementation(() => of(store.current) ); const read = firstValueFrom(service.getPlaylist(original._id)); release(); await outcome; expect((await read).title).toBe( fails ? original.title : updated.title ); } ); it('reads hidden groups saved by a meta update that is still being written', async () => { const original = createBasePlaylist('catalog-hidden-groups'); const { store, electron } = createStatefulElectronStore(original); testWindow.electron = electron; let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); electron.dbUpsertAppPlaylist.mockImplementationOnce( async (playlist) => { await gate; store.current = playlist; } ); const service = createService(); // The meta effect subscribes while the save is dispatched, so the // write is queued before the M3U route reopens the playlist. const write = firstValueFrom( service.updatePlaylistMeta({ _id: original._id, hiddenGroupTitles: ['Sports'], } as PlaylistMeta) ); const read = firstValueFrom(service.getPlaylist(original._id)); release(); await write; expect((await read).hiddenGroupTitles).toEqual(['Sports']); }); it('orders a browser catalog read after a queued IndexedDB refresh', async () => { const original = createBasePlaylist('browser-catalog'); const state = { current: original }; testWindow.electron = undefined; let release!: () => void; let started!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const writing = new Promise((resolve) => { started = resolve; }); const service = createService({ getByID: jest.fn(() => of(state.current)), update: jest.fn((_store: string, playlist: Playlist) => { started(); return from( gate.then(() => { state.current = playlist; return playlist; }) ); }), }); const updated = { ...original, title: 'Refreshed browser catalog' }; const write = firstValueFrom( service.updatePlaylist(original._id, updated) ); await writing; const read = firstValueFrom(service.getPlaylist(original._id)); release(); await write; expect((await read).title).toBe(updated.title); }); it('does not block a different catalog behind a pending write', async () => { const original = createBasePlaylist('catalog-busy'); const { electron } = createStatefulElectronStore(original); testWindow.electron = electron; let release!: () => void; let started!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const writing = new Promise((resolve) => { started = resolve; }); electron.dbUpsertAppPlaylist.mockImplementationOnce(async () => { started(); await gate; }); const service = createService(); const write = firstValueFrom( service.updatePlaylist(original._id, original) ); await writing; try { await firstValueFrom(service.getPlaylist('catalog-other')); expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith( 'catalog-other' ); } finally { release(); await write; } }); 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('forwards refresh instrumentation only to its SQLite read and write', async () => { const { electron } = createStatefulElectronStore( createBasePlaylist('playlist-instrumented-refresh') ); testWindow.electron = electron; const service = createService(); const operationId = 'playlist-refresh-operation'; await firstValueFrom( service.updatePlaylist( 'playlist-instrumented-refresh', { _id: 'playlist-instrumented-refresh', playlist: { items: [] }, } as Playlist, operationId ) ); expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith( 'playlist-instrumented-refresh', operationId ); expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith( expect.objectContaining({ _id: 'playlist-instrumented-refresh', }), operationId ); }); 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('serializes deletion behind queued writes so nothing resurrects the row', async () => { const { store, electron } = createStatefulElectronStore( createBasePlaylist('portal-delete-race') ); testWindow.electron = electron; const service = createService(); // A conditional transform (the repair's write path) is queued // when the user deletes the playlist: the delete must run AFTER // the queued write, leaving the row deleted — not upserted back. await Promise.all([ firstValueFrom( service.transformPlaylistMeta( 'portal-delete-race', (current) => ({ ...current, portalUrl: 'http://x/portal.php', }) ) ), firstValueFrom(service.deletePlaylist('portal-delete-race')), ]); expect(store.current).toBeUndefined(); const upsertOrder = electron.dbUpsertAppPlaylist.mock.invocationCallOrder[0]; const deleteOrder = electron.dbDeletePlaylist.mock.invocationCallOrder[0]; expect(deleteOrder).toBeGreaterThan(upsertOrder); }); it('transformPlaylistMeta aborts without writing when the transform returns null', async () => { const { electron } = createStatefulElectronStore( createBasePlaylist('portal-meta-abort') ); testWindow.electron = electron; const service = createService(); const writesBefore = electron.dbUpsertAppPlaylist.mock.calls.length; const result = await firstValueFrom( service.transformPlaylistMeta('portal-meta-abort', () => null) ); expect(result).toBeNull(); expect(electron.dbUpsertAppPlaylist.mock.calls.length).toBe( writesBefore ); }); it('transformPlaylistMeta persists the transformed row and serializes with queued edits', async () => { const { store, electron } = createStatefulElectronStore( createBasePlaylist('portal-meta-write') ); testWindow.electron = electron; const service = createService(); // A queued edit commits first; the conditional transform then // sees ITS result — the property the Stalker portal repair // relies on to never overwrite a user edit racing the probe. await Promise.all([ firstValueFrom( service.updatePlaylistMeta({ _id: 'portal-meta-write', title: 'Edited Title', } as never) ), firstValueFrom( service.transformPlaylistMeta( 'portal-meta-write', (current) => current.title === 'Edited Title' ? { ...current, portalUrl: 'http://x/portal.php', } : null ) ), ]); expect(store.current.title).toBe('Edited Title'); expect(store.current.portalUrl).toBe('http://x/portal.php'); }); 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); }); }); });