diff --git a/libs/services/src/lib/server-storage/index.ts b/libs/services/src/lib/server-storage/index.ts index cd2d294ba..4677a4de3 100644 --- a/libs/services/src/lib/server-storage/index.ts +++ b/libs/services/src/lib/server-storage/index.ts @@ -1,2 +1,3 @@ export * from './server-storage.config'; export * from './server-api-client'; +export * from './server-playlist-document-store'; diff --git a/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts b/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts new file mode 100644 index 000000000..a66a69b52 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts @@ -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); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-playlist-document-store.ts b/libs/services/src/lib/server-storage/server-playlist-document-store.ts new file mode 100644 index 000000000..9b926929b --- /dev/null +++ b/libs/services/src/lib/server-storage/server-playlist-document-store.ts @@ -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; + +/** + * `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(); + private readonly servedPayloads = new WeakSet(); + + constructor(private readonly api: ServerApi) {} + + dbGetAppPlaylists(): Promise { + return this.api.getJson('/playlists'); + } + + dbGetAppPlaylistMetas(): Promise { + return this.api.getJson('/playlists?view=meta'); + } + + async dbGetAppPlaylist(playlistId: string): Promise { + 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 { + 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 { + for (const playlist of playlists) { + await this.dbUpsertAppPlaylist(playlist); + } + return { success: true, count: playlists.length }; + } + + async dbDeletePlaylist(playlistId: string): Promise { + await this.api.request(`/playlists/${encodeURIComponent(playlistId)}`, { + method: 'DELETE', + }); + this.cache.delete(playlistId); + return { success: true }; + } + + async dbDeleteAllPlaylists(): Promise { + await this.api.request('/playlists', { method: 'DELETE' }); + this.cache.clear(); + return { success: true }; + } + + async dbGetAppState(key: string): Promise { + const body = await this.api.getJson<{ value: string | null }>( + `/app-state/${encodeURIComponent(key)}` + ); + return body.value; + } + + async dbSetAppState( + key: string, + value: string + ): Promise { + 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; + 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; + 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 { + const copy = { ...playlist } as Record; + for (const field of SERVER_PAYLOAD_FIELDS) { + delete copy[field]; + } + return copy; +}