mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(portals): restore fresh-import pins atomically
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
+1
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
+22
-16
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-22
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user