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

+3 -2
View File
@@ -253,8 +253,9 @@ self-hosted server storage is covered too.
- An unreadable map (storage error, malformed JSON, a playlist entry that is
not a list) fails the export instead of being written as an empty list.
So does server mode before its first read of the server's user data:
the export waits for that read (`UserDataStorage.ready`) and fails when
it does not succeed.
the export waits for a read in flight (`UserDataStorage.ready`) and fails
when none succeeded. A restore does the same and stays parked, since its
replacement lists would otherwise merge into the server's.
- A pending restore snapshot still wins over the stored maps, as on desktop.
- Import parks the restore state like the desktop does, then applies it at
once through `XtreamPendingRestoreService.applyAndConsume`: the browser has
+12 -4
View File
@@ -226,7 +226,9 @@ settings and guards read them in every build.
the caller read: when the cache changed since (another tab, a re-read),
only the entries the caller changed are applied to it. Batches go out one after another
(the page-hide flush waits for a batch still travelling), a failed write,
including a refused `keepalive` one, stays queued for the next flush, and
including a refused `keepalive` one or one the server took and never
answered (it ends after the request timeout), stays queued for the next
flush, and
every unconfirmed write (queued or travelling) is saved in `localStorage`
per page load (`iptvnator-server:unsent-writes:<instanceId>:<page>`). The
tab remembers its page load in `sessionStorage`, so a reload takes its
@@ -249,9 +251,15 @@ settings and guards read them in every build.
newer value. Tabs never clear each other's.
Cache and saved writes belong to one server database
(`iptvnator-server:cache-owner`); another one behind the same address
starts clean. A backup export waits for the first read
(`UserDataStorage.ready`) and fails if it cannot happen: maps that never
read the server would export as empty lists. Also,
starts clean. Startup waits for the first read only a few seconds;
`UserDataStorage.ready()` waits for a first read in flight (it starts
none, so an offline page is not held up) and tells whether one
succeeded. The Xtream data source's favorites, history and position
reads wait for it, so a new device does not show empty lists or start
playback without its resume position. A backup export or restore fails
while no read succeeded: unread maps would export as empty lists, and a
restore's lists would merge into the server's instead of replacing them
(the restore stays parked). Also,
re-reads run one at a time and keep entries edited while they were in
flight or not yet saved. A response body cut off after its headers counts
as offline. Each write is sent on the tag of the entry it was made
@@ -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