mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user