fix(server-storage): Xtream reads and backups wait for the first read; keepalive deadline

Startup waits for the first user-data read only a few seconds. ready()
now waits for a first read in flight without starting one (an offline
page is not held up) and tells whether one succeeded:

- PwaXtreamDataSource awaits it before reading favorites, recent items
  and playback positions, so a new device whose /user-state read is slow
  does not show empty lists or start playback without its resume point.
- A PWA backup export and restore fail while no read succeeded; the
  restore stays parked, since its replacement lists would otherwise
  merge into the server's (an empty favorites list changed nothing).

A page-hide keepalive write gets the request timeout: a server that
takes it and never answers no longer holds every later write, re-read
and delete behind it until reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 23:46:43 +02:00
1 parent ed956a454c
commit f4663746d5
11 files changed
+170 -25

No files matched your search

@@ -953,5 +953,60 @@ describe('PwaXtreamDataSource', () => {
);
expect(localStorage.getItem('xtream-favorites')).toBeNull();
});
it('reads positions once the first server read settled', async () => {
const values = new Map<string, string>();
let firstRead = (): void => undefined;
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
// Server mode: the server's data lands in the cache later.
ready: () =>
new Promise<boolean>((resolve) => {
firstRead = () => {
values.set(
'xtream-playback-positions',
JSON.stringify({
p1: [
{
contentXtreamId: 501,
contentType: 'vod',
positionSeconds: 120,
},
],
})
);
resolve(true);
};
}),
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{ provide: USER_DATA_STORAGE, useValue: storage },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamApiService, useValue: apiService },
{ provide: PlaylistsService, useValue: playlistsService },
],
});
const source = TestBed.inject(PwaXtreamDataSource);
const position = source.getPlaybackPosition('p1', 501, 'vod');
// The read is still running while other work goes on.
for (let turn = 0; turn < 5; turn += 1) {
await Promise.resolve();
}
firstRead();
await expect(position).resolves.toMatchObject({
positionSeconds: 120,
});
});
});
});
@@ -695,6 +695,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// =========================================================================
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
await this.userDataRead();
await this.getCollectionItemsWithHydration(
playlistId,
this.collectionStorage.getFavoritesFromStorage()[playlistId] || []
@@ -820,6 +821,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null> {
await this.userDataRead();
const allPositions = this.getPlaybackPositionsFromStorage();
const playlistPositions = allPositions[playlistId] || [];
@@ -836,6 +838,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]> {
await this.userDataRead();
const allPositions = this.getPlaybackPositionsFromStorage();
const playlistPositions = allPositions[playlistId] || [];
@@ -850,6 +853,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
limit?: number
): Promise<PlaybackPositionData[]> {
await this.userDataRead();
const allPositions = this.getPlaybackPositionsFromStorage();
const playlistPositions = allPositions[playlistId] || [];
@@ -866,6 +870,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
await this.userDataRead();
const allPositions = this.getPlaybackPositionsFromStorage();
return allPositions[playlistId] || [];
}
@@ -941,6 +946,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.savePlaybackPositionsToStorage(allPositions);
}
/**
* Server mode: favorites, history and positions show the server's data
* once its first read settled, which startup does not wait for beyond a
* few seconds. A read before it would show a new device empty lists.
*/
private async userDataRead(): Promise<void> {
await this.userDataStorage.ready?.();
}
private getPlaybackPositionsFromStorage(): Record<
string,
PlaybackPositionData[]
@@ -975,6 +989,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// =========================================================================
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
await this.userDataRead();
await this.getCollectionItemsWithHydration(
playlistId,
(
@@ -272,7 +272,10 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
it('waits for server user data before it reads the lists', async () => {
// Server mode: the maps hold the server's data once it was read.
const ready = jest.fn(async () => seed());
const ready = jest.fn(async () => {
seed();
return true;
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
@@ -296,7 +299,7 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
getItem: () => null,
setItem: jest.fn(),
removeItem: jest.fn(),
ready: () => Promise.reject(new Error('offline')),
ready: async () => false,
}),
});
@@ -507,6 +510,33 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
});
});
it('keeps the restore parked until server user data was read', async () => {
const manifest = await exportSeeded();
localStorage.clear();
localStorage.setItem(
KEYS.favorites,
JSON.stringify({ 'xtream-1': ['live:1'] })
);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
ready: async () => false,
}),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
// Nothing merged into lists the server may still replace.
expect(read(KEYS.favorites)).toEqual({ 'xtream-1': ['live:1'] });
expect(
pendingRestore(service).getOrThrow('xtream-1')
).not.toBeNull();
});
it('replaces a damaged title cache but refuses an unreadable storage', async () => {
const manifest = await exportSeeded();
localStorage.setItem(KEYS.collectionItems, '{"xtream-1": {');
@@ -32,16 +32,15 @@ export class PwaXtreamBackupStateService {
private readonly storage = inject(USER_DATA_STORAGE);
/**
* Before an export: in server mode the maps show the server's data only
* after the first read, which may still run or have failed at start,
* and an unread map would be written as empty lists.
* Before an export or a restore: in server mode the maps show the
* server's data only after the first read, which may still run or have
* failed at start. An export would write unread lists as empty, and a
* restore's replacement lists would merge into the server's instead.
*/
async ready(): Promise<void> {
try {
await this.storage.ready?.();
} catch {
if ((await this.storage.ready?.()) === false) {
throw new Error(
"The server's user data could not be read; the backup would omit it."
"The server's user data could not be read yet; try again once the server answers."
);
}
}
@@ -118,6 +117,7 @@ export class PwaXtreamBackupStateService {
state: XtreamPendingRestoreState,
entry: XtreamPlaylistBackupEntry
): Promise<void> {
await this.ready();
// A user-supplied file: the field may be anything.
const archived: unknown = entry.userState.collectionItems;
const snapshots = snapshotsByKey(
@@ -85,6 +85,29 @@ describe('ServerApiClient', () => {
expect(status.reportOffline).not.toHaveBeenCalled();
});
it('gives up on a keepalive write the server never answers', async () => {
const hanging = jest.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('Timed out', 'TimeoutError'))
);
})
);
const slow = new ServerApiClient({
baseUrl: '/api',
status,
fetchFn: hanging as unknown as typeof fetch,
accessToken: () => null,
timeoutMs: 10,
});
// Later writes queue behind it: it must end, as a failed one.
await expect(
slow.sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
).resolves.toEqual({ status: 'failed' });
});
it('reports how a keepalive write ended', async () => {
const ok = jest.fn().mockResolvedValue(response(200, { tag: 't2' }));
await expect(
@@ -196,6 +196,9 @@ export class ServerApiClient {
: { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
// A server that takes the request and never answers would
// hold every later write behind it.
signal: this.timeoutSignal(),
});
if (response.ok) {
const body = (await response
@@ -577,14 +577,20 @@ describe('ServerUserDataStorage', () => {
expect(local.values.get('iptvnator-server:cache-owner')).toBe('new-db');
});
it('is ready once it read the server, and not while it cannot', async () => {
it('tells, once a first read in flight settled, whether it read the server', async () => {
const { api, storage } = setup();
api.getJson.mockRejectedValueOnce(new Error('offline'));
await expect(storage.ready()).rejects.toThrow('offline');
await storage.ready();
await storage.ready();
const failed = storage.hydrate().catch(() => undefined);
await expect(storage.ready()).resolves.toBe(false);
await failed;
// It starts no read of its own: an offline page is not held up.
expect(api.getJson).toHaveBeenCalledTimes(1);
const read = storage.hydrate();
await expect(storage.ready()).resolves.toBe(true);
await read;
await expect(storage.ready()).resolves.toBe(true);
expect(api.getJson).toHaveBeenCalledTimes(2);
});
@@ -212,9 +212,13 @@ export class ServerUserDataStorage implements UserDataStorage {
return !this.hydrated || this.hasUnsentWrites();
}
/** Reads show the server's data: read once, or rejects. */
ready(): Promise<void> {
return this.hydrated ? Promise.resolve() : this.hydrate();
/**
* Once a first read in flight settled: whether reads show the server's
* data. Starts no read, so an offline page is not held up.
*/
async ready(): Promise<boolean> {
await (this.hydrated ? undefined : this.hydration);
return this.hydrated;
}
/** Whether a write is queued, being retried, or still travelling. */
+3 -3
View File
@@ -3,13 +3,13 @@ import { InjectionToken } from '@angular/core';
/**
* The slice of the Web Storage API that PWA user-data stores rely on, plus
* the stored keys where the store can list them, and `ready` where reads
* show a source's data only once it was read (server mode): it rejects
* when that read fails.
* show a source's data only once it was read (server mode): it waits for
* a first read in flight and tells whether one succeeded.
*/
export type UserDataStorage = Pick<
Storage,
'getItem' | 'setItem' | 'removeItem'
> & { keys?(): string[]; ready?(): Promise<void> };
> & { keys?(): string[]; ready?(): Promise<boolean> };
/**
* `localStorage` behind an adapter that reads the global only inside each