From b0598f8f70dccf870948c229b4deb07eeb86f6ac Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Jul 2026 23:24:48 +0200 Subject: [PATCH] fix(portals): restore fresh-import pins atomically --- .changes/portals-atomic-fresh-import-pins.md | 7 +++ docs/architecture/vod-multi-source.md | 8 +++ ...lectron-xtream-data-source.test-helpers.ts | 1 + .../electron-xtream-data-source.ts | 38 +++++++------ ...ctron-xtream-data-source.user-data.spec.ts | 53 +++++++++++-------- .../src/lib/xtream-restore-state.util.ts | 3 +- 6 files changed, 71 insertions(+), 39 deletions(-) create mode 100644 .changes/portals-atomic-fresh-import-pins.md diff --git a/.changes/portals-atomic-fresh-import-pins.md b/.changes/portals-atomic-fresh-import-pins.md new file mode 100644 index 000000000..2c9fb55e0 --- /dev/null +++ b/.changes/portals-atomic-fresh-import-pins.md @@ -0,0 +1,7 @@ +--- +type: fix +area: portals +--- + +Importing an Xtream backup now restores all preferred VOD sources together, so +a database failure cannot leave only some source preferences applied. diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index d33efbace..63e0f12e7 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -673,6 +673,14 @@ optional `sourcePins` collection. See sanitizing rules — the short version is that `matchKey` names the film and survives as-is, while the playlist id becomes the imported copy's. +Both restore routes use the same atomic replacement: the direct backup restore +and the parked replay after a fresh Xtream import call +`VodSourcePinService.replaceForPlaylist`, whose worker operation clears the +playlist's pins and inserts the complete restored set in one transaction. For +direct restore, that preserves the existing pins if an insert fails. A fresh +import has no pre-existing pins to lose; there it prevents a partially applied +prefix from being visible before the parked state is retried. + ## Which engines can fail over Only the built-in web players (HTML5, Video.js, ArtPlayer) raise the playback 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 3c33d89ff..a39f632d4 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 @@ -60,6 +60,7 @@ export function createDbServiceMock() { export function createVodSourcePinServiceMock() { return { listForPlaylist: jest.fn().mockResolvedValue([]), + replaceForPlaylist: jest.fn().mockResolvedValue(true), set: jest.fn().mockResolvedValue(true), }; } 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 d417cc333..9ee72e4bf 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 @@ -585,23 +585,29 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { // 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 // user state is parked and applied once the import finishes. - for (const pin of restoreState.sourcePins ?? []) { - const written = await this.vodSourcePinService.set({ - matchKey: pin.matchKey, - playlistId, - contentId: pin.contentId, - portalType: 'xtream', - ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), - }); + if (!restoreState.sourcePins) { + return; + } - // Throwing keeps the pending state for a later retry — the caller - // only clears it when this resolves. Dropping it here would lose - // the preference with the import still reporting success. - if (!written) { - throw new Error( - `Restoring the pinned source for "${pin.matchKey}" failed.` - ); - } + const pins = restoreState.sourcePins.map((pin) => ({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: 'xtream' as const, + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + })); + const replaced = await this.vodSourcePinService.replaceForPlaylist( + playlistId, + pins + ); + + // Throwing keeps the pending state for a later retry — the caller only + // clears it when this resolves. Dropping it here would lose the + // preference with the import still reporting success. + if (!replaced) { + throw new Error( + `Restoring the pinned sources for "${playlistId}" failed.` + ); } } } 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 af578ea28..efd317d2d 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 @@ -198,7 +198,7 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { }); }); - it('applies parked source pins against the imported playlist', async () => { + it('atomically applies all parked source pins and rejects a failed replacement', async () => { // The fresh-import path: a new playlist has no content when the // archive is read, so its user state is parked and replayed here. // Without this the backup's pins are dropped for every new import. @@ -213,36 +213,45 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { contentId: 501, updatedAt: '2026-07-06T09:00:00.000Z', }, + { + matchKey: 'title:the-matrix:1999', + contentId: 502, + }, ], } as never; await harness.dataSource.restoreUserData(playlistId, restoreState); - expect(harness.vodSourcePinService.set).toHaveBeenCalledWith({ - matchKey: 'tmdb:603', - playlistId, - contentId: 501, - portalType: 'xtream', - updatedAt: '2026-07-06T09:00:00.000Z', - }); - }); - - it('keeps the pending state when a pin cannot be written', async () => { - harness.vodSourcePinService.set.mockResolvedValue(false); - const restoreState = { - hiddenCategories: [], - favorites: [], - recentlyViewed: [], - playbackPositions: [], - sourcePins: [{ matchKey: 'tmdb:603', contentId: 501 }], - } as never; + expect( + harness.vodSourcePinService.replaceForPlaylist + ).toHaveBeenCalledTimes(1); + expect( + harness.vodSourcePinService.replaceForPlaylist + ).toHaveBeenCalledWith(playlistId, [ + { + matchKey: 'tmdb:603', + playlistId, + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }, + { + matchKey: 'title:the-matrix:1999', + playlistId, + contentId: 502, + portalType: 'xtream', + }, + ]); + expect(harness.vodSourcePinService.set).not.toHaveBeenCalled(); // The caller clears the parked state only when this resolves, so - // resolving here would drop the preference on a transient DB - // failure while the import still reported success. + // resolving here would drop the retry on a transient DB failure. + harness.vodSourcePinService.replaceForPlaylist.mockResolvedValue( + false + ); await expect( harness.dataSource.restoreUserData(playlistId, restoreState) - ).rejects.toThrow('tmdb:603'); + ).rejects.toThrow(playlistId); }); it('restores user data, then resets and replays playback positions', async () => { diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts index 6f17b039b..99cbc5af2 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -14,7 +14,8 @@ export interface XtreamPendingRestoreState { playbackPositions: PlaybackPositionData[]; /** * Optional: absent from archives and persisted entries written before - * multi-source existed. The normalizer always fills it. + * multi-source existed. The normalizer preserves that absence so restore + * can distinguish "no opinion" from an authoritative empty collection. */ sourcePins?: XtreamBackupSourcePin[]; }