fix(portals): serialize Xtream restore revisions

This commit is contained in:
4gray committed 2026-07-30 01:29:52 +02:00
1 parent 83fb06e79a
commit 9be009d809
13 files changed
+732 -79

No files matched your search

@@ -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(
@@ -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>
) {
@@ -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;
}
}