diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts new file mode 100644 index 000000000..e086f39e9 --- /dev/null +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -0,0 +1,171 @@ +import { firstValueFrom, of } from 'rxjs'; +import { DbStores, Playlist } from 'shared-interfaces'; +import { PlaylistsService } 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 originalElectron = (window as Window & { electron?: unknown }).electron; + + afterEach(() => { + (window as Window & { electron?: unknown }).electron = originalElectron; + localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG); + jest.restoreAllMocks(); + }); + + function createService(overrides: Record = {}) { + const service = Object.create(PlaylistsService.prototype) as PlaylistsService; + + Object.assign(service as object, { + dbService: { + clear: jest.fn(() => of(undefined)), + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(undefined)), + update: jest.fn(() => of(undefined)), + ...overrides, + }, + snackBar: { + open: jest.fn(), + }, + translateService: { + instant: jest.fn((key: string) => key), + }, + electronMigrationPromise: null, + indexedDbMigrationPromise: null, + }); + + return service; + } + + 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 + ); + }), + }; + (window as Window & { electron?: unknown }).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'); + }); + + 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(), + }; + (window as Window & { electron?: unknown }).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 IndexedDB before returning full playlists', 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 dbService = { + getAll: jest.fn(() => of(storedPlaylists)), + update: jest.fn((_storeName: string, playlist: Playlist) => { + storedPlaylists = storedPlaylists.map((current) => + current._id === playlist._id ? playlist : current + ); + return of(playlist); + }), + }; + (window as Window & { electron?: unknown }).electron = undefined; + + const service = createService(dbService); + + await expect(firstValueFrom(service.getAllData())).resolves.toEqual([ + expect.objectContaining({ + _id: 'stalker-2', + isFullStalkerPortal: false, + }), + ]); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + _id: 'stalker-2', + isFullStalkerPortal: false, + }) + ); + expect( + localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) + ).toBe('1'); + }); +}); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index c8b645bd9..0ce5b5e8c 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -33,6 +33,8 @@ import { } from 'shared-interfaces'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; +const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG = + 'm3u-playlists-stalker-metadata-v1'; type PortalFavoriteItem = StalkerPortalItem & { category_id?: string; @@ -44,6 +46,21 @@ type PlaylistRawItem = { raw?: string; }; +type PlaylistStorageElectronApi = { + dbDeleteAllPlaylists: () => Promise; + dbDeletePlaylist: (playlistId: string) => Promise; + dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylists: () => Promise; + dbGetAppState: (key: string) => Promise; + dbSetAppState: (key: string, value: string) => Promise; + dbUpsertAppPlaylist: (playlist: Playlist) => Promise; + dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise; +}; + +type PlaylistStorageWindow = Window & { + electron?: PlaylistStorageElectronApi; +}; + @Injectable({ providedIn: 'root', }) @@ -51,40 +68,77 @@ export class PlaylistsService { private readonly dbService = inject(NgxIndexedDBService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); - private migrationPromise: Promise | null = null; + private electronMigrationPromise: Promise | null = null; + private indexedDbMigrationPromise: Promise | null = null; + + private get electronApi(): PlaylistStorageElectronApi | null { + if (typeof window === 'undefined') { + return null; + } + + return (window as PlaylistStorageWindow).electron ?? null; + } private get isElectronStorageAvailable(): boolean { + const electron = this.electronApi; + return ( - typeof window !== 'undefined' && - !!window.electron && - typeof window.electron.dbGetAppPlaylists === 'function' && - typeof window.electron.dbUpsertAppPlaylist === 'function' && - typeof window.electron.dbGetAppState === 'function' && - typeof window.electron.dbSetAppState === 'function' + !!electron && + typeof electron.dbGetAppPlaylists === 'function' && + typeof electron.dbUpsertAppPlaylist === 'function' && + typeof electron.dbGetAppState === 'function' && + typeof electron.dbSetAppState === 'function' ); } private runOnSqlite(operation: () => Promise) { return from( - this.ensureSqlitePlaylistMigration().then(() => operation()) + this.ensureElectronPlaylistMigrations().then(() => operation()) ); } - private async ensureSqlitePlaylistMigration(): Promise { + private runOnIndexedDb(operation: () => Promise) { + return from( + this.ensureIndexedDbPlaylistMigrations().then(() => operation()) + ); + } + + private async ensureElectronPlaylistMigrations(): Promise { if (!this.isElectronStorageAvailable) { return; } - if (!this.migrationPromise) { - this.migrationPromise = this.migrateIndexedDbPlaylistsToSqlite(); + if (!this.electronMigrationPromise) { + this.electronMigrationPromise = (async () => { + await this.migrateIndexedDbPlaylistsToSqlite(); + await this.migrateStalkerPlaylistMetadataInSqlite(); + })(); } - return this.migrationPromise; + return this.electronMigrationPromise; + } + + private async ensureIndexedDbPlaylistMigrations(): Promise { + if (this.isElectronStorageAvailable) { + return; + } + + if (!this.indexedDbMigrationPromise) { + this.indexedDbMigrationPromise = + this.migrateStalkerPlaylistMetadataInIndexedDb(); + } + + return this.indexedDbMigrationPromise; } private async migrateIndexedDbPlaylistsToSqlite(): Promise { try { - const alreadyMigrated = await window.electron.dbGetAppState( + const electron = this.electronApi; + if (!electron) { + return; + } + + const alreadyMigrated = await electron.dbGetAppState( SQLITE_PLAYLIST_MIGRATION_FLAG ); if (alreadyMigrated === '1') { @@ -96,14 +150,11 @@ export class PlaylistsService { ); if (indexedDbPlaylists.length > 0) { - await window.electron.dbUpsertAppPlaylists(indexedDbPlaylists); + await electron.dbUpsertAppPlaylists(indexedDbPlaylists); await firstValueFrom(this.dbService.clear(DbStores.Playlists)); } - await window.electron.dbSetAppState( - SQLITE_PLAYLIST_MIGRATION_FLAG, - '1' - ); + await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1'); } catch (error) { console.error( 'Failed to migrate IndexedDB playlists to SQLite:', @@ -116,7 +167,7 @@ export class PlaylistsService { playlist: Partial & { _id?: string; id?: string } ): Playlist { const id = String(playlist._id ?? playlist.id ?? ''); - return this.normalizeStalkerPortalFlags({ + return { ...playlist, _id: id, title: playlist.title ?? '', @@ -148,14 +199,10 @@ export class PlaylistsService { isFullStalkerPortal: playlist.isFullStalkerPortal, stalkerToken: playlist.stalkerToken, stalkerAccountInfo: playlist.stalkerAccountInfo, - } as Playlist); + } as Playlist; } - /** - * Full Stalker portals (e.g. Ministra) require handshake/token auth. - * Infer the mode from URL when legacy records are missing explicit flag. - */ - private normalizeStalkerPortalFlags(playlist: Playlist): Playlist { + private withExplicitLegacyStalkerPortalFlag(playlist: Playlist): Playlist { if ( !playlist?.macAddress || playlist.isFullStalkerPortal !== undefined @@ -174,16 +221,130 @@ export class PlaylistsService { }; } + private collectStalkerMetadataMigrationUpdates( + playlists: Playlist[] + ): Playlist[] { + return playlists.reduce((updates, playlist) => { + const migratedPlaylist = + this.withExplicitLegacyStalkerPortalFlag(playlist); + + if (migratedPlaylist !== playlist) { + updates.push(migratedPlaylist); + } + + return updates; + }, []); + } + + private readIndexedDbMigrationFlag(key: string): string | null { + try { + const value = localStorage.getItem(key); + return value && value.trim().length > 0 ? value : null; + } catch { + return null; + } + } + + private writeIndexedDbMigrationFlag(key: string): void { + try { + localStorage.setItem(key, '1'); + } catch { + // Ignore storage write failures. + } + } + + private async migrateStalkerPlaylistMetadataInSqlite(): Promise { + try { + const electron = this.electronApi; + if (!electron) { + return; + } + + const alreadyMigrated = await electron.dbGetAppState( + STALKER_PLAYLIST_METADATA_MIGRATION_FLAG + ); + if (alreadyMigrated === '1') { + return; + } + + const storedPlaylists = + (await electron.dbGetAppPlaylists()) as Playlist[]; + const updates = + this.collectStalkerMetadataMigrationUpdates(storedPlaylists); + + if (updates.length > 0) { + await electron.dbUpsertAppPlaylists(updates); + } + + await electron.dbSetAppState( + STALKER_PLAYLIST_METADATA_MIGRATION_FLAG, + '1' + ); + } catch (error) { + console.error( + 'Failed to migrate Stalker playlist metadata in SQLite:', + error + ); + } + } + + private async migrateStalkerPlaylistMetadataInIndexedDb(): Promise { + try { + if ( + this.readIndexedDbMigrationFlag( + STALKER_PLAYLIST_METADATA_MIGRATION_FLAG + ) === '1' + ) { + return; + } + + const storedPlaylists = await firstValueFrom( + this.dbService.getAll(DbStores.Playlists) + ); + const updates = + this.collectStalkerMetadataMigrationUpdates(storedPlaylists); + + if (updates.length > 0) { + await firstValueFrom( + combineLatest( + updates.map((playlist) => + this.dbService.update(DbStores.Playlists, playlist) + ) + ) + ); + } + + this.writeIndexedDbMigrationFlag( + STALKER_PLAYLIST_METADATA_MIGRATION_FLAG + ); + } catch (error) { + console.error( + 'Failed to migrate Stalker playlist metadata in IndexedDB:', + error + ); + } + } + private upsertSqlitePlaylist(playlist: Playlist) { return this.runOnSqlite(async () => { - await window.electron.dbUpsertAppPlaylist(playlist); + const electron = this.electronApi; + if (!electron) { + return playlist; + } + + await electron.dbUpsertAppPlaylist(playlist); return playlist; }); } private upsertManySqlitePlaylists(playlists: Playlist[]) { return this.runOnSqlite(async () => { - await window.electron.dbUpsertAppPlaylists(playlists); + const electron = this.electronApi; + if (!electron) { + return playlists; + } + + await electron.dbUpsertAppPlaylists(playlists); return playlists; }); } @@ -191,19 +352,24 @@ export class PlaylistsService { getAllPlaylists() { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { - const playlists = await window.electron.dbGetAppPlaylists(); + const electron = this.electronApi; + const playlists = electron + ? await electron.dbGetAppPlaylists() + : []; return (playlists as Playlist[]).map( ({ playlist, items, header, ...rest }) => ({ - ...this.normalizeStalkerPortalFlags(rest as Playlist), + ...(rest as Playlist), }) ); }); } - return this.dbService.getAll(DbStores.Playlists).pipe( + return this.runOnIndexedDb(() => + firstValueFrom(this.dbService.getAll(DbStores.Playlists)) + ).pipe( map((data) => data.map(({ playlist, items, header, ...rest }) => ({ - ...this.normalizeStalkerPortalFlags(rest as Playlist), + ...(rest as Playlist), })) ) ); @@ -214,7 +380,9 @@ export class PlaylistsService { return this.upsertSqlitePlaylist(playlist); } - return this.dbService.add(DbStores.Playlists, playlist); + return this.dbService + .add(DbStores.Playlists, playlist) + .pipe(map(() => playlist)); } getPlaylist(id: string) { @@ -226,9 +394,15 @@ export class PlaylistsService { deletePlaylist(playlistId: string): Observable<{ success: boolean }> { if (this.isElectronStorageAvailable) { - return this.runOnSqlite(() => - window.electron.dbDeletePlaylist(playlistId) - ).pipe(map(() => ({ success: true }))); + return this.runOnSqlite(async () => { + const electron = this.electronApi; + if (!electron) { + return undefined; + } + + await electron.dbDeletePlaylist(playlistId); + return undefined; + }).pipe(map(() => ({ success: true }))); } return this.dbService @@ -266,14 +440,19 @@ export class PlaylistsService { getPlaylistById(id: string) { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { - const playlist = await window.electron.dbGetAppPlaylist(id); + const electron = this.electronApi; + const playlist = electron + ? await electron.dbGetAppPlaylist(id) + : null; return playlist ? this.createSqliteFallbackPlaylist(playlist as Playlist) : (undefined as unknown as Playlist); }); } - return this.dbService.getByID(DbStores.Playlists, id); + return this.runOnIndexedDb(() => + firstValueFrom(this.dbService.getByID(DbStores.Playlists, id)) + ); } updatePlaylistMeta(updatedPlaylist: PlaylistMeta) { @@ -520,8 +699,10 @@ export class PlaylistsService { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { - const playlists = - (await window.electron.dbGetAppPlaylists()) as Playlist[]; + const electron = this.electronApi; + const playlists = electron + ? ((await electron.dbGetAppPlaylists()) as Playlist[]) + : []; const positionsById = new Map( positionUpdates.map((item) => [ item.id, @@ -536,7 +717,9 @@ export class PlaylistsService { position: positionsById.get(playlist._id), })); - await window.electron.dbUpsertAppPlaylists(updatedPlaylists); + if (electron) { + await electron.dbUpsertAppPlaylists(updatedPlaylists); + } return updatedPlaylists; }); } @@ -653,21 +836,28 @@ export class PlaylistsService { getAllData() { if (this.isElectronStorageAvailable) { - return this.runOnSqlite(async () => - ((await window.electron.dbGetAppPlaylists()) as Playlist[]).map( - (playlist) => this.normalizeStalkerPortalFlags(playlist) - ) - ); + return this.runOnSqlite(async () => { + const electron = this.electronApi; + return electron + ? ((await electron.dbGetAppPlaylists()) as Playlist[]) + : []; + }); } - return this.dbService.getAll(DbStores.Playlists); + return this.runOnIndexedDb(() => + firstValueFrom(this.dbService.getAll(DbStores.Playlists)) + ); } removeAll(): Observable { if (this.isElectronStorageAvailable) { - return this.runOnSqlite(() => - window.electron.dbDeleteAllPlaylists() - ).pipe(map(() => undefined)); + return this.runOnSqlite(async () => { + const electron = this.electronApi; + if (electron) { + await electron.dbDeleteAllPlaylists(); + } + return undefined; + }).pipe(map(() => undefined)); } return this.dbService diff --git a/libs/services/src/material-snack-bar.d.ts b/libs/services/src/material-snack-bar.d.ts new file mode 100644 index 000000000..f67502bb1 --- /dev/null +++ b/libs/services/src/material-snack-bar.d.ts @@ -0,0 +1,9 @@ +declare module '@angular/material/snack-bar' { + export class MatSnackBar { + open( + message: string, + action?: string, + config?: { duration?: number } + ): void; + } +} diff --git a/libs/services/tsconfig.spec.json b/libs/services/tsconfig.spec.json index cc2617958..aae2e4e2e 100644 --- a/libs/services/tsconfig.spec.json +++ b/libs/services/tsconfig.spec.json @@ -2,10 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, "files": ["src/test-setup.ts"], "include": [