diff --git a/.changes/xtream-backup-fidelity.md b/.changes/xtream-backup-fidelity.md new file mode 100644 index 000000000..ad6f46344 --- /dev/null +++ b/.changes/xtream-backup-fidelity.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Xtream backups preserve imported favorites, history, hidden categories, resume positions, and pinned sources even before the catalog loads. Storage failures now report a backup or restore error; failed position restores preserve the previous positions and remain available to retry. diff --git a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts index b02d42fe3..ad1e8447c 100644 --- a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts +++ b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Locator, Page } from '@playwright/test'; +import type { PlaylistBackupManifestV1 } from '@iptvnator/shared/interfaces'; import { addXtreamPortal, closeElectronApp, @@ -21,20 +22,20 @@ import { readVisibleSidebarCategories } from './sidebar-categories.e2e-support'; /** * Full backup round-trip through the real UI, DB worker and IPC stack: - * hide a category, export the backup, delete the source, import the file - * back and verify the restored portal hides the same category again after - * its content is re-imported from the mock server (regression for #1017 — - * exported hidden categories lost their xtream IDs and the restore either - * hid everything or nothing). + * Preserve hidden categories, collections, positions and source pins through + * an immediate re-export while catalog restoration is still pending, then + * verify the restored database after reopening the portal. The category UI + * assertions also guard #1017's missing provider category IDs. */ test.describe('Electron playlist backup round-trip', () => { - test('exports a backup and re-imports it with hidden categories restored', async ({ + test('preserves pending backup state on re-export and restores it after restart', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const portalName = 'Backup Roundtrip Xtream'; const exportPath = join(dataDir, 'roundtrip-backup.json'); + const pendingExportPath = join(dataDir, 'pending-backup.json'); const app = await launchElectronApp(dataDir); try { @@ -61,6 +62,36 @@ test.describe('Electron playlist backup round-trip', () => { sidebarCategoryById(app.mainWindow, targetCategory.id) ).toHaveCount(0); + const playlistId = + app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? ''; + expect(playlistId).not.toEqual(''); + // Seed realistic resume/collection state through the same IPC used + // by playback. No player runs here to overwrite the saved position. + const movieXtreamId = await app.mainWindow.evaluate(async (id) => { + const [movie] = await window.electron.dbGetContent(id, 'movie'); + if (!movie) throw new Error('Mock catalog has no movie.'); + const results = await Promise.all([ + window.electron.dbAddFavorite(movie.id, id), + window.electron.dbAddRecentItem(movie.id, id), + window.electron.dbSavePlaybackPosition(id, { + contentXtreamId: movie.xtream_id, + contentType: 'vod', + positionSeconds: 123, + durationSeconds: 7200, + }), + window.electron.dbSetVodSourcePin({ + matchKey: 'title:backup-roundtrip', + playlistId: id, + contentId: movie.xtream_id, + portalType: 'xtream', + }), + ]); + if (results.some((result) => !result.success)) { + throw new Error('Seeding backup state through IPC failed.'); + } + return movie.xtream_id; + }, playlistId); + // Export through the real settings flow with the native save // dialog stubbed to a fixed path inside the test data dir. await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => { @@ -83,17 +114,9 @@ test.describe('Electron playlist backup round-trip', () => { // The exported manifest must reference hidden categories by // numeric xtream ID — the #1017 regression exported anonymous // { categoryType } entries. - const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as { - playlists: Array<{ - portalType: string; - userState?: { - hiddenCategories?: Array<{ - categoryType?: string; - xtreamId?: unknown; - }>; - }; - }>; - }; + const manifest = JSON.parse( + readFileSync(exportPath, 'utf-8') + ) as PlaylistBackupManifestV1; const xtreamEntry = manifest.playlists.find( (entry) => entry.portalType === 'xtream' ); @@ -107,6 +130,18 @@ test.describe('Electron playlist backup round-trip', () => { ) ).toBe(true); + expect(xtreamEntry?.userState.favorites).toHaveLength(1); + expect(xtreamEntry?.userState.recentlyViewed).toHaveLength(1); + expect(xtreamEntry?.userState.sourcePins).toHaveLength(1); + expect(xtreamEntry?.userState.playbackPositions).toEqual([ + expect.objectContaining({ + contentXtreamId: movieXtreamId, + contentType: 'vod', + positionSeconds: 123, + durationSeconds: 7200, + }), + ]); + await openSources(app.mainWindow); await deleteSource(app.mainWindow, portalName); await expect( @@ -128,6 +163,46 @@ test.describe('Electron playlist backup round-trip', () => { app.mainWindow.getByText(/Backup import finished: 1 imported/) ).toBeVisible({ timeout: 15000 }); + // No catalog has been opened since import. Verify this is a + // genuinely pending restore, then export it again through the UI. + const pendingBeforeExport = await app.mainWindow.evaluate( + async (id) => ({ + pending: localStorage.getItem(`xtream-restore-${id}`), + hasMovies: await window.electron.dbHasContent(id, 'movie'), + positions: + await window.electron.dbGetAllPlaybackPositions(id), + }), + playlistId + ); + expect(pendingBeforeExport.pending).not.toBeNull(); + expect(pendingBeforeExport.hasMovies).toBe(false); + expect(pendingBeforeExport.positions).toEqual([]); + await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => { + nativeDialog.showSaveDialog = async () => ({ + canceled: false, + filePath: path, + }); + }, pendingExportPath); + await backupSection + .getByRole('button', { name: 'Export', exact: true }) + .click(); + await expect( + app.mainWindow.getByText('Playlist backup exported.') + ).toBeVisible({ timeout: 15000 }); + const pendingManifest = JSON.parse( + readFileSync(pendingExportPath, 'utf-8') + ) as PlaylistBackupManifestV1; + const pendingEntry = pendingManifest.playlists.find( + (entry) => entry.portalType === 'xtream' + ); + expect(pendingEntry?.userState).toEqual(xtreamEntry?.userState); + expect( + await app.mainWindow.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ).toEqual(pendingBeforeExport.pending); + // Restart before opening the restored portal: the root-provided // XtreamStore still holds the deleted portal's in-memory state // under the same playlist id and would skip content @@ -179,6 +254,40 @@ test.describe('Electron playlist backup round-trip', () => { .map((row) => row.name) ).toEqual([targetCategory.name]); + const restoredState = await app.mainWindow.evaluate( + async (id) => ({ + positions: + await window.electron.dbGetAllPlaybackPositions(id), + favorites: await window.electron.dbGetFavorites(id), + recent: await window.electron.dbGetRecentItems(id), + pins: await window.electron.dbListVodSourcePins(id), + pending: localStorage.getItem(`xtream-restore-${id}`), + }), + restoredPlaylistId + ); + expect(restoredState.positions).toEqual([ + expect.objectContaining({ + contentXtreamId: movieXtreamId, + contentType: 'vod', + positionSeconds: 123, + durationSeconds: 7200, + }), + ]); + expect( + restoredState.favorites.map((item) => item.xtream_id) + ).toEqual([movieXtreamId]); + expect(restoredState.recent.map((item) => item.xtream_id)).toEqual([ + movieXtreamId, + ]); + expect(restoredState.pins).toEqual([ + expect.objectContaining({ + matchKey: 'title:backup-roundtrip', + playlistId: restoredPlaylistId, + contentId: movieXtreamId, + }), + ]); + expect(restoredState.pending).toBeNull(); + dialog = await openManageCategoriesDialog(app.mainWindow); await dialog .locator('input[type="search"]') diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 97b7a8fb2..0465345fd 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -414,6 +414,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_CLEAR_PLAYBACK_POSITION', forwardedArgs: [playlistId, 42, 'vod'], }, + { + method: 'dbReplaceAllPlaybackPositions', + args: [playlistId, [playbackData]], + channel: 'DB_REPLACE_ALL_PLAYBACK_POSITIONS', + forwardedArgs: [playlistId, [playbackData]], + }, { method: 'dbSavePlaybackPositionsBatch', args: [playlistId, [playbackData]], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 8fa824fde..3f907ede8 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1078,6 +1078,10 @@ const electronApi: ElectronBridgeApi = { ), dbGetAllPlaybackPositions: (playlistId: string) => ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId), + dbReplaceAllPlaybackPositions: ( + playlistId: string, + items: ElectronBridgePlaybackPositionInput[] + ) => ipcRenderer.invoke('DB_REPLACE_ALL_PLAYBACK_POSITIONS', playlistId, items), dbClearAllPlaybackPositions: (playlistId: string) => ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId), dbClearPlaybackPosition: ( diff --git a/apps/electron-backend/src/app/database/operations/playback-position-replacement.sqlite.spec.ts b/apps/electron-backend/src/app/database/operations/playback-position-replacement.sqlite.spec.ts new file mode 100644 index 000000000..666d1eb24 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playback-position-replacement.sqlite.spec.ts @@ -0,0 +1,165 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { getTableColumns } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { + getAllPlaybackPositions, + replaceAllPlaybackPositions, + savePlaybackPosition, +} from './playback-position.operations'; + +describe('atomic playback-position replacement against SQLite', () => { + let sqlite: Database.Database; + let db: AppDatabase; + const replacement = [ + { + contentXtreamId: 42, + contentType: 'vod' as const, + positionSeconds: 120, + durationSeconds: 3600, + }, + { + contentXtreamId: 42, + contentType: 'episode' as const, + positionSeconds: 90, + seriesXtreamId: 2, + seasonNumber: 1, + episodeNumber: 3, + }, + ]; + + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + sqlite.exec(` + CREATE TABLE playlists (${Object.values( + getTableColumns(schema.playlists) + ) + .map( + (column) => + `"${column.name}" ${column.getSQLType()}${column.primary ? ' PRIMARY KEY' : ''}` + ) + .join(', ')}); + INSERT INTO playlists (id) VALUES ('target'), ('other'); + CREATE TABLE playback_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + content_xtream_id INTEGER NOT NULL, + content_type TEXT NOT NULL, + series_xtream_id INTEGER, + season_number INTEGER, + episode_number INTEGER, + position_seconds INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(content_xtream_id, playlist_id, content_type) + ); + INSERT INTO playback_positions (playlist_id, content_xtream_id, content_type, position_seconds) + VALUES ('target', 1, 'vod', 50), ('other', 1, 'vod', 80); + `); + db = drizzle(sqlite, { schema }); + }); + + afterEach(() => sqlite.close()); + + it('replaces only the requested playlist and keeps typed identities distinct', async () => { + await expect( + replaceAllPlaybackPositions(db, 'target', replacement) + ).resolves.toEqual({ success: true }); + expect(await getAllPlaybackPositions(db, 'target')).toEqual([ + expect.objectContaining({ + ...replacement[0], + playlistId: 'target', + }), + expect.objectContaining({ + ...replacement[1], + playlistId: 'target', + }), + ]); + expect(await getAllPlaybackPositions(db, 'other')).toEqual([ + expect.objectContaining({ + contentXtreamId: 1, + positionSeconds: 80, + }), + ]); + await replaceAllPlaybackPositions(db, 'target', []); + expect(await getAllPlaybackPositions(db, 'target')).toEqual([]); + expect(await getAllPlaybackPositions(db, 'other')).toHaveLength(1); + }); + + it('rejects restoring rows for an unregistered playlist without creating placeholder metadata', async () => { + await expect( + replaceAllPlaybackPositions(db, 'missing', replacement) + ).rejects.toThrow(); + expect( + sqlite + .prepare("SELECT id FROM playlists WHERE id = 'missing'") + .all() + ).toEqual([]); + expect(await getAllPlaybackPositions(db, 'target')).toHaveLength(1); + }); + + it('preserves last-write-wins for repeated identities in older snapshots', async () => { + await replaceAllPlaybackPositions(db, 'target', [ + ...replacement, + { ...replacement[0], positionSeconds: 600 }, + ]); + expect(await getAllPlaybackPositions(db, 'target')).toEqual([ + expect.objectContaining({ + contentXtreamId: 42, + contentType: 'vod', + positionSeconds: 600, + }), + expect.objectContaining({ + contentXtreamId: 42, + contentType: 'episode', + positionSeconds: 90, + }), + ]); + }); + + it('preserves earlier episode metadata when a duplicate only updates position', async () => { + const items = [ + { ...replacement[1], durationSeconds: 2400 }, + { + contentXtreamId: 42, + contentType: 'episode' as const, + positionSeconds: 600, + }, + ]; + // Compare with the former restore loop's actual SQLite update behavior. + for (const item of items) await savePlaybackPosition(db, 'other', item); + await replaceAllPlaybackPositions(db, 'target', items); + const [restored] = await getAllPlaybackPositions(db, 'target'); + const prior = (await getAllPlaybackPositions(db, 'other')).find( + (item) => item.contentType === 'episode' + ); + expect(restored).toEqual( + expect.objectContaining({ + contentXtreamId: prior?.contentXtreamId, + contentType: 'episode', + positionSeconds: 600, + seriesXtreamId: prior?.seriesXtreamId, + seasonNumber: prior?.seasonNumber, + episodeNumber: prior?.episodeNumber, + durationSeconds: prior?.durationSeconds, + }) + ); + expect(prior?.seriesXtreamId).toBe(2); + }); + + it('rolls back both deletion and earlier inserts when a later insert fails', async () => { + const before = await getAllPlaybackPositions(db, 'target'); + sqlite.exec(`CREATE TRIGGER reject_episode BEFORE INSERT ON playback_positions + WHEN NEW.content_type = 'episode' BEGIN SELECT RAISE(ABORT, 'injected write failure'); END;`); + await expect( + replaceAllPlaybackPositions(db, 'target', replacement) + ).rejects.toThrow(); + expect(await getAllPlaybackPositions(db, 'target')).toEqual(before); + expect(await getAllPlaybackPositions(db, 'other')).toHaveLength(1); + sqlite.exec('DROP TRIGGER reject_episode'); + await replaceAllPlaybackPositions(db, 'target', replacement); + expect(await getAllPlaybackPositions(db, 'target')).toHaveLength(2); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playback-position.operations.ts b/apps/electron-backend/src/app/database/operations/playback-position.operations.ts index 6bb20052e..c4cdb0887 100644 --- a/apps/electron-backend/src/app/database/operations/playback-position.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.ts @@ -232,6 +232,46 @@ export async function getAllPlaybackPositions( .where(eq(schema.playbackPositions.playlistId, playlistId)); } +/** Backup replacement must never commit deletion without all restored rows. */ +export async function replaceAllPlaybackPositions( + db: AppDatabase, + playlistId: string, + items: PlaybackPositionPayload[] +): Promise<{ success: boolean }> { + db.transaction((tx) => { + tx.delete(schema.playbackPositions) + .where(eq(schema.playbackPositions.playlistId, playlistId)) + .run(); + for (const item of items) { + const values = { + playlistId, + contentXtreamId: item.contentXtreamId, + contentType: item.contentType, + seriesXtreamId: item.seriesXtreamId, + seasonNumber: item.seasonNumber, + episodeNumber: item.episodeNumber, + positionSeconds: item.positionSeconds, + durationSeconds: item.durationSeconds, + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + // Match the former save loop: later duplicate values win, while + // omitted optional fields retain the earlier snapshot's metadata. + tx.insert(schema.playbackPositions) + .values(values) + .onConflictDoUpdate({ + target: [ + schema.playbackPositions.contentXtreamId, + schema.playbackPositions.playlistId, + schema.playbackPositions.contentType, + ], + set: values, + }) + .run(); + } + }); + return { success: true }; +} + export async function clearAllPlaybackPositions( db: AppDatabase, playlistId: string diff --git a/apps/electron-backend/src/app/events/database/playback-position.events.ts b/apps/electron-backend/src/app/events/database/playback-position.events.ts index 04b4cf5cb..add9503ac 100644 --- a/apps/electron-backend/src/app/events/database/playback-position.events.ts +++ b/apps/electron-backend/src/app/events/database/playback-position.events.ts @@ -84,3 +84,8 @@ handleWorkerRequest( items, }) ); + +handleWorkerRequest( + 'DB_REPLACE_ALL_PLAYBACK_POSITIONS', + (playlistId: string, items: unknown[]) => ({ playlistId, items }) +); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index f50a2a7db..51b4b49ea 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -372,6 +372,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [playlistId, 42, 'vod'], payload: { playlistId, contentXtreamId: 42, contentType: 'vod' }, }, + { + operation: 'DB_REPLACE_ALL_PLAYBACK_POSITIONS', + args: [playlistId, playbackBatchItems], + payload: { playlistId, items: playbackBatchItems }, + }, { operation: 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', args: [playlistId, playbackBatchItems], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 732b80312..31ca8687f 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -54,6 +54,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_GET_RECENT_PLAYBACK_POSITIONS', 'DB_GET_ALL_PLAYBACK_POSITIONS', 'DB_CLEAR_ALL_PLAYBACK_POSITIONS', + 'DB_REPLACE_ALL_PLAYBACK_POSITIONS', 'DB_CLEAR_PLAYBACK_POSITION', 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 3f8404593..fa36f4aa8 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -52,6 +52,7 @@ import { import { setContentMetadataIfMissing } from '../database/operations/content-metadata.operations'; import { clearAllPlaybackPositions, + replaceAllPlaybackPositions, clearPlaybackPosition, clearPlaybackPositionsBatch, getAllPlaybackPositions, @@ -1201,6 +1202,14 @@ async function executeRequest( return getAllPlaybackPositions(db, payload.playlistId); } + case 'DB_REPLACE_ALL_PLAYBACK_POSITIONS': { + const payload = message.payload as { + playlistId: string; + items: Parameters[2]; + }; + return replaceAllPlaybackPositions(db, payload.playlistId, payload.items); + } + case 'DB_CLEAR_ALL_PLAYBACK_POSITIONS': { const payload = message.payload as { playlistId: string }; return clearAllPlaybackPositions(db, payload.playlistId); diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 71d955448..0554de983 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -5,13 +5,13 @@ settings screen. ## Entry Points -- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts` +- UI: `apps/web/src/app/settings/settings-backup-section.component.ts` (embedded in `settings.component.html`), with the file read/handoff in - `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup.facade.ts` -- Backup service: `/Users/4gray/Code/iptvnator/libs/services/src/lib/playlist-backup.service.ts` -- Manifest types: `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/playlist-backup.interface.ts` + `apps/web/src/app/settings/settings-backup.facade.ts` +- Backup service: `libs/services/src/lib/playlist-backup.service.ts` +- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts` - Xtream pending restore storage: - `/Users/4gray/Code/iptvnator/libs/services/src/lib/xtream-pending-restore.service.ts` + `libs/services/src/lib/xtream-pending-restore.service.ts` ## Manifest Contract @@ -63,6 +63,19 @@ Xtream backups export only connection metadata plus portable user state. - favorites by `{ contentType, xtreamId, addedAt?, position? }` - recently viewed by `{ contentType, xtreamId, viewedAt }` - playback positions as `PlaybackPositionData[]` + - optional VOD source pins by `{ matchKey, contentId, updatedAt? }` + +An imported Xtream archive can wait for its catalog to download. Export reads +that pending snapshot before consulting the catalog: its hidden categories, +favorites, history, and positions are authoritative, including empty lists. +Pending pins are authoritative when present; an absent `sourcePins` field keeps +the existing store's pins when that store is available, or stays absent when it +is unavailable. Re-export neither applies nor consumes the pending snapshot, +so it also works before the portal has been opened and in browser runtimes. +Parental category locks still come from the current lock store. + +A failed pending-storage or playback-position read fails export. An unreadable +collection must never be serialized as a successful empty collection. Explicitly excluded: @@ -164,11 +177,10 @@ worker IPC boundary in the snake_case wire shape declared by `XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project their Drizzle rows explicitly to keep that contract true. -Clearing the playlist's existing pins goes through a dedicated -delete-by-playlist operation, not the keyed clear: that one caps its key list -to bound an IN clause, so a playlist with more pinned movies than the cap kept -the surplus while still reporting success. A failure now fails the entry -rather than leaving the union of old and archived pins. +Replacing the playlist's pins deletes by playlist and writes the replacements +in one transaction. It does not use the capped keyed-clear operation, which +could leave surplus pins behind. Any replacement failure fails the entry and +retains the pending snapshot for retry. `sourcePins` (VOD multi-source) is the one **optional** collection, and the normalizer preserves that: an absent field stays absent rather than becoming @@ -190,12 +202,28 @@ Electron restore behavior: 1. Category import reads pending hidden-category state while saving categories. 2. After content import, favorites/recent state is restored by typed `{ contentType, xtreamId }` matching. -3. Playback positions are cleared and re-applied from backup state. +3. Playback positions are replaced for the destination playlist in one SQLite + transaction through `DB_REPLACE_ALL_PLAYBACK_POSITIONS`. Empty means clear; + a failed insert rolls back the deletion and all earlier inserts. 4. VOD source pins are re-applied against the IMPORTED playlist id. For existing Xtream playlists with a fully populated offline cache, backup import applies the restore immediately. Otherwise the typed restore payload is -left pending until the next Xtream initialization/import. +left pending until the next Xtream initialization/import. Both paths use strict +playback-position replacement: unavailable storage, rejected IPC, or an +unsuccessful response fail the restore and leave the pending snapshot intact. +Atomic restore support is checked separately from the ordinary Xtream SQLite +capability gate. An older preload missing the replacement method keeps using +SQLite for playback and backup export. Import parks the snapshot first, then +rejects unsupported atomic restore before checking catalog completeness or +writing category visibility, collections, positions, or pins. Deferred restore +performs the same preflight before applying user state. The retained snapshot +can be retried once atomic replacement becomes available. Ordinary playback +keeps its existing capability checks and best-effort persistence API. +The whole restore is not one database transaction; already applied +categories/favorites/history can +remain after a later failure, but the snapshot remains available to retry the +complete restore. It is consumed only after every restore step succeeds. ## Current UX diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 4b8e5e92d..baa435811 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -661,6 +661,20 @@ is unavailable or the SQLite playlist migration has not completed. 4. `DB_GET_RECENT_PLAYBACK_POSITIONS` 5. `DB_GET_ALL_PLAYBACK_POSITIONS` 6. `DB_CLEAR_PLAYBACK_POSITION` +7. `DB_REPLACE_ALL_PLAYBACK_POSITIONS` + +Backup restore uses `DB_REPLACE_ALL_PLAYBACK_POSITIONS` to replace only the +specified playlist's positions in one synchronous transaction. Every insert +and the delete use `.run()` so an insertion failure rolls the whole replacement +back. An empty replacement deliberately clears that playlist. Renderer backup +callers propagate read/write errors and retain pending restore state for retry; +normal playback persistence keeps its best-effort behavior. The renderer checks +atomic replacement support at the start of the immediate or deferred restore +pass, before updating existing categories, collections, positions, or pins. +Missing support fails restore with the pending snapshot retained; it does not +switch ordinary Xtream storage or backup export away from SQLite. Deferred +catalog import can still seed newly imported categories from pending visibility +state before that restore pass runs. ## SQLite Concurrency Rules diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index f29d0dbf8..c52766a51 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -76,6 +76,8 @@ export function createPlaybackServiceMock() { getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]), clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), + replaceAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), + assertSupportsAtomicReplacement: jest.fn(), savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), clearPlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), }; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 3f98305aa..e6a8f5270 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -688,6 +688,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { restoreState: XtreamPendingRestoreState, options?: XtreamOperationOptions ): Promise { + this.playbackService.assertSupportsAtomicReplacement(); const categoriesByType = await Promise.all([ this.dbService.getAllXtreamCategories(playlistId, 'live'), this.dbService.getAllXtreamCategories(playlistId, 'movies'), @@ -737,14 +738,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { options ); - await this.playbackService.clearAllPlaybackPositions(playlistId); - - for (const playbackPosition of restoreState.playbackPositions) { - await this.playbackService.savePlaybackPosition( - playlistId, - playbackPosition - ); - } + await this.playbackService.replaceAllPlaybackPositions( + playlistId, + restoreState.playbackPositions + ); // The fresh-import path lands here rather than in the backup service: // a new playlist has no content yet when the archive is read, so its diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index 8934d48df..c48bd45c4 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -336,7 +336,73 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { ).rejects.toThrow(playlistId); }); - it('restores user data, then resets and replays playback positions', async () => { + it('rejects unsupported deferred restore before reading or changing any user state', async () => { + harness.playbackService.assertSupportsAtomicReplacement.mockImplementation( + () => { + throw new Error( + 'Playback position replacement method is unavailable' + ); + } + ); + await expect( + harness.dataSource.restoreUserData(playlistId, { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + sourcePins: [], + }) + ).rejects.toThrow('unavailable'); + expect( + harness.dbService.getAllXtreamCategories + ).not.toHaveBeenCalled(); + expect( + harness.dbService.updateCategoryVisibility + ).not.toHaveBeenCalled(); + expect( + harness.dbService.restoreXtreamUserData + ).not.toHaveBeenCalled(); + expect( + harness.playbackService.replaceAllPlaybackPositions + ).not.toHaveBeenCalled(); + expect( + harness.vodSourcePinService.replaceForPlaylist + ).not.toHaveBeenCalled(); + }); + + it('rejects deferred restore when position replacement fails, retaining the caller retry', async () => { + const restoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [ + { + contentXtreamId: 202, + contentType: 'vod', + positionSeconds: 60, + }, + ], + sourcePins: [], + } as never; + harness.playbackService.replaceAllPlaybackPositions.mockRejectedValue( + new Error('SQLITE_BUSY') + ); + + await expect( + harness.dataSource.restoreUserData(playlistId, restoreState) + ).rejects.toThrow('SQLITE_BUSY'); + expect( + harness.playbackService.clearAllPlaybackPositions + ).not.toHaveBeenCalled(); + expect( + harness.playbackService.savePlaybackPosition + ).not.toHaveBeenCalled(); + expect( + harness.vodSourcePinService.replaceForPlaylist + ).not.toHaveBeenCalled(); + }); + + it('restores user data, then replaces playback positions atomically', async () => { const positionA = { contentXtreamId: 1 } as never; const positionB = { contentXtreamId: 2 } as never; const restoreState = { @@ -387,14 +453,14 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { [[11], true], ]); expect( - harness.playbackService.clearAllPlaybackPositions - ).toHaveBeenCalledWith(playlistId); + harness.playbackService.replaceAllPlaybackPositions + ).toHaveBeenCalledWith(playlistId, [positionA, positionB]); expect( - harness.playbackService.savePlaybackPosition.mock.calls - ).toEqual([ - [playlistId, positionA], - [playlistId, positionB], - ]); + harness.playbackService.clearAllPlaybackPositions + ).not.toHaveBeenCalled(); + expect( + harness.playbackService.savePlaybackPosition + ).not.toHaveBeenCalled(); }); }); }); diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts index 813e35f50..e91d0fe15 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts @@ -60,6 +60,95 @@ describe('PlaybackPositionRuntimeBridgeService', () => { jest.restoreAllMocks(); }); + describe('strict backup storage', () => { + it('checks atomic replacement support without reading or writing storage', () => { + expect(() => service.assertSupportsAtomicReplacement()).toThrow( + 'unavailable' + ); + runtimeCapabilities.supportsPlaybackPositionStorage = true; + window.electron = {} as typeof window.electron; + expect(() => service.assertSupportsAtomicReplacement()).toThrow( + 'unavailable' + ); + const replace = jest.fn(); + const read = jest.fn(); + window.electron = { + dbReplaceAllPlaybackPositions: replace, + dbGetAllPlaybackPositions: read, + } as unknown as typeof window.electron; + expect(() => + service.assertSupportsAtomicReplacement() + ).not.toThrow(); + expect(replace).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); + }); + + it('rejects reads and replacements when storage is unavailable', async () => { + await expect( + service.getAllPlaybackPositionsOrThrow('playlist-1') + ).rejects.toThrow('unavailable'); + await expect( + service.replaceAllPlaybackPositions('playlist-1', []) + ).rejects.toThrow('unavailable'); + }); + + it('rejects a missing bridge method instead of exporting or restoring empty data', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + window.electron = {} as typeof window.electron; + await expect( + service.getAllPlaybackPositionsOrThrow('playlist-1') + ).rejects.toThrow('unavailable'); + await expect( + service.replaceAllPlaybackPositions('playlist-1', []) + ).rejects.toThrow('unavailable'); + }); + + it('propagates read failures and rejects malformed read responses', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + const read = jest.fn().mockRejectedValue(new Error('SQLITE_BUSY')); + window.electron = { + dbGetAllPlaybackPositions: read, + } as unknown as typeof window.electron; + await expect( + service.getAllPlaybackPositionsOrThrow('playlist-1') + ).rejects.toThrow('SQLITE_BUSY'); + read.mockResolvedValue(undefined); + await expect( + service.getAllPlaybackPositionsOrThrow('playlist-1') + ).rejects.toThrow('did not succeed'); + read.mockResolvedValue([]); + await expect( + service.getAllPlaybackPositionsOrThrow('playlist-1') + ).resolves.toEqual([]); + }); + + it('requires an acknowledged atomic replacement, including authoritative empty lists', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + const replace = jest.fn().mockResolvedValue({ success: false }); + window.electron = { + dbReplaceAllPlaybackPositions: replace, + } as unknown as typeof window.electron; + await expect( + service.replaceAllPlaybackPositions( + 'playlist-1', + batchSaveItems + ) + ).rejects.toThrow('did not succeed'); + replace.mockRejectedValue(new Error('SQLITE_BUSY')); + await expect( + service.replaceAllPlaybackPositions( + 'playlist-1', + batchSaveItems + ) + ).rejects.toThrow('SQLITE_BUSY'); + replace.mockResolvedValue({ success: true }); + await expect( + service.replaceAllPlaybackPositions('playlist-1', []) + ).resolves.toBeUndefined(); + expect(replace).toHaveBeenLastCalledWith('playlist-1', []); + }); + }); + it('does not call Electron playback-position methods when storage support is unavailable', async () => { const dbSavePlaybackPosition = jest.fn().mockResolvedValue({ success: true, @@ -226,12 +315,9 @@ describe('PlaybackPositionRuntimeBridgeService', () => { dbSavePlaybackPosition: implementation, } as unknown as typeof window.electron; }, - invokeLenient: ( - target: PlaybackPositionRuntimeBridgeService - ) => target.savePlaybackPosition('playlist-1', createPosition()), - invokeStrict: ( - target: PlaybackPositionRuntimeBridgeService - ) => + invokeLenient: (target: PlaybackPositionRuntimeBridgeService) => + target.savePlaybackPosition('playlist-1', createPosition()), + invokeStrict: (target: PlaybackPositionRuntimeBridgeService) => target.savePlaybackPositionOrThrow( 'playlist-1', createPosition() @@ -245,22 +331,10 @@ describe('PlaybackPositionRuntimeBridgeService', () => { dbClearPlaybackPosition: implementation, } as unknown as typeof window.electron; }, - invokeLenient: ( - target: PlaybackPositionRuntimeBridgeService - ) => - target.clearPlaybackPosition( - 'playlist-1', - 100, - 'vod' - ), - invokeStrict: ( - target: PlaybackPositionRuntimeBridgeService - ) => - target.clearPlaybackPositionOrThrow( - 'playlist-1', - 100, - 'vod' - ), + invokeLenient: (target: PlaybackPositionRuntimeBridgeService) => + target.clearPlaybackPosition('playlist-1', 100, 'vod'), + invokeStrict: (target: PlaybackPositionRuntimeBridgeService) => + target.clearPlaybackPositionOrThrow('playlist-1', 100, 'vod'), }, ])('$name persistence', (operation) => { it('accepts only an explicit success result', async () => { @@ -269,7 +343,9 @@ describe('PlaybackPositionRuntimeBridgeService', () => { jest.fn().mockResolvedValue({ success: true }) ); - await expect(operation.invokeStrict(service)).resolves.toBeUndefined(); + await expect( + operation.invokeStrict(service) + ).resolves.toBeUndefined(); }); it('propagates rejected IPC', async () => { @@ -293,9 +369,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => { ); it('rejects when the storage capability is unavailable', async () => { - const bridgeMethod = jest - .fn() - .mockResolvedValue({ success: true }); + const bridgeMethod = jest.fn().mockResolvedValue({ success: true }); operation.installBridge(bridgeMethod); await expect(operation.invokeStrict(service)).rejects.toThrow( @@ -352,10 +426,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => { } as unknown as typeof window.electron; }, invoke: (target: PlaybackPositionRuntimeBridgeService) => - target.savePlaybackPositionsBatch( - 'playlist-1', - batchSaveItems - ), + target.savePlaybackPositionsBatch('playlist-1', batchSaveItems), invokeEmpty: (target: PlaybackPositionRuntimeBridgeService) => target.savePlaybackPositionsBatch('playlist-1', []), }, @@ -378,9 +449,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => { }, ])('$name persistence', (operation) => { it('silently no-ops when the storage capability is unavailable', async () => { - const bridgeMethod = jest - .fn() - .mockResolvedValue({ success: true }); + const bridgeMethod = jest.fn().mockResolvedValue({ success: true }); operation.installBridge(bridgeMethod); await expect(operation.invoke(service)).resolves.toBeUndefined(); @@ -389,9 +458,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => { it('silently no-ops on an empty item list', async () => { runtimeCapabilities.supportsPlaybackPositionStorage = true; - const bridgeMethod = jest - .fn() - .mockResolvedValue({ success: true }); + const bridgeMethod = jest.fn().mockResolvedValue({ success: true }); operation.installBridge(bridgeMethod); await expect( @@ -402,9 +469,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => { it('invokes the batch bridge method with the playlist and items', async () => { runtimeCapabilities.supportsPlaybackPositionStorage = true; - const bridgeMethod = jest - .fn() - .mockResolvedValue({ success: true }); + const bridgeMethod = jest.fn().mockResolvedValue({ success: true }); operation.installBridge(bridgeMethod); await expect(operation.invoke(service)).resolves.toBeUndefined(); diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.ts index 8456135d9..fc3b09e01 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.ts @@ -25,6 +25,10 @@ type PlaybackPositionElectronBridge = Partial<{ dbGetAllPlaybackPositions: ( playlistId: string ) => Promise; + dbReplaceAllPlaybackPositions: ( + playlistId: string, + items: PlaybackPositionData[] + ) => Promise<{ success: boolean }>; dbClearAllPlaybackPositions: ( playlistId: string ) => Promise<{ success: boolean }>; @@ -86,9 +90,7 @@ export class PlaybackPositionRuntimeBridgeService { const bridge = this.bridge; if (typeof bridge?.dbSavePlaybackPosition !== 'function') { - throw new Error( - 'Playback position save method is unavailable' - ); + throw new Error('Playback position save method is unavailable'); } const result = await bridge.dbSavePlaybackPosition(playlistId, data); @@ -158,6 +160,57 @@ export class PlaybackPositionRuntimeBridgeService { ); } + async getAllPlaybackPositionsOrThrow( + playlistId: string + ): Promise { + if (!this.supportsStorage) { + throw new Error('Playback position storage is unavailable'); + } + const bridge = this.bridge; + if (typeof bridge?.dbGetAllPlaybackPositions !== 'function') { + throw new Error('Playback position read method is unavailable'); + } + const result = await bridge.dbGetAllPlaybackPositions(playlistId); + if (!Array.isArray(result)) { + throw new Error('Playback position read did not succeed'); + } + return result; + } + + /** Preflight restores before any other user-state mutation or IPC work. */ + assertSupportsAtomicReplacement(): void { + if (!this.supportsStorage) { + throw new Error('Playback position storage is unavailable'); + } + if (typeof this.bridge?.dbReplaceAllPlaybackPositions !== 'function') { + throw new Error( + 'Playback position replacement method is unavailable' + ); + } + } + + async replaceAllPlaybackPositions( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + if (!this.supportsStorage) { + throw new Error('Playback position storage is unavailable'); + } + const bridge = this.bridge; + if (typeof bridge?.dbReplaceAllPlaybackPositions !== 'function') { + throw new Error( + 'Playback position replacement method is unavailable' + ); + } + const result = await bridge.dbReplaceAllPlaybackPositions( + playlistId, + items + ); + if (result?.success !== true) { + throw new Error('Playback position replacement did not succeed'); + } + } + async clearAllPlaybackPositions(playlistId: string): Promise { if (!this.supportsStorage) { return; @@ -193,9 +246,7 @@ export class PlaybackPositionRuntimeBridgeService { const bridge = this.bridge; if (typeof bridge?.dbClearPlaybackPosition !== 'function') { - throw new Error( - 'Playback position clear method is unavailable' - ); + throw new Error('Playback position clear method is unavailable'); } const result = await bridge.dbClearPlaybackPosition( diff --git a/libs/services/src/lib/playback-position.service.spec.ts b/libs/services/src/lib/playback-position.service.spec.ts index 556765e03..96bf8103d 100644 --- a/libs/services/src/lib/playback-position.service.spec.ts +++ b/libs/services/src/lib/playback-position.service.spec.ts @@ -18,6 +18,9 @@ describe('PlaybackPositionService', () => { | 'getSeriesPlaybackPositions' | 'getRecentPlaybackPositions' | 'getAllPlaybackPositions' + | 'getAllPlaybackPositionsOrThrow' + | 'replaceAllPlaybackPositions' + | 'assertSupportsAtomicReplacement' | 'clearAllPlaybackPositions' | 'clearPlaybackPosition' | 'savePlaybackPositionsBatch' @@ -32,6 +35,9 @@ describe('PlaybackPositionService', () => { getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]), getRecentPlaybackPositions: jest.fn().mockResolvedValue([]), getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]), + replaceAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), + assertSupportsAtomicReplacement: jest.fn(), clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), @@ -58,6 +64,38 @@ describe('PlaybackPositionService', () => { jest.restoreAllMocks(); }); + it('preflights atomic restore support without altering any positions', () => { + bridge.assertSupportsAtomicReplacement.mockImplementation(() => { + throw new Error( + 'Playback position replacement method is unavailable' + ); + }); + expect(() => service.assertSupportsAtomicReplacement()).toThrow( + 'unavailable' + ); + expect(bridge.replaceAllPlaybackPositions).not.toHaveBeenCalled(); + expect(bridge.clearAllPlaybackPositions).not.toHaveBeenCalled(); + expect(bridge.savePlaybackPosition).not.toHaveBeenCalled(); + }); + + it('propagates strict backup read and replacement failures', async () => { + const error = new Error('SQLITE_BUSY'); + bridge.getAllPlaybackPositionsOrThrow.mockRejectedValue(error); + bridge.replaceAllPlaybackPositions.mockRejectedValue(error); + await expect( + service.getAllPlaybackPositionsOrThrow('playlist-1') + ).rejects.toBe(error); + await expect( + service.replaceAllPlaybackPositions('playlist-1', []) + ).rejects.toBe(error); + expect(bridge.getAllPlaybackPositions).not.toHaveBeenCalled(); + expect(bridge.clearAllPlaybackPositions).not.toHaveBeenCalled(); + expect(bridge.replaceAllPlaybackPositions).toHaveBeenCalledWith( + 'playlist-1', + [] + ); + }); + it('delegates playback-position storage through the runtime bridge', async () => { const position = createPosition(); @@ -192,9 +230,7 @@ describe('PlaybackPositionService', () => { bridge.clearPlaybackPositionsBatch.mockRejectedValue(clearError); await expect( - service.savePlaybackPositionsBatch('playlist-1', [ - createPosition(), - ]) + service.savePlaybackPositionsBatch('playlist-1', [createPosition()]) ).rejects.toBe(saveError); await expect( service.clearPlaybackPositionsBatch('playlist-1', [ diff --git a/libs/services/src/lib/playback-position.service.ts b/libs/services/src/lib/playback-position.service.ts index e88c232ba..ce7eeb600 100644 --- a/libs/services/src/lib/playback-position.service.ts +++ b/libs/services/src/lib/playback-position.service.ts @@ -92,7 +92,24 @@ export class PlaybackPositionService { getAllPlaybackPositionsOrThrow( playlistId: string ): Promise { - return this.playbackPositionBridge.getAllPlaybackPositions(playlistId); + return this.playbackPositionBridge.getAllPlaybackPositionsOrThrow( + playlistId + ); + } + + assertSupportsAtomicReplacement(): void { + this.playbackPositionBridge.assertSupportsAtomicReplacement(); + } + + /** Replace a backup snapshot atomically; failures must remain retryable. */ + replaceAllPlaybackPositions( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + return this.playbackPositionBridge.replaceAllPlaybackPositions( + playlistId, + items + ); } async clearAllPlaybackPositions(playlistId: string): Promise { 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 eb7ccfe9f..e518cc66f 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -1,6 +1,8 @@ import { Playlist, PlaylistBackupManifestV1, + XtreamPlaylistBackupEntry, + XtreamPendingRestoreState, } from '@iptvnator/shared/interfaces'; import { createPlaylistBackupService, @@ -169,6 +171,233 @@ describe('PlaylistBackupService export → import round-trip', () => { return { ...manifest, exportedAt: 'normalized' }; } + function xtreamEntry( + manifest: PlaylistBackupManifestV1 + ): XtreamPlaylistBackupEntry { + const entry = manifest.playlists.find( + (item) => item.portalType === 'xtream' + ); + if (!entry || entry.portalType !== 'xtream') + throw new Error('Missing Xtream entry'); + return entry; + } + + it.each([true, false])( + 'preserves a fresh pending import on re-export (Electron: %s)', + async (electron) => { + const state = seedState(); + state.xtreamFavorites.push({ + xtream_id: 501, + type: 'series', + position: 1, + }); + const collaborators = createStatefulBackupCollaborators(state); + const service = createPlaylistBackupService(collaborators); + const original = await service.exportBackup(); + const originalState = xtreamEntry(original.manifest).userState; + expect(originalState.sourcePins).toHaveLength(1); + state.xtreamCategories = []; + state.xtreamFavorites = []; + state.xtreamRecent = []; + state.playbackPositions = []; + state.sourcePins = []; + collaborators.databaseService.getXtreamImportStatus = async () => + 'idle'; + if (!electron) delete electronWindow.electron; + + const summary = await service.importBackup(original.json); + expect(summary.failed).toBe(0); + const snapshot = + collaborators.pendingRestoreService.getSnapshotOrThrow( + 'xtream-1' + ); + expect(snapshot).not.toBeNull(); + // Pending state must remain exportable even while catalog/storage + // reads are unavailable; it is already the complete restore input. + jest.spyOn( + collaborators.databaseService, + 'getFavorites' + ).mockRejectedValue(new Error('Catalog unavailable')); + jest.spyOn( + collaborators.playbackPositionService, + 'getAllPlaybackPositionsOrThrow' + ).mockRejectedValue(new Error('Position storage unavailable')); + const exported = await service.exportBackup(); + expect(xtreamEntry(exported.manifest).userState).toEqual( + originalState + ); + expect( + collaborators.pendingRestoreService.getSnapshotOrThrow( + 'xtream-1' + ) + ).toEqual(snapshot); + } + ); + + it.each([true, false])( + 'honors empty pending collections and optional source pins (present: %s)', + async (pinsPresent) => { + const state = seedState(); + const collaborators = createStatefulBackupCollaborators(state); + const pending: XtreamPendingRestoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + ...(pinsPresent ? { sourcePins: [] } : {}), + }; + collaborators.pendingRestoreService.set('xtream-1', pending); + const service = createPlaylistBackupService(collaborators); + const exported = await service.exportBackup(); + const userState = xtreamEntry(exported.manifest).userState; + expect(userState).toEqual({ + ...pending, + sourcePins: pinsPresent + ? [] + : [ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ], + }); + } + ); + + it('keeps absent pending pins absent when the pin store is unavailable', async () => { + const state = seedState(); + const collaborators = createStatefulBackupCollaborators(state); + collaborators.vodSourcePinService.isAvailable = false; + const pending: XtreamPendingRestoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }; + collaborators.pendingRestoreService.set('xtream-1', pending); + const exported = + await createPlaylistBackupService(collaborators).exportBackup(); + expect(xtreamEntry(exported.manifest).userState).toEqual(pending); + }); + + it('refuses to export if pending storage cannot be read', async () => { + const collaborators = createStatefulBackupCollaborators(seedState()); + jest.spyOn( + collaborators.pendingRestoreService, + 'getOrThrow' + ).mockImplementation(() => { + throw new Error('Storage unavailable'); + }); + await expect( + createPlaylistBackupService(collaborators).exportBackup() + ).rejects.toThrow('Storage unavailable'); + }); + + it('refuses to export when playback positions cannot be read', async () => { + const collaborators = createStatefulBackupCollaborators(seedState()); + jest.spyOn( + collaborators.playbackPositionService, + 'getAllPlaybackPositionsOrThrow' + ).mockRejectedValue(new Error('SQLITE_BUSY')); + await expect( + createPlaylistBackupService(collaborators).exportBackup() + ).rejects.toThrow('SQLITE_BUSY'); + }); + + it.each(['completed', 'idle'])( + 'keeps SQLite exports and parks unsupported atomic restores before writes (%s catalog)', + async (catalogStatus) => { + const state = seedState(); + const collaborators = createStatefulBackupCollaborators(state); + const preflight = + collaborators.playbackPositionService + .assertSupportsAtomicReplacement; + preflight.mockImplementation(() => { + throw new Error( + 'Atomic playback position replacement unavailable' + ); + }); + const service = createPlaylistBackupService(collaborators); + const original = await service.exportBackup(); + const userState = xtreamEntry(original.manifest).userState; + expect(userState.favorites).toHaveLength(1); + expect(userState.playbackPositions).toEqual( + state.playbackPositions + ); + expect(userState.hiddenCategories).toHaveLength(2); + expect(userState.sourcePins).toHaveLength(1); + expect(preflight).not.toHaveBeenCalled(); + collaborators.databaseService.getXtreamImportStatus = async () => + catalogStatus; + const visibility = jest.spyOn( + collaborators.databaseService, + 'updateCategoryVisibility' + ); + const collections = jest.spyOn( + collaborators.databaseService, + 'restoreXtreamUserData' + ); + const positions = jest.spyOn( + collaborators.playbackPositionService, + 'replaceAllPlaybackPositions' + ); + const pins = jest.spyOn( + collaborators.vodSourcePinService, + 'replaceForPlaylist' + ); + + const failed = await service.importBackup(original.json); + expect(failed.failed).toBe(1); + expect(visibility).not.toHaveBeenCalled(); + expect(collections).not.toHaveBeenCalled(); + expect(positions).not.toHaveBeenCalled(); + expect(pins).not.toHaveBeenCalled(); + expect( + collaborators.pendingRestoreService.getOrThrow('xtream-1') + ).toEqual(userState); + const parkedExport = await service.exportBackup(); + expect(xtreamEntry(parkedExport.manifest).userState).toEqual( + userState + ); + + preflight.mockImplementation(() => undefined); + collaborators.databaseService.getXtreamImportStatus = async () => + 'completed'; + const retried = await service.importBackup(original.json); + expect(retried.failed).toBe(0); + expect(positions).toHaveBeenCalledTimes(1); + expect( + collaborators.pendingRestoreService.getOrThrow('xtream-1') + ).toBeNull(); + } + ); + + it('retains failed position restores for retry and consumes only after success', async () => { + const state = seedState(); + const collaborators = createStatefulBackupCollaborators(state); + const service = createPlaylistBackupService(collaborators); + const original = await service.exportBackup(); + const replace = jest + .spyOn( + collaborators.playbackPositionService, + 'replaceAllPlaybackPositions' + ) + .mockRejectedValueOnce(new Error('SQLITE_BUSY')); + const failed = await service.importBackup(original.json); + expect(failed.failed).toBe(1); + expect( + collaborators.pendingRestoreService.getOrThrow('xtream-1') + ?.playbackPositions + ).toEqual(state.playbackPositions); + const retried = await service.importBackup(original.json); + expect(retried.failed).toBe(0); + expect(replace).toHaveBeenCalledTimes(2); + expect( + collaborators.pendingRestoreService.getOrThrow('xtream-1') + ).toBeNull(); + }); + it('re-importing its own export restores the full state and exports an identical manifest', async () => { const state = seedState(); const collaborators = createStatefulBackupCollaborators(state); diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index f53ef0e19..4a48344cf 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -28,6 +28,7 @@ export function createPlaylistBackupService( let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null; let lastConsumedRevision: number | null = null; const pendingRestoreService = { + getOrThrow: jest.fn(() => pendingSnapshot?.state ?? null), set: jest.fn( ( playlistId: string, @@ -109,6 +110,9 @@ export function createPlaylistBackupService( updateCategoryVisibility: jest.fn().mockResolvedValue(true), }, playbackPositionService: { + assertSupportsAtomicReplacement: jest.fn(), + getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]), + replaceAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), getAllPlaybackPositions: jest.fn().mockResolvedValue([]), clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), savePlaybackPosition: jest.fn().mockResolvedValue(undefined), @@ -283,6 +287,17 @@ export function createStatefulBackupCollaborators( }, }, playbackPositionService: { + assertSupportsAtomicReplacement: jest.fn(), + getAllPlaybackPositionsOrThrow: async () => + state.playbackPositions.map((item) => ({ ...item })), + replaceAllPlaybackPositions: async ( + _playlistId: string, + positions: PlaybackPositionData[] + ) => { + state.playbackPositions = positions.map((item) => ({ + ...item, + })); + }, getAllPlaybackPositions: async () => state.playbackPositions.map((item) => ({ ...item })), clearAllPlaybackPositions: async () => { @@ -296,6 +311,16 @@ export function createStatefulBackupCollaborators( }, }, vodSourcePinService: { + isAvailable: true, + replaceForPlaylist: async ( + playlistId: string, + pins: VodSourcePin[] + ) => { + state.sourcePins = state.sourcePins + .filter((pin) => pin.playlistId !== playlistId) + .concat(pins.map((pin) => ({ ...pin }))); + return true; + }, listForPlaylistOrThrow: async (playlistId: string) => state.sourcePins .filter((pin) => pin.playlistId === playlistId) diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 3d332f5c7..3078285ea 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -296,37 +296,43 @@ export class PlaylistBackupService { private async buildXtreamEntry( playlist: Playlist ): Promise { - if (!this.hasElectronApi()) { + const entry: XtreamPlaylistBackupEntry = { + portalType: 'xtream', + exportedId: playlist._id, + title: playlist.title, + autoRefresh: Boolean(playlist.autoRefresh), + position: playlist.position, + connection: { + serverUrl: playlist.serverUrl ?? '', + username: playlist.username ?? '', + ...(playlist.password ? { password: playlist.password } : {}), + }, + userState: { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + ...this.optionalLockedXtreamCategories(playlist._id), + }, + }; + const pending = this.pendingRestoreService.getOrThrow(playlist._id); + if (pending) { + // A parked import is authoritative, including empty collections. + // Reading the incomplete catalog instead would silently lose it. + const sourcePins = + pending.sourcePins ?? + (await this.readSourcePinsForExport(playlist._id)); return { - portalType: 'xtream', - exportedId: playlist._id, - title: playlist.title, - autoRefresh: Boolean(playlist.autoRefresh), - position: playlist.position, - connection: { - serverUrl: playlist.serverUrl ?? '', - username: playlist.username ?? '', - ...(playlist.password - ? { password: playlist.password } - : {}), - }, + ...entry, userState: { - hiddenCategories: [], - favorites: [], - recentlyViewed: [], - playbackPositions: [], + ...pending, ...this.optionalLockedXtreamCategories(playlist._id), - // NOT `[]`. Pins are Electron-only, so out here we cannot - // read them — which is not the same as knowing there are - // none. Restore treats the collection as authoritative and - // clears the playlist's pins before applying it, so an - // empty array exported from the web would wipe them the - // moment the archive was imported on the desktop. Omitted, - // the archive says "no opinion", exactly as one written - // before pins existed does. + ...(sourcePins ? { sourcePins } : {}), }, }; } + // An unavailable pin store is no opinion, never an authoritative []. + if (!this.hasElectronApi()) return entry; const [ liveCategories, @@ -342,7 +348,9 @@ export class PlaylistBackupService { this.databaseService.getAllXtreamCategories(playlist._id, 'series'), this.databaseService.getFavorites(playlist._id), this.databaseService.getRecentItems(playlist._id), - this.playbackPositionService.getAllPlaybackPositions(playlist._id), + this.playbackPositionService.getAllPlaybackPositionsOrThrow( + playlist._id + ), // Throws rather than reading a failure as "no pins": restore // treats this collection as authoritative and clears the // playlist's pins before applying it, so an empty list born of a @@ -352,16 +360,7 @@ export class PlaylistBackupService { ]); return { - portalType: 'xtream', - exportedId: playlist._id, - title: playlist.title, - autoRefresh: Boolean(playlist.autoRefresh), - position: playlist.position, - connection: { - serverUrl: playlist.serverUrl ?? '', - username: playlist.username ?? '', - ...(playlist.password ? { password: playlist.password } : {}), - }, + ...entry, userState: { hiddenCategories: [ ...this.mapHiddenCategories(liveCategories, 'live'), @@ -938,6 +937,8 @@ export class PlaylistBackupService { return; } + this.playbackPositionService.assertSupportsAtomicReplacement(); + if (!(await this.hasCompletedOfflineCache(playlistId))) { return; } @@ -1002,6 +1003,7 @@ export class PlaylistBackupService { playlistId: string, state: XtreamPendingRestoreState ): Promise { + this.playbackPositionService.assertSupportsAtomicReplacement(); await this.restoreXtreamCategoryVisibility(playlistId, state); await this.databaseService.restoreXtreamUserData( playlistId, @@ -1009,17 +1011,11 @@ export class PlaylistBackupService { state.recentlyViewed ); - await this.playbackPositionService.clearAllPlaybackPositions( - playlistId + await this.playbackPositionService.replaceAllPlaybackPositions( + playlistId, + state.playbackPositions ); - for (const playbackPosition of state.playbackPositions) { - await this.playbackPositionService.savePlaybackPosition( - playlistId, - playbackPosition - ); - } - // Present-but-empty is an answer, like the positions cleared above: a // backup that holds no pin for this playlist means the user had none, // so leaving the current ones would resurrect preferences the archive diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 168221aea..5b1307bea 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -118,6 +118,7 @@ describe('RuntimeCapabilitiesService', () => { onPlaybackPositionUpdate: jest.fn(), dbDeleteXtreamContent: jest.fn(), dbRestoreXtreamUserData: jest.fn(), + dbReplaceAllPlaybackPositions: jest.fn(), downloadsStart: jest.fn(), downloadsCancel: jest.fn(), downloadsPause: jest.fn(), @@ -594,6 +595,18 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsXtreamSectionNavigation).toBe(true); }); + it('keeps Xtream SQLite and ordinary position storage when atomic restore is unavailable', () => { + const bridge = createXtreamSqliteBridge(); + testWindow.electron = bridge; + const service = new RuntimeCapabilitiesService(); + expect(service.supportsXtreamSqliteDataSource).toBe(true); + delete bridge['dbReplaceAllPlaybackPositions']; + expect(service.supportsXtreamSqliteDataSource).toBe(true); + expect(service.supportsPlaybackPositionStorage).toBe(true); + bridge['dbReplaceAllPlaybackPositions'] = jest.fn(); + expect(service.supportsXtreamSqliteDataSource).toBe(true); + }); + it('supports Xtream section navigation in Electron when only the SQLite data source is available', () => { testWindow.electron = createXtreamSqliteBridge(); @@ -648,6 +661,7 @@ function createXtreamSqliteBridge(): Record { dbRemoveFavorite: jest.fn(), dbRemoveRecentItem: jest.fn(), dbRestoreXtreamUserData: jest.fn(), + dbReplaceAllPlaybackPositions: jest.fn(), dbSaveCategories: jest.fn(), dbSaveContent: jest.fn(), dbSavePlaybackPosition: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 6efda14e1..30bafb0d7 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -5,9 +5,10 @@ export type RuntimeEnvironment = 'electron' | 'pwa'; type RuntimeElectronBridge = Partial; -// The full method set the position-storage layer may invoke — including the -// season-batch variants, so a bridge lacking them degrades to the in-memory -// path wholesale instead of throwing mid-action. +// Ordinary playback methods include season batches, so partial bridges use +// the in-memory path instead of throwing mid-action. Atomic backup restore is +// checked separately by a restore-only preflight; it never changes data-source +// selection or disables ordinary SQLite reads and playback writes. const playbackPositionStorageMethods = [ 'dbSavePlaybackPosition', 'dbGetPlaybackPosition', diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index c902e087c..7452189cb 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1246,6 +1246,10 @@ export interface ElectronBridgeApi { dbGetAllPlaybackPositions: ( playlistId: string ) => Promise; + dbReplaceAllPlaybackPositions: ( + playlistId: string, + items: ElectronBridgePlaybackPositionInput[] + ) => Promise; dbClearAllPlaybackPositions: ( playlistId: string ) => Promise;