fix(xtream): validate backup capabilities before favorite writes

This commit is contained in:
4gray committed 2026-10-14 00:28:01 +02:00
1 parent 2a8009fe6c
commit c0edfef616
5 files changed
+62 -4

No files matched your search

@@ -90,7 +90,10 @@ an online catalog. Existing history, hidden-category and playback-position
restore contracts remain separate.
Both Xtream data-source and portal-activity capabilities require the pending
favorite conversion bridge; activity reads also require playlist lookup.
favorite conversion bridge; activity reads also require playlist lookup. The
SQLite capability also requires the unfiltered favorite backup reader. An
unsupported atomic restore performs no favorite writes: a fresh archive stays
parked, while an older pending restore is retained without being overwritten.
Records already deleted by an older build cannot be recovered without a
surviving pending state or backup. Opening an Xtream section may initialize
@@ -413,6 +413,10 @@ describe('PlaylistBackupService export → import round-trip', () => {
collaborators.databaseService,
'restoreXtreamUserData'
);
const favorites = jest.spyOn(
collaborators.databaseService,
'restoreXtreamFavoriteSnapshots'
);
const positions = jest.spyOn(
collaborators.playbackPositionService,
'replaceAllPlaybackPositions'
@@ -426,6 +430,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
expect(failed.failed).toBe(1);
expect(visibility).not.toHaveBeenCalled();
expect(collections).not.toHaveBeenCalled();
expect(favorites).not.toHaveBeenCalled();
expect(positions).not.toHaveBeenCalled();
expect(pins).not.toHaveBeenCalled();
expect(
@@ -448,6 +453,37 @@ describe('PlaylistBackupService export → import round-trip', () => {
}
);
it('retains an older pending restore without writing favorites when atomic restore is unsupported', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
const service = createPlaylistBackupService(collaborators);
const archive = await service.exportBackup();
collaborators.pendingRestoreService.set('xtream-1', {
favorites: [{ contentType: 'movie', xtreamId: 9999 }],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
});
const previous =
collaborators.pendingRestoreService.getOrThrow('xtream-1');
collaborators.playbackPositionService.assertSupportsAtomicReplacement.mockImplementation(
() => {
throw new Error(
'Atomic playback position replacement unavailable'
);
}
);
const favorites = jest.spyOn(
collaborators.databaseService,
'restoreXtreamFavoriteSnapshots'
);
const result = await service.importBackup(archive.json);
expect(result.failed).toBe(1);
expect(favorites).not.toHaveBeenCalled();
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toEqual(previous);
});
it('retains failed position restores for retry and consumes only after success', async () => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
@@ -940,8 +940,11 @@ export class PlaylistBackupService {
// Preserve an older unfinished import before parking the incoming backup.
// Canonical favorites are additive; legacy-only ones need the same protection.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
const previous = this.pendingRestoreService.getOrThrow(playlistId);
if (previous && this.runtime.supportsXtreamSqliteDataSource) {
if (previous && sqlite) {
// An unsupported restore must leave the older pending state intact.
this.playbackPositionService.assertSupportsAtomicReplacement();
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
previous.favorites,
@@ -959,14 +962,16 @@ export class PlaylistBackupService {
}
// Browser storage has no catalog cache to wait for: apply at once.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
if (sqlite) {
// With no older pending state, park the archive before failing preflight.
if (!previous) {
this.playbackPositionService.assertSupportsAtomicReplacement();
}
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
restoreState.favorites,
restoreState.favoritesRestoreId
);
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
}
@@ -87,6 +87,7 @@ describe('RuntimeCapabilitiesService', () => {
dbRemoveRecentItem: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetFavoritesForBackup: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
@@ -603,6 +604,17 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
it('requires the durable favorite backup reader in the Xtream SQLite contract', () => {
const bridge = createXtreamSqliteBridge();
testWindow.electron = bridge;
const service = new RuntimeCapabilitiesService();
expect(service.supportsXtreamSqliteDataSource).toBe(true);
delete bridge['dbGetFavoritesForBackup'];
expect(service.supportsXtreamSqliteDataSource).toBe(false);
bridge['dbGetFavoritesForBackup'] = jest.fn();
expect(service.supportsXtreamSqliteDataSource).toBe(true);
});
it('keeps Xtream SQLite and ordinary position storage when atomic restore is unavailable', () => {
const bridge = createXtreamSqliteBridge();
testWindow.electron = bridge;
@@ -658,6 +670,7 @@ function createXtreamSqliteBridge(): Record<string, jest.Mock> {
dbGetContent: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetFavoritesForBackup: jest.fn(),
dbGetPlaylist: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetRecentItems: jest.fn(),
@@ -212,6 +212,7 @@ export class RuntimeCapabilitiesService {
'dbSetAppState',
'dbSearchContent',
'dbGetFavorites',
'dbGetFavoritesForBackup',
'dbAddFavorite',
'dbRemoveFavorite',
'dbIsFavorite',