feat(playlists): implement migration for Stalker playlist metadata and update Electron API integration

This commit is contained in:
4gray committed 2026-03-28 21:42:40 +01:00
1 parent 688d9ec123
commit ec4a4f48f4
4 files changed
+421 -51

No files matched your search

@@ -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<string, unknown> = {}) {
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<string, string>([
[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');
});
});
+239 -49
View File
@@ -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<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<unknown>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
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<void> | null = null;
private electronMigrationPromise: Promise<void> | null = null;
private indexedDbMigrationPromise: Promise<void> | 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<T>(operation: () => Promise<T>) {
return from(
this.ensureSqlitePlaylistMigration().then(() => operation())
this.ensureElectronPlaylistMigrations().then(() => operation())
);
}
private async ensureSqlitePlaylistMigration(): Promise<void> {
private runOnIndexedDb<T>(operation: () => Promise<T>) {
return from(
this.ensureIndexedDbPlaylistMigrations().then(() => operation())
);
}
private async ensureElectronPlaylistMigrations(): Promise<void> {
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<void> {
if (this.isElectronStorageAvailable) {
return;
}
if (!this.indexedDbMigrationPromise) {
this.indexedDbMigrationPromise =
this.migrateStalkerPlaylistMetadataInIndexedDb();
}
return this.indexedDbMigrationPromise;
}
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
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<Playlist> & { _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<Playlist[]>((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<void> {
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<void> {
try {
if (
this.readIndexedDbMigrationFlag(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
) === '1'
) {
return;
}
const storedPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(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<Playlist>(DbStores.Playlists).pipe(
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getAll<Playlist>(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<Playlist>(DbStores.Playlists, id);
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getByID<Playlist>(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<Playlist>(DbStores.Playlists);
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getAll<Playlist>(DbStores.Playlists))
);
}
removeAll(): Observable<void> {
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
+9
View File
@@ -0,0 +1,9 @@
declare module '@angular/material/snack-bar' {
export class MatSnackBar {
open(
message: string,
action?: string,
config?: { duration?: number }
): void;
}
}
+2 -2
View File
@@ -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": [