fix(xtream): protect favorite snapshots and collection navigation

This commit is contained in:
4gray committed 2026-10-13 23:57:30 +02:00
1 parent 8ccfcb68cc
commit e5a5d0581d
16 files changed
+427 -50

No files matched your search

@@ -86,6 +86,64 @@ describe('DatabaseService browser guards', () => {
);
});
it.each([
'getGlobalFavorites',
'getAllGlobalFavorites',
'getFavorites',
] as const)(
'%s still reads healthy favorites when a pending conversion fails',
async (method) => {
const pending = JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7.5 }],
});
localStorage.setItem('xtream-restore-broken', pending);
localStorage.setItem(
'xtream-restore-healthy',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 9 }],
})
);
const rows = [{ favorite_id: 1, title: 'Saved favorite' }];
const read = jest.fn().mockResolvedValue(rows);
const restore = jest.fn(async (id: string) => {
if (id === 'broken')
throw new Error('Invalid favorite identity');
return { success: true };
});
Object.defineProperty(window, 'electron', {
configurable: true,
value: {
dbGetPlaylist: jest
.fn()
.mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: restore,
dbGetGlobalFavorites: read,
dbGetAllGlobalFavorites: read,
dbGetFavorites: read,
dbGetFavoritesForBackup: jest.fn(),
},
});
await expect(service[method]('broken')).resolves.toEqual(rows);
expect(read).toHaveBeenCalledTimes(1);
if (method !== 'getFavorites') {
expect(restore).toHaveBeenCalledWith(
'healthy',
[{ contentType: 'movie', xtreamId: 9 }],
undefined
);
}
expect(localStorage.getItem('xtream-restore-broken')).toBe(pending);
restore.mockResolvedValueOnce({ success: false });
await expect(service.getFavorites('broken')).resolves.toEqual(rows);
await expect(service.getFavoritesOrThrow('broken')).rejects.toThrow(
'Invalid favorite identity'
);
expect(
window.electron.dbGetFavoritesForBackup
).not.toHaveBeenCalled();
}
);
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
localStorage.setItem(
'xtream-restore-orphan',
@@ -741,7 +741,11 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
await materializePendingXtreamFavorites(
window.electron,
undefined,
'continue'
);
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
} catch (error) {
@@ -759,7 +763,11 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
await materializePendingXtreamFavorites(
window.electron,
undefined,
'continue'
);
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
} catch (error) {
@@ -901,7 +909,8 @@ export class DatabaseService {
try {
await materializePendingXtreamFavorites(
window.electron,
playlistId
playlistId,
'continue'
);
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
@@ -10,7 +10,8 @@ export async function materializePendingXtreamFavorites(
ElectronBridgeApi,
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
>,
playlistId?: string
playlistId?: string,
failureMode: 'throw' | 'continue' = 'throw'
): Promise<void> {
const prefix = getXtreamPendingRestoreStorageKey('');
const ids = playlistId
@@ -35,14 +36,20 @@ export async function materializePendingXtreamFavorites(
if (!state.favorites.length) continue;
// Old localStorage can outlive deletion of its owning SQLite playlist.
// Never recreate a playlist or let an orphan block other saved entries.
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,
state.favoritesRestoreId
);
if (!result.success)
throw new Error('Recovering saved favorites failed.');
try {
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,
state.favoritesRestoreId
);
if (!result.success)
throw new Error('Recovering saved favorites failed.');
} catch (error) {
if (failureMode === 'throw') throw error;
// Keep pending state for retry while ordinary reads show healthy data.
console.warn('A pending favorites conversion could not complete.');
}
}
}