fix(migration): preserve and recover legacy desktop sources (#1550)

* fix(migration): recover legacy desktop sources without replacing current data

* test(migration): cover legacy recovery IPC contracts

* test(migration): use static legacy Electron bootstrap
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 00:45:35 +02:00
1 parent 002faafbd7
commit 9bcdbc0efb
26 files changed
+1505 -38

No files matched your search

@@ -0,0 +1,68 @@
import { firstValueFrom, of } from 'rxjs';
import { PlaylistsService } from './playlists.service';
describe('Electron legacy playlist migration', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
jest.restoreAllMocks();
});
function setup() {
const playlists = [
{ _id: 'stalker-1', macAddress: '00:1A:79:00:00:01' },
{ _id: 'xtream-1', serverUrl: 'https://synthetic.invalid' },
{ _id: 'm3u-1', playlist: { items: [] } },
];
const dbService = {
getAll: jest.fn(() => of(playlists)),
clear: jest.fn(() => of(undefined)),
};
const electron = {
dbGetAppState: jest.fn(async () => null),
dbSetAppState: jest.fn(async () => undefined),
dbGetAppPlaylists: jest.fn(async () => []),
dbUpsertAppPlaylists: jest.fn(async () => ({
success: true,
count: playlists.length,
})),
dbMigrateAppPlaylists: jest.fn(async () => ({
success: true,
count: playlists.length,
})),
};
window.electron = electron as unknown as typeof window.electron;
const service = Object.create(
PlaylistsService.prototype
) as PlaylistsService;
Object.assign(service, {
dbService,
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
});
return { playlists, dbService, electron, service };
}
it('retains legacy IndexedDB and uses the atomic non-overwriting migration', async () => {
const { playlists, dbService, electron, service } = setup();
await firstValueFrom(service.getAllPlaylists());
expect(electron.dbMigrateAppPlaylists).toHaveBeenCalledWith(playlists);
expect(dbService.clear).not.toHaveBeenCalled();
expect(electron.dbSetAppState).not.toHaveBeenCalledWith(
'm3u-playlists-indexeddb-to-sqlite-v1',
'1'
);
});
it('retries failed migration and does not present an incomplete source list as success', async () => {
const { electron, service } = setup();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
electron.dbMigrateAppPlaylists.mockRejectedValueOnce(
new Error('synthetic failure')
);
await expect(firstValueFrom(service.getAllPlaylists())).rejects.toThrow(
'synthetic failure'
);
await expect(
firstValueFrom(service.getAllPlaylists())
).resolves.toEqual([]);
expect(electron.dbMigrateAppPlaylists).toHaveBeenCalledTimes(2);
});
});
+22 -28
View File
@@ -72,6 +72,8 @@ type PlaylistStorageElectronApi = {
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
dbRecoverLegacyPlaylists?: () => Promise<void>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
@@ -141,8 +143,12 @@ export class PlaylistsService {
if (!this.electronMigrationPromise) {
this.electronMigrationPromise = (async () => {
await this.migrateIndexedDbPlaylistsToSqlite();
await this.electronApi?.dbRecoverLegacyPlaylists?.();
await this.migrateStalkerPlaylistMetadataInSqlite();
})();
})().catch((error) => {
this.electronMigrationPromise = null;
throw error;
});
}
return this.electronMigrationPromise;
@@ -176,34 +182,22 @@ export class PlaylistsService {
}
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
try {
const electron = this.electronApi;
if (!electron) {
return;
}
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
return;
}
const indexedDbPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
if (indexedDbPlaylists.length > 0) {
await electron.dbUpsertAppPlaylists(indexedDbPlaylists);
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
}
const electron = this.electronApi;
if (!electron) return;
if (
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
)
return;
const playlists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
if (playlists.length) {
// The worker commits rows and the receipt atomically. Keep the
// original IndexedDB as a recovery source, even after success.
await electron.dbMigrateAppPlaylists(playlists);
} else {
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
} catch (error) {
console.error(
'Failed to migrate IndexedDB playlists to SQLite:',
error
);
}
}
+2 -1
View File
@@ -239,7 +239,6 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`,
`CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_epg_channel ON content(epg_channel_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added)`,
`CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`,
// Partial covering index for visible categories — supports the dashboard's
@@ -429,6 +428,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
];
const INDEX_MIGRATION_STATEMENTS = [
// Existing v0.19 content tables gain this column above, after CREATE TABLE.
`CREATE INDEX IF NOT EXISTS idx_content_epg_channel ON content(epg_channel_id)`,
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
@@ -889,6 +889,10 @@ export interface ElectronBridgeApi {
*/
operationId?: string
) => Promise<ElectronBridgeResult>;
dbMigrateAppPlaylists: (
playlists: Playlist[]
) => Promise<{ success: boolean; count: number }>;
dbRecoverLegacyPlaylists: () => Promise<void>;
dbUpsertAppPlaylists: (
playlists: Playlist[]
) => Promise<ElectronBridgeCountResult>;