mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
1 parent
ed956a454c
commit
f4663746d5
11 files changed
+170
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in new issue
Block a user