mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases
- Unconfirmed writes and early edits are kept in the tab's own sessionStorage, so tabs never clear each other's; a shared registry of unconfirmed entries (one record per tab, ignored after ten minutes) keeps a re-read in one tab from replacing another tab's values. - A cache value too big for localStorage stays readable from memory, so later edits are diffed against what the app reads. - Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so a reference not yet rewritten still resolves and a retry can type it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cedc468325
commit
f49ad59549
9 files changed
+292
-74
No files matched your search
@@ -201,8 +201,9 @@ settings and guards read them in every build.
|
||||
`keepalive` flush when the page hides. 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
|
||||
the page-hide flush saves every unconfirmed write (queued or travelling)
|
||||
under `iptvnator-server:unsent-writes:<instanceId>` for the next visit.
|
||||
every unconfirmed write (queued or travelling) is saved in the tab's own
|
||||
`sessionStorage` (`iptvnator-server:unsent-writes:<instanceId>`) and sent
|
||||
first after a reload; 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,
|
||||
@@ -212,9 +213,13 @@ settings and guards read them in every build.
|
||||
its headers counts as offline. Edits made before the first successful read
|
||||
are held as before → after and applied to the server's value once it
|
||||
arrives (`applyEdit`): a new device or a stale cache adds its favorite
|
||||
instead of replacing the server's list. They are saved too
|
||||
(`iptvnator-server:deferred-edits:<instanceId>`), and queued writes are
|
||||
saved as soon as they are queued, so a re-read in another tab keeps them. A re-read
|
||||
instead of replacing the server's list. They are saved in the tab too
|
||||
(`iptvnator-server:deferred-edits:<instanceId>`). A shared registry in
|
||||
`localStorage` (`iptvnator-server:unconfirmed:<instanceId>`, one record
|
||||
per tab, ignored after ten minutes) lets a re-read in one tab keep the
|
||||
cache values another tab is still sending. A value too big for
|
||||
`localStorage` stays readable from memory, so callers and the diff
|
||||
baseline agree. A re-read
|
||||
updates the cache; views already open show another device's changes on
|
||||
their next load (no live push). Start-up waits
|
||||
up to 3 s for the first read.
|
||||
@@ -246,7 +251,8 @@ settings and guards read them in every build.
|
||||
URL; older ones by a channel id that is random per import and is
|
||||
translated through the URL; nothing is dropped), and the legacy Xtream maps follow under
|
||||
the server id; each copy's bare legacy Xtream ids are typed from its own
|
||||
snapshots first (`typeLegacyXtreamRefs`), then `combineUserData` unions
|
||||
snapshots first (`typeLegacyXtreamRefs`; snapshots keep their bare key as
|
||||
an alias), then `combineUserData` unions
|
||||
lists (playback positions by content type and Xtream id, renamed to the
|
||||
server playlist) and merges maps, the server's value winning on a
|
||||
conflict. Parental locks stay in the browser but move to the server
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('typeLegacyXtreamRefs', () => {
|
||||
'xtream-favorites': ['live:42', 'movie:7', 99],
|
||||
'xtream-recent-items': [{ id: 'live:42', viewedAt: 't' }],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'live', title: 'News' },
|
||||
'live:42': { xtream_id: 42, type: 'live', title: 'News' },
|
||||
'movie:7': { xtream_id: 7, type: 'movie' },
|
||||
},
|
||||
@@ -48,6 +49,8 @@ describe('typeLegacyXtreamRefs', () => {
|
||||
browser['xtream-collection-items'],
|
||||
])
|
||||
).toEqual({
|
||||
// The server's own bare alias wins its key; both typed keys stay.
|
||||
'42': { xtream_id: 42, type: 'movie' },
|
||||
'movie:42': { xtream_id: 42, type: 'movie' },
|
||||
'live:42': { xtream_id: 42, type: 'live' },
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@ const SNAPSHOTS = 'xtream-collection-items';
|
||||
* repeats across live, movie and series. Before two copies are combined,
|
||||
* each one's bare ids become typed keys (`live:42`) using that copy's own
|
||||
* collection snapshots, the same evidence the PWA reads them with; ids
|
||||
* without a snapshot stay as they are.
|
||||
* without a snapshot stay as they are. Snapshots gain the typed key and
|
||||
* keep the bare one.
|
||||
*/
|
||||
export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
|
||||
const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {};
|
||||
@@ -45,12 +46,16 @@ export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
|
||||
);
|
||||
}
|
||||
if (isRecord(data[SNAPSHOTS])) {
|
||||
const rekeyed: Record<string, unknown> = {};
|
||||
// The bare key stays as an alias: a reference not yet rewritten (a
|
||||
// failed write, an older client) still finds its snapshot, and a
|
||||
// retry can type it again.
|
||||
const aliased: Record<string, unknown> = { ...snapshots };
|
||||
for (const [key, snapshot] of Object.entries(snapshots)) {
|
||||
rekeyed[String(/^\d+$/.test(key) ? typed(Number(key)) : key)] =
|
||||
snapshot;
|
||||
if (/^\d+$/.test(key)) {
|
||||
aliased[String(typed(Number(key)))] = snapshot;
|
||||
}
|
||||
}
|
||||
result[SNAPSHOTS] = rekeyed;
|
||||
result[SNAPSHOTS] = aliased;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
SERVER_SIGN_IN_PATH,
|
||||
ServerStorageStatusService,
|
||||
} from '../server-storage-status.service';
|
||||
import { createBrowserUserDataStorage } from '../user-data-storage';
|
||||
import {
|
||||
createBrowserSessionStorage,
|
||||
createBrowserUserDataStorage,
|
||||
} from '../user-data-storage';
|
||||
import { ServerApiClient, type ServerApiError } from './server-api-client';
|
||||
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
@@ -43,7 +46,8 @@ export class ServerStorageSession {
|
||||
// Lazy: a browser that denies storage must not stop the app here.
|
||||
createBrowserUserDataStorage(),
|
||||
() => this.announceWriteFailure(),
|
||||
this.config.instance.instanceId ?? undefined
|
||||
this.config.instance.instanceId ?? undefined,
|
||||
createBrowserSessionStorage()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
markUnconfirmed,
|
||||
othersUnconfirmed,
|
||||
UNCONFIRMED_TTL_MS,
|
||||
} from './server-unsent-writes';
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
describe('unconfirmed entries across tabs', () => {
|
||||
it('lists other live tabs only and lets a tab clear just its record', () => {
|
||||
const local = memoryStorage();
|
||||
markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 1_000);
|
||||
markUnconfirmed(local, 'db', 'b', ['xtream-favorites|p2'], 1_000);
|
||||
|
||||
expect([...othersUnconfirmed(local, 'db', 'a', 2_000)]).toEqual([
|
||||
'xtream-favorites|p2',
|
||||
]);
|
||||
|
||||
markUnconfirmed(local, 'db', 'b', [], 3_000);
|
||||
expect([...othersUnconfirmed(local, 'db', 'b', 3_000)]).toEqual([
|
||||
'xtream-favorites|p1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores the record of a tab that is gone', () => {
|
||||
const local = memoryStorage();
|
||||
markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 0);
|
||||
|
||||
expect(
|
||||
othersUnconfirmed(local, 'db', 'b', UNCONFIRMED_TTL_MS + 1).size
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,16 @@ export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes';
|
||||
export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner';
|
||||
|
||||
const writesKey = (instanceId: string) => `${UNSENT_WRITES_KEY}:${instanceId}`;
|
||||
/**
|
||||
* Which entries each open tab has not had confirmed yet, so a re-read in
|
||||
* one tab keeps the cache values another tab is still sending. A record
|
||||
* older than `UNCONFIRMED_TTL_MS` belongs to a tab that is gone.
|
||||
*/
|
||||
export const UNCONFIRMED_KEY = 'iptvnator-server:unconfirmed';
|
||||
export const UNCONFIRMED_TTL_MS = 10 * 60 * 1000;
|
||||
const unconfirmedKey = (instanceId: string) =>
|
||||
`${UNCONFIRMED_KEY}:${instanceId}`;
|
||||
|
||||
/** Edits made before the first read of a visit, kept as before → after. */
|
||||
export const DEFERRED_EDITS_KEY = 'iptvnator-server:deferred-edits';
|
||||
const deferredKey = (instanceId: string) =>
|
||||
@@ -36,6 +46,7 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
|
||||
local.removeItem(`iptvnator-server:${namespace}`);
|
||||
}
|
||||
if (owner) {
|
||||
local.removeItem(unconfirmedKey(owner));
|
||||
local.removeItem(writesKey(owner));
|
||||
local.removeItem(deferredKey(owner));
|
||||
}
|
||||
@@ -194,3 +205,69 @@ export function saveDeferredEdits(
|
||||
// Storage unavailable or full: the edits stay in memory only.
|
||||
}
|
||||
}
|
||||
|
||||
type UnconfirmedRegistry = Record<string, { at: number; keys: string[] }>;
|
||||
|
||||
function readRegistry(
|
||||
local: UserDataStorage,
|
||||
instanceId: string
|
||||
): UnconfirmedRegistry {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
local.getItem(unconfirmedKey(instanceId)) ?? '{}'
|
||||
);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as UnconfirmedRegistry)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces only this tab's record. */
|
||||
export function markUnconfirmed(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
tabId: string,
|
||||
keys: readonly string[],
|
||||
now = Date.now()
|
||||
): void {
|
||||
const registry = readRegistry(local, instanceId);
|
||||
if (keys.length === 0) {
|
||||
if (!(tabId in registry)) {
|
||||
return;
|
||||
}
|
||||
delete registry[tabId];
|
||||
} else {
|
||||
registry[tabId] = { at: now, keys: [...keys] };
|
||||
}
|
||||
try {
|
||||
local.setItem(unconfirmedKey(instanceId), JSON.stringify(registry));
|
||||
} catch {
|
||||
// Storage full: re-reads in other tabs lose this protection only.
|
||||
}
|
||||
}
|
||||
|
||||
/** Entry keys other live tabs have not had confirmed yet. */
|
||||
export function othersUnconfirmed(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
tabId: string,
|
||||
now = Date.now()
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const [owner, record] of Object.entries(
|
||||
readRegistry(local, instanceId)
|
||||
)) {
|
||||
if (
|
||||
owner !== tabId &&
|
||||
Array.isArray(record?.keys) &&
|
||||
now - Number(record.at) < UNCONFIRMED_TTL_MS
|
||||
) {
|
||||
for (const key of record.keys) {
|
||||
keys.add(String(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -408,7 +408,7 @@ describe('ServerUserDataStorage', () => {
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps cache and baseline together when the cache is full', async () => {
|
||||
it('keeps a value that does not fit the cache readable and consistent', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
await storage.hydrate();
|
||||
// The server now has a second playlist the cache cannot take.
|
||||
@@ -425,20 +425,25 @@ describe('ServerUserDataStorage', () => {
|
||||
setItem(key, value);
|
||||
};
|
||||
await storage.hydrate();
|
||||
local.setItem = setItem;
|
||||
|
||||
// The app reads, edits and writes back, as PwaCollectionStorage does.
|
||||
const current = JSON.parse(storage.getItem('xtream-favorites') ?? '');
|
||||
expect(current).toEqual({ p1: ['live:1'], p2: ['live:2'] });
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:3'] })
|
||||
JSON.stringify({ ...current, p1: ['live:1', 'live:3'] })
|
||||
);
|
||||
await storage.flush();
|
||||
local.setItem = setItem;
|
||||
|
||||
expect(api.request).not.toHaveBeenCalled();
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:3'] }
|
||||
);
|
||||
expect(api.sendJson.mock.calls).toEqual([
|
||||
[
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:3'] },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps unconfirmed writes across a reload and sends them first', async () => {
|
||||
@@ -623,40 +628,70 @@ describe('ServerUserDataStorage', () => {
|
||||
});
|
||||
|
||||
it("does not replace another tab's unconfirmed edit on a re-read", async () => {
|
||||
// A server that keeps what it is sent.
|
||||
const state: Record<string, Record<string, unknown>> = {
|
||||
'xtream-favorites': { p1: ['live:1'] },
|
||||
const shared = memoryStorage();
|
||||
const api = {
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': { p1: ['live:1'] } },
|
||||
}),
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
request: jest.fn().mockResolvedValue(undefined),
|
||||
sendKeepalive: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const tabA = setup();
|
||||
tabA.api.getJson.mockImplementation(async () => ({
|
||||
entries: structuredClone(state),
|
||||
}));
|
||||
tabA.api.sendJson.mockImplementation(
|
||||
async (_method: string, path: string, body: { value: unknown }) => {
|
||||
const [, , namespace, playlistId] = path.split('/');
|
||||
state[namespace] = {
|
||||
...state[namespace],
|
||||
[playlistId]: body.value,
|
||||
};
|
||||
}
|
||||
);
|
||||
await tabA.storage.hydrate();
|
||||
tabA.storage.setItem(
|
||||
const tab = () =>
|
||||
new ServerUserDataStorage(
|
||||
api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
const tabA = tab();
|
||||
const tabB = tab();
|
||||
await tabA.hydrate();
|
||||
await tabB.hydrate();
|
||||
|
||||
// Tab A's edit is queued, not confirmed; tab B re-reads.
|
||||
tabA.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:7'] })
|
||||
);
|
||||
|
||||
// Tab A's write is queued, not sent yet; tab B starts and re-reads.
|
||||
const tabB = new ServerUserDataStorage(
|
||||
tabA.api as never,
|
||||
tabA.local,
|
||||
jest.fn()
|
||||
);
|
||||
await tabB.hydrate();
|
||||
|
||||
expect(JSON.parse(tabB.getItem('xtream-favorites') ?? '')).toEqual({
|
||||
p1: ['live:1', 'live:7'],
|
||||
});
|
||||
expect(state['xtream-favorites']['p1']).toEqual(['live:1', 'live:7']);
|
||||
// Tab B never takes over tab A's saved writes.
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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"]}');
|
||||
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') {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
};
|
||||
await storage.hydrate();
|
||||
|
||||
const current = JSON.parse(storage.getItem('xtream-favorites') ?? '');
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: [...current.p1, 'C'] })
|
||||
);
|
||||
await storage.flush();
|
||||
local.setItem = setItem;
|
||||
|
||||
expect(api.sendJson).toHaveBeenLastCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['S', 'B', 'C'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
import { type DeferredEdit, refreshedNamespace } from './server-user-data-edit';
|
||||
import {
|
||||
claimCache,
|
||||
markUnconfirmed,
|
||||
newestWrites,
|
||||
othersUnconfirmed,
|
||||
readDeferredEdits,
|
||||
readUnsentWrites,
|
||||
saveDeferredEdits,
|
||||
@@ -58,6 +60,10 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
*/
|
||||
private readonly deferred = new Map<string, DeferredEdit>();
|
||||
private hydrated = false;
|
||||
/** This tab, in the shared registry of unconfirmed entries. */
|
||||
private readonly tabId = Math.random().toString(36).slice(2);
|
||||
/** Cache values that did not fit into browser storage. */
|
||||
private readonly overflow = new Map<ServerUserStateNamespace, string>();
|
||||
/** Re-reads run one at a time, so an older one never lands last. */
|
||||
private hydration: Promise<void> = Promise.resolve();
|
||||
/** Edit counter per `namespace|playlistId`, for hydrate to keep them. */
|
||||
@@ -69,16 +75,22 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
private readonly local: UserDataStorage,
|
||||
private readonly onWriteFailure: (error: unknown) => void,
|
||||
/** Scopes the cache and saved writes to one server database. */
|
||||
private readonly instanceId = 'default'
|
||||
private readonly instanceId = 'default',
|
||||
/**
|
||||
* Where this tab keeps what the server has not confirmed: its own
|
||||
* (`sessionStorage` in the app), so tabs never clear each other's,
|
||||
* and a reload of the tab finds it again.
|
||||
*/
|
||||
private readonly tabStore: UserDataStorage = local
|
||||
) {
|
||||
claimCache(local, instanceId);
|
||||
// Writes a previous visit could not confirm go out with the first
|
||||
// flush, and the first read keeps them.
|
||||
for (const write of readUnsentWrites(local, instanceId)) {
|
||||
for (const write of readUnsentWrites(tabStore, instanceId)) {
|
||||
this.queue(write.namespace, write.playlistId, write.value);
|
||||
}
|
||||
this.unsentSaved = this.pending.size > 0;
|
||||
for (const edit of readDeferredEdits(local, instanceId)) {
|
||||
for (const edit of readDeferredEdits(tabStore, instanceId)) {
|
||||
this.deferred.set(`${edit.namespace}|${edit.playlistId}`, edit);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +106,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.isSynced(key)
|
||||
? this.local.getItem(SERVER_CACHE_PREFIX + key)
|
||||
? this.readCache(key)
|
||||
: this.local.getItem(key);
|
||||
}
|
||||
|
||||
@@ -104,7 +116,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
return;
|
||||
}
|
||||
const before = this.baseline(key);
|
||||
this.local.setItem(SERVER_CACHE_PREFIX + key, value);
|
||||
this.writeCache(key, value);
|
||||
this.queueChanges(key, parseMap(value), before);
|
||||
}
|
||||
|
||||
@@ -114,7 +126,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
return;
|
||||
}
|
||||
const before = this.baseline(key);
|
||||
this.local.removeItem(SERVER_CACHE_PREFIX + key);
|
||||
this.writeCache(key, null);
|
||||
this.queueChanges(key, {}, before);
|
||||
}
|
||||
|
||||
@@ -148,8 +160,12 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
}
|
||||
}
|
||||
// Another tab's unconfirmed writes are in the shared cache too.
|
||||
for (const write of readUnsentWrites(this.local, this.instanceId)) {
|
||||
unsaved.add(`${write.namespace}|${write.playlistId}`);
|
||||
for (const key of othersUnconfirmed(
|
||||
this.local,
|
||||
this.instanceId,
|
||||
this.tabId
|
||||
)) {
|
||||
unsaved.add(key);
|
||||
}
|
||||
const deferred = [...this.deferred.values()];
|
||||
const refreshed = new Map<string, Record<string, unknown>>();
|
||||
@@ -163,23 +179,14 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
}
|
||||
const value = refreshedNamespace(
|
||||
entries[namespace] ?? {},
|
||||
parseMap(
|
||||
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
|
||||
),
|
||||
parseMap(this.readCache(namespace) ?? '{}'),
|
||||
keepCached,
|
||||
deferred.filter((edit) => edit.namespace === namespace)
|
||||
);
|
||||
refreshed.set(namespace, value);
|
||||
try {
|
||||
this.local.setItem(
|
||||
SERVER_CACHE_PREFIX + namespace,
|
||||
JSON.stringify(value)
|
||||
);
|
||||
} catch {
|
||||
// Storage full: keep the old cache and the baseline it was
|
||||
// diffed against together, or an edit would read as deletes.
|
||||
continue;
|
||||
}
|
||||
// A value too big for browser storage stays readable in memory,
|
||||
// so callers and the diff baseline see the same map.
|
||||
this.writeCache(namespace, JSON.stringify(value));
|
||||
this.synced.set(namespace, value);
|
||||
}
|
||||
for (const [key, edit] of this.edits) {
|
||||
@@ -202,7 +209,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
refreshed.get(edit.namespace)?.[edit.playlistId]
|
||||
);
|
||||
}
|
||||
saveDeferredEdits(this.local, this.instanceId, []);
|
||||
saveDeferredEdits(this.tabStore, this.instanceId, []);
|
||||
this.scheduleFlush();
|
||||
}
|
||||
}
|
||||
@@ -266,6 +273,30 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
);
|
||||
}
|
||||
|
||||
private readCache(namespace: ServerUserStateNamespace): string | null {
|
||||
return (
|
||||
this.overflow.get(namespace) ??
|
||||
this.local.getItem(SERVER_CACHE_PREFIX + namespace)
|
||||
);
|
||||
}
|
||||
|
||||
/** `null` empties the entry. Browser storage first, memory if full. */
|
||||
private writeCache(
|
||||
namespace: ServerUserStateNamespace,
|
||||
value: string | null
|
||||
): void {
|
||||
try {
|
||||
if (value === null) {
|
||||
this.local.removeItem(SERVER_CACHE_PREFIX + namespace);
|
||||
} else {
|
||||
this.local.setItem(SERVER_CACHE_PREFIX + namespace, value);
|
||||
}
|
||||
this.overflow.delete(namespace);
|
||||
} catch {
|
||||
this.overflow.set(namespace, value ?? '{}');
|
||||
}
|
||||
}
|
||||
|
||||
private isSynced(key: string): key is ServerUserStateNamespace {
|
||||
return isServerUserStateNamespace(key);
|
||||
}
|
||||
@@ -279,9 +310,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
): Record<string, unknown> {
|
||||
return (
|
||||
this.synced.get(namespace) ??
|
||||
parseMap(
|
||||
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
|
||||
)
|
||||
parseMap(this.readCache(namespace) ?? '{}')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,9 +333,10 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
before: earlier ? earlier.before : previous[playlistId],
|
||||
after: value,
|
||||
});
|
||||
saveDeferredEdits(this.local, this.instanceId, [
|
||||
saveDeferredEdits(this.tabStore, this.instanceId, [
|
||||
...this.deferred.values(),
|
||||
]);
|
||||
this.publishUnconfirmed();
|
||||
};
|
||||
for (const [playlistId, value] of Object.entries(next)) {
|
||||
if (
|
||||
@@ -424,7 +454,16 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
if (writes.length === 0 && !this.unsentSaved) {
|
||||
return;
|
||||
}
|
||||
saveUnsentWrites(this.local, this.instanceId, writes);
|
||||
saveUnsentWrites(this.tabStore, this.instanceId, writes);
|
||||
this.unsentSaved = writes.length > 0;
|
||||
this.publishUnconfirmed();
|
||||
}
|
||||
|
||||
private publishUnconfirmed(): void {
|
||||
markUnconfirmed(this.local, this.instanceId, this.tabId, [
|
||||
...this.pending.keys(),
|
||||
...this.inFlight.keys(),
|
||||
...this.deferred.keys(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ export function createBrowserUserDataStorage(): UserDataStorage {
|
||||
};
|
||||
}
|
||||
|
||||
/** The same lazy adapter over `sessionStorage`: one per tab, kept on reload. */
|
||||
export function createBrowserSessionStorage(): UserDataStorage {
|
||||
return {
|
||||
getItem: (key) => globalThis.sessionStorage.getItem(key),
|
||||
setItem: (key, value) => globalThis.sessionStorage.setItem(key, value),
|
||||
removeItem: (key) => globalThis.sessionStorage.removeItem(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser persistence for PWA user data such as Xtream favorites, recently
|
||||
* viewed items and playback positions. Defaults to `localStorage`; the
|
||||
|
||||
Reference in new issue
Block a user