mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat(services): playlist document store over the server API
Full documents are cached by ETag (weak tags included) and served as private copies; an upsert whose payload is the one the store handed out sends keepPayload=1 without it and retries in full on payload_missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b74584fdb2
commit
ead47697bd
3 files changed
+280
No files matched your search
@@ -1,2 +1,3 @@
|
||||
export * from './server-storage.config';
|
||||
export * from './server-api-client';
|
||||
export * from './server-playlist-document-store';
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { ServerApiError } from './server-api-client';
|
||||
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
|
||||
|
||||
function fakeResponse(status: number, body?: unknown, etag?: string) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: new Headers(etag ? { etag } : {}),
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('ServerPlaylistDocumentStore', () => {
|
||||
const doc = {
|
||||
_id: 'p1',
|
||||
title: 'Family',
|
||||
favorites: [],
|
||||
playlist: { header: { raw: '#EXTM3U' }, items: [{ id: 'a' }] },
|
||||
} as unknown as Playlist;
|
||||
|
||||
function setup() {
|
||||
const api = {
|
||||
request: jest.fn(),
|
||||
getJson: jest.fn(),
|
||||
sendJson: jest.fn(),
|
||||
};
|
||||
return { api, store: new ServerPlaylistDocumentStore(api as never) };
|
||||
}
|
||||
|
||||
it('caches by ETag and serves a private copy on 304', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request
|
||||
.mockResolvedValueOnce(fakeResponse(200, doc, 'W/"b.0.1"'))
|
||||
.mockResolvedValueOnce(fakeResponse(304));
|
||||
|
||||
const first = await store.dbGetAppPlaylist('p1');
|
||||
const second = await store.dbGetAppPlaylist('p1');
|
||||
|
||||
expect(api.request.mock.calls[1][1]).toEqual({
|
||||
headers: { 'If-None-Match': 'W/"b.0.1"' },
|
||||
});
|
||||
expect(second).toEqual(doc);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('returns null for an unknown playlist', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockRejectedValue(
|
||||
new ServerApiError(404, 'not_found', 'x')
|
||||
);
|
||||
|
||||
await expect(store.dbGetAppPlaylist('zz')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('omits an unchanged payload and keeps it on the server', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
|
||||
api.sendJson.mockResolvedValue(
|
||||
fakeResponse(200, { success: true }, '"b.0.2"')
|
||||
);
|
||||
const served = await store.dbGetAppPlaylist('p1');
|
||||
if (!served) throw new Error('expected a document');
|
||||
|
||||
await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] });
|
||||
|
||||
const [method, path, body] = api.sendJson.mock.calls[0];
|
||||
expect([method, path]).toEqual(['PUT', '/playlists/p1?keepPayload=1']);
|
||||
expect(body).toEqual({ _id: 'p1', title: 'Family', favorites: ['a'] });
|
||||
});
|
||||
|
||||
it('uploads a new payload in full', async () => {
|
||||
const { api, store } = setup();
|
||||
api.sendJson.mockResolvedValue(
|
||||
fakeResponse(200, { success: true }, '"b.0.1"')
|
||||
);
|
||||
|
||||
await store.dbUpsertAppPlaylist(doc);
|
||||
|
||||
expect(api.sendJson.mock.calls[0][1]).toBe('/playlists/p1');
|
||||
expect(api.sendJson.mock.calls[0][2]).toBe(doc);
|
||||
});
|
||||
|
||||
it('retries in full when the server has no payload to keep', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
|
||||
const served = await store.dbGetAppPlaylist('p1');
|
||||
if (!served) throw new Error('expected a document');
|
||||
api.sendJson
|
||||
.mockRejectedValueOnce(
|
||||
new ServerApiError(409, 'payload_missing', 'x')
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
fakeResponse(200, { success: true }, '"b.1.1"')
|
||||
);
|
||||
|
||||
await store.dbUpsertAppPlaylist(served);
|
||||
|
||||
expect(api.sendJson.mock.calls[1][1]).toBe('/playlists/p1');
|
||||
expect(api.sendJson.mock.calls[1][2]).toBe(served);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
SERVER_PAYLOAD_FIELDS,
|
||||
type ElectronBridgeCountResult,
|
||||
type ElectronBridgeResult,
|
||||
type Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlaylistDocumentStore } from '../playlist-storage-backend';
|
||||
import { ServerApiError, type ServerApiClient } from './server-api-client';
|
||||
|
||||
interface CachedDocument {
|
||||
readonly etag: string;
|
||||
readonly document: Playlist;
|
||||
}
|
||||
|
||||
type ServerApi = Pick<ServerApiClient, 'request' | 'getJson' | 'sendJson'>;
|
||||
|
||||
/**
|
||||
* `PlaylistDocumentStore` over the server storage API. Full documents are
|
||||
* cached by ETag; every read hands out a private copy and remembers its
|
||||
* payload objects, so an upsert whose payload is one of them omits it
|
||||
* (`keepPayload=1`). `PlaylistsService` builds a new object per mutation,
|
||||
* so cached documents are never mutated in place.
|
||||
*/
|
||||
export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
private readonly cache = new Map<string, CachedDocument>();
|
||||
private readonly servedPayloads = new WeakSet<object>();
|
||||
|
||||
constructor(private readonly api: ServerApi) {}
|
||||
|
||||
dbGetAppPlaylists(): Promise<Playlist[]> {
|
||||
return this.api.getJson<Playlist[]>('/playlists');
|
||||
}
|
||||
|
||||
dbGetAppPlaylistMetas(): Promise<Playlist[]> {
|
||||
return this.api.getJson<Playlist[]>('/playlists?view=meta');
|
||||
}
|
||||
|
||||
async dbGetAppPlaylist(playlistId: string): Promise<Playlist | null> {
|
||||
const cached = this.cache.get(playlistId);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.api.request(
|
||||
`/playlists/${encodeURIComponent(playlistId)}`,
|
||||
cached ? { headers: { 'If-None-Match': cached.etag } } : {}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError && error.status === 404) {
|
||||
this.cache.delete(playlistId);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status === 304 && cached) {
|
||||
return this.serve(cached.document);
|
||||
}
|
||||
const document = (await response.json()) as Playlist;
|
||||
this.remember(playlistId, response, document);
|
||||
return this.serve(document);
|
||||
}
|
||||
|
||||
async dbUpsertAppPlaylist(
|
||||
playlist: Playlist
|
||||
): Promise<ElectronBridgeResult> {
|
||||
const path = `/playlists/${encodeURIComponent(playlist._id)}`;
|
||||
const keepPayload = this.carriesServedPayload(playlist);
|
||||
let response: Response;
|
||||
try {
|
||||
response = keepPayload
|
||||
? await this.api.sendJson(
|
||||
'PUT',
|
||||
`${path}?keepPayload=1`,
|
||||
withoutPayload(playlist)
|
||||
)
|
||||
: await this.api.sendJson('PUT', path, playlist);
|
||||
} catch (error) {
|
||||
if (!(
|
||||
keepPayload &&
|
||||
error instanceof ServerApiError &&
|
||||
error.code === 'payload_missing'
|
||||
)) {
|
||||
throw error;
|
||||
}
|
||||
response = await this.api.sendJson('PUT', path, playlist);
|
||||
}
|
||||
this.remember(playlist._id, response, playlist);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async dbUpsertAppPlaylists(
|
||||
playlists: Playlist[]
|
||||
): Promise<ElectronBridgeCountResult> {
|
||||
for (const playlist of playlists) {
|
||||
await this.dbUpsertAppPlaylist(playlist);
|
||||
}
|
||||
return { success: true, count: playlists.length };
|
||||
}
|
||||
|
||||
async dbDeletePlaylist(playlistId: string): Promise<ElectronBridgeResult> {
|
||||
await this.api.request(`/playlists/${encodeURIComponent(playlistId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
this.cache.delete(playlistId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async dbDeleteAllPlaylists(): Promise<ElectronBridgeResult> {
|
||||
await this.api.request('/playlists', { method: 'DELETE' });
|
||||
this.cache.clear();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async dbGetAppState(key: string): Promise<string | null> {
|
||||
const body = await this.api.getJson<{ value: string | null }>(
|
||||
`/app-state/${encodeURIComponent(key)}`
|
||||
);
|
||||
return body.value;
|
||||
}
|
||||
|
||||
async dbSetAppState(
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<ElectronBridgeResult> {
|
||||
await this.api.sendJson(
|
||||
'PUT',
|
||||
`/app-state/${encodeURIComponent(key)}`,
|
||||
{
|
||||
value,
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private remember(id: string, response: Response, document: Playlist): void {
|
||||
const etag = response.headers.get('etag');
|
||||
if (etag) {
|
||||
this.cache.set(id, { etag, document });
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private serve(document: Playlist): Playlist {
|
||||
const copy = structuredClone(document);
|
||||
const record = copy as unknown as Record<string, unknown>;
|
||||
for (const field of SERVER_PAYLOAD_FIELDS) {
|
||||
const value = record[field];
|
||||
if (value && typeof value === 'object') {
|
||||
this.servedPayloads.add(value);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private carriesServedPayload(playlist: Playlist): boolean {
|
||||
const record = playlist as unknown as Record<string, unknown>;
|
||||
const present = SERVER_PAYLOAD_FIELDS.filter(
|
||||
(field) => record[field] !== undefined && record[field] !== null
|
||||
);
|
||||
return (
|
||||
present.length > 0 &&
|
||||
present.every(
|
||||
(field) =>
|
||||
typeof record[field] === 'object' &&
|
||||
this.servedPayloads.has(record[field] as object)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function withoutPayload(playlist: Playlist): Record<string, unknown> {
|
||||
const copy = { ...playlist } as Record<string, unknown>;
|
||||
for (const field of SERVER_PAYLOAD_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
Reference in new issue
Block a user