mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(portals): serialize Xtream restore revisions
This commit is contained in:
1 parent
83fb06e79a
commit
9be009d809
13 files changed
+732
-79
No files matched your search
@@ -684,8 +684,13 @@ prefix from being visible before the parked state is retried.
|
||||
The parked state is consumed only after that atomic replacement succeeds, and
|
||||
its removal is verified (with an empty tombstone as the safe fallback when
|
||||
storage removal fails). Consumption compares the current parked snapshot with
|
||||
the one that was applied, and backup imports are serialized so an older restore
|
||||
cannot finish after and overwrite a newer one. Either restore path reports a
|
||||
the one that was applied. Store-owned replay and direct backup restore share a
|
||||
playlist-scoped FIFO coordinator and consume a revision captured for the
|
||||
content generation they restored. Duplicate consumers of that revision
|
||||
coalesce, while a newer revision remains parked for its own post-import
|
||||
consumer; an older asynchronous replacement therefore cannot clear or finish
|
||||
after a newer one. Parking is verified before an import can report success, and
|
||||
whole backup imports are serialized as well. Either restore path reports a
|
||||
failed import instead of claiming success when consumption cannot be
|
||||
confirmed. Fresh-import initialization stays blocked and retryable on either
|
||||
failure. Cached content is not exposed while parked state exists: a complete
|
||||
|
||||
@@ -158,10 +158,16 @@ export class PlaylistRefreshActionService {
|
||||
]
|
||||
);
|
||||
|
||||
this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
});
|
||||
if (
|
||||
!this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`Parking pending restore state for "${item._id}" failed.`
|
||||
);
|
||||
}
|
||||
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
|
||||
|
||||
@@ -402,10 +402,16 @@ export class RecentPlaylistsComponent {
|
||||
]
|
||||
);
|
||||
|
||||
this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
});
|
||||
if (
|
||||
!this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`Parking pending restore state for "${item._id}" failed.`
|
||||
);
|
||||
}
|
||||
|
||||
// Update the timestamp in NgRx / IndexedDB
|
||||
measureRendererPerformancePhase(
|
||||
|
||||
+70
@@ -4,6 +4,7 @@ import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { XtreamPendingRestoreSnapshot } from '@iptvnator/services';
|
||||
import { XtreamPlaylistData } from '../../data-sources/xtream-data-source.interface';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import { withContent } from './with-content.feature';
|
||||
@@ -25,6 +26,75 @@ export const PENDING_RESTORE_STATE: XtreamPendingRestoreState = {
|
||||
sourcePins: [{ matchKey: 'tmdb:603', contentId: 501 }],
|
||||
};
|
||||
|
||||
export function createPendingRestoreServiceMock() {
|
||||
let nextRevision = 0;
|
||||
let activeSnapshot: XtreamPendingRestoreSnapshot | null = null;
|
||||
let activeSerializedState: string | null = null;
|
||||
let lastConsumedRevision: number | null = null;
|
||||
const mock = {
|
||||
getOrThrow: jest.fn().mockReturnValue(null),
|
||||
getSnapshotOrThrow: jest.fn(),
|
||||
clear: jest.fn().mockReturnValue(true),
|
||||
applyAndConsume: jest.fn(),
|
||||
};
|
||||
mock.getSnapshotOrThrow.mockImplementation((playlistId: string) => {
|
||||
const state = mock.getOrThrow(
|
||||
playlistId
|
||||
) as XtreamPendingRestoreState | null;
|
||||
if (!state) {
|
||||
activeSnapshot = null;
|
||||
activeSerializedState = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const serializedState = JSON.stringify(state);
|
||||
if (
|
||||
!activeSnapshot ||
|
||||
activeSerializedState !== serializedState
|
||||
) {
|
||||
activeSnapshot = {
|
||||
playlistId,
|
||||
revision: ++nextRevision,
|
||||
state,
|
||||
};
|
||||
activeSerializedState = serializedState;
|
||||
lastConsumedRevision = null;
|
||||
}
|
||||
return activeSnapshot;
|
||||
});
|
||||
mock.applyAndConsume.mockImplementation(
|
||||
async (
|
||||
playlistId: string,
|
||||
expectedSnapshot: XtreamPendingRestoreSnapshot,
|
||||
apply: (state: XtreamPendingRestoreState) => Promise<void>
|
||||
) => {
|
||||
const currentSnapshot =
|
||||
mock.getSnapshotOrThrow(playlistId);
|
||||
if (!currentSnapshot) {
|
||||
return lastConsumedRevision === expectedSnapshot.revision
|
||||
? 'consumed'
|
||||
: 'superseded';
|
||||
}
|
||||
if (
|
||||
currentSnapshot.revision !== expectedSnapshot.revision
|
||||
) {
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
await apply(currentSnapshot.state);
|
||||
if (!mock.clear(playlistId, currentSnapshot.state)) {
|
||||
return 'consume-failed';
|
||||
}
|
||||
lastConsumedRevision = currentSnapshot.revision;
|
||||
activeSnapshot = null;
|
||||
activeSerializedState = null;
|
||||
mock.getOrThrow.mockReturnValue(null);
|
||||
return 'consumed';
|
||||
}
|
||||
);
|
||||
return mock;
|
||||
}
|
||||
|
||||
export function createContentTestStore(
|
||||
checkPortalStatus: () => Promise<PortalStatusType>
|
||||
) {
|
||||
|
||||
+20
-11
@@ -10,6 +10,7 @@ import {
|
||||
createAbortError,
|
||||
createContentTestStore,
|
||||
createDeferred,
|
||||
createPendingRestoreServiceMock,
|
||||
PENDING_RESTORE_STATE,
|
||||
readPendingRestoreBlocked,
|
||||
setPerformanceHook,
|
||||
@@ -56,10 +57,9 @@ describe('withContent import state', () => {
|
||||
let xtreamApiService: {
|
||||
cancelSession: jest.Mock;
|
||||
};
|
||||
let pendingRestoreService: {
|
||||
getOrThrow: jest.Mock;
|
||||
clear: jest.Mock;
|
||||
};
|
||||
let pendingRestoreService: ReturnType<
|
||||
typeof createPendingRestoreServiceMock
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -94,10 +94,7 @@ describe('withContent import state', () => {
|
||||
xtreamApiService = {
|
||||
cancelSession: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
pendingRestoreService = {
|
||||
getOrThrow: jest.fn().mockReturnValue(null),
|
||||
clear: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
pendingRestoreService = createPendingRestoreServiceMock();
|
||||
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -1069,7 +1066,7 @@ describe('withContent import state', () => {
|
||||
expect(store.contentInitBlockReason()).toBeNull();
|
||||
});
|
||||
|
||||
it('consumes pending state parked during an active import', async () => {
|
||||
it('leaves state parked during an active import for the next content generation', async () => {
|
||||
const pendingSeries = createDeferred<any[]>();
|
||||
dataSource.getContent.mockImplementation(
|
||||
(_playlistId: string, _credentials: unknown, type: ContentType) =>
|
||||
@@ -1092,18 +1089,30 @@ describe('withContent import state', () => {
|
||||
pendingSeries.resolve([]);
|
||||
await initialization;
|
||||
|
||||
expect(dataSource.restoreUserData).not.toHaveBeenCalled();
|
||||
expect(readPendingRestoreBlocked(store)).toBe(true);
|
||||
expect(store.isContentInitialized()).toBe(false);
|
||||
|
||||
dataSource.getContent.mockResolvedValue([]);
|
||||
await store.initializeContent();
|
||||
|
||||
expect(dataSource.restoreUserData).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
PENDING_RESTORE_STATE,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(pendingRestoreService.applyAndConsume).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
expect.objectContaining({
|
||||
state: PENDING_RESTORE_STATE,
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(pendingRestoreService.clear).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
PENDING_RESTORE_STATE
|
||||
);
|
||||
expect(readPendingRestoreBlocked(store)).toBe(false);
|
||||
expect(store.isImporting()).toBe(false);
|
||||
expect(store.activeImportSessionId()).toBeNull();
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -855,11 +855,13 @@ export function withContent() {
|
||||
// content. A retry may load each type from the DB without
|
||||
// emitting an import phase, so the store-owned gate is what
|
||||
// prevents source-pin edits until replay is consumed.
|
||||
const initialRestoreData = pendingRestoreService.getOrThrow(
|
||||
ctx.playlistId
|
||||
);
|
||||
const initialRestoreSnapshot =
|
||||
pendingRestoreService.getSnapshotOrThrow(
|
||||
ctx.playlistId
|
||||
);
|
||||
patchState(store, {
|
||||
isPendingRestoreBlocked: initialRestoreData !== null,
|
||||
isPendingRestoreBlocked:
|
||||
initialRestoreSnapshot !== null,
|
||||
});
|
||||
|
||||
// Electron content persistence maps remote category IDs
|
||||
@@ -876,19 +878,21 @@ export function withContent() {
|
||||
});
|
||||
throwIfImportCancelled(importSessionId);
|
||||
|
||||
// A backup may be imported while this initialization is
|
||||
// awaiting network or DB work. Use the latest snapshot;
|
||||
// restoreUserData applies category visibility too, so a
|
||||
// late arrival is complete rather than pin-only.
|
||||
const restoreData = pendingRestoreService.getOrThrow(
|
||||
ctx.playlistId
|
||||
);
|
||||
// Only the revision captured before import belongs to this
|
||||
// content generation. A newer revision may come from a
|
||||
// refresh that has deleted the catalog and must remain
|
||||
// parked until its own replacement import completes.
|
||||
const currentRestoreSnapshot =
|
||||
pendingRestoreService.getSnapshotOrThrow(
|
||||
ctx.playlistId
|
||||
);
|
||||
patchState(store, {
|
||||
isPendingRestoreBlocked: restoreData !== null,
|
||||
isPendingRestoreBlocked:
|
||||
currentRestoreSnapshot !== null,
|
||||
});
|
||||
|
||||
// Restore user data if needed
|
||||
if (restoreData) {
|
||||
if (initialRestoreSnapshot) {
|
||||
try {
|
||||
throwIfImportCancelled(importSessionId);
|
||||
const restoreOperationId =
|
||||
@@ -899,28 +903,29 @@ export function withContent() {
|
||||
patchState(store, {
|
||||
importPhase: 'restoring-favorites',
|
||||
});
|
||||
await dataSource.restoreUserData(
|
||||
ctx.playlistId,
|
||||
restoreData,
|
||||
{
|
||||
onEvent: trackImportEvent,
|
||||
operationId: restoreOperationId,
|
||||
}
|
||||
);
|
||||
throwIfImportCancelled(importSessionId);
|
||||
if (
|
||||
!pendingRestoreService.clear(
|
||||
const restoreResult =
|
||||
await pendingRestoreService.applyAndConsume(
|
||||
ctx.playlistId,
|
||||
restoreData
|
||||
)
|
||||
) {
|
||||
initialRestoreSnapshot,
|
||||
async (pendingState) => {
|
||||
throwIfImportCancelled(importSessionId);
|
||||
await dataSource.restoreUserData(
|
||||
ctx.playlistId,
|
||||
pendingState,
|
||||
{
|
||||
onEvent: trackImportEvent,
|
||||
operationId:
|
||||
restoreOperationId,
|
||||
}
|
||||
);
|
||||
throwIfImportCancelled(importSessionId);
|
||||
}
|
||||
);
|
||||
if (restoreResult === 'consume-failed') {
|
||||
throw new Error(
|
||||
`Clearing pending restore state for "${ctx.playlistId}" failed.`
|
||||
);
|
||||
}
|
||||
patchState(store, {
|
||||
isPendingRestoreBlocked: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throwIfImportCancelled(importSessionId);
|
||||
|
||||
@@ -932,6 +937,15 @@ export function withContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const isRestoreStillPending =
|
||||
hasPendingRestoreOrReadFailure(ctx.playlistId);
|
||||
patchState(store, {
|
||||
isPendingRestoreBlocked: isRestoreStillPending,
|
||||
});
|
||||
if (isRestoreStillPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
throwIfImportCancelled(importSessionId);
|
||||
|
||||
// Mark as initialized so next routings won't re-trigger it
|
||||
|
||||
@@ -217,6 +217,33 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
expect(summary.errors[0]).toMatch(/pending restore state/i);
|
||||
});
|
||||
|
||||
it('reports a restore whose pending state could not be parked', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
collaborators.pendingRestoreService.set.mockReturnValueOnce(null);
|
||||
const replacePins = jest.fn().mockResolvedValue(true);
|
||||
const service = createPlaylistBackupService({
|
||||
...collaborators,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
|
||||
replaceForPlaylist: replacePins,
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = createXtreamManifest(
|
||||
[],
|
||||
[{ matchKey: 'tmdb:603', contentId: 501 }]
|
||||
);
|
||||
|
||||
const summary = await service.importBackup(JSON.stringify(manifest));
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({ merged: 0, failed: 1 })
|
||||
);
|
||||
expect(summary.errors[0]).toMatch(/parking pending restore state/i);
|
||||
expect(replacePins).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops pins the backup does not contain', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
const clearPins = jest.fn().mockResolvedValue(true);
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
VodSourcePin,
|
||||
XtreamBackupFavoriteItem,
|
||||
XtreamBackupRecentlyViewedItem,
|
||||
XtreamPendingRestoreState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistBackupService } from './playlist-backup.service';
|
||||
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
|
||||
import {
|
||||
XtreamPendingRestoreService,
|
||||
XtreamPendingRestoreSnapshot,
|
||||
} from './xtream-pending-restore.service';
|
||||
|
||||
/**
|
||||
* Shared factory for PlaylistBackupService specs. Instantiates the service
|
||||
@@ -20,6 +24,58 @@ export function createPlaylistBackupService(
|
||||
const service = Object.create(
|
||||
PlaylistBackupService.prototype
|
||||
) as PlaylistBackupService;
|
||||
let nextRevision = 0;
|
||||
let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null;
|
||||
let lastConsumedRevision: number | null = null;
|
||||
const pendingRestoreService = {
|
||||
set: jest.fn(
|
||||
(
|
||||
playlistId: string,
|
||||
state: XtreamPendingRestoreState
|
||||
): XtreamPendingRestoreSnapshot | null => {
|
||||
pendingSnapshot = {
|
||||
playlistId,
|
||||
revision: ++nextRevision,
|
||||
state,
|
||||
};
|
||||
lastConsumedRevision = null;
|
||||
return pendingSnapshot;
|
||||
}
|
||||
),
|
||||
clear: jest.fn().mockReturnValue(true),
|
||||
applyAndConsume: jest.fn(),
|
||||
};
|
||||
pendingRestoreService.applyAndConsume.mockImplementation(
|
||||
async (
|
||||
playlistId: string,
|
||||
expectedSnapshot: XtreamPendingRestoreSnapshot,
|
||||
apply: (state: XtreamPendingRestoreState) => Promise<void>
|
||||
) => {
|
||||
if (!pendingSnapshot) {
|
||||
return lastConsumedRevision === expectedSnapshot.revision
|
||||
? 'consumed'
|
||||
: 'superseded';
|
||||
}
|
||||
if (
|
||||
pendingSnapshot.revision !== expectedSnapshot.revision
|
||||
) {
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
await apply(pendingSnapshot.state);
|
||||
if (
|
||||
!pendingRestoreService.clear(
|
||||
playlistId,
|
||||
pendingSnapshot.state
|
||||
)
|
||||
) {
|
||||
return 'consume-failed';
|
||||
}
|
||||
lastConsumedRevision = pendingSnapshot.revision;
|
||||
pendingSnapshot = null;
|
||||
return 'consumed';
|
||||
}
|
||||
);
|
||||
|
||||
Object.assign(service as object, {
|
||||
playlistsService: {
|
||||
@@ -68,10 +124,7 @@ export function createPlaylistBackupService(
|
||||
clear: jest.fn().mockResolvedValue(true),
|
||||
clearForPlaylist: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
pendingRestoreService: {
|
||||
set: jest.fn(),
|
||||
clear: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
pendingRestoreService,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -841,7 +841,15 @@ export class PlaylistBackupService {
|
||||
const restoreState: XtreamPendingRestoreState =
|
||||
normalizeXtreamPendingRestoreState(entry.userState);
|
||||
|
||||
this.pendingRestoreService.set(playlistId, restoreState);
|
||||
const restoreSnapshot = this.pendingRestoreService.set(
|
||||
playlistId,
|
||||
restoreState
|
||||
);
|
||||
if (!restoreSnapshot) {
|
||||
throw new PlaylistBackupError(
|
||||
`Parking pending restore state for "${playlistId}" failed.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.hasElectronApi()) {
|
||||
return;
|
||||
@@ -851,8 +859,14 @@ export class PlaylistBackupService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applyXtreamRestoreState(playlistId, restoreState);
|
||||
if (!this.pendingRestoreService.clear(playlistId, restoreState)) {
|
||||
const restoreResult =
|
||||
await this.pendingRestoreService.applyAndConsume(
|
||||
playlistId,
|
||||
restoreSnapshot,
|
||||
(pendingState) =>
|
||||
this.applyXtreamRestoreState(playlistId, pendingState)
|
||||
);
|
||||
if (restoreResult !== 'consumed') {
|
||||
throw new PlaylistBackupError(
|
||||
`Clearing pending restore state for "${playlistId}" failed.`
|
||||
);
|
||||
|
||||
@@ -86,6 +86,19 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
|
||||
expect(
|
||||
collaborators.databaseService.updateCategoryVisibility
|
||||
).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
collaborators.pendingRestoreService.applyAndConsume
|
||||
).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
state: expect.objectContaining({
|
||||
hiddenCategories: [
|
||||
{ categoryType: 'live', xtreamId: 101 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(collaborators.pendingRestoreService.clear).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
PlaylistBackupManifestV1,
|
||||
PLAYLIST_BACKUP_KIND,
|
||||
PLAYLIST_BACKUP_VERSION,
|
||||
XtreamPendingRestoreState,
|
||||
XtreamPlaylistBackupEntry,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { XtreamPendingRestoreSnapshot } from './xtream-pending-restore.service';
|
||||
|
||||
/**
|
||||
* The Xtream restore scaffolding both backup suites need — the existing
|
||||
@@ -91,6 +93,59 @@ export function createXtreamManifest(
|
||||
}
|
||||
|
||||
export function createRestoreCollaborators() {
|
||||
let nextRevision = 0;
|
||||
let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null;
|
||||
let lastConsumedRevision: number | null = null;
|
||||
const pendingRestoreService = {
|
||||
set: jest.fn(
|
||||
(
|
||||
playlistId: string,
|
||||
state: XtreamPendingRestoreState
|
||||
): XtreamPendingRestoreSnapshot | null => {
|
||||
pendingSnapshot = {
|
||||
playlistId,
|
||||
revision: ++nextRevision,
|
||||
state,
|
||||
};
|
||||
lastConsumedRevision = null;
|
||||
return pendingSnapshot;
|
||||
}
|
||||
),
|
||||
clear: jest.fn().mockReturnValue(true),
|
||||
applyAndConsume: jest.fn(),
|
||||
};
|
||||
pendingRestoreService.applyAndConsume.mockImplementation(
|
||||
async (
|
||||
playlistId: string,
|
||||
expectedSnapshot: XtreamPendingRestoreSnapshot,
|
||||
apply: (state: XtreamPendingRestoreState) => Promise<void>
|
||||
) => {
|
||||
if (!pendingSnapshot) {
|
||||
return lastConsumedRevision === expectedSnapshot.revision
|
||||
? 'consumed'
|
||||
: 'superseded';
|
||||
}
|
||||
if (
|
||||
pendingSnapshot.revision !== expectedSnapshot.revision
|
||||
) {
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
await apply(pendingSnapshot.state);
|
||||
if (
|
||||
!pendingRestoreService.clear(
|
||||
playlistId,
|
||||
pendingSnapshot.state
|
||||
)
|
||||
) {
|
||||
return 'consume-failed';
|
||||
}
|
||||
lastConsumedRevision = pendingSnapshot.revision;
|
||||
pendingSnapshot = null;
|
||||
return 'consumed';
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
playlistsService: {
|
||||
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
|
||||
@@ -111,9 +166,6 @@ export function createRestoreCollaborators() {
|
||||
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
|
||||
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
pendingRestoreService: {
|
||||
set: jest.fn(),
|
||||
clear: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
pendingRestoreService,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { getXtreamPendingRestoreStorageKey } from '@iptvnator/shared/interfaces';
|
||||
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
|
||||
import {
|
||||
XtreamPendingRestoreService,
|
||||
XtreamPendingRestoreSnapshot,
|
||||
} from './xtream-pending-restore.service';
|
||||
|
||||
function requireSnapshot(
|
||||
snapshot: XtreamPendingRestoreSnapshot | null
|
||||
): XtreamPendingRestoreSnapshot {
|
||||
if (!snapshot) {
|
||||
throw new Error('Expected pending restore snapshot');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe('XtreamPendingRestoreService', () => {
|
||||
const playlistId = 'playlist-1';
|
||||
@@ -39,7 +51,7 @@ describe('XtreamPendingRestoreService', () => {
|
||||
});
|
||||
|
||||
it('normalizes state on write', () => {
|
||||
service.set(playlistId, {
|
||||
const snapshot = service.set(playlistId, {
|
||||
hiddenCategories: [
|
||||
{ categoryType: 'live', xtreamId: 101 },
|
||||
{ categoryType: 'live' } as never,
|
||||
@@ -49,6 +61,7 @@ describe('XtreamPendingRestoreService', () => {
|
||||
playbackPositions: [],
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
const persisted = JSON.parse(
|
||||
localStorage.getItem(storageKey) ?? 'null'
|
||||
);
|
||||
@@ -152,4 +165,222 @@ describe('XtreamPendingRestoreService', () => {
|
||||
expect(service.clear(playlistId, restoredState)).toBe(false);
|
||||
expect(service.getOrThrow(playlistId)).toEqual(newerState);
|
||||
});
|
||||
|
||||
it('leaves a newer snapshot for its own queued consumer', async () => {
|
||||
const olderState = {
|
||||
hiddenCategories: [],
|
||||
favorites: [{ xtreamId: 101, contentType: 'live' as const }],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
const newerState = {
|
||||
...olderState,
|
||||
favorites: [{ xtreamId: 202, contentType: 'movie' as const }],
|
||||
};
|
||||
let releaseOlder!: () => void;
|
||||
const olderCanFinish = new Promise<void>((resolve) => {
|
||||
releaseOlder = resolve;
|
||||
});
|
||||
let signalOlderStarted!: () => void;
|
||||
const olderStarted = new Promise<void>((resolve) => {
|
||||
signalOlderStarted = resolve;
|
||||
});
|
||||
const appliedSnapshots: number[] = [];
|
||||
|
||||
const olderSnapshot = requireSnapshot(
|
||||
service.set(playlistId, olderState)
|
||||
);
|
||||
const olderApplication = service.applyAndConsume(
|
||||
playlistId,
|
||||
olderSnapshot,
|
||||
async (state) => {
|
||||
const xtreamId = state.favorites[0]?.xtreamId;
|
||||
signalOlderStarted();
|
||||
await olderCanFinish;
|
||||
appliedSnapshots.push(xtreamId);
|
||||
}
|
||||
);
|
||||
await olderStarted;
|
||||
|
||||
const newerSnapshot = requireSnapshot(
|
||||
service.set(playlistId, newerState)
|
||||
);
|
||||
const newerApply = jest.fn(async (state) => {
|
||||
appliedSnapshots.push(state.favorites[0]?.xtreamId);
|
||||
});
|
||||
const newerApplication = service.applyAndConsume(
|
||||
playlistId,
|
||||
newerSnapshot,
|
||||
newerApply
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(newerApply).not.toHaveBeenCalled();
|
||||
|
||||
releaseOlder();
|
||||
await expect(olderApplication).resolves.toBe('superseded');
|
||||
await expect(newerApplication).resolves.toBe('consumed');
|
||||
expect(appliedSnapshots).toEqual([101, 202]);
|
||||
expect(newerApply).toHaveBeenCalledTimes(1);
|
||||
expect(service.getOrThrow(playlistId)).toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces concurrent consumers of the same snapshot', async () => {
|
||||
const state = {
|
||||
hiddenCategories: [],
|
||||
favorites: [{ xtreamId: 101, contentType: 'live' as const }],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
let releaseFirst!: () => void;
|
||||
const firstCanFinish = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let signalFirstStarted!: () => void;
|
||||
const firstStarted = new Promise<void>((resolve) => {
|
||||
signalFirstStarted = resolve;
|
||||
});
|
||||
const firstApply = jest.fn(async () => {
|
||||
signalFirstStarted();
|
||||
await firstCanFinish;
|
||||
});
|
||||
const secondApply = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const snapshot = requireSnapshot(service.set(playlistId, state));
|
||||
const firstApplication = service.applyAndConsume(
|
||||
playlistId,
|
||||
snapshot,
|
||||
firstApply
|
||||
);
|
||||
await firstStarted;
|
||||
const secondApplication = service.applyAndConsume(
|
||||
playlistId,
|
||||
snapshot,
|
||||
secondApply
|
||||
);
|
||||
|
||||
releaseFirst();
|
||||
await expect(firstApplication).resolves.toBe('consumed');
|
||||
await expect(secondApplication).resolves.toBe('consumed');
|
||||
expect(firstApply).toHaveBeenCalledTimes(1);
|
||||
expect(secondApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not consume an identical snapshot parked by a newer producer', async () => {
|
||||
const state = {
|
||||
hiddenCategories: [],
|
||||
favorites: [{ xtreamId: 101, contentType: 'live' as const }],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
let releaseOlder!: () => void;
|
||||
const olderCanFinish = new Promise<void>((resolve) => {
|
||||
releaseOlder = resolve;
|
||||
});
|
||||
let signalOlderStarted!: () => void;
|
||||
const olderStarted = new Promise<void>((resolve) => {
|
||||
signalOlderStarted = resolve;
|
||||
});
|
||||
const olderSnapshot = requireSnapshot(
|
||||
service.set(playlistId, state)
|
||||
);
|
||||
const olderApplication = service.applyAndConsume(
|
||||
playlistId,
|
||||
olderSnapshot,
|
||||
async () => {
|
||||
signalOlderStarted();
|
||||
await olderCanFinish;
|
||||
}
|
||||
);
|
||||
await olderStarted;
|
||||
|
||||
const newerSnapshot = requireSnapshot(
|
||||
service.set(playlistId, state)
|
||||
);
|
||||
expect(newerSnapshot.revision).not.toBe(olderSnapshot.revision);
|
||||
releaseOlder();
|
||||
|
||||
await expect(
|
||||
olderApplication
|
||||
).resolves.toBe('superseded');
|
||||
expect(service.getSnapshotOrThrow(playlistId)).toEqual(newerSnapshot);
|
||||
});
|
||||
|
||||
it('continues the playlist queue after an application rejects', async () => {
|
||||
const olderState = {
|
||||
hiddenCategories: [],
|
||||
favorites: [{ xtreamId: 101, contentType: 'live' as const }],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
const newerState = {
|
||||
...olderState,
|
||||
favorites: [{ xtreamId: 202, contentType: 'movie' as const }],
|
||||
};
|
||||
const olderSnapshot = requireSnapshot(
|
||||
service.set(playlistId, olderState)
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.applyAndConsume(
|
||||
playlistId,
|
||||
olderSnapshot,
|
||||
async () => {
|
||||
throw new Error('database is locked');
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('database is locked');
|
||||
const newerSnapshot = requireSnapshot(
|
||||
service.set(playlistId, newerState)
|
||||
);
|
||||
const applyNewer = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.applyAndConsume(
|
||||
playlistId,
|
||||
newerSnapshot,
|
||||
applyNewer
|
||||
)
|
||||
).resolves.toBe('consumed');
|
||||
expect(applyNewer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports failure when an applied snapshot cannot be consumed', async () => {
|
||||
const state = {
|
||||
hiddenCategories: [],
|
||||
favorites: [{ xtreamId: 101, contentType: 'live' as const }],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
const snapshot = requireSnapshot(service.set(playlistId, state));
|
||||
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('storage is locked');
|
||||
});
|
||||
jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||
throw new Error('storage is locked');
|
||||
});
|
||||
const apply = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.applyAndConsume(playlistId, snapshot, apply)
|
||||
).resolves.toBe('consume-failed');
|
||||
expect(apply).toHaveBeenCalledWith(state);
|
||||
expect(service.getOrThrow(playlistId)).toEqual(state);
|
||||
});
|
||||
|
||||
it('reports failure when pending state cannot be parked', () => {
|
||||
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('storage is locked');
|
||||
});
|
||||
|
||||
expect(
|
||||
service.set(playlistId, {
|
||||
hiddenCategories: [],
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
})
|
||||
).toBeNull();
|
||||
expect(service.getOrThrow(playlistId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,32 @@ import {
|
||||
XtreamPendingRestoreState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface XtreamPendingRestoreSnapshot {
|
||||
readonly playlistId: string;
|
||||
readonly revision: number;
|
||||
readonly state: XtreamPendingRestoreState;
|
||||
}
|
||||
|
||||
export type XtreamPendingRestoreApplicationResult =
|
||||
| 'consumed'
|
||||
| 'superseded'
|
||||
| 'consume-failed';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class XtreamPendingRestoreService {
|
||||
private readonly restoreApplicationTails = new Map<
|
||||
string,
|
||||
Promise<void>
|
||||
>();
|
||||
private readonly restoreRevisions = new Map<
|
||||
string,
|
||||
{ revision: number; serializedState: string }
|
||||
>();
|
||||
private readonly lastConsumedRevisions = new Map<string, number>();
|
||||
private nextRestoreRevision = 0;
|
||||
|
||||
get(playlistId: string): XtreamPendingRestoreState | null {
|
||||
try {
|
||||
return this.getOrThrow(playlistId);
|
||||
@@ -44,19 +66,119 @@ export class XtreamPendingRestoreService {
|
||||
}
|
||||
}
|
||||
|
||||
set(playlistId: string, state: XtreamPendingRestoreState): void {
|
||||
if (!playlistId) {
|
||||
return;
|
||||
getSnapshotOrThrow(
|
||||
playlistId: string
|
||||
): XtreamPendingRestoreSnapshot | null {
|
||||
const state = this.getOrThrow(playlistId);
|
||||
if (!state) {
|
||||
this.restoreRevisions.delete(playlistId);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
getXtreamPendingRestoreStorageKey(playlistId),
|
||||
JSON.stringify(normalizeXtreamPendingRestoreState(state))
|
||||
);
|
||||
} catch {
|
||||
// Ignore local storage write failures.
|
||||
const serializedState = JSON.stringify(state);
|
||||
let revision = this.restoreRevisions.get(playlistId);
|
||||
if (!revision || revision.serializedState !== serializedState) {
|
||||
revision = {
|
||||
revision: ++this.nextRestoreRevision,
|
||||
serializedState,
|
||||
};
|
||||
this.restoreRevisions.set(playlistId, revision);
|
||||
this.lastConsumedRevisions.delete(playlistId);
|
||||
}
|
||||
|
||||
return {
|
||||
playlistId,
|
||||
revision: revision.revision,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
set(
|
||||
playlistId: string,
|
||||
state: XtreamPendingRestoreState
|
||||
): XtreamPendingRestoreSnapshot | null {
|
||||
if (!playlistId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedState = normalizeXtreamPendingRestoreState(state);
|
||||
const serializedState = JSON.stringify(normalizedState);
|
||||
|
||||
try {
|
||||
const storageKey = getXtreamPendingRestoreStorageKey(playlistId);
|
||||
localStorage.setItem(storageKey, serializedState);
|
||||
if (localStorage.getItem(storageKey) !== serializedState) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revision = ++this.nextRestoreRevision;
|
||||
this.restoreRevisions.set(playlistId, {
|
||||
revision,
|
||||
serializedState,
|
||||
});
|
||||
this.lastConsumedRevisions.delete(playlistId);
|
||||
return {
|
||||
playlistId,
|
||||
revision,
|
||||
state: normalizedState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes state-bound restore consumers for one playlist. Consumers of
|
||||
* the same revision coalesce after the first succeeds, while a newer
|
||||
* revision remains parked for the importer whose content generation is
|
||||
* ready to accept it.
|
||||
*/
|
||||
async applyAndConsume(
|
||||
playlistId: string,
|
||||
expectedSnapshot: XtreamPendingRestoreSnapshot,
|
||||
apply: (state: XtreamPendingRestoreState) => Promise<void>
|
||||
): Promise<XtreamPendingRestoreApplicationResult> {
|
||||
if (
|
||||
!playlistId ||
|
||||
expectedSnapshot.playlistId !== playlistId
|
||||
) {
|
||||
return 'consume-failed';
|
||||
}
|
||||
|
||||
return this.enqueueRestoreApplication(playlistId, async () => {
|
||||
const currentSnapshot = this.getSnapshotOrThrow(playlistId);
|
||||
if (!currentSnapshot) {
|
||||
return this.lastConsumedRevisions.get(playlistId) ===
|
||||
expectedSnapshot.revision
|
||||
? 'consumed'
|
||||
: 'superseded';
|
||||
}
|
||||
if (
|
||||
currentSnapshot.revision !== expectedSnapshot.revision
|
||||
) {
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
await apply(currentSnapshot.state);
|
||||
|
||||
const snapshotAfterApply =
|
||||
this.getSnapshotOrThrow(playlistId);
|
||||
if (
|
||||
!snapshotAfterApply ||
|
||||
snapshotAfterApply.revision !== expectedSnapshot.revision
|
||||
) {
|
||||
return 'superseded';
|
||||
}
|
||||
if (!this.clear(playlistId, snapshotAfterApply.state)) {
|
||||
return 'consume-failed';
|
||||
}
|
||||
|
||||
this.lastConsumedRevisions.set(
|
||||
playlistId,
|
||||
expectedSnapshot.revision
|
||||
);
|
||||
return 'consumed';
|
||||
});
|
||||
}
|
||||
|
||||
clear(
|
||||
@@ -77,10 +199,7 @@ export class XtreamPendingRestoreService {
|
||||
|
||||
if (
|
||||
!currentState ||
|
||||
JSON.stringify(currentState) !==
|
||||
JSON.stringify(
|
||||
normalizeXtreamPendingRestoreState(expectedState)
|
||||
)
|
||||
!this.restoreStatesEqual(currentState, expectedState)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -102,12 +221,46 @@ export class XtreamPendingRestoreService {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
if (localStorage.getItem(storageKey) === null) {
|
||||
this.restoreRevisions.delete(playlistId);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// A verified tombstone is sufficient even if removal fails.
|
||||
}
|
||||
|
||||
if (tombstoneStored) {
|
||||
this.restoreRevisions.delete(playlistId);
|
||||
}
|
||||
return tombstoneStored;
|
||||
}
|
||||
|
||||
private restoreStatesEqual(
|
||||
left: XtreamPendingRestoreState,
|
||||
right: XtreamPendingRestoreState
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify(normalizeXtreamPendingRestoreState(left)) ===
|
||||
JSON.stringify(normalizeXtreamPendingRestoreState(right))
|
||||
);
|
||||
}
|
||||
|
||||
private enqueueRestoreApplication<T>(
|
||||
playlistId: string,
|
||||
apply: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous =
|
||||
this.restoreApplicationTails.get(playlistId) ?? Promise.resolve();
|
||||
const result = previous.then(apply);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
this.restoreApplicationTails.set(playlistId, tail);
|
||||
void tail.then(() => {
|
||||
if (this.restoreApplicationTails.get(playlistId) === tail) {
|
||||
this.restoreApplicationTails.delete(playlistId);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user