mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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 {
|
||||
|
||||
Reference in new issue
Block a user