fix(portals): restore fresh-import pins atomically

This commit is contained in:
4gray committed 2026-07-29 23:24:53 +02:00
1 parent 78df3e7dbb
commit b0598f8f70
6 files changed
+71 -39

No files matched your search

@@ -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.
+8
View File
@@ -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
@@ -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),
};
}
@@ -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.`
);
}
}
}
@@ -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 () => {
@@ -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[];
}