fix(server-storage): Stalker keys by category, keepalive ordering, cut-off probe

- Moving Stalker favorites and history keys items by id and category, so
  a channel and a movie with one id both survive.
- Writes after a page-hide flush wait for its keepalive requests.
- A probe whose body is cut off counts as unreachable and keeps the
  remembered server.
- Settings → Server renders without @defer so settings search can reveal
  its rows on the first visit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:38:18 +02:00
1 parent 6820b87005
commit 676733cd7b
8 files changed
+101 -39

No files matched your search

@@ -78,6 +78,14 @@ describe('probeServerInstance', () => {
for (const fetchFn of [
jest.fn().mockRejectedValue(new TypeError('Failed to fetch')),
answer('Bad Gateway', 502),
// Headers arrived, then the body was cut off.
jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new DOMException('aborted', 'AbortError');
},
}),
]) {
await expect(
probeServerInstance({ ...config, fetchFn: fetchFn as never })
@@ -64,8 +64,11 @@ export async function probeServerInstance(
return isServerInstance(body) && body.storage === 'server'
? { kind: 'server', instance: body }
: { kind: 'none' };
} catch {
return { kind: 'none' };
} catch (error) {
// Unparseable JSON is an answer; a cut-off body is an outage.
return error instanceof SyntaxError
? { kind: 'none' }
: { kind: 'unreachable' };
}
}
@@ -22,8 +22,8 @@ import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces';
/**
* Settings → Server: shown only when the PWA runs against self-hosted
* server storage. Loaded with `@defer`, so other builds never fetch the
* server-storage code for the settings page.
* server storage. Rendered without `@defer` so settings search can reveal
* its rows on the first visit; the code rides in the lazy settings chunk.
*/
@Component({
selector: 'app-settings-server-section',
@@ -120,13 +120,9 @@
/>
}
@case ('server') {
<!-- Deferred: keeps the server-storage code out of
the settings chunk in every other build. -->
@defer {
<app-settings-server-section
(exportData)="exportData()"
/>
}
<app-settings-server-section
(exportData)="exportData()"
/>
}
@case ('backup') {
<!-- Backup & data: the former Reset page is this
@@ -45,6 +45,23 @@ describe('unionItems', () => {
});
});
describe('Stalker items', () => {
it('keep the same id in different categories apart', () => {
expect(
unionItems(
[{ id: 7, category_id: 'itv', name: 'Channel' }],
[
{ id: 7, category_id: 'vod', name: 'Movie' },
{ id: 7, category_id: 'itv', name: 'Channel again' },
]
)
).toEqual([
{ id: 7, category_id: 'itv', name: 'Channel' },
{ id: 7, category_id: 'vod', name: 'Movie' },
]);
});
});
describe('playback positions', () => {
it('keep the server position of the same item', () => {
expect(
@@ -56,10 +56,16 @@ function itemKey(item: unknown): string {
) {
return `position:${item['contentType']}:${item['contentXtreamId']}`;
}
// Stalker ids repeat across content types; the category tells them apart.
const category = item['category_id'];
const scope =
typeof category === 'string' || typeof category === 'number'
? `|category:${category}`
: '';
for (const field of ['id', 'stream_id', 'series_id', 'movie_id', 'url']) {
const value = item[field];
if (typeof value === 'string' || typeof value === 'number') {
return `${field}:${value}`;
return `${field}:${value}${scope}`;
}
}
return `json:${JSON.stringify(item)}`;
@@ -299,4 +299,29 @@ describe('ServerUserDataStorage', () => {
await second;
expect(api.getJson).toHaveBeenCalledTimes(3);
});
it('lets a newer write wait for a page-hide write still travelling', async () => {
const { api, storage } = setup();
await storage.hydrate();
let acknowledge: (ok: boolean) => void = () => undefined;
api.sendKeepalive.mockReturnValueOnce(
new Promise<boolean>((resolve) => (acknowledge = resolve))
);
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['old'] }));
storage.flushWithKeepalive();
// Back on the page, a newer edit.
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['new'] }));
const newer = storage.flush();
await settle();
expect(api.sendJson).not.toHaveBeenCalled();
acknowledge(true);
await newer;
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['new'] }
);
});
});
@@ -38,7 +38,7 @@ export class ServerUserDataStorage implements UserDataStorage {
private flushTimer: ReturnType<typeof setTimeout> | null = null;
/** Batches go out one at a time, so an older write never lands last. */
private sending: Promise<void> = Promise.resolve();
private batchesInFlight = 0;
private writesInFlight = 0;
/** Page-hide writes still travelling, by `namespace|playlistId`. */
private readonly keepaliveKeys = new Set<string>();
/** Re-reads run one at a time, so an older one never lands last. */
@@ -148,11 +148,11 @@ export class ServerUserDataStorage implements UserDataStorage {
if (batch.length === 0) {
return;
}
this.batchesInFlight += 1;
this.writesInFlight += 1;
try {
await Promise.all(batch.map((entry) => this.send(entry)));
} finally {
this.batchesInFlight -= 1;
this.writesInFlight -= 1;
}
});
this.sending = run.then(
@@ -164,38 +164,31 @@ export class ServerUserDataStorage implements UserDataStorage {
/**
* Page hide: `keepalive` requests survive the page. They go out at once
* unless a batch is still travelling, which they must not overtake.
* unless a write is still travelling, which they must not overtake, and
* later writes wait for them in turn.
*/
flushWithKeepalive(): void {
const entries = this.takePending();
if (entries.length === 0) {
return;
}
const sendAll = () => {
for (const entry of entries) {
const key = entryKey(entry);
const edit = this.edits.get(key);
this.keepaliveKeys.add(key);
const path = entryPath(entry);
const sent =
entry.value === undefined
? this.api.sendKeepalive('DELETE', path)
: this.api.sendKeepalive('PUT', path, {
value: entry.value,
});
void sent.then((ok) => {
this.keepaliveKeys.delete(key);
if (!ok) {
this.requeue(entry, edit);
}
});
const sendAll = async (): Promise<void> => {
this.writesInFlight += 1;
try {
await Promise.all(
entries.map((entry) => this.sendKeepalive(entry))
);
} finally {
this.writesInFlight -= 1;
}
};
if (this.batchesInFlight === 0) {
sendAll();
} else {
this.sending = this.sending.then(sendAll);
}
// Idle: send synchronously, while the hiding page still runs.
const run =
this.writesInFlight === 0 ? sendAll() : this.sending.then(sendAll);
this.sending = run.then(
() => undefined,
() => undefined
);
}
private isSynced(key: string): key is ServerUserStateNamespace {
@@ -263,6 +256,20 @@ export class ServerUserDataStorage implements UserDataStorage {
}
}
private async sendKeepalive(entry: PendingEntry): Promise<void> {
const key = entryKey(entry);
const edit = this.edits.get(key);
const path = entryPath(entry);
this.keepaliveKeys.add(key);
const ok = await (entry.value === undefined
? this.api.sendKeepalive('DELETE', path)
: this.api.sendKeepalive('PUT', path, { value: entry.value }));
this.keepaliveKeys.delete(key);
if (!ok) {
this.requeue(entry, edit);
}
}
private async send(entry: PendingEntry): Promise<void> {
const edit = this.edits.get(entryKey(entry));
try {