From c0edfef61659fe46b55b929b049a685c2bde7046 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 00:28:01 +0200 Subject: [PATCH] fix(xtream): validate backup capabilities before favorite writes --- .../xtream-portal-compatibility.md | 5 ++- .../playlist-backup.service.roundtrip.spec.ts | 36 +++++++++++++++++++ .../src/lib/playlist-backup.service.ts | 11 ++++-- .../lib/runtime-capabilities.service.spec.ts | 13 +++++++ .../src/lib/runtime-capabilities.service.ts | 1 + 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 42b2d62ec..5739eec75 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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 diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index 96015bc5d..7c49af265 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -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); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 2ac06762a..a43cb2e3e 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -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; } diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index e0f27d358..a49e6b744 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -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 { dbGetContent: jest.fn(), dbGetContentByXtreamId: jest.fn(), dbGetFavorites: jest.fn(), + dbGetFavoritesForBackup: jest.fn(), dbGetPlaylist: jest.fn(), dbGetPlaybackPosition: jest.fn(), dbGetRecentItems: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 8cc0ec5a1..25243888c 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -212,6 +212,7 @@ export class RuntimeCapabilitiesService { 'dbSetAppState', 'dbSearchContent', 'dbGetFavorites', + 'dbGetFavoritesForBackup', 'dbAddFavorite', 'dbRemoveFavorite', 'dbIsFavorite',