mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read
The user-data cache is keyed by server instance (`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on the old backend after BACKEND_URL changed no longer writes over the new server's cache, which the new tab's next edit would have sent there. claimCache also removes the old owner's keys and the unscoped keys of earlier builds. touch(), which runs every 30 s, adopts closed tabs' saved writes once this tab has read: a tab with nothing to sync never re-reads, so they waited until a tab switch or reload. UserDataStorage gains an optional ready(); the server storage resolves it after its first read and rejects while that fails. A PWA backup export awaits it, so a fresh browser whose /user-state read is slow or failed cannot export empty favorites, history and positions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9e5ee97d7
commit
fdaa2b5aa0
12 files changed
+199
-30
No files matched your search
@@ -252,6 +252,9 @@ self-hosted server storage is covered too.
|
||||
browser tracks neither.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -217,8 +217,10 @@ settings and guards read them in every build.
|
||||
keeps its copy only when `X-Replaced-ETag` names the version its payload
|
||||
came from.
|
||||
- **Xtream user data.** `USER_DATA_STORAGE` becomes `ServerUserDataStorage`:
|
||||
the four synced namespaces are cached under the `iptvnator-server:` key
|
||||
prefix (the legacy unprefixed keys are never written in server mode) and
|
||||
the four synced namespaces are cached per server instance, under
|
||||
`iptvnator-server:cache:<instanceId>:<namespace>` (the legacy unprefixed
|
||||
keys are never written in server mode; a tab still open on another
|
||||
server never writes over this one's cache) and
|
||||
each change sends only the playlist entries that differ, debounced, with a
|
||||
`keepalive` flush when the page hides. A change is compared with the map
|
||||
the caller read: when the cache changed since (another tab, a re-read),
|
||||
@@ -238,15 +240,18 @@ settings and guards read them in every build.
|
||||
tab's: its edits go ahead of this page's newer ones, and without a
|
||||
server version this page read they are sent so that they conflict and
|
||||
replay; the writes of a tab closed before it
|
||||
could send them are queued, ahead of its own, by the next re-read in
|
||||
another tab once that tab's record has gone stale; the result replays the
|
||||
could send them are queued, ahead of its own, by another tab once that
|
||||
tab's record has gone stale (at its next re-read, or its 30 s check when
|
||||
it has nothing to sync and never re-reads); the result replays the
|
||||
closed tab's edits first and this tab's newer ones last. An adopted edit
|
||||
changes only what is still as its page saw it (`EditStep.yields`): an
|
||||
item or key changed since, by this tab or another device, keeps its
|
||||
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. Also,
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -269,6 +269,41 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
|
||||
|
||||
await expect(service.exportBackup()).rejects.toThrow('denied');
|
||||
});
|
||||
|
||||
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 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,
|
||||
}),
|
||||
});
|
||||
|
||||
const { manifest } = await service.exportBackup();
|
||||
|
||||
expect(ready).toHaveBeenCalledTimes(1);
|
||||
expect(xtreamEntry(manifest).userState.favorites).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses to export server user data it could not read', async () => {
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: () => null,
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
ready: () => Promise.reject(new Error('offline')),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.exportBackup()).rejects.toThrow(
|
||||
/user data could not be read/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import', () => {
|
||||
|
||||
@@ -91,6 +91,9 @@ export class PlaylistBackupService {
|
||||
'The parental lock store could not be read; the backup would omit its locks.'
|
||||
);
|
||||
}
|
||||
if (!this.runtime.supportsXtreamSqliteDataSource) {
|
||||
await this.pwaXtream.ready();
|
||||
}
|
||||
const playlists = await firstValueFrom(
|
||||
this.playlistsService.getAllData()
|
||||
);
|
||||
|
||||
@@ -31,6 +31,21 @@ const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series'];
|
||||
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.
|
||||
*/
|
||||
async ready(): Promise<void> {
|
||||
try {
|
||||
await this.storage.ready?.();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"The server's user data could not be read; the backup would omit it."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry with the playlist's stored lists and snapshots filled in;
|
||||
* hidden categories and pins, which the browser build does not track,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ServerUserStateNamespace,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import { cacheKey } from './server-user-data-cache';
|
||||
|
||||
/**
|
||||
* User-data writes the server has not confirmed, kept in `localStorage`
|
||||
@@ -49,7 +50,11 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
|
||||
return;
|
||||
}
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
// Also the unscoped keys of earlier builds.
|
||||
local.removeItem(`iptvnator-server:${namespace}`);
|
||||
if (owner) {
|
||||
local.removeItem(cacheKey(owner, namespace));
|
||||
}
|
||||
}
|
||||
if (owner) {
|
||||
for (const prefix of PAGE_PREFIXES) {
|
||||
|
||||
@@ -6,7 +6,18 @@ import type { UserDataStorage } from '../user-data-storage';
|
||||
import { parseMap } from './server-user-data-entries';
|
||||
|
||||
/** Server-mode cache keys; the legacy unprefixed keys are never touched. */
|
||||
export const SERVER_CACHE_PREFIX = 'iptvnator-server:';
|
||||
export const SERVER_CACHE_PREFIX = 'iptvnator-server:cache';
|
||||
|
||||
/**
|
||||
* One namespace's cache of one server instance: a tab still open on
|
||||
* another server (a changed backend URL) never writes over it.
|
||||
*/
|
||||
export function cacheKey(
|
||||
instanceId: string,
|
||||
namespace: ServerUserStateNamespace
|
||||
): string {
|
||||
return `${SERVER_CACHE_PREFIX}:${instanceId}:${namespace}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The synced namespaces as this browser shows them: in browser storage,
|
||||
@@ -16,7 +27,10 @@ export const SERVER_CACHE_PREFIX = 'iptvnator-server:';
|
||||
export class UserDataCache {
|
||||
private readonly overflow = new Map<ServerUserStateNamespace, string>();
|
||||
|
||||
constructor(private readonly local: UserDataStorage) {}
|
||||
constructor(
|
||||
private readonly local: UserDataStorage,
|
||||
private readonly instanceId: string
|
||||
) {}
|
||||
|
||||
read(namespace: ServerUserStateNamespace): string | null {
|
||||
const kept = this.overflow.get(namespace);
|
||||
@@ -24,7 +38,7 @@ export class UserDataCache {
|
||||
return kept;
|
||||
}
|
||||
try {
|
||||
return this.local.getItem(SERVER_CACHE_PREFIX + namespace);
|
||||
return this.local.getItem(cacheKey(this.instanceId, namespace));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -33,10 +47,11 @@ export class UserDataCache {
|
||||
/** `null` empties the namespace. */
|
||||
write(namespace: ServerUserStateNamespace, value: string | null): void {
|
||||
try {
|
||||
const key = cacheKey(this.instanceId, namespace);
|
||||
if (value === null) {
|
||||
this.local.removeItem(SERVER_CACHE_PREFIX + namespace);
|
||||
this.local.removeItem(key);
|
||||
} else {
|
||||
this.local.setItem(SERVER_CACHE_PREFIX + namespace, value);
|
||||
this.local.setItem(key, value);
|
||||
}
|
||||
this.overflow.delete(namespace);
|
||||
} catch {
|
||||
|
||||
@@ -25,7 +25,8 @@ import { newestWrites } from './server-unsent-writes';
|
||||
|
||||
const FLUSH_DELAY_MS = 500;
|
||||
|
||||
type ServerApi = Pick<
|
||||
/** What the queue needs of the API client. */
|
||||
export type ServerApi = Pick<
|
||||
ServerApiClient,
|
||||
'readJson' | 'sendJson' | 'request' | 'sendKeepalive'
|
||||
>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { KeepaliveOutcome } from './server-api-client';
|
||||
import { ServerUserDataStorage } from './server-user-data-storage';
|
||||
|
||||
const FAVORITES_CACHE = 'iptvnator-server:cache:default:xtream-favorites';
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
@@ -66,9 +68,7 @@ describe('ServerUserDataStorage', () => {
|
||||
await storage.hydrate();
|
||||
|
||||
expect(storage.getItem('xtream-favorites')).toBe('{"p1":["live:1"]}');
|
||||
expect(local.values.get('iptvnator-server:xtream-favorites')).toBe(
|
||||
'{"p1":["live:1"]}'
|
||||
);
|
||||
expect(local.values.get(FAVORITES_CACHE)).toBe('{"p1":["live:1"]}');
|
||||
expect(local.values.has('xtream-favorites')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -351,7 +351,7 @@ describe('ServerUserDataStorage', () => {
|
||||
const { api, local, storage } = setup();
|
||||
// The cache from the last visit; no read from the server yet.
|
||||
local.setItem(
|
||||
'iptvnator-server:xtream-favorites',
|
||||
FAVORITES_CACHE,
|
||||
JSON.stringify({ p1: ['a'], p2: ['b'] })
|
||||
);
|
||||
|
||||
@@ -445,7 +445,7 @@ describe('ServerUserDataStorage', () => {
|
||||
});
|
||||
const setItem = local.setItem;
|
||||
local.setItem = (key: string, value: string) => {
|
||||
if (key === 'iptvnator-server:xtream-favorites') {
|
||||
if (key === FAVORITES_CACHE) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
@@ -535,6 +535,11 @@ describe('ServerUserDataStorage', () => {
|
||||
it('drops cache and saved writes of another server database', () => {
|
||||
const local = memoryStorage();
|
||||
local.setItem('iptvnator-server:cache-owner', 'old-db');
|
||||
local.setItem(
|
||||
'iptvnator-server:cache:old-db:xtream-favorites',
|
||||
'{"p1":["a"]}'
|
||||
);
|
||||
// An earlier build's unscoped key.
|
||||
local.setItem('iptvnator-server:xtream-favorites', '{"p1":["a"]}');
|
||||
local.setItem(
|
||||
'iptvnator-server:unconfirmed:old-db:tab',
|
||||
@@ -562,6 +567,9 @@ describe('ServerUserDataStorage', () => {
|
||||
expect(local.values.has('iptvnator-server:xtream-favorites')).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
local.values.has('iptvnator-server:cache:old-db:xtream-favorites')
|
||||
).toBe(false);
|
||||
expect(savedWrites(local, 'old-db')).toEqual([]);
|
||||
expect(
|
||||
local.values.has('iptvnator-server:unconfirmed:old-db:tab')
|
||||
@@ -569,6 +577,40 @@ 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 () => {
|
||||
const { api, storage } = setup();
|
||||
api.getJson.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
await expect(storage.ready()).rejects.toThrow('offline');
|
||||
await storage.ready();
|
||||
await storage.ready();
|
||||
|
||||
expect(api.getJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never writes over another server instance's cache", async () => {
|
||||
const local = memoryStorage();
|
||||
const tab = (instanceId: string, favorites: unknown) =>
|
||||
new ServerUserDataStorage(
|
||||
{
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': favorites },
|
||||
}),
|
||||
} as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
instanceId
|
||||
);
|
||||
// The tab of the old backend stays open after a new one started.
|
||||
const old = tab('old-db', { p1: ['old'] });
|
||||
const current = tab('new-db', { p1: ['new'] });
|
||||
await current.hydrate();
|
||||
|
||||
await old.hydrate();
|
||||
|
||||
expect(current.getItem('xtream-favorites')).toBe('{"p1":["new"]}');
|
||||
});
|
||||
|
||||
it('keeps a newer travelling write when an older one is answered', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
await storage.hydrate();
|
||||
@@ -688,14 +730,14 @@ describe('ServerUserDataStorage', () => {
|
||||
|
||||
it('keeps server favorites through a cache-full first read and a later edit', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
local.setItem('iptvnator-server:xtream-favorites', '{"p1":["A"]}');
|
||||
local.setItem(FAVORITES_CACHE, '{"p1":["A"]}');
|
||||
storage.setItem('xtream-favorites', '{"p1":["A","B"]}');
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: { 'xtream-favorites': { p1: ['S'] } },
|
||||
});
|
||||
const setItem = local.setItem;
|
||||
local.setItem = (key: string, value: string) => {
|
||||
if (key === 'iptvnator-server:xtream-favorites') {
|
||||
if (key === FAVORITES_CACHE) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
refreshedNamespace,
|
||||
withoutDeleted,
|
||||
} from './server-user-data-edit';
|
||||
import { UserDataWriteQueue } from './server-user-data-queue';
|
||||
import { type ServerApi, UserDataWriteQueue } from './server-user-data-queue';
|
||||
import {
|
||||
claimCache,
|
||||
markClosed,
|
||||
@@ -37,13 +37,6 @@ import {
|
||||
swapPage,
|
||||
} from './server-unsent-writes';
|
||||
|
||||
export { SERVER_CACHE_PREFIX } from './server-user-data-cache';
|
||||
|
||||
type ServerApi = Pick<
|
||||
ServerApiClient,
|
||||
'getJson' | 'readJson' | 'sendJson' | 'request' | 'sendKeepalive'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Write-through `USER_DATA_STORAGE` for server mode. Synced namespaces are
|
||||
* maps keyed by playlist id; a write compares the new map with the one the
|
||||
@@ -86,7 +79,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
private hydration: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly api: ServerApi,
|
||||
private readonly api: ServerApi & Pick<ServerApiClient, 'getJson'>,
|
||||
private readonly local: UserDataStorage,
|
||||
onWriteFailure: (error: unknown) => void,
|
||||
/** Scopes the cache and saved writes to one server database. */
|
||||
@@ -97,7 +90,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
*/
|
||||
tabStore: UserDataStorage = local
|
||||
) {
|
||||
this.cache = new UserDataCache(local);
|
||||
this.cache = new UserDataCache(local, instanceId);
|
||||
this.writes = new UserDataWriteQueue(api, {
|
||||
changed: () => this.saveUnsent(),
|
||||
show: (namespace, playlistId, value) =>
|
||||
@@ -204,6 +197,11 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
this.awaited = null;
|
||||
this.adoptPage(page);
|
||||
}
|
||||
// A tab with nothing to sync never re-reads, which is where closed
|
||||
// tabs' saved writes are otherwise picked up.
|
||||
if (this.hydrated) {
|
||||
this.adoptClosedTabs();
|
||||
}
|
||||
if (this.hasUnsentWrites()) {
|
||||
this.publishUnconfirmed();
|
||||
}
|
||||
@@ -214,6 +212,11 @@ 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();
|
||||
}
|
||||
|
||||
/** Whether a write is queued, being retried, or still travelling. */
|
||||
hasUnsentWrites(): boolean {
|
||||
return this.writes.hasUnsent() || this.deferred.size > 0;
|
||||
|
||||
@@ -681,6 +681,46 @@ describe('ServerUserDataStorage with other devices', () => {
|
||||
expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30));
|
||||
});
|
||||
|
||||
it("sends a closed tab's saved write from an idle tab", async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
|
||||
// Read, and with nothing to sync it never reads again.
|
||||
const idle = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await idle.hydrate();
|
||||
const closed = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(new ServerApiError(0, 'network', 'offline'))
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await closed.hydrate();
|
||||
closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await closed.flush();
|
||||
closed.close();
|
||||
|
||||
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
|
||||
expect(idle.needsSync()).toBe(false);
|
||||
idle.touch();
|
||||
await idle.flush();
|
||||
await idle.flush();
|
||||
clock.mockRestore();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
|
||||
});
|
||||
|
||||
it("keeps progress this tab saved over a closed tab's older edit", async () => {
|
||||
const at = (seconds: number) => [
|
||||
{
|
||||
|
||||
@@ -2,12 +2,14 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
export type UserDataStorage = Pick<
|
||||
Storage,
|
||||
'getItem' | 'setItem' | 'removeItem'
|
||||
> & { keys?(): string[] };
|
||||
> & { keys?(): string[]; ready?(): Promise<void> };
|
||||
|
||||
/**
|
||||
* `localStorage` behind an adapter that reads the global only inside each
|
||||
|
||||
Reference in new issue
Block a user