From f4663746d5e970e7a816fde56def171ffedcb85d Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 23:46:43 +0200 Subject: [PATCH] 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 --- docs/architecture/playlist-backup-restore.md | 5 +- docs/architecture/pwa-self-hosted.md | 16 ++++-- .../pwa-xtream-data-source.spec.ts | 55 +++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 15 +++++ ...playlist-backup.service.pwa-xtream.spec.ts | 34 +++++++++++- .../lib/pwa-xtream-backup-state.service.ts | 14 ++--- .../server-storage/server-api-client.spec.ts | 23 ++++++++ .../lib/server-storage/server-api-client.ts | 3 + .../server-user-data-storage.spec.ts | 14 +++-- .../server-user-data-storage.ts | 10 +++- libs/services/src/lib/user-data-storage.ts | 6 +- 11 files changed, 170 insertions(+), 25 deletions(-) diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 148d679e0..5c28aa9be 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -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 diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index e70af222b..3f73070e6 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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::`). 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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 21756bda8..acbc64034 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -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(); + 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((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, + }); + }); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index af61c2906..1a40dffb2 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -695,6 +695,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // ========================================================================= async getFavorites(playlistId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.userDataStorage.ready?.(); + } + private getPlaybackPositionsFromStorage(): Record< string, PlaybackPositionData[] @@ -975,6 +989,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // ========================================================================= async getRecentItems(playlistId: string): Promise { + await this.userDataRead(); await this.getCollectionItemsWithHydration( playlistId, ( diff --git a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts index f656edb1b..d593cb85e 100644 --- a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts @@ -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": {'); diff --git a/libs/services/src/lib/pwa-xtream-backup-state.service.ts b/libs/services/src/lib/pwa-xtream-backup-state.service.ts index e893281be..c3b1dfa74 100644 --- a/libs/services/src/lib/pwa-xtream-backup-state.service.ts +++ b/libs/services/src/lib/pwa-xtream-backup-state.service.ts @@ -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 { - 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 { + await this.ready(); // A user-supplied file: the field may be anything. const archived: unknown = entry.userState.collectionItems; const snapshots = snapshotsByKey( diff --git a/libs/services/src/lib/server-storage/server-api-client.spec.ts b/libs/services/src/lib/server-storage/server-api-client.spec.ts index 024128790..135ebfe4d 100644 --- a/libs/services/src/lib/server-storage/server-api-client.spec.ts +++ b/libs/services/src/lib/server-storage/server-api-client.spec.ts @@ -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((_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( diff --git a/libs/services/src/lib/server-storage/server-api-client.ts b/libs/services/src/lib/server-storage/server-api-client.ts index 9f6a27cba..a10ffb087 100644 --- a/libs/services/src/lib/server-storage/server-api-client.ts +++ b/libs/services/src/lib/server-storage/server-api-client.ts @@ -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 diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts b/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts index 78c94774d..46b797970 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts @@ -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); }); diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index ca8ed6c6d..0be7127fd 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -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 { - 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 { + await (this.hydrated ? undefined : this.hydration); + return this.hydrated; } /** Whether a write is queued, being retried, or still travelling. */ diff --git a/libs/services/src/lib/user-data-storage.ts b/libs/services/src/lib/user-data-storage.ts index f5710b482..29d4b91f8 100644 --- a/libs/services/src/lib/user-data-storage.ts +++ b/libs/services/src/lib/user-data-storage.ts @@ -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 }; +> & { keys?(): string[]; ready?(): Promise }; /** * `localStorage` behind an adapter that reads the global only inside each