diff --git a/.changes/self-hosted-server-storage-backend.md b/.changes/self-hosted-server-storage-backend.md deleted file mode 100644 index 282b743c9..000000000 --- a/.changes/self-hosted-server-storage-backend.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -type: feature -area: self-hosted -issues: [305] ---- - -The Docker image now keeps a server database on a `/data` volume, the groundwork for sharing playlists and watch progress across devices. Mount a volume to keep it; nothing changes in the browser yet. diff --git a/.changes/self-hosted-server-storage.md b/.changes/self-hosted-server-storage.md new file mode 100644 index 000000000..68053952a --- /dev/null +++ b/.changes/self-hosted-server-storage.md @@ -0,0 +1,8 @@ +--- +type: feature +area: self-hosted +issues: [305] +highlight: Playlists on your server +--- + +The Docker version now keeps playlists, favorites and watch progress on the server, so every phone, TV and computer that opens it shares one library. Playlists already in a browser move over with one click, and an optional access token keeps the library private. diff --git a/README.md b/README.md index 95f767353..0bf2eac6c 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ The application is available at . See compose file and [`docker/README.md`](./docker/README.md) for environment variables, reverse proxy notes, PWA limitations, and build details. +The image keeps playlists, favorites and watch progress on the server, so +every browser that opens it shares one library. The first browser that opens +an updated instance offers to move the playlists it already has. + The self-hosted image runs the browser PWA rather than the Electron desktop app: EPG/XMLTV panels, Embedded MPV, managed MPV/VLC launching, the download manager, and Electron remote-control features are not available there. If browser diff --git a/apps/web-backend/src/app/project-config.spec.ts b/apps/web-backend/src/app/project-config.spec.ts index 473153b61..81b26b070 100644 --- a/apps/web-backend/src/app/project-config.spec.ts +++ b/apps/web-backend/src/app/project-config.spec.ts @@ -41,9 +41,25 @@ describe('Playwright web-backend launch', () => { expect(source).not.toMatch(/\bnx\b[^'"`\n]*web-backend/); expect(start).toBeGreaterThan(-1); - expect( - source.indexOf(launchCommand, start + launchCommand.length) - ).toBe(-1); + // One stateless backend, plus at most one storage-enabled one. + const second = source.indexOf( + `'${launchCommand}'`, + start + launchCommand.length + ); + if (second !== -1) { + const storageEntry = source.slice( + second, + source.indexOf('url:', second) + ); + expect(storageEntry).toContain(`IPTVNATOR_STORAGE: 'server'`); + expect(storageEntry).toContain('PORT: storageBackendPort,'); + expect( + source.indexOf( + `'${launchCommand}'`, + second + launchCommand.length + ) + ).toBe(-1); + } // WEB_BACKEND_PORT relocates the run; its default is the target's. const { PORT: port, ...fixedEnv } = serveEnv; diff --git a/apps/web-backend/src/app/storage/access-guard.spec.ts b/apps/web-backend/src/app/storage/access-guard.spec.ts index 49e90b308..92d2b69aa 100644 --- a/apps/web-backend/src/app/storage/access-guard.spec.ts +++ b/apps/web-backend/src/app/storage/access-guard.spec.ts @@ -30,6 +30,13 @@ describe('createAccessGuard', () => { code: 'unauthorized', }); expect(wrong.status).toBe(401); + // Lets the PWA tell it from a provider's 401 on a proxy route. + expect(wrong.headers.get('www-authenticate')).toBe( + 'Bearer realm="iptvnator"' + ); + expect(wrong.headers.get('access-control-expose-headers')).toBe( + 'WWW-Authenticate' + ); })); it('accepts the right token and lets preflight through', () => diff --git a/apps/web-backend/src/app/storage/access-guard.ts b/apps/web-backend/src/app/storage/access-guard.ts index 084cc3d10..a8068069e 100644 --- a/apps/web-backend/src/app/storage/access-guard.ts +++ b/apps/web-backend/src/app/storage/access-guard.ts @@ -1,5 +1,6 @@ import { createHash, timingSafeEqual } from 'node:crypto'; import type { RequestHandler } from 'express'; +import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces'; /** Routes a browser must reach before it has a token. */ const PUBLIC_PATHS = new Set(['/', '/health', '/config.js', '/v1/instance']); @@ -24,11 +25,14 @@ export function createAccessGuard(token: string): RequestHandler { return; } - res.status(401).json({ - message: 'This IPTVnator server requires an access token.', - status: 401, - code: 'unauthorized', - }); + res.status(401) + .set('WWW-Authenticate', SERVER_ACCESS_CHALLENGE) + .set('Access-Control-Expose-Headers', 'WWW-Authenticate') + .json({ + message: 'This IPTVnator server requires an access token.', + status: 401, + code: 'unauthorized', + }); }; } diff --git a/apps/web-backend/src/app/storage/playlists-router.ts b/apps/web-backend/src/app/storage/playlists-router.ts index 78a71c553..ebf85b955 100644 --- a/apps/web-backend/src/app/storage/playlists-router.ts +++ b/apps/web-backend/src/app/storage/playlists-router.ts @@ -1,4 +1,5 @@ import express, { Router, type Response } from 'express'; +import { SERVER_REPLACED_ETAG_HEADER as REPLACED_ETAG_HEADER } from '@iptvnator/shared/interfaces'; import { matchesEtag } from './etag-match'; import { deleteAllPlaylistsWithState, @@ -53,11 +54,34 @@ export function createPlaylistsRouter( router.put('/playlists/:id', json, (req, res) => { const id = requirePlaylistId(req.params.id); + const replaced = revisions.etag(id); + // An edit built on a version another device has replaced since. + const ifMatch = req.get('if-match'); + if (ifMatch && !matchesEtag(ifMatch, replaced)) { + throw new StorageRequestError( + 412, + 'conflict', + 'The playlist changed since it was read.' + ); + } + // A create-only upload (a move from browser storage) that another + // tab or device made first. Checked and written in one handler. + if (req.get('if-none-match')?.trim() === '*' && playlists.exists(id)) { + throw new StorageRequestError( + 412, + 'conflict', + 'A playlist with this id exists already.' + ); + } playlists.upsert( { ...requireDocument(req.body), _id: id }, { keepPayload: req.query['keepPayload'] === '1' } ); - withEtag(res, revisions.bump(id)).json({ success: true }); + // With keepPayload a client caches its copy only if the payload it + // holds is the one this write replaced. + withEtag(res, revisions.bump(id)) + .set(REPLACED_ETAG_HEADER, replaced) + .json({ success: true }); }); router.delete('/playlists/:id', (req, res) => { @@ -76,7 +100,7 @@ export function createPlaylistsRouter( function withEtag(res: Response, etag: string): Response { return res .set('ETag', etag) - .set('Access-Control-Expose-Headers', 'ETag') + .set('Access-Control-Expose-Headers', `ETag, ${REPLACED_ETAG_HEADER}`) .set('Cache-Control', 'no-cache'); } diff --git a/apps/web-backend/src/app/storage/server-user-state-repository.ts b/apps/web-backend/src/app/storage/server-user-state-repository.ts index a61b0de2f..732526ffe 100644 --- a/apps/web-backend/src/app/storage/server-user-state-repository.ts +++ b/apps/web-backend/src/app/storage/server-user-state-repository.ts @@ -1,16 +1,88 @@ +import { createHash } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; import type { DatabaseInstance } from '@iptvnator/shared/database'; import { SERVER_USER_STATE_NAMESPACES, + type ServerUserStateCurrent, type ServerUserStateNamespace, type ServerUserStateResponse, } from '@iptvnator/shared/interfaces'; import { serverUserState } from './server-user-state.table'; +/** + * What a conditional write is based on: `undefined` writes unconditionally, + * `null` expects no entry, a string expects that tag. + */ +export type ExpectedTag = string | null | undefined; + +export type ConditionalWriteResult = + | { readonly tag: string | null } + | { readonly conflict: ServerUserStateCurrent }; + +/** Version tag of a stored value: its content, so equal values match. */ +export function userStateTag(serialized: string): string { + return createHash('sha256').update(serialized).digest('hex').slice(0, 16); +} + /** Xtream PWA user data, one JSON value per namespace and playlist. */ export class ServerUserStateRepository { constructor(private readonly db: DatabaseInstance) {} + /** Every entry with its tag, as `GET /v1/user-state` serves them. */ + allWithTags(): Required { + const tags = Object.fromEntries( + SERVER_USER_STATE_NAMESPACES.map((namespace) => [ + namespace, + Object.create(null) as Record, + ]) + ) as Required['tags']; + for (const row of this.db.select().from(serverUserState).all()) { + const bucket = tags[row.namespace as ServerUserStateNamespace]; + if (bucket) { + bucket[row.playlistId] = userStateTag(row.value); + } + } + return { entries: this.all(), tags }; + } + + /** + * Writes (`value`) or deletes (`undefined`) one entry if it still has + * the expected tag; otherwise reports the current entry. Synchronous on + * the single connection, so the check and the write cannot interleave. + */ + writeIf( + namespace: ServerUserStateNamespace, + playlistId: string, + value: unknown, + expected: ExpectedTag + ): ConditionalWriteResult { + if (expected !== undefined) { + const row = this.db + .select({ value: serverUserState.value }) + .from(serverUserState) + .where( + and( + eq(serverUserState.namespace, namespace), + eq(serverUserState.playlistId, playlistId) + ) + ) + .get(); + const tag = row ? userStateTag(row.value) : null; + if (tag !== expected) { + return { + conflict: row + ? { value: JSON.parse(row.value), tag } + : { tag: null }, + }; + } + } + if (value === undefined) { + this.delete(namespace, playlistId); + return { tag: null }; + } + return { tag: this.put(namespace, playlistId, value) }; + } + all(): ServerUserStateResponse['entries'] { // Prototype-free buckets: a playlist id such as `__proto__` must be // stored as an own key, not change the bucket's prototype. @@ -30,11 +102,12 @@ export class ServerUserStateRepository { return entries; } + /** Returns the stored value's tag. */ put( namespace: ServerUserStateNamespace, playlistId: string, value: unknown - ): void { + ): string { const serialized = JSON.stringify(value ?? null); const updatedAt = new Date().toISOString(); this.db @@ -45,6 +118,7 @@ export class ServerUserStateRepository { set: { value: serialized, updatedAt }, }) .run(); + return userStateTag(serialized); } delete(namespace: ServerUserStateNamespace, playlistId: string): void { diff --git a/apps/web-backend/src/app/storage/storage-config.spec.ts b/apps/web-backend/src/app/storage/storage-config.spec.ts index 999fec6bd..28a998b0f 100644 --- a/apps/web-backend/src/app/storage/storage-config.spec.ts +++ b/apps/web-backend/src/app/storage/storage-config.spec.ts @@ -1,4 +1,7 @@ -import { readServerStorageConfig } from './storage-config'; +import { + readServerStorageConfig, + serverStorageConfigProblem, +} from './storage-config'; describe('readServerStorageConfig', () => { it('keeps storage off and the API open by default', () => { @@ -42,4 +45,20 @@ describe('readServerStorageConfig', () => { .maxDocumentBytes ).toBe(64 * 1024 * 1024); }); + + it('refuses an access token without server storage', () => { + const problem = (env: Record) => + serverStorageConfigProblem(readServerStorageConfig(env)); + + expect(problem({ IPTVNATOR_ACCESS_TOKEN: 's3cret' })).toContain( + 'IPTVNATOR_STORAGE=server' + ); + expect( + problem({ + IPTVNATOR_ACCESS_TOKEN: 's3cret', + IPTVNATOR_STORAGE: 'server', + }) + ).toBeNull(); + expect(problem({})).toBeNull(); + }); }); diff --git a/apps/web-backend/src/app/storage/storage-config.ts b/apps/web-backend/src/app/storage/storage-config.ts index a87900751..1d97b92e8 100644 --- a/apps/web-backend/src/app/storage/storage-config.ts +++ b/apps/web-backend/src/app/storage/storage-config.ts @@ -10,6 +10,19 @@ export interface ServerStorageConfig { const DEFAULT_MAX_DOCUMENT_MB = 64; const BYTES_PER_MB = 1024 * 1024; +/** + * A combination the browser app cannot use, as a start-up message, or + * `null`. The PWA asks for the access token only in server storage mode, + * so a token on a stateless backend would lock every browser out. + */ +export function serverStorageConfigProblem( + config: ServerStorageConfig +): string | null { + return config.accessToken && !config.enabled + ? 'IPTVNATOR_ACCESS_TOKEN needs IPTVNATOR_STORAGE=server: the browser app asks for the token only when the server stores its playlists. Unset the token, or protect a stateless backend in your reverse proxy.' + : null; +} + export function readServerStorageConfig( env: Record = process.env ): ServerStorageConfig { diff --git a/apps/web-backend/src/app/storage/storage-routes.spec.ts b/apps/web-backend/src/app/storage/storage-routes.spec.ts index 88a3ac911..5059c680e 100644 --- a/apps/web-backend/src/app/storage/storage-routes.spec.ts +++ b/apps/web-backend/src/app/storage/storage-routes.spec.ts @@ -91,6 +91,17 @@ describe('server storage routes', () => { headers: { 'if-none-match': `W/${etag}` }, }); expect(weakened.status).toBe(304); + + // A write names the version it replaced. + const next = await fetch( + `${baseUrl}/v1/playlists/p1?keepPayload=1`, + json({ _id: 'p1', title: 'Renamed' }, { method: 'PUT' }) + ); + expect(next.headers.get('x-replaced-etag')).toBe(etag); + expect(next.headers.get('etag')).not.toBe(etag); + expect( + next.headers.get('access-control-expose-headers') + ).toContain('X-Replaced-ETag'); }) )); @@ -194,6 +205,154 @@ describe('server storage routes', () => { }) )); + it('refuses user state for a playlist it does not have', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + const entry = `${baseUrl}/v1/user-state/xtream-favorites/gone`; + const put = await fetch( + entry, + json({ value: ['live:1'] }, { method: 'PUT' }) + ); + expect(put.status).toBe(404); + await expect(put.json()).resolves.toMatchObject({ + code: 'playlist_missing', + }); + // Removing what is not there stays harmless. + expect((await fetch(entry, { method: 'DELETE' })).status).toBe( + 200 + ); + }) + )); + + it('refuses a playlist write built on a replaced version', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + const first = await fetch( + `${baseUrl}/v1/playlists/p1`, + json(m3uDocument('p1'), { method: 'PUT' }) + ); + const read = first.headers.get('etag') ?? ''; + const edit = (ifMatch: string) => + fetch( + `${baseUrl}/v1/playlists/p1?keepPayload=1`, + json( + { _id: 'p1', title: 'Renamed' }, + { method: 'PUT', headers: { 'if-match': ifMatch } } + ) + ); + + expect((await edit(read)).status).toBe(200); + // Another device wrote since `read`. + const stale = await edit(read); + expect(stale.status).toBe(412); + await expect(stale.json()).resolves.toMatchObject({ + code: 'conflict', + }); + }) + )); + + it('creates a playlist create-only, refused once it exists', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + const create = (title: string) => + fetch( + `${baseUrl}/v1/playlists/p1`, + json( + { ...m3uDocument('p1'), title }, + { + method: 'PUT', + headers: { 'if-none-match': '*' }, + } + ) + ); + + expect((await create('First')).status).toBe(200); + // Another tab or device moved the same playlist first. + const second = await create('Second'); + expect(second.status).toBe(412); + await expect(second.json()).resolves.toMatchObject({ + code: 'conflict', + }); + const stored = await fetch(`${baseUrl}/v1/playlists/p1`); + await expect(stored.json()).resolves.toMatchObject({ + title: 'First', + }); + }) + )); + + it('writes user state only on the tag it was read at', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + await fetch( + `${baseUrl}/v1/playlists/p1`, + json(m3uDocument('p1'), { method: 'PUT' }) + ); + const entry = `${baseUrl}/v1/user-state/xtream-favorites/p1`; + // Created only while absent. + const created = await fetch( + entry, + json( + { value: ['live:1'] }, + { method: 'PUT', headers: { 'if-none-match': '*' } } + ) + ); + const { tag } = (await created.json()) as { tag: string }; + expect(tag).toMatch(/^[0-9a-f]{16}$/); + const again = await fetch( + entry, + json( + { value: ['live:2'] }, + { method: 'PUT', headers: { 'if-none-match': '*' } } + ) + ); + expect(again.status).toBe(412); + await expect(again.json()).resolves.toMatchObject({ + code: 'conflict', + current: { value: ['live:1'], tag }, + }); + + const read = (await ( + await fetch(`${baseUrl}/v1/user-state`) + ).json()) as ServerUserStateResponse; + expect(read.tags?.['xtream-favorites']['p1']).toBe(tag); + + // Another device wrote meanwhile: a stale tag is refused. + const next = await fetch( + entry, + json( + { value: ['live:1', 'live:9'] }, + { method: 'PUT', headers: { 'if-match': `"${tag}"` } } + ) + ); + const nextTag = ((await next.json()) as { tag: string }).tag; + const stale = await fetch( + entry, + json( + { value: ['live:1', 'live:5'] }, + { method: 'PUT', headers: { 'if-match': `"${tag}"` } } + ) + ); + expect(stale.status).toBe(412); + await expect(stale.json()).resolves.toMatchObject({ + current: { value: ['live:1', 'live:9'], tag: nextTag }, + }); + + const removed = await fetch(entry, { + method: 'DELETE', + headers: { 'if-match': `"${nextTag}"` }, + }); + await expect(removed.json()).resolves.toMatchObject({ + tag: null, + }); + // Unconditional writes keep working for older clients. + const plain = await fetch( + entry, + json({ value: ['x'] }, { method: 'PUT' }) + ); + expect(plain.status).toBe(200); + }) + )); + it('keeps browser app state away from server-owned keys', () => withTempStorage((storage) => withServer(app(storage), async (baseUrl) => { diff --git a/apps/web-backend/src/app/storage/user-state-router.ts b/apps/web-backend/src/app/storage/user-state-router.ts index b5a4122b0..0c0ede517 100644 --- a/apps/web-backend/src/app/storage/user-state-router.ts +++ b/apps/web-backend/src/app/storage/user-state-router.ts @@ -1,8 +1,9 @@ -import express, { Router } from 'express'; +import express, { Router, type Request, type Response } from 'express'; import { isServerUserStateNamespace, type ServerUserStateNamespace, } from '@iptvnator/shared/interfaces'; +import type { ExpectedTag } from './server-user-state-repository'; import type { ServerStorage } from './server-storage'; import { StorageRequestError } from './storage-route-errors'; @@ -18,11 +19,41 @@ export function createUserStateRouter(storage: ServerStorage): Router { const json = express.json({ limit: USER_STATE_BODY_LIMIT }); router.get('/user-state', (_req, res) => { - res.set('Cache-Control', 'no-store').json({ - entries: storage.userState.all(), - }); + res.set('Cache-Control', 'no-store').json( + storage.userState.allWithTags() + ); }); + const write = (req: Request, res: Response, value: unknown) => { + const namespace = requireNamespace(String(req.params['namespace'])); + const playlistId = String(req.params['playlistId']); + // An edit another device queued before the playlist was deleted + // must not bring its user state back. + if (value !== undefined && !storage.playlists.exists(playlistId)) { + throw new StorageRequestError( + 404, + 'playlist_missing', + 'The playlist does not exist.' + ); + } + const result = storage.userState.writeIf( + namespace, + playlistId, + value, + expectedTag(req) + ); + if ('conflict' in result) { + res.status(412).json({ + message: 'The entry changed since it was read.', + status: 412, + code: 'conflict', + current: result.conflict, + }); + return; + } + res.json({ success: true, tag: result.tag }); + }; + router.put('/user-state/:namespace/:playlistId', json, (req, res) => { const body = req.body as { value?: unknown } | undefined; if (!body || typeof body !== 'object' || !('value' in body)) { @@ -32,20 +63,11 @@ export function createUserStateRouter(storage: ServerStorage): Router { 'Expected { value }.' ); } - storage.userState.put( - requireNamespace(req.params.namespace), - req.params.playlistId, - body.value - ); - res.json({ success: true }); + write(req, res, body.value ?? null); }); router.delete('/user-state/:namespace/:playlistId', (req, res) => { - storage.userState.delete( - requireNamespace(req.params.namespace), - req.params.playlistId - ); - res.json({ success: true }); + write(req, res, undefined); }); router.get('/app-state/:key', (req, res) => { @@ -75,6 +97,18 @@ export function createUserStateRouter(storage: ServerStorage): Router { return router; } +/** + * `If-Match: ""` expects that tag, `If-None-Match: *` expects no + * entry; without either the write is unconditional, as older clients send. + */ +function expectedTag(req: Request): ExpectedTag { + const ifMatch = req.get('if-match')?.trim(); + if (ifMatch) { + return ifMatch.replace(/^W\//, '').replace(/^"|"$/g, ''); + } + return req.get('if-none-match')?.trim() === '*' ? null : undefined; +} + function requireNamespace(value: string): ServerUserStateNamespace { if (!isServerUserStateNamespace(value)) { throw new StorageRequestError( diff --git a/apps/web-backend/src/main.ts b/apps/web-backend/src/main.ts index e826f08ce..9123f7580 100644 --- a/apps/web-backend/src/main.ts +++ b/apps/web-backend/src/main.ts @@ -4,7 +4,10 @@ import { } from '@iptvnator/shared/host-health'; import { closeGracefully } from './app/graceful-shutdown'; import { createWebBackendApp } from './app/web-backend-app'; -import { readServerStorageConfig } from './app/storage/storage-config'; +import { + readServerStorageConfig, + serverStorageConfigProblem, +} from './app/storage/storage-config'; import { openServerStorage, type ServerStorage, @@ -20,6 +23,11 @@ async function start(): Promise { } const config = readServerStorageConfig(); + const problem = serverStorageConfigProblem(config); + if (problem) { + console.error(`[web-backend] ${problem}`); + process.exit(1); + } let storage: ServerStorage | null = null; if (config.enabled) { try { diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 8f64d1d4d..344dc016f 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -1,3 +1,5 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { defineConfig, devices } from '@playwright/test'; import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; @@ -16,6 +18,12 @@ const webServerCommand = : 'pnpm nx run web:serve'; const reuseExistingWebServer = isStaticPwaE2E ? false : !process.env['CI']; const webBackendPort = process.env['WEB_BACKEND_PORT'] ?? '3333'; +const storageBackendPort = process.env['STORAGE_BACKEND_PORT'] ?? '3334'; +// Path only; the backend creates it. A new pid gives every run an empty store. +const storageBackendDataDir = join( + tmpdir(), + `iptvnator-web-e2e-storage-${process.pid}` +); /** * Read environment variables from file. @@ -106,6 +114,26 @@ export default defineConfig({ reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, + /* A second backend with server storage, used only by + * server-storage.e2e.ts; the specs above keep the stateless one. + * STORAGE_BACKEND_PORT relocates it like WEB_BACKEND_PORT. */ + { + command: 'node --import tsx apps/web-backend/src/main.ts', + env: { + PORT: storageBackendPort, + CLIENT_URL: `http://localhost:4200,http://localhost:${staticPwaPort}`, + BACKEND_URL: '/api', + IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: '1', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + IPTVNATOR_STORAGE: 'server', + IPTVNATOR_DATA_DIR: storageBackendDataDir, + }, + url: `http://localhost:${storageBackendPort}/health`, + // Never another backend on that port: the specs delete every + // playlist before each test, which would wipe its library. + reuseExistingServer: false, + cwd: workspaceRoot, + }, ], projects: [ { diff --git a/apps/web-e2e/src/server-storage.e2e.ts b/apps/web-e2e/src/server-storage.e2e.ts new file mode 100644 index 000000000..6b08c873f --- /dev/null +++ b/apps/web-e2e/src/server-storage.e2e.ts @@ -0,0 +1,173 @@ +import type { Browser, Page } from '@playwright/test'; +import { expect, test } from './fixtures'; +import { + addM3uUrlPlaylist, + openSources, + sourceRowByTitle, +} from './sources-pwa.helpers'; + +/** The storage-enabled backend from playwright.config.ts. */ +const STORAGE_BACKEND_URL = `http://localhost:${ + process.env['STORAGE_BACKEND_PORT'] ?? '3334' +}`; + +/** A fresh browser profile (a "device") whose runtime config points at it. */ +async function newServerPage(browser: Browser): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.route('**/assets/app-config.js', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: `window.__IPTVNATOR_CONFIG__ = { BACKEND_URL: ${JSON.stringify(STORAGE_BACKEND_URL)} };\n`, + }) + ); + return page; +} + +/** A legacy browser playlist, written before the app boots. */ +async function seedBrowserPlaylist( + page: Page, + id: string, + title: string +): Promise { + await page.route('**/__indexeddb-seed__', (route) => + route.fulfill({ contentType: 'text/html', body: '' }) + ); + await page.goto('/__indexeddb-seed__'); + await page.evaluate( + ([playlistId, playlistTitle]) => + new Promise((resolve, reject) => { + const request = indexedDB.open('iptvnator', 1); + request.onerror = () => reject(request.error); + request.onupgradeneeded = () => { + const store = request.result.createObjectStore( + 'playlists', + { keyPath: '_id', autoIncrement: false } + ); + for (const name of [ + '_id', + 'filename', + 'title', + 'count', + 'playlist', + 'importDate', + 'lastUsage', + 'favorites', + 'recentlyViewed', + 'autoRefresh', + 'url', + 'filePath', + ]) { + store.createIndex(name, name, { unique: false }); + } + }; + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction( + 'playlists', + 'readwrite' + ); + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onabort = () => { + db.close(); + reject(transaction.error); + }; + transaction.objectStore('playlists').add({ + _id: playlistId, + title: playlistTitle, + importDate: '2026-01-01T00:00:00.000Z', + lastUsage: '2026-01-01T00:00:00.000Z', + count: 1, + autoRefresh: false, + favorites: [], + recentlyViewed: [], + playlist: { + header: { attrs: {}, raw: '#EXTM3U' }, + items: [ + { + id: 'c1', + name: 'Channel', + url: 'https://example.invalid/c1.m3u8', + }, + ], + }, + }); + }; + }), + [id, title] as const + ); +} + +test.describe('server storage @self-hosted @persistence', () => { + // Both tests share one server database, and so would the other browser + // projects: keep it to one browser. + test.describe.configure({ mode: 'serial' }); + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'One shared server database' + ); + + test.beforeEach(async ({ request }) => { + const response = await request.delete( + `${STORAGE_BACKEND_URL}/v1/playlists` + ); + expect(response.ok()).toBe(true); + }); + + test('a playlist added on one device appears on another', async ({ + browser, + }) => { + const first = await newServerPage(browser); + await openSources(first); + await addM3uUrlPlaylist(first, 'Shared M3U'); + + const second = await newServerPage(browser); + await openSources(second); + await expect(sourceRowByTitle(second, 'Shared M3U')).toBeVisible(); + + await first.context().close(); + await second.context().close(); + }); + + test('moves browser playlists to an empty server', async ({ + browser, + request, + }) => { + const page = await newServerPage(browser); + await seedBrowserPlaylist(page, 'browser-only', 'Browser only'); + + await page.goto('/workspace/sources'); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toContainText('Browser only'); + await dialog.locator('[data-test-id="server-migration-move"]').click(); + await expect( + dialog.locator('[data-test-id="server-migration-reload"]') + ).toBeVisible(); + + const response = await request.get( + `${STORAGE_BACKEND_URL}/v1/playlists?view=meta` + ); + const metas = (await response.json()) as { + _id: string; + title: string; + }[]; + expect(metas).toEqual([ + expect.objectContaining({ + _id: 'browser-only', + title: 'Browser only', + }), + ]); + + // After the reload the library comes from the server. + await dialog + .locator('[data-test-id="server-migration-reload"]') + .click(); + await expect(sourceRowByTitle(page, 'Browser only')).toBeVisible(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + + await page.context().close(); + }); +}); diff --git a/apps/web/src/app/app-startup-status.component.spec.ts b/apps/web/src/app/app-startup-status.component.spec.ts index dae454396..76e56762b 100644 --- a/apps/web/src/app/app-startup-status.component.spec.ts +++ b/apps/web/src/app/app-startup-status.component.spec.ts @@ -6,6 +6,7 @@ import { selectPlaylistsLoadFailed, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; +import { provideRouter, Router } from '@angular/router'; import { AppStartupStatusComponent } from './app-startup-status.component'; describe('AppStartupStatusComponent', () => { @@ -13,6 +14,7 @@ describe('AppStartupStatusComponent', () => { TestBed.configureTestingModule({ imports: [AppStartupStatusComponent, TranslateModule.forRoot()], providers: [ + provideRouter([{ path: 'server-sign-in', children: [] }]), provideMockStore({ selectors: [ { selector: selectPlaylistsLoadingFlag, value: false }, @@ -48,6 +50,23 @@ describe('AppStartupStatusComponent', () => { expect(fixture.componentInstance.complete()).toBe(true); }); + it('shows the server sign-in page although sources could not load', async () => { + TestBed.inject(MockStore).overrideSelector( + selectPlaylistsLoadFailed, + true + ); + const fixture = TestBed.createComponent(AppStartupStatusComponent); + fixture.componentRef.setInput('routeReady', true); + fixture.detectChanges(); + expect(fixture.componentInstance.complete()).toBe(false); + + await TestBed.inject(Router).navigateByUrl('/server-sign-in'); + fixture.detectChanges(); + + expect(fixture.componentInstance.complete()).toBe(true); + expect(fixture.nativeElement.querySelector('section')).toBeNull(); + }); + it('offers a retry on failure without rendering raw storage errors', () => { TestBed.inject(MockStore).overrideSelector( selectPlaylistsLoadFailed, diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts index ac99aae8b..b965ad015 100644 --- a/apps/web/src/app/app-startup-status.component.ts +++ b/apps/web/src/app/app-startup-status.component.ts @@ -5,14 +5,18 @@ import { inject, input, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; +import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { filter, map } from 'rxjs'; import { PlaylistActions, selectPlaylistsLoadFailed, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; +import { SERVER_SIGN_IN_PATH } from '@iptvnator/services'; /** The first route also waits for settings and XMLTV source reconciliation. */ @Component({ @@ -304,8 +308,23 @@ export class AppStartupStatusComponent { selectPlaylistsLoadingFlag ); readonly failed = this.store.selectSignal(selectPlaylistsLoadFailed); + private readonly router = inject(Router); + private readonly url = toSignal( + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map((event) => event.urlAfterRedirects) + ), + { initialValue: this.router.url } + ); + /** + * Server storage asking for a token: the playlists cannot load, and the + * sign-in page must stay up whatever its check reports. + */ + private readonly onSignInPage = computed(() => + this.url().startsWith(SERVER_SIGN_IN_PATH) + ); readonly complete = computed( - () => this.routeReady() && this.sourcesReady() + () => this.routeReady() && (this.sourcesReady() || this.onSignInPage()) ); retry(): void { diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 6a2f87f5d..577b4e7b3 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -6,6 +6,10 @@ import { SettingsStore, } from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; +import { + serverAccessGuard, + serverSignInGuard, +} from './server-storage/server-access.guard'; import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard'; // The workspace activates only once settings AND the parental lock state @@ -77,6 +81,7 @@ export const routes: Routes = [ }, { path: 'workspace', + canActivate: [serverAccessGuard], resolve: { settingsReady: settingsReadyResolver, }, @@ -207,6 +212,14 @@ export const routes: Routes = [ }, ], }, + { + path: 'server-sign-in', + canActivate: [serverSignInGuard], + loadComponent: () => + import('./server-storage/server-sign-in.component').then( + (c) => c.ServerSignInComponent + ), + }, { path: 'settings', redirectTo: '/workspace/settings', diff --git a/apps/web/src/app/server-storage/server-access.guard.spec.ts b/apps/web/src/app/server-storage/server-access.guard.spec.ts new file mode 100644 index 000000000..7d388ecc9 --- /dev/null +++ b/apps/web/src/app/server-storage/server-access.guard.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router, UrlTree } from '@angular/router'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { serverAccessGuard, serverSignInGuard } from './server-access.guard'; + +describe('server access guards', () => { + function run(guard: typeof serverAccessGuard, state: string) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ServerStorageStatusService, + useValue: { state: () => state }, + }, + ], + }); + return TestBed.runInInjectionContext(() => + guard({} as never, {} as never) + ); + } + + const serialize = (result: unknown) => + TestBed.inject(Router).serializeUrl(result as UrlTree); + + afterEach(() => TestBed.resetTestingModule()); + + it('sends an unauthorized workspace visit to the sign-in page', () => { + expect(serialize(run(serverAccessGuard, 'unauthorized'))).toBe( + '/server-sign-in' + ); + }); + + it('lets everyone else into the workspace', () => { + expect(run(serverAccessGuard, 'online')).toBe(true); + expect(run(serverAccessGuard, 'offline')).toBe(true); + expect(run(serverAccessGuard, 'inactive')).toBe(true); + }); + + it('opens the sign-in page only while unauthorized', () => { + expect(run(serverSignInGuard, 'unauthorized')).toBe(true); + expect(serialize(run(serverSignInGuard, 'online'))).toBe('/workspace'); + expect(serialize(run(serverSignInGuard, 'inactive'))).toBe( + '/workspace' + ); + }); +}); diff --git a/apps/web/src/app/server-storage/server-access.guard.ts b/apps/web/src/app/server-storage/server-access.guard.ts new file mode 100644 index 000000000..e8518890a --- /dev/null +++ b/apps/web/src/app/server-storage/server-access.guard.ts @@ -0,0 +1,17 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { + SERVER_SIGN_IN_PATH, + ServerStorageStatusService, +} from '@iptvnator/services'; + +/** Without a valid token the workspace would show an empty library. */ +export const serverAccessGuard: CanActivateFn = () => + inject(ServerStorageStatusService).state() === 'unauthorized' + ? inject(Router).parseUrl(SERVER_SIGN_IN_PATH) + : true; + +export const serverSignInGuard: CanActivateFn = () => + inject(ServerStorageStatusService).state() === 'unauthorized' + ? true + : inject(Router).parseUrl('/workspace'); diff --git a/apps/web/src/app/server-storage/server-instance-probe.spec.ts b/apps/web/src/app/server-storage/server-instance-probe.spec.ts new file mode 100644 index 000000000..433346ab8 --- /dev/null +++ b/apps/web/src/app/server-storage/server-instance-probe.spec.ts @@ -0,0 +1,120 @@ +import { markSlowStart } from '@iptvnator/services'; +import { probeServerInstance } from './server-instance-probe'; + +const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'none', +}; + +describe('probeServerInstance', () => { + const answer = (body: unknown, status = 200) => + jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); + const config = { + runtimeConfig: { BACKEND_URL: '/api' }, + isElectron: false, + }; + + it('skips the probe without a runtime backend URL', async () => { + const fetchFn = answer(instance); + await expect( + probeServerInstance({ + runtimeConfig: {}, + fetchFn: fetchFn as never, + }) + ).resolves.toEqual({ kind: 'skipped' }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('skips the probe in Electron', async () => { + const fetchFn = answer(instance); + await expect( + probeServerInstance({ + ...config, + isElectron: true, + fetchFn: fetchFn as never, + }) + ).resolves.toEqual({ kind: 'skipped' }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('returns the instance when it reports server storage', async () => { + const fetchFn = answer(instance); + await expect( + probeServerInstance({ + runtimeConfig: { BACKEND_URL: '/api/' }, + isElectron: false, + fetchFn: fetchFn as never, + }) + ).resolves.toEqual({ kind: 'server', instance }); + expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance'); + }); + + it('waits longer once after a reload for a server that answered late', async () => { + const timeout = jest.spyOn(AbortSignal, 'timeout'); + try { + markSlowStart(); + await probeServerInstance({ + ...config, + fetchFn: answer(instance) as never, + }); + await probeServerInstance({ + ...config, + fetchFn: answer(instance) as never, + }); + + // A slow server otherwise times out again and reloads forever. + expect(timeout.mock.calls.map(([ms]) => ms)).toEqual([ + 20_000, 2_500, + ]); + } finally { + timeout.mockRestore(); + } + }); + + it('reports no server storage for an answer without it', async () => { + for (const fetchFn of [ + answer({ ...instance, storage: 'none' }), + answer({ hello: 1 }), + answer({ error: 'not found' }, 404), + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('not json'); + }, + }), + ]) { + await expect( + probeServerInstance({ ...config, fetchFn: fetchFn as never }) + ).resolves.toEqual({ kind: 'none' }); + } + }); + + it('reports an unreachable server for failures, 5xx and refusals', async () => { + for (const fetchFn of [ + jest.fn().mockRejectedValue(new TypeError('Failed to fetch')), + answer('Bad Gateway', 502), + answer('Too Many Requests', 429), + answer('Forbidden', 403), + // 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 }) + ).resolves.toEqual({ kind: 'unreachable' }); + } + }); +}); diff --git a/apps/web/src/app/server-storage/server-instance-probe.ts b/apps/web/src/app/server-storage/server-instance-probe.ts new file mode 100644 index 000000000..858e696d2 --- /dev/null +++ b/apps/web/src/app/server-storage/server-instance-probe.ts @@ -0,0 +1,92 @@ +import { RuntimeCapabilitiesService, takeSlowStart } from '@iptvnator/services'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import type { IptvnatorRuntimeConfig } from '../services/runtime-config'; + +const PROBE_TIMEOUT_MS = 2_500; +/** After a reload for a server that answered later: as long as requests. */ +const SLOW_PROBE_TIMEOUT_MS = 20_000; + +/** + * - `skipped`: no runtime backend URL, or Electron. + * - `server` / `none`: the backend answered (a 404 is an older backend). + * - `unreachable`: no answer, a timeout, a 5xx (nginx answers 502 while + * the backend restarts) or another refusal such as a proxy's 429, so the + * result says nothing about storage. + */ +export type ServerInstanceProbe = + | { readonly kind: 'skipped' | 'none' | 'unreachable' } + | { readonly kind: 'server'; readonly instance: ServerInstanceInfo }; + +export interface ServerInstanceProbeOptions { + readonly runtimeConfig?: IptvnatorRuntimeConfig; + readonly isElectron?: boolean; + readonly fetchFn?: typeof fetch; +} + +/** + * Asks the backend whether it offers server storage. Runs only when the + * deployment wrote a runtime `BACKEND_URL` (the Docker entrypoint does), so + * hosted builds never call an external parser API for it. + */ +export async function probeServerInstance( + options: ServerInstanceProbeOptions = {} +): Promise { + const runtimeConfig = + options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__; + // The capability service has no dependencies, so it works before + // bootstrap and keeps one definition of the desktop runtime. + const isElectron = + options.isElectron ?? new RuntimeCapabilitiesService().isElectron; + const baseUrl = runtimeConfig?.BACKEND_URL?.trim(); + if (isElectron || !baseUrl) { + return { kind: 'skipped' }; + } + + let response: Response; + try { + const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + response = await fetchFn(`${baseUrl.replace(/\/+$/, '')}/v1/instance`, { + cache: 'no-store', + signal: + typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout( + takeSlowStart() + ? SLOW_PROBE_TIMEOUT_MS + : PROBE_TIMEOUT_MS + ) + : undefined, + }); + } catch { + return { kind: 'unreachable' }; + } + if (response.status === 404) { + // A backend from before server storage has no /v1 routes. + return { kind: 'none' }; + } + if (!response.ok) { + return { kind: 'unreachable' }; + } + try { + const body: unknown = await response.json(); + return isServerInstance(body) && body.storage === 'server' + ? { kind: 'server', instance: body } + : { kind: 'none' }; + } catch (error) { + // Unparseable JSON is an answer; a cut-off body is an outage. + return error instanceof SyntaxError + ? { kind: 'none' } + : { kind: 'unreachable' }; + } +} + +function isServerInstance(value: unknown): value is ServerInstanceInfo { + const record = value as Partial | null; + return ( + !!record && + record.service === 'iptvnator' && + typeof record.apiVersion === 'number' && + typeof record.instanceId === 'string' && + (record.storage === 'server' || record.storage === 'none') && + (record.auth === 'none' || record.auth === 'token') + ); +} diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.html b/apps/web/src/app/server-storage/server-migration-dialog.component.html new file mode 100644 index 000000000..b5c06a9da --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.html @@ -0,0 +1,69 @@ +

+ {{ + 'SERVER_STORAGE.MIGRATION_TITLE' + | translate: { count: playlists.length } + }} +

+ + + @if (result(); as done) { +

+ {{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }} +

+ } @else { +

{{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}

+
    + @for (playlist of playlists; track playlist.id) { +
  • + + {{ playlist.title }} +
  • + } +
+

+ {{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }} +

+ @if (failed()) { +

+ {{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }} +

+ } + } +
+ + + @if (result()) { + + } @else { + + + } + diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.scss b/apps/web/src/app/server-storage/server-migration-dialog.component.scss new file mode 100644 index 000000000..a5030ec0b --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.scss @@ -0,0 +1,46 @@ +.server-migration { + p { + margin: 0 0 12px; + color: var(--app-body-color); + } +} + +.server-migration__list { + list-style: none; + margin: 0 0 12px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 10px 12px; + border-radius: 12px; + background: var(--app-card-hover-bg); + color: var(--app-heading-color); + + mat-icon { + flex-shrink: 0; + } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.server-migration .server-migration__hint { + color: var(--app-muted-color); + font-size: 0.8rem; +} + +.server-migration .server-migration__error { + color: var(--app-expiry-expired-text); +} diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts b/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts new file mode 100644 index 000000000..2abb52a8f --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerMigrationService } from '@iptvnator/services/server-storage'; +import { ServerMigrationDialogComponent } from './server-migration-dialog.component'; + +describe('ServerMigrationDialogComponent', () => { + let fixture: ComponentFixture; + const migration = { skip: jest.fn(), moveBrowserData: jest.fn() }; + const dialogRef = { close: jest.fn() }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ServerMigrationDialogComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: [{ id: 'm', title: 'Family', kind: 'm3u' }], + }, + { provide: ServerMigrationService, useValue: migration }, + { provide: MatDialogRef, useValue: dialogRef }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(ServerMigrationDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => jest.resetAllMocks()); + + const byTestId = (id: string) => + fixture.nativeElement.querySelector(`[data-test-id="${id}"]`); + + it('lists the browser playlists', () => { + expect(fixture.nativeElement.textContent).toContain('Family'); + }); + + it('records "not now" and closes', () => { + byTestId('server-migration-not-now').click(); + + expect(migration.skip).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('moves the data and offers a reload', async () => { + const result = { imported: 1, merged: 0, failed: 0 }; + migration.moveBrowserData.mockResolvedValue(result); + + await fixture.componentInstance.move(); + fixture.detectChanges(); + + expect(fixture.componentInstance.result()).toEqual(result); + expect(byTestId('server-migration-result')).not.toBeNull(); + expect(byTestId('server-migration-reload')).not.toBeNull(); + expect(byTestId('server-migration-move')).toBeNull(); + }); + + it('keeps the dialog open with a message when moving fails', async () => { + migration.moveBrowserData.mockRejectedValue(new Error('offline')); + + await fixture.componentInstance.move(); + fixture.detectChanges(); + + expect(fixture.componentInstance.failed()).toBe(true); + expect(fixture.nativeElement.textContent).toContain( + 'SERVER_STORAGE.MIGRATION_FAILED' + ); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.ts b/apps/web/src/app/server-storage/server-migration-dialog.component.ts new file mode 100644 index 000000000..dd741a96d --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.ts @@ -0,0 +1,70 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + ServerMigrationService, + type ServerMigrationPlaylist, + type ServerMigrationResult, +} from '@iptvnator/services/server-storage'; +import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; + +@Component({ + selector: 'app-server-migration-dialog', + imports: [ + MatButtonModule, + MatDialogModule, + MatIconModule, + MatProgressSpinnerModule, + TranslatePipe, + ], + templateUrl: './server-migration-dialog.component.html', + styleUrl: './server-migration-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ServerMigrationDialogComponent { + private readonly migration = inject(ServerMigrationService); + private readonly dialogRef = inject( + MatDialogRef + ); + + readonly playlists = + inject(MAT_DIALOG_DATA); + readonly icons = SOURCE_TYPE_ICONS; + readonly busy = signal(false); + readonly failed = signal(false); + readonly result = signal(null); + + notNow(): void { + this.migration.skip(); + this.dialogRef.close(); + } + + async move(): Promise { + this.busy.set(true); + this.failed.set(false); + try { + this.result.set(await this.migration.moveBrowserData()); + } catch { + this.failed.set(true); + } finally { + this.busy.set(false); + } + } + + /** The library was replaced underneath every open view. */ + reload(): void { + window.location.reload(); + } +} diff --git a/apps/web/src/app/server-storage/server-migration-prompt.service.ts b/apps/web/src/app/server-storage/server-migration-prompt.service.ts new file mode 100644 index 000000000..0fb3ef18f --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-prompt.service.ts @@ -0,0 +1,50 @@ +import { inject, Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter, take } from 'rxjs'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { + ServerMigrationService, + type ServerMigrationPlaylist, +} from '@iptvnator/services/server-storage'; + +/** Opens the migration dialog once, after the first navigation settles. */ +@Injectable({ providedIn: 'root' }) +export class ServerMigrationPrompt { + private readonly router = inject(Router); + private readonly dialog = inject(MatDialog); + private readonly status = inject(ServerStorageStatusService); + private readonly migration = inject(ServerMigrationService); + + schedule(): void { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + take(1) + ) + .subscribe(() => void this.offer()); + } + + private async offer(): Promise { + if (this.status.state() !== 'online') { + return; + } + let playlists: ServerMigrationPlaylist[] | null; + try { + playlists = await this.migration.offerOnStartup(); + } catch { + return; // IndexedDB or server unreadable: ask again next start. + } + if (!playlists) { + return; + } + const { ServerMigrationDialogComponent } = + await import('./server-migration-dialog.component'); + this.dialog.open(ServerMigrationDialogComponent, { + data: playlists, + width: '540px', + maxWidth: 'calc(100vw - 32px)', + disableClose: true, + }); + } +} diff --git a/apps/web/src/app/server-storage/server-sign-in.component.html b/apps/web/src/app/server-storage/server-sign-in.component.html new file mode 100644 index 000000000..f107ace9a --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.html @@ -0,0 +1,65 @@ +
+ +
diff --git a/apps/web/src/app/server-storage/server-sign-in.component.scss b/apps/web/src/app/server-storage/server-sign-in.component.scss new file mode 100644 index 000000000..14880084f --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.scss @@ -0,0 +1,56 @@ +:host { + display: block; + min-height: 100%; + background: var(--app-shell-bg); +} + +.server-sign-in { + min-height: 100vh; + display: grid; + place-items: center; + padding: 16px; + box-sizing: border-box; +} + +.server-sign-in__card { + width: min(100%, 420px); + display: flex; + flex-direction: column; + gap: 14px; + padding: 28px; + box-sizing: border-box; + border-radius: 20px; + background: var(--app-widget-bg); + box-shadow: var(--app-widget-shadow); + color: var(--app-on-surface); + + h1 { + margin: 0; + font-size: 1.3rem; + font-weight: 600; + color: var(--app-heading-color); + } + + p { + margin: 0; + color: var(--app-body-color); + font-size: 0.9rem; + line-height: 1.45; + } + + button[type='submit'] { + align-self: flex-end; + } +} + +.server-sign-in__logo { + border-radius: 12px; +} + +.server-sign-in .server-sign-in__feedback { + color: var(--app-expiry-expired-text); + + &:empty { + display: none; + } +} diff --git a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts new file mode 100644 index 000000000..f7f4ed605 --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts @@ -0,0 +1,202 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + readServerAccessToken, + writeServerAccessToken, +} from '@iptvnator/services'; +import { + SERVER_STORAGE_CONFIG, + ServerApiError, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { REMEMBERED_SERVER_KEY } from './server-storage-start'; +import { ServerSignInComponent } from './server-sign-in.component'; + +const TOKEN_KEY = 'iptvnator-server:access-token'; + +describe('ServerSignInComponent', () => { + let fixture: ComponentFixture; + let component: ServerSignInComponent; + const session = { fetchStatus: jest.fn(), ensureVerified: jest.fn() }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ServerSignInComponent, TranslateModule.forRoot()], + providers: [ + { provide: ServerStorageSession, useValue: session }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { + baseUrl: '/api', + instance: { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'token', + }, + }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(ServerSignInComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + localStorage.clear(); + jest.resetAllMocks(); + }); + + const feedbackText = () => + fixture.nativeElement + .querySelector('[data-test-id="server-sign-in-feedback"]') + .textContent.trim(); + + it('keeps an accepted token and reloads into the workspace', async () => { + session.fetchStatus.mockResolvedValue({ playlists: 0 }); + const open = jest + .spyOn(component, 'openWorkspace') + .mockImplementation(() => undefined); + + component.edit(' right '); + await component.submit(new Event('submit')); + + expect(localStorage.getItem(TOKEN_KEY)).toBe('right'); + // Only for the instance it was given for. + expect(readServerAccessToken('i-1')).toBe('right'); + expect(readServerAccessToken('i-2')).toBeNull(); + expect(open).toHaveBeenCalled(); + }); + + it('says the token was refused and forgets it', async () => { + session.fetchStatus.mockRejectedValue( + new ServerApiError(401, 'unauthorized', 'refused') + ); + + component.edit('wrong'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + + expect(component.feedback()).toBe('rejected'); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_REJECTED'); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + }); + + it("keeps another tab's newer sign-in when this attempt fails", async () => { + session.fetchStatus.mockImplementation(async () => { + // Another tab signs in while this answer is on its way. + writeServerAccessToken('right', 'i-1'); + throw new ServerApiError(401, 'unauthorized', 'refused'); + }); + + component.edit('wrong'); + await component.submit(new Event('submit')); + + expect(component.feedback()).toBe('rejected'); + expect(readServerAccessToken('i-1')).toBe('right'); + }); + + it('leaves a token another tab stored to it after a network failure', async () => { + session.fetchStatus.mockImplementation(async () => { + // Another tab signs in with the same token meanwhile. + writeServerAccessToken('right', 'i-1'); + throw new ServerApiError(0, 'network', 'offline'); + }); + + component.edit('right'); + await component.submit(new Event('submit')); + + expect(component.feedback()).toBe('unreachable'); + expect(readServerAccessToken('i-1')).toBe('right'); + }); + + it('tells an unreachable server apart and clears the message on edit', async () => { + session.fetchStatus.mockRejectedValue( + new ServerApiError(0, undefined, 'offline') + ); + + component.edit('token'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE'); + + component.edit('token2'); + fixture.detectChanges(); + expect(feedbackText()).toBe(''); + }); + + it('does not submit an empty token', async () => { + component.edit(' '); + await component.submit(new Event('submit')); + + expect(session.fetchStatus).not.toHaveBeenCalled(); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + }); + + it('says when the browser will not keep the token', async () => { + const refuse = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new DOMException('full', 'QuotaExceededError'); + }); + try { + component.edit('right'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + } finally { + refuse.mockRestore(); + } + + expect(session.fetchStatus).not.toHaveBeenCalled(); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_STORAGE'); + }); + + it('remembers the server the token was accepted for', async () => { + session.fetchStatus.mockResolvedValue({ playlists: 0 }); + jest.spyOn(component, 'openWorkspace').mockImplementation( + () => undefined + ); + + component.edit('right'); + await component.submit(new Event('submit')); + + expect( + JSON.parse(localStorage.getItem(REMEMBERED_SERVER_KEY) ?? '{}') + ).toMatchObject({ baseUrl: '/api', instance: { instanceId: 'i-1' } }); + }); + + it('signs in only when the server can be remembered too', async () => { + const refuse = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation((key: string) => { + if (key === REMEMBERED_SERVER_KEY) { + throw new DOMException('full', 'QuotaExceededError'); + } + }); + try { + component.edit('right'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + } finally { + refuse.mockRestore(); + } + + expect(session.fetchStatus).not.toHaveBeenCalled(); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_STORAGE'); + }); + + it('sends the token only after the server proved to be the remembered one', async () => { + session.ensureVerified.mockRejectedValue(new Error('another server')); + + component.edit('right'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + + expect(session.fetchStatus).not.toHaveBeenCalled(); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE'); + }); +}); diff --git a/apps/web/src/app/server-storage/server-sign-in.component.ts b/apps/web/src/app/server-storage/server-sign-in.component.ts new file mode 100644 index 000000000..4a7d02a87 --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.ts @@ -0,0 +1,115 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + clearServerAccessToken, + readServerAccessToken, + writeServerAccessToken, +} from '@iptvnator/services'; +import { + SERVER_STORAGE_CONFIG, + ServerApiError, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { rememberServer } from './server-storage-start'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; +import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle'; + +export type ServerSignInFeedback = 'rejected' | 'unreachable' | 'storage'; + +@Component({ + selector: 'app-server-sign-in', + imports: [ + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + PasswordVisibilityToggleDirective, + TranslatePipe, + ], + providers: [provideFormFieldDefaults()], + templateUrl: './server-sign-in.component.html', + styleUrl: './server-sign-in.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ServerSignInComponent { + private readonly session = inject(ServerStorageSession); + private readonly config = inject(SERVER_STORAGE_CONFIG); + + readonly token = signal(''); + readonly busy = signal(false); + readonly feedback = signal(null); + + edit(value: string): void { + this.token.set(value); + this.feedback.set(null); + } + + async submit(event: Event): Promise { + event.preventDefault(); + const token = this.token().trim(); + if (!token || this.busy()) { + return; + } + this.busy.set(true); + this.feedback.set(null); + try { + // A page started from memory checks the server first: the token + // must reach only the one it is meant for. + await this.session.ensureVerified(); + } catch { + this.feedback.set('unreachable'); + this.busy.set(false); + return; + } + // Every request and the reload read the token from storage, and the + // next start keeps it only for the server it remembers. + if ( + !writeServerAccessToken(token, this.config.instance.instanceId) || + !rememberServer(this.config.baseUrl, this.config.instance) + ) { + forgetAttempt(token); + this.feedback.set('storage'); + this.busy.set(false); + return; + } + try { + await this.session.fetchStatus(); + this.openWorkspace(); + } catch (error) { + const rejected = + error instanceof ServerApiError && error.status === 401; + // A refused token is no one's. A network failure says nothing + // about it, and another tab may have stored the same one since. + if (rejected) { + forgetAttempt(token); + } + this.feedback.set(rejected ? 'rejected' : 'unreachable'); + } finally { + this.busy.set(false); + } + } + + /** Full reload so every service starts with the token. */ + openWorkspace(): void { + window.location.assign(new URL('workspace', document.baseURI).href); + } +} + +/** + * Forgets a token whose sign-in failed, unless another tab has signed in + * since: its token replaced this one and is not this attempt's to clear. + */ +function forgetAttempt(token: string): void { + if (readServerAccessToken() === token) { + clearServerAccessToken(); + } +} diff --git a/apps/web/src/app/server-storage/server-storage-start.spec.ts b/apps/web/src/app/server-storage/server-storage-start.spec.ts new file mode 100644 index 000000000..eda02cad1 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage-start.spec.ts @@ -0,0 +1,119 @@ +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import { + REMEMBERED_SERVER_KEY, + resolveServerStorageStart, +} from './server-storage-start'; + +const instance: ServerInstanceInfo = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'none', +}; + +describe('resolveServerStorageStart', () => { + afterEach(() => localStorage.clear()); + + it('starts online and remembers a server that answered', () => { + expect( + resolveServerStorageStart({ kind: 'server', instance }, '/api') + ).toEqual({ instance, startedOffline: false }); + expect( + JSON.parse(localStorage.getItem(REMEMBERED_SERVER_KEY) ?? '') + ).toEqual({ baseUrl: '/api', instance }); + }); + + it('starts offline when a remembered server does not answer', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toEqual({ instance, startedOffline: true }); + }); + + it('matches the remembered server however its URL is written', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + + for (const spelling of ['/api/', ' /api', `${location.origin}/api//`]) { + expect( + resolveServerStorageStart({ kind: 'unreachable' }, spelling) + ).toEqual({ instance, startedOffline: true }); + } + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/apis') + ).toBeNull(); + }); + + it('keeps the token only for the same server and database', () => { + const token = 'iptvnator-server:access-token'; + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + localStorage.setItem(token, 'secret'); + + resolveServerStorageStart({ kind: 'server', instance }, '/api/'); + expect(localStorage.getItem(token)).toBe('secret'); + + resolveServerStorageStart( + { kind: 'server', instance: { ...instance, instanceId: 'i-2' } }, + '/api' + ); + expect(localStorage.getItem(token)).toBeNull(); + + localStorage.setItem(token, 'secret'); + resolveServerStorageStart( + { kind: 'server', instance: { ...instance, instanceId: 'i-2' } }, + '/other' + ); + expect(localStorage.getItem(token)).toBeNull(); + }); + + it('forgets the token of a server that no longer offers storage', () => { + localStorage.setItem('iptvnator-server:access-token', 'old'); + + resolveServerStorageStart({ kind: 'none' }, '/api'); + + expect( + localStorage.getItem('iptvnator-server:access-token') + ).toBeNull(); + }); + + it('uses browser mode for an unknown or different server', () => { + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toBeNull(); + + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + expect( + resolveServerStorageStart( + { kind: 'unreachable' }, + 'http://other:3000' + ) + ).toBeNull(); + }); + + it('forgets the server once it answers without server storage', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + + expect(resolveServerStorageStart({ kind: 'none' }, '/api')).toBeNull(); + expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).toBeNull(); + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toBeNull(); + }); + + it('ignores a skipped probe and unreadable memory', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + expect( + resolveServerStorageStart({ kind: 'skipped' }, '/api') + ).toBeNull(); + expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).not.toBeNull(); + + localStorage.setItem(REMEMBERED_SERVER_KEY, '{broken'); + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toBeNull(); + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api', null) + ).toBeNull(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-storage-start.ts b/apps/web/src/app/server-storage/server-storage-start.ts new file mode 100644 index 000000000..51c682f9b --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage-start.ts @@ -0,0 +1,137 @@ +import { clearServerAccessToken } from '@iptvnator/services'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import type { ServerInstanceProbe } from './server-instance-probe'; + +/** Last instance this browser saw, per backend URL. */ +export const REMEMBERED_SERVER_KEY = 'iptvnator-server:instance'; + +export interface ServerStorageStart { + readonly instance: ServerInstanceInfo; + /** No answer at start; the instance comes from this browser's memory. */ + readonly startedOffline: boolean; +} + +interface RememberedServer { + readonly baseUrl: string; + readonly instance: ServerInstanceInfo; +} + +/** + * Decides whether the app starts in server mode. A browser that has used + * this server before stays in server mode while it does not answer (shown + * as offline), so it never falls back to an empty browser library and + * collects playlists the server will not see. An answer without server + * storage forgets the server. + */ +export function resolveServerStorageStart( + probe: ServerInstanceProbe, + baseUrl: string, + storage: Pick< + Storage, + 'getItem' | 'setItem' | 'removeItem' + > | null = safeLocalStorage() +): ServerStorageStart | null { + switch (probe.kind) { + case 'server': { + const remembered = read(storage); + // Another server, or another database behind this one: the + // stored token was not given for it. + if ( + !remembered || + backendIdentity(remembered.baseUrl) !== + backendIdentity(baseUrl) || + remembered.instance.instanceId !== probe.instance.instanceId + ) { + clearServerAccessToken(); + } + write(storage, { baseUrl, instance: probe.instance }); + return { instance: probe.instance, startedOffline: false }; + } + case 'none': + remove(storage); + // The token belonged to that server; another must not see it. + clearServerAccessToken(); + return null; + case 'unreachable': { + const remembered = read(storage); + return remembered && + backendIdentity(remembered.baseUrl) === backendIdentity(baseUrl) + ? { instance: remembered.instance, startedOffline: true } + : null; + } + default: + return null; + } +} + +/** + * Origin and path of a backend URL, without trailing slashes: `/api` and + * `/api/` (or the same URL written absolute) name one server, as they do + * for the probe and the API client. + */ +function backendIdentity(url: string): string { + try { + const resolved = new URL(url.trim(), globalThis.location?.href); + return `${resolved.origin}${resolved.pathname.replace(/\/+$/, '')}`; + } catch { + return url.trim().replace(/\/+$/, ''); + } +} + +function read( + storage: Pick | null +): RememberedServer | null { + try { + const raw = storage?.getItem(REMEMBERED_SERVER_KEY); + const parsed = raw ? (JSON.parse(raw) as RememberedServer) : null; + return parsed && + typeof parsed.baseUrl === 'string' && + parsed.instance?.storage === 'server' && + typeof parsed.instance.instanceId === 'string' + ? parsed + : null; + } catch { + return null; + } +} + +/** + * Remembers the server a token was accepted for; false when storage + * refused it (the next start would take the token for another server's). + */ +export function rememberServer( + baseUrl: string, + instance: ServerInstanceInfo, + storage: Pick | null = safeLocalStorage() +): boolean { + return write(storage, { baseUrl, instance }); +} + +function write( + storage: Pick | null, + value: RememberedServer +): boolean { + try { + storage?.setItem(REMEMBERED_SERVER_KEY, JSON.stringify(value)); + return !!storage; + } catch { + // Without storage an offline start falls back to browser mode. + return false; + } +} + +function remove(storage: Pick | null): void { + try { + storage?.removeItem(REMEMBERED_SERVER_KEY); + } catch { + // Nothing stored. + } +} + +function safeLocalStorage(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} diff --git a/apps/web/src/app/server-storage/server-storage.providers.ts b/apps/web/src/app/server-storage/server-storage.providers.ts new file mode 100644 index 000000000..d70327df8 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage.providers.ts @@ -0,0 +1,43 @@ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { + EnvironmentProviders, + inject, + provideAppInitializer, + Provider, +} from '@angular/core'; +import { USER_DATA_STORAGE } from '@iptvnator/services'; +import { + SERVER_STORAGE_CONFIG, + ServerAccessTokenInterceptor, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { ServerMigrationPrompt } from './server-migration-prompt.service'; +import type { ServerStorageStart } from './server-storage-start'; + +/** Loaded with a dynamic import from `main.ts`, only in server mode. */ +export function provideServerStorage( + start: ServerStorageStart, + baseUrl: string +): (Provider | EnvironmentProviders)[] { + return [ + { + provide: SERVER_STORAGE_CONFIG, + useValue: { + instance: start.instance, + startedOffline: start.startedOffline, + baseUrl, + }, + }, + { + provide: USER_DATA_STORAGE, + useFactory: () => inject(ServerStorageSession).userData, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: ServerAccessTokenInterceptor, + multi: true, + }, + provideAppInitializer(() => inject(ServerStorageSession).start()), + provideAppInitializer(() => inject(ServerMigrationPrompt).schedule()), + ]; +} diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index a132d5038..6dba8849a 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -20,6 +20,11 @@ import { getStalkerRequestErrorStatus, isStalkerProbeTimeout, } from '@iptvnator/portal/stalker/data-access'; +import { + clearServerAccessToken, + ServerStorageStatusService, + writeServerAccessToken, +} from '@iptvnator/services'; import { PwaService } from './pwa.service'; describe('PwaService', () => { @@ -328,6 +333,103 @@ describe('PwaService', () => { globalThis.fetch = originalFetch; }); + it('sends a stored server token only while server mode runs', async () => { + writeServerAccessToken('kept-token', 'i'); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => ({ js: [] }), + } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const call = async () => { + const outcome = ( + service.sendIpcEvent(STALKER_REQUEST, { + url: 'http://portal.example/portal.php', + macAddress: '00:1A:79:AA:BB:CC', + params: { action: 'get_genres' }, + silent: true, + }) as Promise + ).catch((error: unknown) => error); + await Promise.resolve(); + for (const request of http.match((candidate) => + candidate.url.endsWith('/provider-targets') + )) { + request.flush({ targetId: 'target-1' }); + } + await outcome; + }; + + try { + // A token kept from another backend: server mode is not active. + await call(); + expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty( + 'Authorization' + ); + + TestBed.inject(ServerStorageStatusService).activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'token', + }, + () => undefined + ); + await call(); + expect(fetchMock.mock.calls[1][1].headers).toMatchObject({ + Authorization: 'Bearer kept-token', + }); + } finally { + globalThis.fetch = originalFetch; + clearServerAccessToken(); + } + }); + + it('asks for a new server token when the access guard refuses a Stalker call', async () => { + const status = TestBed.inject(ServerStorageStatusService); + status.activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'token', + }, + () => undefined + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer realm="iptvnator"', + }), + json: async () => ({ code: 'unauthorized' }), + } as unknown as Response) as unknown as typeof fetch; + + const outcome = ( + service.sendIpcEvent(STALKER_REQUEST, { + url: 'http://portal.example/portal.php', + macAddress: '00:1A:79:AA:BB:CC', + params: { action: 'get_genres' }, + silent: true, + }) as Promise + ).catch((error: unknown) => error); + await Promise.resolve(); + http.expectOne((candidate) => + candidate.url.endsWith('/provider-targets') + ).flush({ targetId: 'target-1' }); + await outcome; + + expect(status.state()).toBe('unauthorized'); + globalThis.fetch = originalFetch; + }); + it('sends Stalker credentials as /stalker control params, including the serial', async () => { // JSDOM ships no fetch; install one for the proxy call. const fetchMock = jest.fn().mockResolvedValue({ @@ -518,6 +620,118 @@ describe('PwaService', () => { } }); + it('sends the server token to the backend only, also with BACKEND_URL=/', async () => { + writeServerAccessToken('kept-token', 'i'); + TestBed.inject(ServerStorageStatusService).activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'token', + }, + () => undefined + ); + window.__IPTVNATOR_CONFIG__ = { BACKEND_URL: '/' }; + const atRoot = TestBed.runInInjectionContext(() => new PwaService()); + delete window.__IPTVNATOR_CONFIG__; + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ reset: true }), + } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const reset = () => + atRoot.sendIpcEvent(CONNECTIVITY_GUARD_RESET, { + url: 'http://portal.example:8080/portal.php', + }); + + try { + await reset(); + // Not `//connectivity-guard/reset`: that is another host. + expect(fetchMock.mock.calls[0][0]).toBe( + '/connectivity-guard/reset' + ); + expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ + Authorization: 'Bearer kept-token', + }); + + atRoot.corsProxyUrl = 'https://elsewhere.example'; + await reset(); + expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty( + 'Authorization' + ); + } finally { + globalThis.fetch = originalFetch; + clearServerAccessToken(); + } + }); + + it('reports a backend outage seen by its raw fetches', async () => { + const status = TestBed.inject(ServerStorageStatusService); + const activate = () => + status.activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', + }, + () => undefined + ); + const originalFetch = globalThis.fetch; + const reset = () => + ( + service.sendIpcEvent(CONNECTIVITY_GUARD_RESET, { + url: 'http://portal.example:8080/portal.php', + }) as Promise + ).catch(() => undefined); + const answer = (code: number, contentType: string) => + ({ + ok: false, + status: code, + headers: new Headers({ 'content-type': contentType }), + json: async () => ({}), + }) as unknown as Response; + + try { + activate(); + // The backend's own JSON error: it answered. + globalThis.fetch = jest + .fn() + .mockResolvedValue( + answer(502, 'application/json') + ) as unknown as typeof fetch; + await reset(); + expect(status.state()).toBe('online'); + + // A gateway's page in front of a backend that is down. + globalThis.fetch = jest + .fn() + .mockResolvedValue( + answer(502, 'text/html') + ) as unknown as typeof fetch; + await reset(); + expect(status.state()).toBe('offline'); + expect(status.isVerified()).toBe(false); + + // No answer at all. + activate(); + globalThis.fetch = jest + .fn() + .mockRejectedValue( + new TypeError('Failed to fetch') + ) as unknown as typeof fetch; + await reset(); + expect(status.state()).toBe('offline'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('asks the backend to forget a host when the connectivity guard is reset', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 0b5369ddb..53583bfc7 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -13,7 +13,13 @@ import { switchMap, throwError, } from 'rxjs'; -import { DataService } from '@iptvnator/services'; +import { + DataService, + isBackendUrl, + isGatewayOutage, + isServerAccessChallenge, + ServerStorageStatusService, +} from '@iptvnator/services'; import { CONNECTIVITY_GUARD_RESET, ERROR, @@ -83,6 +89,7 @@ export class PwaService extends DataService { private readonly store = inject(Store); private readonly swUpdate = inject(SwUpdate); private readonly translateService = inject(TranslateService); + private readonly serverStatus = inject(ServerStorageStatusService); private readonly logger = createLogger('PwaService'); private readonly providerTargetIds = new Map>(); private readonly silentXtreamActions = new Set([ @@ -95,8 +102,15 @@ export class PwaService extends DataService { XtreamCodeActions.GetSimpleDateTable, ]); - /** Proxy URL to avoid CORS issues */ - corsProxyUrl = getRuntimeBackendUrl(); + /** The backend's URL as configured: `/` for the page's own origin. */ + private readonly backendUrl = getRuntimeBackendUrl(); + + /** + * Proxy URL to avoid CORS issues, without trailing slashes: paths are + * appended to it, and `BACKEND_URL=/` must give `/xtream`, never the + * protocol-relative `//xtream` (another host). + */ + corsProxyUrl = this.backendUrl.replace(/\/+$/, ''); constructor() { super(); @@ -197,7 +211,7 @@ export class PwaService extends DataService { ); try { - const response = await fetch( + const response = await this.backendFetch( `${this.corsProxyUrl}/connectivity-guard/reset`, { body: JSON.stringify({ url: payload.url }), @@ -647,7 +661,7 @@ export class PwaService extends DataService { logPortalDebugRequest(context); // Make the fetch request - const response = await fetch(requestUrl); + const response = await this.backendFetch(requestUrl, {}); if (!response.ok) { throw new Error( @@ -799,4 +813,67 @@ export class PwaService extends DataService { getAppEnvironment(): string { return 'pwa'; } + + /** + * The stored server token, only while server mode runs for this + * backend and only for a request that goes to it: a token kept from + * another backend must not reach this one, nor any other host. + */ + private serverAccessToken(url: string): string | null { + return isBackendUrl(url, this.backendUrl) + ? this.serverStatus.accessToken() + : null; + } + + /** + * `fetch` to the backend with the server token, telling the server + * session what any backend call can show: a token changed on the + * server, or an outage (no answer, or a gateway's 502/503/504), after + * which the instance is checked before the token goes out again. A + * timeout of this page's own is no outage: the backend may be slow. + */ + private async backendFetch( + url: string, + init: RequestInit + ): Promise { + const token = this.serverAccessToken(url); + let response: Response; + try { + response = await fetch(url, { + ...init, + headers: { + ...(init.headers as Record | undefined), + ...bearer(token), + }, + }); + } catch (error) { + if (!( + error instanceof DOMException && error.name === 'AbortError' + )) { + this.serverStatus.reportOffline(); + } + throw error; + } + if ( + isGatewayOutage( + response.status, + response.headers?.get('content-type') + ) + ) { + this.serverStatus.reportOffline(); + } else if ( + response.status === 401 && + isServerAccessChallenge( + response.status, + response.headers.get('WWW-Authenticate') + ) + ) { + this.serverStatus.reportAccessDenied(token); + } + return response; + } +} + +function bearer(token: string | null): Record { + return token ? { Authorization: `Bearer ${token}` } : {}; } diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html index f9e84765e..51bc1b240 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html @@ -9,6 +9,14 @@ | translate: { count: dialogData.summary.total } }}

+ @if (clearsServer()) { +

+ {{ 'SERVER_STORAGE.DELETE_ALL_WARNING' | translate }} +

+ }

diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.spec.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.spec.ts index 2736033e2..31f536931 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.spec.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.spec.ts @@ -2,6 +2,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; import { SettingsDeleteAllPlaylistsDialogComponent } from './settings-delete-all-playlists-dialog.component'; describe('SettingsDeleteAllPlaylistsDialogComponent', () => { @@ -93,4 +94,26 @@ describe('SettingsDeleteAllPlaylistsDialogComponent', () => { expect(buttons).toEqual(['Cancel', 'Delete everything']); }); + + it('warns about every device only with server storage', () => { + const warning = () => + fixture.nativeElement.querySelector( + '[data-test-id="delete-all-server-warning"]' + ); + expect(warning()).toBeNull(); + + TestBed.inject(ServerStorageStatusService).activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', + }, + () => undefined + ); + fixture.detectChanges(); + + expect(warning()).not.toBeNull(); + }); }); diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts index 9f4a0fea2..90649f56b 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts @@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; export interface SettingsDeleteAllPlaylistsDialogData { @@ -43,6 +44,8 @@ type SettingsDeleteSummaryItem = { export class SettingsDeleteAllPlaylistsDialogComponent { readonly dialogData = inject(MAT_DIALOG_DATA); + /** Self-hosted server storage: the playlists go for every device. */ + readonly clearsServer = inject(ServerStorageStatusService).isActive; readonly summaryItems = computed(() => [ { diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts index 6b919a4f3..bdb8db5e7 100644 --- a/apps/web/src/app/settings/settings-options.spec.ts +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -42,10 +42,12 @@ describe('buildSettingsSectionNavItems', () => { const supportedItems = buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }); const unsupportedItems = buildSettingsSectionNavItems({ supportsEpg: false, supportsRemoteControl: false, + supportsServerStorage: false, }); expect(supportedItems.map((item) => item.id)).toEqual( @@ -65,6 +67,7 @@ describe('buildSettingsSectionNavItems', () => { buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }).map((item) => item.id) ); const renderableIds = collectRenderableSectionIds(); @@ -86,6 +89,7 @@ describe('buildSettingsSectionNavItems', () => { buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }).map((item) => item.id) ).toEqual([ 'general', @@ -95,6 +99,7 @@ describe('buildSettingsSectionNavItems', () => { 'tmdb', 'parental', 'remote-control', + 'server', 'backup', 'about', ]); @@ -104,6 +109,7 @@ describe('buildSettingsSectionNavItems', () => { const items = buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }); const groupOf = (id: string) => items.find((item) => item.id === id)?.group; @@ -111,7 +117,24 @@ describe('buildSettingsSectionNavItems', () => { expect(groupOf('general')).toBe('app'); expect(groupOf('tmdb')).toBe('library'); expect(groupOf('remote-control')).toBe('devices'); + expect(groupOf('server')).toBe('data'); expect(groupOf('backup')).toBe('data'); expect(groupOf('about')).toBeUndefined(); }); + + it('shows the server section only with server storage', () => { + const build = (supportsServerStorage: boolean) => + buildSettingsSectionNavItems({ + supportsEpg: false, + supportsRemoteControl: false, + supportsServerStorage, + }).find((item) => item.id === 'server'); + + expect(build(true)).toMatchObject({ + visible: true, + group: 'data', + icon: 'dns', + }); + expect(build(false)?.visible).toBe(false); + }); }); diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index ff0c085d2..e37c99b66 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -162,15 +162,18 @@ export function buildSettingsPlayerOptions({ export interface SettingsSectionVisibility { supportsEpg: boolean; supportsRemoteControl: boolean; + supportsServerStorage: boolean; } export function buildSettingsSectionNavItems({ supportsEpg, supportsRemoteControl, + supportsServerStorage, }: SettingsSectionVisibility): SettingsSection[] { const capabilities = { epg: supportsEpg, 'remote-control': supportsRemoteControl, + 'server-storage': supportsServerStorage, }; return SETTINGS_SECTION_DEFINITIONS.map((section) => ({ id: section.id, diff --git a/apps/web/src/app/settings/settings-server-section.component.html b/apps/web/src/app/settings/settings-server-section.component.html new file mode 100644 index 000000000..641379835 --- /dev/null +++ b/apps/web/src/app/settings/settings-server-section.component.html @@ -0,0 +1,156 @@ +
+
+

{{ 'SETTINGS.NAV_SERVER' | translate }}

+

{{ 'SETTINGS.SERVER_DESCRIPTION' | translate }}

+
+ +
+
+
+

{{ 'SETTINGS.SERVER_CONNECTION' | translate }}

+

+ {{ 'SETTINGS.SERVER_CONNECTION_DESCRIPTION' | translate }} +

+

+ {{ address }} +

+
+
+ + {{ + (state() === 'online' + ? 'SETTINGS.SERVER_CONNECTED' + : 'SETTINGS.SERVER_OFFLINE' + ) | translate + }} + +
+
+ +
+
+

{{ 'SETTINGS.SERVER_STORAGE' | translate }}

+

{{ 'SETTINGS.SERVER_STORAGE_DESCRIPTION' | translate }}

+ @if (details(); as info) { +

+ {{ + 'SETTINGS.SERVER_STORAGE_SUMMARY' + | translate + : { + playlists: info.playlists, + size: sizeLabel(), + } + }} +

+

+ {{ info.databasePath }} +

+ } +
+
+ +
+
+ + @if (showBrowserImport()) { +
+
+

{{ 'SETTINGS.SERVER_IMPORT_BROWSER' | translate }}

+

+ {{ + 'SETTINGS.SERVER_IMPORT_BROWSER_DESCRIPTION' + | translate + }} +

+

+ @if (importResult(); as done) { + {{ + 'SERVER_STORAGE.MIGRATION_DONE' + | translate: done + }} + } @else if (importFailed()) { + {{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }} + } @else { + {{ + 'SETTINGS.SERVER_IMPORT_BROWSER_COUNT' + | translate + : { count: browserPlaylistCount() } + }} + } +

+
+
+ @if (movedSomething()) { + + + } + @if (browserPlaylistCount() > 0) { + + } +
+
+ } + +
+
+

{{ 'SETTINGS.SERVER_ACCESS' | translate }}

+

{{ 'SETTINGS.SERVER_ACCESS_DESCRIPTION' | translate }}

+

+ {{ + (usesToken() + ? 'SETTINGS.SERVER_ACCESS_TOKEN' + : 'SETTINGS.SERVER_ACCESS_OPEN' + ) | translate + }} +

+
+ @if (usesToken()) { +
+ +
+ } +
+
+
diff --git a/apps/web/src/app/settings/settings-server-section.component.spec.ts b/apps/web/src/app/settings/settings-server-section.component.spec.ts new file mode 100644 index 000000000..04ff8cf8b --- /dev/null +++ b/apps/web/src/app/settings/settings-server-section.component.spec.ts @@ -0,0 +1,128 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { + SERVER_STORAGE_CONFIG, + ServerMigrationService, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { SettingsServerSectionComponent } from './settings-server-section.component'; + +describe('SettingsServerSectionComponent', () => { + let fixture: ComponentFixture; + const session = { fetchStatus: jest.fn(), signOut: jest.fn() }; + const migration = { + browserPlaylists: jest.fn(), + browserDataMoved: jest.fn(), + moveBrowserData: jest.fn(), + }; + + async function render( + auth: 'none' | 'token', + browserPlaylists = [{ id: 'a', title: 'A', kind: 'm3u' }] + ) { + session.fetchStatus.mockResolvedValue({ + playlists: 4, + databaseBytes: 38 * 1024 * 1024, + databasePath: '/data/databases/iptvnator.db', + startedAt: '2026-10-11T08:00:00.000Z', + }); + migration.browserDataMoved.mockReturnValue(false); + migration.browserPlaylists.mockResolvedValue(browserPlaylists); + await TestBed.configureTestingModule({ + imports: [ + SettingsServerSectionComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: ServerStorageSession, useValue: session }, + { provide: ServerMigrationService, useValue: migration }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { baseUrl: 'http://nas.local:4333/api' }, + }, + { + provide: ServerStorageStatusService, + useValue: { + state: signal('online'), + instance: signal({ auth, instanceId: 'i-1' }), + }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(SettingsServerSectionComponent); + await fixture.componentInstance.load(); + fixture.detectChanges(); + } + + const byTestId = (id: string): HTMLElement | null => + fixture.nativeElement.querySelector(`[data-test-id="${id}"]`); + + afterEach(() => { + TestBed.resetTestingModule(); + jest.resetAllMocks(); + }); + + it('shows the storage figures and the database path', async () => { + await render('none'); + const text = fixture.nativeElement.textContent; + + expect(text).toContain('/data/databases/iptvnator.db'); + expect(byTestId('server-address')?.textContent?.trim()).toBe( + 'http://nas.local:4333' + ); + expect(byTestId('server-state')?.dataset['state']).toBe('online'); + expect(fixture.componentInstance.sizeLabel()).toBe('38.0 MB'); + expect(byTestId('server-sign-out')).toBeNull(); + }); + + it('moves browser playlists and reports the result', async () => { + await render('none'); + migration.moveBrowserData.mockResolvedValue({ + imported: 1, + merged: 0, + failed: 0, + }); + migration.browserDataMoved.mockReturnValue(true); + + byTestId('server-import-browser')?.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(migration.moveBrowserData).toHaveBeenCalled(); + expect(byTestId('server-import-browser')).toBeNull(); + expect(byTestId('server-import-reload')).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'SERVER_STORAGE.MIGRATION_DONE' + ); + }); + + it('hides the browser import when there is nothing to move', async () => { + await render('none', []); + + expect( + fixture.nativeElement.querySelector( + '[data-setting-id="server-import-browser"]' + ) + ).toBeNull(); + }); + + it('offers sign out when a token is in use', async () => { + await render('token'); + + byTestId('server-sign-out')?.click(); + + expect(session.signOut).toHaveBeenCalled(); + }); + + it('asks the page to export a backup', async () => { + await render('none'); + const exported = jest.fn(); + fixture.componentInstance.exportData.subscribe(exported); + + byTestId('server-export-backup')?.click(); + + expect(exported).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/settings/settings-server-section.component.ts b/apps/web/src/app/settings/settings-server-section.component.ts new file mode 100644 index 000000000..a003016f2 --- /dev/null +++ b/apps/web/src/app/settings/settings-server-section.component.ts @@ -0,0 +1,122 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + output, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { + SERVER_STORAGE_CONFIG, + ServerMigrationService, + ServerStorageSession, + type ServerMigrationResult, +} from '@iptvnator/services/server-storage'; +import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces'; + +/** + * Settings → Server: shown only when the PWA runs against self-hosted + * 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', + imports: [ + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + TranslatePipe, + ], + templateUrl: './settings-server-section.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsServerSectionComponent { + private readonly status = inject(ServerStorageStatusService); + private readonly session = inject(ServerStorageSession); + private readonly migration = inject(ServerMigrationService); + + readonly exportData = output(); + + readonly state = this.status.state; + readonly usesToken = computed( + () => this.status.instance()?.auth === 'token' + ); + /** The backend's origin; the page's own in Docker (`/api`). */ + readonly address = backendOrigin(inject(SERVER_STORAGE_CONFIG).baseUrl); + readonly details = signal(null); + readonly browserPlaylistCount = signal(0); + readonly importing = signal(false); + readonly importFailed = signal(false); + readonly importResult = signal(null); + readonly sizeLabel = computed(() => + formatMegabytes(this.details()?.databaseBytes ?? 0) + ); + readonly showBrowserImport = computed( + () => this.browserPlaylistCount() > 0 || this.importResult() !== null + ); + readonly movedSomething = computed(() => { + const result = this.importResult(); + return !!result && result.imported + result.merged > 0; + }); + + constructor() { + void this.load(); + } + + async load(): Promise { + try { + this.details.set(await this.session.fetchStatus()); + } catch { + this.details.set(null); + } + try { + this.browserPlaylistCount.set( + this.migration.browserDataMoved() + ? 0 + : (await this.migration.browserPlaylists()).length + ); + } catch { + this.browserPlaylistCount.set(0); + } + } + + async importBrowserPlaylists(): Promise { + this.importing.set(true); + this.importFailed.set(false); + try { + this.importResult.set(await this.migration.moveBrowserData()); + await this.load(); + } catch { + this.importFailed.set(true); + } finally { + this.importing.set(false); + } + } + + signOut(): void { + this.session.signOut(); + } + + reload(): void { + window.location.reload(); + } +} + +function backendOrigin(baseUrl: string): string { + try { + return new URL(baseUrl, globalThis.location?.href).origin; + } catch { + return baseUrl; + } +} + +function formatMegabytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 5823c6e0a..75c6702be 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -119,6 +119,11 @@ (unlock)="parentalLock.unlock()" /> } + @case ('server') { + + } @case ('backup') { diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 61e825477..573ede123 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -606,3 +606,31 @@ display: none; } } + +.server-state { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + color: var(--app-selection-color); + background: var(--app-selection-surface); + + &[data-state='offline'], + &[data-state='unauthorized'] { + color: var(--app-expiry-warn-text); + background: var(--app-expiry-warn-surface); + } +} + +.setting-item__meta .server-detail { + color: var(--app-muted-color); + overflow-wrap: anywhere; +} + +.setting-item__meta .server-detail--path { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-weight: 500; + font-size: 0.78rem; +} diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index db039148c..f0c153944 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -22,6 +22,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { EpgSourceReconciliationError, RuntimeCapabilitiesService, + ServerStorageStatusService, injectTranslationTick, } from '@iptvnator/services'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; @@ -61,6 +62,7 @@ import { SettingsParentalLockSectionComponent } from './settings-parental-sectio import { SettingsPlaybackSectionComponent } from './settings-playback-section.component'; import { SettingsRemoteControlFacade } from './settings-remote-control.facade'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; +import { SettingsServerSectionComponent } from './settings-server-section.component'; import { SettingsSearchFacade } from './settings-search.facade'; import { SettingsSearchResultsComponent } from './settings-search-results.component'; import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component'; @@ -108,6 +110,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsPlaybackSectionComponent, SettingsRemoteControlSectionComponent, SettingsSearchResultsComponent, + SettingsServerSectionComponent, SettingsTmdbSectionComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -166,6 +169,9 @@ export class SettingsComponent this.runtime.supportsExternalPlayerPathSettings; readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable; readonly supportsRemoteControl = this.runtime.supportsRemoteControl; + readonly supportsServerStorage = inject( + ServerStorageStatusService + ).isActive(); readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode; readonly supportsPortalConnectivityGuard = this.runtime.supportsPortalConnectivityGuard; @@ -230,6 +236,7 @@ export class SettingsComponent readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({ supportsEpg: this.supportsEpg, supportsRemoteControl: this.supportsRemoteControl, + supportsServerStorage: this.supportsServerStorage, }); get sectionNav(): SettingsSection[] { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 7a788f97a..f5e4f69d9 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "عن بعد", "NAV_DATA": "البيانات", "NAV_BACKUP": "النسخ الاحتياطي والبيانات", + "NAV_SERVER": "الخادم", + "SERVER_DESCRIPTION": "تُحفظ قوائم التشغيل والمفضلة وتقدّم المشاهدة على هذا الخادم وتتشاركها كل الأجهزة التي تفتحه.", + "SERVER_CONNECTION": "الاتصال", + "SERVER_CONNECTION_DESCRIPTION": "خادم IPTVnator الذي يحفظ فيه هذا التطبيق بياناته.", + "SERVER_CONNECTED": "متصل", + "SERVER_OFFLINE": "غير متاح", + "SERVER_STORAGE": "التخزين", + "SERVER_STORAGE_DESCRIPTION": "تُحفظ كل البيانات في ملف قاعدة بيانات واحد على الخادم. صدّر نسخة احتياطية للاحتفاظ بنسخة في مكان آخر.", + "SERVER_STORAGE_SUMMARY": "قوائم التشغيل: {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "تصدير نسخة احتياطية", + "SERVER_IMPORT_BROWSER": "قوائم التشغيل في هذا المتصفح", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "قوائم تشغيل حُفظت في هذا المتصفح قبل أن يستخدم الخادم. انقلها لتظهر على كل الأجهزة؛ وتُدمج القوائم الموجودة على الخادم مسبقًا.", + "SERVER_IMPORT_BROWSER_COUNT": "قوائم التشغيل المحفوظة في هذا المتصفح فقط: {{count}}.", + "SERVER_ACCESS": "الوصول", + "SERVER_ACCESS_DESCRIPTION": "من يمكنه فتح مكتبة هذا الخادم.", + "SERVER_ACCESS_OPEN": "يمكن لأي شخص يصل إلى هذا العنوان استخدامه. اضبط IPTVNATOR_ACCESS_TOKEN على الخادم لطلب رمز وصول.", + "SERVER_ACCESS_TOKEN": "يتطلب هذا الخادم رمز وصول.", + "SERVER_SIGN_OUT": "تسجيل الخروج", "NAV_ABOUT": "حول", "NAV_GROUP_APP": "التطبيق", "NAV_GROUP_LIBRARY": "المكتبة", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "تم القفل" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "تعذّر الحفظ على الخادم. تحقق من الاتصال وحاول مرة أخرى.", + "TOO_LARGE": "قائمة التشغيل هذه أكبر مما يقبله الخادم.", + "MIGRATION_TITLE": "تم العثور على قوائم تشغيل في هذا المتصفح: {{count}}", + "MIGRATION_DESCRIPTION": "إنها محفوظة في هذا المتصفح فقط. انقلها إلى الخادم لتراها كل الأجهزة التي تفتح هذا العنوان. لن يُحذف شيء من هذا المتصفح.", + "MIGRATION_LATER_HINT": "يمكنك فعل ذلك لاحقًا من الإعدادات ← الخادم.", + "MIGRATION_NOT_NOW": "ليس الآن", + "MIGRATION_MOVE": "نقل إلى الخادم", + "MIGRATION_DONE": "تم النقل: {{imported}}، الدمج: {{merged}}، الفشل: {{failed}}.", + "MIGRATION_FAILED": "فشل النقل. ما زالت قوائم التشغيل في هذا المتصفح؛ حاول مجددًا من الإعدادات ← الخادم.", + "MIGRATION_RELOAD": "إعادة التحميل", + "SIGN_IN_TITLE": "مطلوب رمز وصول", + "SIGN_IN_DESCRIPTION": "يطلب خادم IPTVnator هذا رمز وصول قبل عرض قوائم التشغيل.", + "SIGN_IN_TOKEN_LABEL": "رمز الوصول", + "SIGN_IN_HINT": "تجده في إعداد IPTVNATOR_ACCESS_TOKEN على الخادم.", + "SIGN_IN_REJECTED": "تم رفض الرمز. تحقق منه وحاول مرة أخرى.", + "SIGN_IN_UNREACHABLE": "تعذّر الوصول إلى الخادم. تحقق من الاتصال وحاول مرة أخرى.", + "SIGN_IN_STORAGE": "لا يسمح هذا المتصفح لـ IPTVnator بحفظ الرمز. اسمح ببيانات الموقع لهذه الصفحة وحاول مرة أخرى.", + "SIGN_IN_CONTINUE": "متابعة", + "STATUS_ONLINE": "الخادم متصل", + "STATUS_OFFLINE": "الخادم غير متاح", + "STATUS_UNAUTHORIZED": "الخادم يحتاج إلى رمز وصول", + "OFFLINE_BANNER": "الخادم غير متاح. لا يمكن حفظ التغييرات حتى يعود.", + "RETRY": "إعادة المحاولة", + "DELETE_ALL_WARNING": "سيؤدي هذا إلى حذف قوائم التشغيل من الخادم لكل الأجهزة." + }, "THEMES": { "DARK_THEME": "داكن", "LIGHT_THEME": "فاتح", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 80153a6f7..c517dd02f 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "التحكم عن بعد", "NAV_DATA": "البيانات", "NAV_BACKUP": "النسخة الاحتياطية والمعطيات", + "NAV_SERVER": "السيرفر", + "SERVER_DESCRIPTION": "لوائح التشغيل والمفضلة وفين وصلتي فالمشاهدة كيتحفظو فهاد السيرفر وكيتشاركو مع كاع الأجهزة اللي كتحلو.", + "SERVER_CONNECTION": "الاتصال", + "SERVER_CONNECTION_DESCRIPTION": "سيرفر IPTVnator اللي كيحفظ فيه هاد التطبيق.", + "SERVER_CONNECTED": "متاصل", + "SERVER_OFFLINE": "ماشي متوفر", + "SERVER_STORAGE": "التخزين", + "SERVER_STORAGE_DESCRIPTION": "كلشي محفوظ فملف واحد ديال قاعدة المعطيات فالسيرفر. صدّر نسخة احتياطية باش تخلي نسخة فبلاصة خرا.", + "SERVER_STORAGE_SUMMARY": "لوائح التشغيل: {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "صدّر نسخة احتياطية", + "SERVER_IMPORT_BROWSER": "لوائح التشغيل فهاد المتصفح", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "لوائح تشغيل تحفظو فهاد المتصفح قبل ما يولي يخدم بالسيرفر. نقلهم باش يبانو فكاع الأجهزة؛ اللوائح اللي ديجا كاينين فالسيرفر كيتجمعو.", + "SERVER_IMPORT_BROWSER_COUNT": "لوائح التشغيل المحفوظين غير فهاد المتصفح: {{count}}.", + "SERVER_ACCESS": "الدخول", + "SERVER_ACCESS_DESCRIPTION": "شكون يقدر يحل المكتبة ديال هاد السيرفر.", + "SERVER_ACCESS_OPEN": "أي واحد يوصل لهاد العنوان يقدر يستعملو. حط IPTVNATOR_ACCESS_TOKEN فالسيرفر باش تطلب رمز.", + "SERVER_ACCESS_TOKEN": "هاد السيرفر كيطلب رمز الدخول.", + "SERVER_SIGN_OUT": "خرج", "NAV_ABOUT": "حول", "NAV_GROUP_APP": "التطبيق", "NAV_GROUP_LIBRARY": "المكتبة", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "تقفل" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "ما قدرناش نحفظو فالسيرفر. شوف الاتصال وعاود جرب.", + "TOO_LARGE": "هاد لائحة التشغيل كبيرة على اللي كيقبل السيرفر.", + "MIGRATION_TITLE": "لقينا لوائح تشغيل فهاد المتصفح: {{count}}", + "MIGRATION_DESCRIPTION": "محفوظين غير فهاد المتصفح. نقلهم للسيرفر باش يشوفهم كاع الأجهزة اللي كتحل هاد العنوان. والو ما غادي يتحيد من هاد المتصفح.", + "MIGRATION_LATER_HINT": "تقدر دير هادشي من بعد فالإعدادات ← السيرفر.", + "MIGRATION_NOT_NOW": "ماشي دابا", + "MIGRATION_MOVE": "نقل للسيرفر", + "MIGRATION_DONE": "تنقلو: {{imported}}، تجمعو: {{merged}}، فشلو: {{failed}}.", + "MIGRATION_FAILED": "النقل فشل. لوائح التشغيل ديالك باقيين فهاد المتصفح؛ عاود جرب من الإعدادات ← السيرفر.", + "MIGRATION_RELOAD": "عاود التحميل", + "SIGN_IN_TITLE": "خاص رمز الدخول", + "SIGN_IN_DESCRIPTION": "هاد سيرفر IPTVnator كيطلب رمز الدخول قبل ما يوريك لوائح التشغيل ديالك.", + "SIGN_IN_TOKEN_LABEL": "رمز الدخول", + "SIGN_IN_HINT": "تلقاه فالإعداد IPTVNATOR_ACCESS_TOKEN ديال السيرفر.", + "SIGN_IN_REJECTED": "الرمز ترفض. تأكد منو وعاود جرب.", + "SIGN_IN_UNREACHABLE": "ما قدرناش نوصلو للسيرفر. شوف الاتصال وعاود جرب.", + "SIGN_IN_STORAGE": "هاد المتصفح ما كيخليش IPTVnator يحفظ الرمز. سمح بداتا ديال الموقع لهاد الصفحة وعاود جرب.", + "SIGN_IN_CONTINUE": "كمّل", + "STATUS_ONLINE": "السيرفر متاصل", + "STATUS_OFFLINE": "السيرفر ماشي متوفر", + "STATUS_UNAUTHORIZED": "السيرفر خاصو رمز الدخول", + "OFFLINE_BANNER": "السيرفر ماشي متوفر. التغييرات ما يمكنش تتحفظ حتى يرجع.", + "RETRY": "عاود جرب", + "DELETE_ALL_WARNING": "هادشي غادي يحيد لوائح التشغيل من السيرفر لكاع الأجهزة." + }, "THEMES": { "DARK_THEME": "داكن", "LIGHT_THEME": "فاتح", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index fdf3e2447..fa25f8fc0 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Кіраванне", "NAV_DATA": "Дадзеныя", "NAV_BACKUP": "Рэзервовая копія і дадзеныя", + "NAV_SERVER": "Сервер", + "SERVER_DESCRIPTION": "Плэйлісты, выбранае і прагрэс прагляду захоўваюцца на гэтым серверы і даступныя на кожнай прыладзе, якая яго адкрывае.", + "SERVER_CONNECTION": "Злучэнне", + "SERVER_CONNECTION_DESCRIPTION": "Сервер IPTVnator, на які гэта праграма захоўвае даныя.", + "SERVER_CONNECTED": "Падключана", + "SERVER_OFFLINE": "Недаступны", + "SERVER_STORAGE": "Сховішча", + "SERVER_STORAGE_DESCRIPTION": "Усё захоўваецца ў адным файле базы даных на серверы. Экспартуйце рэзервовую копію, каб мець копію ў іншым месцы.", + "SERVER_STORAGE_SUMMARY": "Плэйлістоў: {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "Экспартаваць копію", + "SERVER_IMPORT_BROWSER": "Плэйлісты ў гэтым браўзеры", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Плэйлісты, захаваныя ў гэтым браўзеры да пераходу на сервер. Перанясіце іх, каб іх бачыла кожная прылада; плэйлісты, якія ўжо ёсць на серверы, будуць аб’яднаны.", + "SERVER_IMPORT_BROWSER_COUNT": "Плэйлістоў, захаваных толькі ў гэтым браўзеры: {{count}}.", + "SERVER_ACCESS": "Доступ", + "SERVER_ACCESS_DESCRIPTION": "Хто можа адкрыць бібліятэку гэтага сервера.", + "SERVER_ACCESS_OPEN": "Карыстацца можа кожны, хто мае доступ да гэтага адраса. Задайце IPTVNATOR_ACCESS_TOKEN на серверы, каб патрабаваць токен.", + "SERVER_ACCESS_TOKEN": "Гэты сервер патрабуе токен доступу.", + "SERVER_SIGN_OUT": "Выйсці", "NAV_ABOUT": "Аб праграме", "NAV_GROUP_APP": "Праграма", "NAV_GROUP_LIBRARY": "Бібліятэка", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Заблакіравана" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Не ўдалося захаваць на сервер. Праверце злучэнне і паспрабуйце зноў.", + "TOO_LARGE": "Гэты плэйліст большы, чым прымае сервер.", + "MIGRATION_TITLE": "У гэтым браўзеры знойдзена плэйлістоў: {{count}}", + "MIGRATION_DESCRIPTION": "Яны захоўваюцца толькі ў гэтым браўзеры. Перанясіце іх на сервер, каб іх бачыла кожная прылада, што адкрывае гэты адрас. З гэтага браўзера нічога не выдаляецца.", + "MIGRATION_LATER_HINT": "Гэта можна зрабіць пазней у Налады → Сервер.", + "MIGRATION_NOT_NOW": "Не зараз", + "MIGRATION_MOVE": "Перанесці на сервер", + "MIGRATION_DONE": "Перанесена: {{imported}}, аб’яднана: {{merged}}, з памылкай: {{failed}}.", + "MIGRATION_FAILED": "Не ўдалося перанесці. Плэйлісты засталіся ў гэтым браўзеры; паспрабуйце зноў у Налады → Сервер.", + "MIGRATION_RELOAD": "Перазагрузіць", + "SIGN_IN_TITLE": "Патрэбны токен доступу", + "SIGN_IN_DESCRIPTION": "Гэты сервер IPTVnator патрабуе токен доступу, перш чым паказаць вашы плэйлісты.", + "SIGN_IN_TOKEN_LABEL": "Токен доступу", + "SIGN_IN_HINT": "Ён зададзены ў наладзе IPTVNATOR_ACCESS_TOKEN на серверы.", + "SIGN_IN_REJECTED": "Токен адхілены. Праверце яго і паспрабуйце зноў.", + "SIGN_IN_UNREACHABLE": "Сервер недаступны. Праверце злучэнне і паспрабуйце зноў.", + "SIGN_IN_STORAGE": "Браўзер не дазваляе IPTVnator захаваць токен. Дазвольце даныя сайта для гэтай старонкі і паспрабуйце зноў.", + "SIGN_IN_CONTINUE": "Працягнуць", + "STATUS_ONLINE": "Сервер падключаны", + "STATUS_OFFLINE": "Сервер недаступны", + "STATUS_UNAUTHORIZED": "Серверу патрэбны токен доступу", + "OFFLINE_BANNER": "Сервер недаступны. Змены нельга захаваць, пакуль ён не вернецца.", + "RETRY": "Паўтарыць", + "DELETE_ALL_WARNING": "Плэйлісты будуць выдалены з сервера для ўсіх прылад." + }, "THEMES": { "DARK_THEME": "Цёмная", "LIGHT_THEME": "Светлая", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 1c439f42e..5640bc7be 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Remote", "NAV_DATA": "Daten", "NAV_BACKUP": "Datensicherung & Daten", + "NAV_SERVER": "Server", + "SERVER_DESCRIPTION": "Wiedergabelisten, Favoriten und Wiedergabefortschritt werden auf diesem Server gespeichert und mit jedem Gerät geteilt, das ihn öffnet.", + "SERVER_CONNECTION": "Verbindung", + "SERVER_CONNECTION_DESCRIPTION": "Der IPTVnator-Server, auf dem diese App speichert.", + "SERVER_CONNECTED": "Verbunden", + "SERVER_OFFLINE": "Nicht erreichbar", + "SERVER_STORAGE": "Speicher", + "SERVER_STORAGE_DESCRIPTION": "Alles liegt in einer Datenbankdatei auf dem Server. Exportieren Sie eine Datensicherung, um eine Kopie an anderer Stelle aufzubewahren.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} Wiedergabelisten · {{size}}", + "SERVER_EXPORT_BACKUP": "Sicherung exportieren", + "SERVER_IMPORT_BROWSER": "Wiedergabelisten in diesem Browser", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Wiedergabelisten, die in diesem Browser gespeichert wurden, bevor er den Server nutzte. Verschieben Sie sie, damit jedes Gerät sie sieht; Wiedergabelisten, die der Server schon hat, werden zusammengeführt.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} Wiedergabelisten sind nur in diesem Browser gespeichert.", + "SERVER_ACCESS": "Zugriff", + "SERVER_ACCESS_DESCRIPTION": "Wer die Bibliothek dieses Servers öffnen kann.", + "SERVER_ACCESS_OPEN": "Jeder, der diese Adresse erreicht, kann ihn nutzen. Setzen Sie IPTVNATOR_ACCESS_TOKEN auf dem Server, um ein Token zu verlangen.", + "SERVER_ACCESS_TOKEN": "Dieser Server verlangt ein Zugriffstoken.", + "SERVER_SIGN_OUT": "Abmelden", "NAV_ABOUT": "Info", "NAV_GROUP_APP": "App", "NAV_GROUP_LIBRARY": "Bibliothek", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Gesperrt" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Speichern auf dem Server fehlgeschlagen. Prüfen Sie die Verbindung und versuchen Sie es erneut.", + "TOO_LARGE": "Diese Wiedergabeliste ist größer, als der Server annimmt.", + "MIGRATION_TITLE": "{{count}} Wiedergabelisten in diesem Browser gefunden", + "MIGRATION_DESCRIPTION": "Sie sind nur in diesem Browser gespeichert. Verschieben Sie sie auf den Server, damit jedes Gerät, das diese Adresse öffnet, sie sieht. In diesem Browser wird nichts gelöscht.", + "MIGRATION_LATER_HINT": "Das geht auch später unter Einstellungen → Server.", + "MIGRATION_NOT_NOW": "Nicht jetzt", + "MIGRATION_MOVE": "Auf den Server verschieben", + "MIGRATION_DONE": "Verschoben: {{imported}}, zusammengeführt: {{merged}}, fehlgeschlagen: {{failed}}.", + "MIGRATION_FAILED": "Verschieben fehlgeschlagen. Ihre Wiedergabelisten sind weiterhin in diesem Browser; versuchen Sie es unter Einstellungen → Server erneut.", + "MIGRATION_RELOAD": "Neu laden", + "SIGN_IN_TITLE": "Zugriffstoken erforderlich", + "SIGN_IN_DESCRIPTION": "Dieser IPTVnator-Server verlangt ein Zugriffstoken, bevor er Ihre Wiedergabelisten zeigt.", + "SIGN_IN_TOKEN_LABEL": "Zugriffstoken", + "SIGN_IN_HINT": "Es steht in der Einstellung IPTVNATOR_ACCESS_TOKEN des Servers.", + "SIGN_IN_REJECTED": "Token abgelehnt. Prüfen Sie es und versuchen Sie es erneut.", + "SIGN_IN_UNREACHABLE": "Server nicht erreichbar. Prüfen Sie die Verbindung und versuchen Sie es erneut.", + "SIGN_IN_STORAGE": "Dieser Browser lässt IPTVnator das Token nicht speichern. Erlauben Sie Websitedaten für diese Seite und versuchen Sie es erneut.", + "SIGN_IN_CONTINUE": "Weiter", + "STATUS_ONLINE": "Server verbunden", + "STATUS_OFFLINE": "Server nicht erreichbar", + "STATUS_UNAUTHORIZED": "Server verlangt ein Zugriffstoken", + "OFFLINE_BANNER": "Server nicht erreichbar. Änderungen können erst gespeichert werden, wenn er wieder verfügbar ist.", + "RETRY": "Erneut versuchen", + "DELETE_ALL_WARNING": "Damit werden die Wiedergabelisten für alle Geräte vom Server entfernt." + }, "THEMES": { "DARK_THEME": "Dunkel", "LIGHT_THEME": "Hell", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ebe75ba21..6c825cb04 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Τηλεχειριστήριο", "NAV_DATA": "Δεδομένα", "NAV_BACKUP": "Αντίγραφο ασφαλείας και δεδομένα", + "NAV_SERVER": "Διακομιστής", + "SERVER_DESCRIPTION": "Οι λίστες αναπαραγωγής, τα αγαπημένα και η πρόοδος παρακολούθησης αποθηκεύονται σε αυτόν τον διακομιστή και είναι κοινά για κάθε συσκευή που τον ανοίγει.", + "SERVER_CONNECTION": "Σύνδεση", + "SERVER_CONNECTION_DESCRIPTION": "Ο διακομιστής IPTVnator στον οποίο αποθηκεύει αυτή η εφαρμογή.", + "SERVER_CONNECTED": "Συνδεδεμένος", + "SERVER_OFFLINE": "Μη διαθέσιμος", + "SERVER_STORAGE": "Αποθήκευση", + "SERVER_STORAGE_DESCRIPTION": "Όλα φυλάσσονται σε ένα αρχείο βάσης δεδομένων στον διακομιστή. Εξαγάγετε αντίγραφο ασφαλείας για να κρατήσετε ένα αντίγραφο αλλού.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} λίστες αναπαραγωγής · {{size}}", + "SERVER_EXPORT_BACKUP": "Εξαγωγή αντιγράφου", + "SERVER_IMPORT_BROWSER": "Λίστες σε αυτό το πρόγραμμα περιήγησης", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Λίστες αναπαραγωγής που αποθηκεύτηκαν σε αυτό το πρόγραμμα περιήγησης πριν χρησιμοποιήσει τον διακομιστή. Μεταφέρετέ τες ώστε να τις βλέπει κάθε συσκευή· όσες έχει ήδη ο διακομιστής συγχωνεύονται.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} λίστες αναπαραγωγής είναι αποθηκευμένες μόνο σε αυτό το πρόγραμμα περιήγησης.", + "SERVER_ACCESS": "Πρόσβαση", + "SERVER_ACCESS_DESCRIPTION": "Ποιος μπορεί να ανοίξει τη βιβλιοθήκη αυτού του διακομιστή.", + "SERVER_ACCESS_OPEN": "Όποιος φτάνει σε αυτή τη διεύθυνση μπορεί να τον χρησιμοποιήσει. Ορίστε IPTVNATOR_ACCESS_TOKEN στον διακομιστή για να απαιτείται διακριτικό.", + "SERVER_ACCESS_TOKEN": "Αυτός ο διακομιστής απαιτεί διακριτικό πρόσβασης.", + "SERVER_SIGN_OUT": "Αποσύνδεση", "NAV_ABOUT": "Πληροφορίες", "NAV_GROUP_APP": "Εφαρμογή", "NAV_GROUP_LIBRARY": "Βιβλιοθήκη", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Κλειδώθηκε" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Δεν ήταν δυνατή η αποθήκευση στον διακομιστή. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.", + "TOO_LARGE": "Αυτή η λίστα αναπαραγωγής είναι μεγαλύτερη από όσο δέχεται ο διακομιστής.", + "MIGRATION_TITLE": "Βρέθηκαν {{count}} λίστες αναπαραγωγής σε αυτό το πρόγραμμα περιήγησης", + "MIGRATION_DESCRIPTION": "Είναι αποθηκευμένες μόνο σε αυτό το πρόγραμμα περιήγησης. Μεταφέρετέ τες στον διακομιστή ώστε να τις βλέπει κάθε συσκευή που ανοίγει αυτή τη διεύθυνση. Τίποτα δεν διαγράφεται από αυτό το πρόγραμμα περιήγησης.", + "MIGRATION_LATER_HINT": "Μπορείτε να το κάνετε αργότερα από Ρυθμίσεις → Διακομιστής.", + "MIGRATION_NOT_NOW": "Όχι τώρα", + "MIGRATION_MOVE": "Μεταφορά στον διακομιστή", + "MIGRATION_DONE": "Μεταφέρθηκαν {{imported}}, συγχωνεύτηκαν {{merged}}, απέτυχαν {{failed}}.", + "MIGRATION_FAILED": "Η μεταφορά απέτυχε. Οι λίστες σας παραμένουν σε αυτό το πρόγραμμα περιήγησης· δοκιμάστε ξανά από Ρυθμίσεις → Διακομιστής.", + "MIGRATION_RELOAD": "Επαναφόρτωση", + "SIGN_IN_TITLE": "Απαιτείται διακριτικό πρόσβασης", + "SIGN_IN_DESCRIPTION": "Αυτός ο διακομιστής IPTVnator ζητά διακριτικό πρόσβασης πριν εμφανίσει τις λίστες αναπαραγωγής σας.", + "SIGN_IN_TOKEN_LABEL": "Διακριτικό πρόσβασης", + "SIGN_IN_HINT": "Θα το βρείτε στη ρύθμιση IPTVNATOR_ACCESS_TOKEN του διακομιστή.", + "SIGN_IN_REJECTED": "Το διακριτικό απορρίφθηκε. Ελέγξτε το και δοκιμάστε ξανά.", + "SIGN_IN_UNREACHABLE": "Ο διακομιστής δεν είναι προσβάσιμος. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.", + "SIGN_IN_STORAGE": "Αυτό το πρόγραμμα περιήγησης δεν επιτρέπει στο IPTVnator να αποθηκεύσει το διακριτικό. Επιτρέψτε τα δεδομένα ιστότοπου για αυτή τη σελίδα και δοκιμάστε ξανά.", + "SIGN_IN_CONTINUE": "Συνέχεια", + "STATUS_ONLINE": "Ο διακομιστής είναι συνδεδεμένος", + "STATUS_OFFLINE": "Ο διακομιστής δεν είναι διαθέσιμος", + "STATUS_UNAUTHORIZED": "Ο διακομιστής χρειάζεται διακριτικό πρόσβασης", + "OFFLINE_BANNER": "Ο διακομιστής δεν είναι διαθέσιμος. Οι αλλαγές δεν μπορούν να αποθηκευτούν μέχρι να επανέλθει.", + "RETRY": "Επανάληψη", + "DELETE_ALL_WARNING": "Αυτό αφαιρεί τις λίστες αναπαραγωγής από τον διακομιστή για κάθε συσκευή." + }, "THEMES": { "DARK_THEME": "Σκούρο", "LIGHT_THEME": "Ανοιχτό", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 07ba1f306..a5bc388db 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Remote", "NAV_DATA": "Data", "NAV_BACKUP": "Backup & data", + "NAV_SERVER": "Server", + "SERVER_DESCRIPTION": "Playlists, favorites and watch progress are stored on this server and shared by every device that opens it.", + "SERVER_CONNECTION": "Connection", + "SERVER_CONNECTION_DESCRIPTION": "The IPTVnator server this app saves to.", + "SERVER_CONNECTED": "Connected", + "SERVER_OFFLINE": "Unavailable", + "SERVER_STORAGE": "Storage", + "SERVER_STORAGE_DESCRIPTION": "Everything is kept in one database file on the server. Export a backup to keep a copy elsewhere.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} playlists · {{size}}", + "SERVER_EXPORT_BACKUP": "Export backup", + "SERVER_IMPORT_BROWSER": "Playlists in this browser", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists saved in this browser before it used the server. Move them so every device sees them; playlists the server already has are merged.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists are stored only in this browser.", + "SERVER_ACCESS": "Access", + "SERVER_ACCESS_DESCRIPTION": "Who can open this server's library.", + "SERVER_ACCESS_OPEN": "Anyone who can reach this address can use it. Set IPTVNATOR_ACCESS_TOKEN on the server to require a token.", + "SERVER_ACCESS_TOKEN": "This server requires an access token.", + "SERVER_SIGN_OUT": "Sign out", "NAV_ABOUT": "About", "NAV_GROUP_APP": "App", "NAV_GROUP_LIBRARY": "Library", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Locked" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Could not save to the server. Check the connection and try again.", + "TOO_LARGE": "This playlist is larger than the server accepts.", + "MIGRATION_TITLE": "Found {{count}} playlists in this browser", + "MIGRATION_DESCRIPTION": "They are stored only in this browser. Move them to the server so every device that opens this address sees them. Nothing is deleted from this browser.", + "MIGRATION_LATER_HINT": "You can do this later in Settings → Server.", + "MIGRATION_NOT_NOW": "Not now", + "MIGRATION_MOVE": "Move to server", + "MIGRATION_DONE": "Moved {{imported}}, merged {{merged}}, failed {{failed}}.", + "MIGRATION_FAILED": "Moving failed. Your playlists are still in this browser; try again from Settings → Server.", + "MIGRATION_RELOAD": "Reload", + "SIGN_IN_TITLE": "Access token required", + "SIGN_IN_DESCRIPTION": "This IPTVnator server asks for an access token before it shows your playlists.", + "SIGN_IN_TOKEN_LABEL": "Access token", + "SIGN_IN_HINT": "Find it in the server's IPTVNATOR_ACCESS_TOKEN setting.", + "SIGN_IN_REJECTED": "Token rejected. Check it and try again.", + "SIGN_IN_UNREACHABLE": "Cannot reach the server. Check the connection and try again.", + "SIGN_IN_STORAGE": "This browser does not let IPTVnator keep the token. Allow site data for this page and try again.", + "SIGN_IN_CONTINUE": "Continue", + "STATUS_ONLINE": "Server connected", + "STATUS_OFFLINE": "Server unavailable", + "STATUS_UNAUTHORIZED": "Server needs an access token", + "OFFLINE_BANNER": "Server unavailable. Changes cannot be saved until it is back.", + "RETRY": "Retry", + "DELETE_ALL_WARNING": "This removes the playlists from the server for every device." + }, "THEMES": { "DARK_THEME": "Dark", "LIGHT_THEME": "Light", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index df43610e5..69a217a2e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Remoto", "NAV_DATA": "Datos", "NAV_BACKUP": "Copia de seguridad y datos", + "NAV_SERVER": "Servidor", + "SERVER_DESCRIPTION": "Las listas de reproducción, los favoritos y el progreso de visualización se guardan en este servidor y los comparten todos los dispositivos que lo abren.", + "SERVER_CONNECTION": "Conexión", + "SERVER_CONNECTION_DESCRIPTION": "El servidor de IPTVnator en el que guarda esta aplicación.", + "SERVER_CONNECTED": "Conectado", + "SERVER_OFFLINE": "No disponible", + "SERVER_STORAGE": "Almacenamiento", + "SERVER_STORAGE_DESCRIPTION": "Todo se guarda en un único archivo de base de datos en el servidor. Exporte una copia de seguridad para conservar una copia en otro lugar.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} listas de reproducción · {{size}}", + "SERVER_EXPORT_BACKUP": "Exportar copia", + "SERVER_IMPORT_BROWSER": "Listas en este navegador", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Listas de reproducción guardadas en este navegador antes de que usara el servidor. Muévalas para que todos los dispositivos las vean; las que el servidor ya tiene se combinan.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} listas de reproducción solo están guardadas en este navegador.", + "SERVER_ACCESS": "Acceso", + "SERVER_ACCESS_DESCRIPTION": "Quién puede abrir la biblioteca de este servidor.", + "SERVER_ACCESS_OPEN": "Cualquiera que llegue a esta dirección puede usarlo. Defina IPTVNATOR_ACCESS_TOKEN en el servidor para exigir un token.", + "SERVER_ACCESS_TOKEN": "Este servidor requiere un token de acceso.", + "SERVER_SIGN_OUT": "Cerrar sesión", "NAV_ABOUT": "Acerca de", "NAV_GROUP_APP": "Aplicación", "NAV_GROUP_LIBRARY": "Biblioteca", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Bloqueado" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "No se pudo guardar en el servidor. Compruebe la conexión e inténtelo de nuevo.", + "TOO_LARGE": "Esta lista de reproducción supera el tamaño que acepta el servidor.", + "MIGRATION_TITLE": "Se encontraron {{count}} listas de reproducción en este navegador", + "MIGRATION_DESCRIPTION": "Solo están guardadas en este navegador. Muévalas al servidor para que las vean todos los dispositivos que abren esta dirección. No se borra nada de este navegador.", + "MIGRATION_LATER_HINT": "Puede hacerlo más tarde en Ajustes → Servidor.", + "MIGRATION_NOT_NOW": "Ahora no", + "MIGRATION_MOVE": "Mover al servidor", + "MIGRATION_DONE": "Movidas: {{imported}}, combinadas: {{merged}}, fallidas: {{failed}}.", + "MIGRATION_FAILED": "No se pudieron mover. Sus listas siguen en este navegador; inténtelo de nuevo en Ajustes → Servidor.", + "MIGRATION_RELOAD": "Recargar", + "SIGN_IN_TITLE": "Se necesita un token de acceso", + "SIGN_IN_DESCRIPTION": "Este servidor de IPTVnator pide un token de acceso antes de mostrar sus listas de reproducción.", + "SIGN_IN_TOKEN_LABEL": "Token de acceso", + "SIGN_IN_HINT": "Lo encontrará en el ajuste IPTVNATOR_ACCESS_TOKEN del servidor.", + "SIGN_IN_REJECTED": "Token rechazado. Compruébelo e inténtelo de nuevo.", + "SIGN_IN_UNREACHABLE": "No se puede contactar con el servidor. Compruebe la conexión e inténtelo de nuevo.", + "SIGN_IN_STORAGE": "Este navegador no permite que IPTVnator guarde el token. Permita los datos del sitio para esta página e inténtelo de nuevo.", + "SIGN_IN_CONTINUE": "Continuar", + "STATUS_ONLINE": "Servidor conectado", + "STATUS_OFFLINE": "Servidor no disponible", + "STATUS_UNAUTHORIZED": "El servidor necesita un token de acceso", + "OFFLINE_BANNER": "Servidor no disponible. Los cambios no se pueden guardar hasta que vuelva.", + "RETRY": "Reintentar", + "DELETE_ALL_WARNING": "Esto elimina las listas de reproducción del servidor para todos los dispositivos." + }, "THEMES": { "DARK_THEME": "Oscuro", "LIGHT_THEME": "Claro", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 008bf6a3c..2d3628040 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Télécommande", "NAV_DATA": "Données", "NAV_BACKUP": "Sauvegarde et données", + "NAV_SERVER": "Serveur", + "SERVER_DESCRIPTION": "Les playlists, les favoris et la progression de lecture sont stockés sur ce serveur et partagés par tous les appareils qui l’ouvrent.", + "SERVER_CONNECTION": "Connexion", + "SERVER_CONNECTION_DESCRIPTION": "Le serveur IPTVnator sur lequel cette application enregistre.", + "SERVER_CONNECTED": "Connecté", + "SERVER_OFFLINE": "Indisponible", + "SERVER_STORAGE": "Stockage", + "SERVER_STORAGE_DESCRIPTION": "Tout est conservé dans un seul fichier de base de données sur le serveur. Exportez une sauvegarde pour en garder une copie ailleurs.", + "SERVER_STORAGE_SUMMARY": "Playlists : {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "Exporter une sauvegarde", + "SERVER_IMPORT_BROWSER": "Playlists de ce navigateur", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists enregistrées dans ce navigateur avant qu’il utilise le serveur. Déplacez-les pour que tous les appareils les voient ; celles que le serveur possède déjà sont fusionnées.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists sont stockées uniquement dans ce navigateur.", + "SERVER_ACCESS": "Accès", + "SERVER_ACCESS_DESCRIPTION": "Qui peut ouvrir la bibliothèque de ce serveur.", + "SERVER_ACCESS_OPEN": "Toute personne qui atteint cette adresse peut l’utiliser. Définissez IPTVNATOR_ACCESS_TOKEN sur le serveur pour exiger un jeton.", + "SERVER_ACCESS_TOKEN": "Ce serveur exige un jeton d’accès.", + "SERVER_SIGN_OUT": "Se déconnecter", "NAV_ABOUT": "À propos", "NAV_GROUP_APP": "Application", "NAV_GROUP_LIBRARY": "Bibliothèque", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Verrouillé" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Impossible d’enregistrer sur le serveur. Vérifiez la connexion et réessayez.", + "TOO_LARGE": "Cette playlist dépasse la taille acceptée par le serveur.", + "MIGRATION_TITLE": "{{count}} playlists trouvées dans ce navigateur", + "MIGRATION_DESCRIPTION": "Elles ne sont stockées que dans ce navigateur. Déplacez-les vers le serveur pour que tous les appareils qui ouvrent cette adresse les voient. Rien n’est supprimé de ce navigateur.", + "MIGRATION_LATER_HINT": "Vous pourrez le faire plus tard dans Paramètres → Serveur.", + "MIGRATION_NOT_NOW": "Plus tard", + "MIGRATION_MOVE": "Déplacer vers le serveur", + "MIGRATION_DONE": "Déplacées : {{imported}}, fusionnées : {{merged}}, en échec : {{failed}}.", + "MIGRATION_FAILED": "Le déplacement a échoué. Vos playlists sont toujours dans ce navigateur ; réessayez depuis Paramètres → Serveur.", + "MIGRATION_RELOAD": "Recharger", + "SIGN_IN_TITLE": "Jeton d’accès requis", + "SIGN_IN_DESCRIPTION": "Ce serveur IPTVnator demande un jeton d’accès avant d’afficher vos playlists.", + "SIGN_IN_TOKEN_LABEL": "Jeton d’accès", + "SIGN_IN_HINT": "Il figure dans le paramètre IPTVNATOR_ACCESS_TOKEN du serveur.", + "SIGN_IN_REJECTED": "Jeton refusé. Vérifiez-le et réessayez.", + "SIGN_IN_UNREACHABLE": "Serveur injoignable. Vérifiez la connexion et réessayez.", + "SIGN_IN_STORAGE": "Ce navigateur ne permet pas à IPTVnator d’enregistrer le jeton. Autorisez les données de site pour cette page et réessayez.", + "SIGN_IN_CONTINUE": "Continuer", + "STATUS_ONLINE": "Serveur connecté", + "STATUS_OFFLINE": "Serveur indisponible", + "STATUS_UNAUTHORIZED": "Le serveur demande un jeton d’accès", + "OFFLINE_BANNER": "Serveur indisponible. Les modifications ne peuvent pas être enregistrées avant son retour.", + "RETRY": "Réessayer", + "DELETE_ALL_WARNING": "Cela supprime les playlists du serveur pour tous les appareils." + }, "THEMES": { "DARK_THEME": "Sombre", "LIGHT_THEME": "Clair", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 1d9022689..01195a3ed 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Távvezérlés", "NAV_DATA": "Adatok", "NAV_BACKUP": "Biztonsági mentés és adatok", + "NAV_SERVER": "Szerver", + "SERVER_DESCRIPTION": "A lejátszási listák, a kedvencek és a megtekintési előrehaladás ezen a szerveren tárolódik, és minden eszköz ugyanazt látja, amely megnyitja.", + "SERVER_CONNECTION": "Kapcsolat", + "SERVER_CONNECTION_DESCRIPTION": "Az IPTVnator szerver, amelyre ez az alkalmazás ment.", + "SERVER_CONNECTED": "Csatlakozva", + "SERVER_OFFLINE": "Nem érhető el", + "SERVER_STORAGE": "Tárhely", + "SERVER_STORAGE_DESCRIPTION": "Minden egyetlen adatbázisfájlban van a szerveren. Exportáljon biztonsági mentést, hogy máshol is legyen másolata.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} lejátszási lista · {{size}}", + "SERVER_EXPORT_BACKUP": "Mentés exportálása", + "SERVER_IMPORT_BROWSER": "Lejátszási listák ebben a böngészőben", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Lejátszási listák, amelyeket ebben a böngészőben mentett, mielőtt az a szervert használta volna. Helyezze át őket, hogy minden eszköz lássa; a szerveren már meglévők összevonásra kerülnek.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} lejátszási lista csak ebben a böngészőben van tárolva.", + "SERVER_ACCESS": "Hozzáférés", + "SERVER_ACCESS_DESCRIPTION": "Ki nyithatja meg a szerver könyvtárát.", + "SERVER_ACCESS_OPEN": "Bárki használhatja, aki eléri ezt a címet. Token megköveteléséhez állítsa be az IPTVNATOR_ACCESS_TOKEN értéket a szerveren.", + "SERVER_ACCESS_TOKEN": "Ez a szerver hozzáférési tokent kér.", + "SERVER_SIGN_OUT": "Kijelentkezés", "NAV_ABOUT": "Névjegy", "NAV_GROUP_APP": "Alkalmazás", "NAV_GROUP_LIBRARY": "Médiatár", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Zárolva" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Nem sikerült menteni a szerverre. Ellenőrizze a kapcsolatot, és próbálja újra.", + "TOO_LARGE": "Ez a lejátszási lista nagyobb, mint amit a szerver elfogad.", + "MIGRATION_TITLE": "{{count}} lejátszási lista található ebben a böngészőben", + "MIGRATION_DESCRIPTION": "Ezek csak ebben a böngészőben vannak tárolva. Helyezze át őket a szerverre, hogy minden eszköz lássa őket, amely megnyitja ezt a címet. Ebből a böngészőből semmi sem törlődik.", + "MIGRATION_LATER_HINT": "Ezt később is megteheti a Beállítások → Szerver menüben.", + "MIGRATION_NOT_NOW": "Most nem", + "MIGRATION_MOVE": "Áthelyezés a szerverre", + "MIGRATION_DONE": "Áthelyezve: {{imported}}, összevonva: {{merged}}, sikertelen: {{failed}}.", + "MIGRATION_FAILED": "Az áthelyezés nem sikerült. A lejátszási listák továbbra is ebben a böngészőben vannak; próbálja újra a Beállítások → Szerver menüben.", + "MIGRATION_RELOAD": "Újratöltés", + "SIGN_IN_TITLE": "Hozzáférési token szükséges", + "SIGN_IN_DESCRIPTION": "Ez az IPTVnator szerver hozzáférési tokent kér, mielőtt megjeleníti a lejátszási listáit.", + "SIGN_IN_TOKEN_LABEL": "Hozzáférési token", + "SIGN_IN_HINT": "A szerver IPTVNATOR_ACCESS_TOKEN beállításában találja.", + "SIGN_IN_REJECTED": "A tokent elutasította a szerver. Ellenőrizze, és próbálja újra.", + "SIGN_IN_UNREACHABLE": "A szerver nem érhető el. Ellenőrizze a kapcsolatot, és próbálja újra.", + "SIGN_IN_STORAGE": "Ez a böngésző nem engedi, hogy az IPTVnator megőrizze a tokent. Engedélyezze a webhelyadatokat ehhez az oldalhoz, és próbálja újra.", + "SIGN_IN_CONTINUE": "Tovább", + "STATUS_ONLINE": "Szerver csatlakoztatva", + "STATUS_OFFLINE": "A szerver nem érhető el", + "STATUS_UNAUTHORIZED": "A szerver hozzáférési tokent kér", + "OFFLINE_BANNER": "A szerver nem érhető el. A módosítások nem menthetők, amíg újra elérhető nem lesz.", + "RETRY": "Újra", + "DELETE_ALL_WARNING": "Ez minden eszközön eltávolítja a lejátszási listákat a szerverről." + }, "THEMES": { "DARK_THEME": "Sötét", "LIGHT_THEME": "Világos", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 70ded8ebd..3d2fab021 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Remote", "NAV_DATA": "Dati", "NAV_BACKUP": "Copia di sicurezza e dati", + "NAV_SERVER": "Server", + "SERVER_DESCRIPTION": "Playlist, preferiti e avanzamento della visione sono salvati su questo server e condivisi da ogni dispositivo che lo apre.", + "SERVER_CONNECTION": "Connessione", + "SERVER_CONNECTION_DESCRIPTION": "Il server IPTVnator su cui salva questa app.", + "SERVER_CONNECTED": "Connesso", + "SERVER_OFFLINE": "Non disponibile", + "SERVER_STORAGE": "Archiviazione", + "SERVER_STORAGE_DESCRIPTION": "Tutto è conservato in un unico file di database sul server. Esporta una copia di sicurezza per tenerne una copia altrove.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} playlist · {{size}}", + "SERVER_EXPORT_BACKUP": "Esporta copia", + "SERVER_IMPORT_BROWSER": "Playlist in questo browser", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlist salvate in questo browser prima che usasse il server. Spostale perché ogni dispositivo le veda; quelle già presenti sul server vengono unite.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlist sono salvate solo in questo browser.", + "SERVER_ACCESS": "Accesso", + "SERVER_ACCESS_DESCRIPTION": "Chi può aprire la libreria di questo server.", + "SERVER_ACCESS_OPEN": "Chiunque raggiunga questo indirizzo può usarlo. Imposta IPTVNATOR_ACCESS_TOKEN sul server per richiedere un token.", + "SERVER_ACCESS_TOKEN": "Questo server richiede un token di accesso.", + "SERVER_SIGN_OUT": "Esci", "NAV_ABOUT": "Info", "NAV_GROUP_APP": "App", "NAV_GROUP_LIBRARY": "Libreria", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Bloccato" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Impossibile salvare sul server. Controlla la connessione e riprova.", + "TOO_LARGE": "Questa playlist è più grande di quanto il server accetti.", + "MIGRATION_TITLE": "Trovate {{count}} playlist in questo browser", + "MIGRATION_DESCRIPTION": "Sono salvate solo in questo browser. Spostale sul server perché le veda ogni dispositivo che apre questo indirizzo. Da questo browser non viene eliminato nulla.", + "MIGRATION_LATER_HINT": "Puoi farlo più tardi in Impostazioni → Server.", + "MIGRATION_NOT_NOW": "Non ora", + "MIGRATION_MOVE": "Sposta sul server", + "MIGRATION_DONE": "Spostate: {{imported}}, unite: {{merged}}, non riuscite: {{failed}}.", + "MIGRATION_FAILED": "Spostamento non riuscito. Le tue playlist sono ancora in questo browser; riprova da Impostazioni → Server.", + "MIGRATION_RELOAD": "Ricarica", + "SIGN_IN_TITLE": "Token di accesso richiesto", + "SIGN_IN_DESCRIPTION": "Questo server IPTVnator chiede un token di accesso prima di mostrare le tue playlist.", + "SIGN_IN_TOKEN_LABEL": "Token di accesso", + "SIGN_IN_HINT": "Lo trovi nell’impostazione IPTVNATOR_ACCESS_TOKEN del server.", + "SIGN_IN_REJECTED": "Token rifiutato. Controllalo e riprova.", + "SIGN_IN_UNREACHABLE": "Impossibile raggiungere il server. Controlla la connessione e riprova.", + "SIGN_IN_STORAGE": "Questo browser non consente a IPTVnator di salvare il token. Consenti i dati del sito per questa pagina e riprova.", + "SIGN_IN_CONTINUE": "Continua", + "STATUS_ONLINE": "Server connesso", + "STATUS_OFFLINE": "Server non disponibile", + "STATUS_UNAUTHORIZED": "Il server richiede un token di accesso", + "OFFLINE_BANNER": "Server non disponibile. Le modifiche non possono essere salvate finché non torna disponibile.", + "RETRY": "Riprova", + "DELETE_ALL_WARNING": "Questo rimuove le playlist dal server per tutti i dispositivi." + }, "THEMES": { "DARK_THEME": "Scuro", "LIGHT_THEME": "Chiaro", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index f0a250cb8..fab37b338 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "リモート", "NAV_DATA": "データ", "NAV_BACKUP": "バックアップとデータ", + "NAV_SERVER": "サーバー", + "SERVER_DESCRIPTION": "プレイリスト、お気に入り、視聴の進行状況はこのサーバーに保存され、このサーバーを開くすべてのデバイスで共有されます。", + "SERVER_CONNECTION": "接続", + "SERVER_CONNECTION_DESCRIPTION": "このアプリの保存先の IPTVnator サーバーです。", + "SERVER_CONNECTED": "接続済み", + "SERVER_OFFLINE": "利用不可", + "SERVER_STORAGE": "ストレージ", + "SERVER_STORAGE_DESCRIPTION": "すべてサーバー上の 1 つのデータベースファイルに保存されます。別の場所にコピーを残すには、バックアップをエクスポートしてください。", + "SERVER_STORAGE_SUMMARY": "プレイリスト {{playlists}} 件 · {{size}}", + "SERVER_EXPORT_BACKUP": "バックアップをエクスポート", + "SERVER_IMPORT_BROWSER": "このブラウザーのプレイリスト", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "サーバーを使う前にこのブラウザーに保存されたプレイリストです。移動するとすべてのデバイスで表示されます。サーバーに既にあるプレイリストは統合されます。", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} 件のプレイリストがこのブラウザーにのみ保存されています。", + "SERVER_ACCESS": "アクセス", + "SERVER_ACCESS_DESCRIPTION": "このサーバーのライブラリを開けるユーザー。", + "SERVER_ACCESS_OPEN": "このアドレスにアクセスできる人は誰でも利用できます。トークンを必須にするには、サーバーで IPTVNATOR_ACCESS_TOKEN を設定してください。", + "SERVER_ACCESS_TOKEN": "このサーバーにはアクセストークンが必要です。", + "SERVER_SIGN_OUT": "サインアウト", "NAV_ABOUT": "アプリについて", "NAV_GROUP_APP": "アプリ", "NAV_GROUP_LIBRARY": "ライブラリ", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "ロックしました" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "サーバーに保存できませんでした。接続を確認して、もう一度お試しください。", + "TOO_LARGE": "このプレイリストはサーバーが受け付けるサイズを超えています。", + "MIGRATION_TITLE": "このブラウザーに {{count}} 件のプレイリストが見つかりました", + "MIGRATION_DESCRIPTION": "これらはこのブラウザーにのみ保存されています。サーバーに移動すると、このアドレスを開くすべてのデバイスで表示されます。このブラウザーからは何も削除されません。", + "MIGRATION_LATER_HINT": "後で「設定 → サーバー」から実行できます。", + "MIGRATION_NOT_NOW": "後で", + "MIGRATION_MOVE": "サーバーに移動", + "MIGRATION_DONE": "移動 {{imported}} 件、統合 {{merged}} 件、失敗 {{failed}} 件。", + "MIGRATION_FAILED": "移動に失敗しました。プレイリストはこのブラウザーに残っています。「設定 → サーバー」からもう一度お試しください。", + "MIGRATION_RELOAD": "再読み込み", + "SIGN_IN_TITLE": "アクセストークンが必要です", + "SIGN_IN_DESCRIPTION": "この IPTVnator サーバーは、プレイリストを表示する前にアクセストークンを求めます。", + "SIGN_IN_TOKEN_LABEL": "アクセストークン", + "SIGN_IN_HINT": "サーバーの IPTVNATOR_ACCESS_TOKEN 設定で確認できます。", + "SIGN_IN_REJECTED": "トークンが拒否されました。確認して、もう一度お試しください。", + "SIGN_IN_UNREACHABLE": "サーバーに接続できません。接続を確認して、もう一度お試しください。", + "SIGN_IN_STORAGE": "このブラウザーでは IPTVnator がトークンを保存できません。このページのサイトデータを許可して、もう一度お試しください。", + "SIGN_IN_CONTINUE": "続行", + "STATUS_ONLINE": "サーバーに接続済み", + "STATUS_OFFLINE": "サーバーを利用できません", + "STATUS_UNAUTHORIZED": "サーバーにアクセストークンが必要です", + "OFFLINE_BANNER": "サーバーを利用できません。復旧するまで変更を保存できません。", + "RETRY": "再試行", + "DELETE_ALL_WARNING": "すべてのデバイスで、サーバーからプレイリストが削除されます。" + }, "THEMES": { "DARK_THEME": "ダーク", "LIGHT_THEME": "ライト", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0eb25a156..fc7203a7d 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "원격", "NAV_DATA": "데이터", "NAV_BACKUP": "백업 및 데이터", + "NAV_SERVER": "서버", + "SERVER_DESCRIPTION": "플레이리스트, 즐겨찾기, 시청 진행 상황이 이 서버에 저장되며 이 서버를 여는 모든 기기에서 공유됩니다.", + "SERVER_CONNECTION": "연결", + "SERVER_CONNECTION_DESCRIPTION": "이 앱이 데이터를 저장하는 IPTVnator 서버입니다.", + "SERVER_CONNECTED": "연결됨", + "SERVER_OFFLINE": "사용할 수 없음", + "SERVER_STORAGE": "저장소", + "SERVER_STORAGE_DESCRIPTION": "모든 데이터는 서버의 데이터베이스 파일 하나에 보관됩니다. 다른 곳에 사본을 두려면 백업을 내보내세요.", + "SERVER_STORAGE_SUMMARY": "플레이리스트 {{playlists}}개 · {{size}}", + "SERVER_EXPORT_BACKUP": "백업 내보내기", + "SERVER_IMPORT_BROWSER": "이 브라우저의 플레이리스트", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "서버를 사용하기 전에 이 브라우저에 저장된 플레이리스트입니다. 옮기면 모든 기기에서 볼 수 있으며, 서버에 이미 있는 플레이리스트는 병합됩니다.", + "SERVER_IMPORT_BROWSER_COUNT": "플레이리스트 {{count}}개가 이 브라우저에만 저장되어 있습니다.", + "SERVER_ACCESS": "접근", + "SERVER_ACCESS_DESCRIPTION": "이 서버의 라이브러리를 열 수 있는 사람.", + "SERVER_ACCESS_OPEN": "이 주소에 접속할 수 있는 누구나 사용할 수 있습니다. 토큰을 요구하려면 서버에 IPTVNATOR_ACCESS_TOKEN을 설정하세요.", + "SERVER_ACCESS_TOKEN": "이 서버는 액세스 토큰이 필요합니다.", + "SERVER_SIGN_OUT": "로그아웃", "NAV_ABOUT": "정보", "NAV_GROUP_APP": "앱", "NAV_GROUP_LIBRARY": "라이브러리", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "잠겼습니다" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "서버에 저장하지 못했습니다. 연결을 확인하고 다시 시도하세요.", + "TOO_LARGE": "이 플레이리스트는 서버가 허용하는 크기보다 큽니다.", + "MIGRATION_TITLE": "이 브라우저에서 플레이리스트 {{count}}개를 찾았습니다", + "MIGRATION_DESCRIPTION": "이 브라우저에만 저장되어 있습니다. 서버로 옮기면 이 주소를 여는 모든 기기에서 볼 수 있습니다. 이 브라우저에서는 아무것도 삭제되지 않습니다.", + "MIGRATION_LATER_HINT": "나중에 설정 → 서버에서 할 수 있습니다.", + "MIGRATION_NOT_NOW": "나중에", + "MIGRATION_MOVE": "서버로 옮기기", + "MIGRATION_DONE": "이동 {{imported}}개, 병합 {{merged}}개, 실패 {{failed}}개.", + "MIGRATION_FAILED": "옮기지 못했습니다. 플레이리스트는 이 브라우저에 그대로 있습니다. 설정 → 서버에서 다시 시도하세요.", + "MIGRATION_RELOAD": "새로고침", + "SIGN_IN_TITLE": "액세스 토큰 필요", + "SIGN_IN_DESCRIPTION": "이 IPTVnator 서버는 플레이리스트를 보여 주기 전에 액세스 토큰을 요청합니다.", + "SIGN_IN_TOKEN_LABEL": "액세스 토큰", + "SIGN_IN_HINT": "서버의 IPTVNATOR_ACCESS_TOKEN 설정에서 확인할 수 있습니다.", + "SIGN_IN_REJECTED": "토큰이 거부되었습니다. 확인하고 다시 시도하세요.", + "SIGN_IN_UNREACHABLE": "서버에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.", + "SIGN_IN_STORAGE": "이 브라우저에서는 IPTVnator가 토큰을 저장할 수 없습니다. 이 페이지의 사이트 데이터를 허용하고 다시 시도하세요.", + "SIGN_IN_CONTINUE": "계속", + "STATUS_ONLINE": "서버 연결됨", + "STATUS_OFFLINE": "서버를 사용할 수 없음", + "STATUS_UNAUTHORIZED": "서버에 액세스 토큰이 필요함", + "OFFLINE_BANNER": "서버를 사용할 수 없습니다. 서버가 다시 연결될 때까지 변경 사항을 저장할 수 없습니다.", + "RETRY": "다시 시도", + "DELETE_ALL_WARNING": "모든 기기에서 서버의 플레이리스트가 삭제됩니다." + }, "THEMES": { "DARK_THEME": "어둡게", "LIGHT_THEME": "밝게", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 44d9ce9a7..ef9328862 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Afstandsbediening", "NAV_DATA": "Gegevens", "NAV_BACKUP": "Back-up en gegevens", + "NAV_SERVER": "Server", + "SERVER_DESCRIPTION": "Afspeellijsten, favorieten en kijkvoortgang worden op deze server bewaard en gedeeld met elk apparaat dat hem opent.", + "SERVER_CONNECTION": "Verbinding", + "SERVER_CONNECTION_DESCRIPTION": "De IPTVnator-server waarop deze app opslaat.", + "SERVER_CONNECTED": "Verbonden", + "SERVER_OFFLINE": "Niet bereikbaar", + "SERVER_STORAGE": "Opslag", + "SERVER_STORAGE_DESCRIPTION": "Alles staat in één databasebestand op de server. Exporteer een back-up om ergens anders een kopie te bewaren.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} afspeellijsten · {{size}}", + "SERVER_EXPORT_BACKUP": "Back-up exporteren", + "SERVER_IMPORT_BROWSER": "Afspeellijsten in deze browser", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Afspeellijsten die in deze browser zijn opgeslagen voordat hij de server gebruikte. Verplaats ze zodat elk apparaat ze ziet; afspeellijsten die de server al heeft, worden samengevoegd.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} afspeellijsten staan alleen in deze browser.", + "SERVER_ACCESS": "Toegang", + "SERVER_ACCESS_DESCRIPTION": "Wie de bibliotheek van deze server kan openen.", + "SERVER_ACCESS_OPEN": "Iedereen die dit adres kan bereiken, kan hem gebruiken. Stel IPTVNATOR_ACCESS_TOKEN in op de server om een token te vereisen.", + "SERVER_ACCESS_TOKEN": "Deze server vereist een toegangstoken.", + "SERVER_SIGN_OUT": "Afmelden", "NAV_ABOUT": "Over", "NAV_GROUP_APP": "App", "NAV_GROUP_LIBRARY": "Bibliotheek", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Vergrendeld" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Opslaan op de server is mislukt. Controleer de verbinding en probeer het opnieuw.", + "TOO_LARGE": "Deze afspeellijst is groter dan de server accepteert.", + "MIGRATION_TITLE": "{{count}} afspeellijsten gevonden in deze browser", + "MIGRATION_DESCRIPTION": "Ze staan alleen in deze browser. Verplaats ze naar de server zodat elk apparaat dat dit adres opent ze ziet. Er wordt niets uit deze browser verwijderd.", + "MIGRATION_LATER_HINT": "Je kunt dit later doen via Instellingen → Server.", + "MIGRATION_NOT_NOW": "Niet nu", + "MIGRATION_MOVE": "Naar server verplaatsen", + "MIGRATION_DONE": "Verplaatst: {{imported}}, samengevoegd: {{merged}}, mislukt: {{failed}}.", + "MIGRATION_FAILED": "Verplaatsen is mislukt. Je afspeellijsten staan nog in deze browser; probeer het opnieuw via Instellingen → Server.", + "MIGRATION_RELOAD": "Opnieuw laden", + "SIGN_IN_TITLE": "Toegangstoken vereist", + "SIGN_IN_DESCRIPTION": "Deze IPTVnator-server vraagt om een toegangstoken voordat hij je afspeellijsten toont.", + "SIGN_IN_TOKEN_LABEL": "Toegangstoken", + "SIGN_IN_HINT": "Je vindt het in de instelling IPTVNATOR_ACCESS_TOKEN van de server.", + "SIGN_IN_REJECTED": "Token geweigerd. Controleer het en probeer het opnieuw.", + "SIGN_IN_UNREACHABLE": "De server is niet bereikbaar. Controleer de verbinding en probeer het opnieuw.", + "SIGN_IN_STORAGE": "Deze browser laat IPTVnator het token niet bewaren. Sta sitegegevens voor deze pagina toe en probeer het opnieuw.", + "SIGN_IN_CONTINUE": "Doorgaan", + "STATUS_ONLINE": "Server verbonden", + "STATUS_OFFLINE": "Server niet bereikbaar", + "STATUS_UNAUTHORIZED": "Server vraagt om een toegangstoken", + "OFFLINE_BANNER": "Server niet bereikbaar. Wijzigingen kunnen pas worden opgeslagen als hij weer bereikbaar is.", + "RETRY": "Opnieuw proberen", + "DELETE_ALL_WARNING": "Hiermee worden de afspeellijsten voor alle apparaten van de server verwijderd." + }, "THEMES": { "DARK_THEME": "Donker", "LIGHT_THEME": "Licht", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 33e91a86c..9722c6b7b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Pilot", "NAV_DATA": "Dane", "NAV_BACKUP": "Kopia zapasowa i dane", + "NAV_SERVER": "Serwer", + "SERVER_DESCRIPTION": "Listy odtwarzania, ulubione i postęp oglądania są przechowywane na tym serwerze i współdzielone przez każde urządzenie, które go otwiera.", + "SERVER_CONNECTION": "Połączenie", + "SERVER_CONNECTION_DESCRIPTION": "Serwer IPTVnator, na którym ta aplikacja zapisuje dane.", + "SERVER_CONNECTED": "Połączono", + "SERVER_OFFLINE": "Niedostępny", + "SERVER_STORAGE": "Pamięć", + "SERVER_STORAGE_DESCRIPTION": "Wszystko jest przechowywane w jednym pliku bazy danych na serwerze. Wyeksportuj kopię zapasową, aby mieć kopię w innym miejscu.", + "SERVER_STORAGE_SUMMARY": "Listy odtwarzania: {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "Eksportuj kopię", + "SERVER_IMPORT_BROWSER": "Listy w tej przeglądarce", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Listy odtwarzania zapisane w tej przeglądarce, zanim zaczęła korzystać z serwera. Przenieś je, aby każde urządzenie je widziało; listy, które serwer już ma, zostaną scalone.", + "SERVER_IMPORT_BROWSER_COUNT": "Listy odtwarzania zapisane tylko w tej przeglądarce: {{count}}.", + "SERVER_ACCESS": "Dostęp", + "SERVER_ACCESS_DESCRIPTION": "Kto może otworzyć bibliotekę tego serwera.", + "SERVER_ACCESS_OPEN": "Może z niego korzystać każdy, kto dotrze pod ten adres. Ustaw IPTVNATOR_ACCESS_TOKEN na serwerze, aby wymagać tokenu.", + "SERVER_ACCESS_TOKEN": "Ten serwer wymaga tokenu dostępu.", + "SERVER_SIGN_OUT": "Wyloguj", "NAV_ABOUT": "O aplikacji", "NAV_GROUP_APP": "Aplikacja", "NAV_GROUP_LIBRARY": "Biblioteka", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Zablokowano" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Nie udało się zapisać na serwerze. Sprawdź połączenie i spróbuj ponownie.", + "TOO_LARGE": "Ta lista odtwarzania jest większa, niż akceptuje serwer.", + "MIGRATION_TITLE": "Znaleziono listy odtwarzania w tej przeglądarce: {{count}}", + "MIGRATION_DESCRIPTION": "Są zapisane tylko w tej przeglądarce. Przenieś je na serwer, aby widziało je każde urządzenie otwierające ten adres. Nic nie zostanie usunięte z tej przeglądarki.", + "MIGRATION_LATER_HINT": "Możesz to zrobić później w Ustawienia → Serwer.", + "MIGRATION_NOT_NOW": "Nie teraz", + "MIGRATION_MOVE": "Przenieś na serwer", + "MIGRATION_DONE": "Przeniesiono: {{imported}}, scalono: {{merged}}, błędy: {{failed}}.", + "MIGRATION_FAILED": "Przenoszenie nie powiodło się. Twoje listy odtwarzania nadal są w tej przeglądarce; spróbuj ponownie w Ustawienia → Serwer.", + "MIGRATION_RELOAD": "Odśwież", + "SIGN_IN_TITLE": "Wymagany token dostępu", + "SIGN_IN_DESCRIPTION": "Ten serwer IPTVnator prosi o token dostępu, zanim pokaże Twoje listy odtwarzania.", + "SIGN_IN_TOKEN_LABEL": "Token dostępu", + "SIGN_IN_HINT": "Znajdziesz go w ustawieniu IPTVNATOR_ACCESS_TOKEN serwera.", + "SIGN_IN_REJECTED": "Token odrzucony. Sprawdź go i spróbuj ponownie.", + "SIGN_IN_UNREACHABLE": "Brak połączenia z serwerem. Sprawdź połączenie i spróbuj ponownie.", + "SIGN_IN_STORAGE": "Ta przeglądarka nie pozwala IPTVnator zapisać tokenu. Zezwól na dane witryny dla tej strony i spróbuj ponownie.", + "SIGN_IN_CONTINUE": "Dalej", + "STATUS_ONLINE": "Serwer połączony", + "STATUS_OFFLINE": "Serwer niedostępny", + "STATUS_UNAUTHORIZED": "Serwer wymaga tokenu dostępu", + "OFFLINE_BANNER": "Serwer niedostępny. Zmian nie można zapisać, dopóki nie będzie znowu dostępny.", + "RETRY": "Ponów", + "DELETE_ALL_WARNING": "Spowoduje to usunięcie list odtwarzania z serwera dla wszystkich urządzeń." + }, "THEMES": { "DARK_THEME": "Ciemny", "LIGHT_THEME": "Jasny", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1211261b7..e02747f2e 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Remoto", "NAV_DATA": "Dados", "NAV_BACKUP": "Cópia de segurança e dados", + "NAV_SERVER": "Servidor", + "SERVER_DESCRIPTION": "Playlists, favoritos e progresso de exibição ficam neste servidor e são compartilhados por todos os dispositivos que o abrem.", + "SERVER_CONNECTION": "Conexão", + "SERVER_CONNECTION_DESCRIPTION": "O servidor IPTVnator onde este app salva os dados.", + "SERVER_CONNECTED": "Conectado", + "SERVER_OFFLINE": "Indisponível", + "SERVER_STORAGE": "Armazenamento", + "SERVER_STORAGE_DESCRIPTION": "Tudo fica em um único arquivo de banco de dados no servidor. Exporte uma cópia de segurança para manter uma cópia em outro lugar.", + "SERVER_STORAGE_SUMMARY": "Playlists: {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "Exportar cópia", + "SERVER_IMPORT_BROWSER": "Playlists neste navegador", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists salvas neste navegador antes de ele usar o servidor. Mova-as para que todos os dispositivos as vejam; as que o servidor já tem são mescladas.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists estão salvas apenas neste navegador.", + "SERVER_ACCESS": "Acesso", + "SERVER_ACCESS_DESCRIPTION": "Quem pode abrir a biblioteca deste servidor.", + "SERVER_ACCESS_OPEN": "Qualquer pessoa que alcance este endereço pode usá-lo. Defina IPTVNATOR_ACCESS_TOKEN no servidor para exigir um token.", + "SERVER_ACCESS_TOKEN": "Este servidor exige um token de acesso.", + "SERVER_SIGN_OUT": "Sair", "NAV_ABOUT": "Sobre", "NAV_GROUP_APP": "Aplicativo", "NAV_GROUP_LIBRARY": "Biblioteca", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Bloqueado" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Não foi possível salvar no servidor. Verifique a conexão e tente novamente.", + "TOO_LARGE": "Esta playlist é maior do que o servidor aceita.", + "MIGRATION_TITLE": "{{count}} playlists encontradas neste navegador", + "MIGRATION_DESCRIPTION": "Elas estão salvas apenas neste navegador. Mova-as para o servidor para que todos os dispositivos que abrem este endereço as vejam. Nada é apagado deste navegador.", + "MIGRATION_LATER_HINT": "Você pode fazer isso depois em Configurações → Servidor.", + "MIGRATION_NOT_NOW": "Agora não", + "MIGRATION_MOVE": "Mover para o servidor", + "MIGRATION_DONE": "Movidas: {{imported}}, mescladas: {{merged}}, com falha: {{failed}}.", + "MIGRATION_FAILED": "Falha ao mover. Suas playlists continuam neste navegador; tente novamente em Configurações → Servidor.", + "MIGRATION_RELOAD": "Recarregar", + "SIGN_IN_TITLE": "Token de acesso necessário", + "SIGN_IN_DESCRIPTION": "Este servidor IPTVnator pede um token de acesso antes de mostrar suas playlists.", + "SIGN_IN_TOKEN_LABEL": "Token de acesso", + "SIGN_IN_HINT": "Ele está na configuração IPTVNATOR_ACCESS_TOKEN do servidor.", + "SIGN_IN_REJECTED": "Token recusado. Verifique e tente novamente.", + "SIGN_IN_UNREACHABLE": "Não foi possível acessar o servidor. Verifique a conexão e tente novamente.", + "SIGN_IN_STORAGE": "Este navegador não permite que o IPTVnator salve o token. Permita os dados do site para esta página e tente novamente.", + "SIGN_IN_CONTINUE": "Continuar", + "STATUS_ONLINE": "Servidor conectado", + "STATUS_OFFLINE": "Servidor indisponível", + "STATUS_UNAUTHORIZED": "O servidor precisa de um token de acesso", + "OFFLINE_BANNER": "Servidor indisponível. As alterações não podem ser salvas até que ele volte.", + "RETRY": "Tentar novamente", + "DELETE_ALL_WARNING": "Isso remove as playlists do servidor para todos os dispositivos." + }, "THEMES": { "DARK_THEME": "Escuro", "LIGHT_THEME": "Claro", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 3919f253d..c10524532 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Пульт", "NAV_DATA": "Данные", "NAV_BACKUP": "Резервная копия и данные", + "NAV_SERVER": "Сервер", + "SERVER_DESCRIPTION": "Плейлисты, избранное и прогресс просмотра хранятся на этом сервере и доступны на каждом устройстве, которое его открывает.", + "SERVER_CONNECTION": "Подключение", + "SERVER_CONNECTION_DESCRIPTION": "Сервер IPTVnator, на котором это приложение хранит данные.", + "SERVER_CONNECTED": "Подключено", + "SERVER_OFFLINE": "Недоступен", + "SERVER_STORAGE": "Хранилище", + "SERVER_STORAGE_DESCRIPTION": "Всё хранится в одном файле базы данных на сервере. Экспортируйте резервную копию, чтобы сохранить копию в другом месте.", + "SERVER_STORAGE_SUMMARY": "Плейлистов: {{playlists}} · {{size}}", + "SERVER_EXPORT_BACKUP": "Экспортировать копию", + "SERVER_IMPORT_BROWSER": "Плейлисты в этом браузере", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Плейлисты, сохранённые в этом браузере до перехода на сервер. Перенесите их, чтобы их видели все устройства; плейлисты, которые уже есть на сервере, будут объединены.", + "SERVER_IMPORT_BROWSER_COUNT": "Плейлистов, сохранённых только в этом браузере: {{count}}.", + "SERVER_ACCESS": "Доступ", + "SERVER_ACCESS_DESCRIPTION": "Кто может открыть библиотеку этого сервера.", + "SERVER_ACCESS_OPEN": "Пользоваться может любой, кто может открыть этот адрес. Задайте IPTVNATOR_ACCESS_TOKEN на сервере, чтобы требовать токен.", + "SERVER_ACCESS_TOKEN": "Этот сервер требует токен доступа.", + "SERVER_SIGN_OUT": "Выйти", "NAV_ABOUT": "Инфо", "NAV_GROUP_APP": "Приложение", "NAV_GROUP_LIBRARY": "Библиотека", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Заблокировано" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Не удалось сохранить на сервер. Проверьте подключение и попробуйте снова.", + "TOO_LARGE": "Этот плейлист больше, чем принимает сервер.", + "MIGRATION_TITLE": "В этом браузере найдено плейлистов: {{count}}", + "MIGRATION_DESCRIPTION": "Они хранятся только в этом браузере. Перенесите их на сервер, чтобы их видели все устройства, которые открывают этот адрес. Из этого браузера ничего не удаляется.", + "MIGRATION_LATER_HINT": "Это можно сделать позже в Настройки → Сервер.", + "MIGRATION_NOT_NOW": "Не сейчас", + "MIGRATION_MOVE": "Перенести на сервер", + "MIGRATION_DONE": "Перенесено: {{imported}}, объединено: {{merged}}, с ошибкой: {{failed}}.", + "MIGRATION_FAILED": "Не удалось перенести. Плейлисты остались в этом браузере; попробуйте снова в Настройки → Сервер.", + "MIGRATION_RELOAD": "Перезагрузить", + "SIGN_IN_TITLE": "Нужен токен доступа", + "SIGN_IN_DESCRIPTION": "Этот сервер IPTVnator запрашивает токен доступа, прежде чем показать ваши плейлисты.", + "SIGN_IN_TOKEN_LABEL": "Токен доступа", + "SIGN_IN_HINT": "Он задан в настройке IPTVNATOR_ACCESS_TOKEN на сервере.", + "SIGN_IN_REJECTED": "Токен отклонён. Проверьте его и попробуйте снова.", + "SIGN_IN_UNREACHABLE": "Сервер недоступен. Проверьте подключение и попробуйте снова.", + "SIGN_IN_STORAGE": "Браузер не даёт IPTVnator сохранить токен. Разрешите данные сайта для этой страницы и попробуйте снова.", + "SIGN_IN_CONTINUE": "Продолжить", + "STATUS_ONLINE": "Сервер подключён", + "STATUS_OFFLINE": "Сервер недоступен", + "STATUS_UNAUTHORIZED": "Серверу нужен токен доступа", + "OFFLINE_BANNER": "Сервер недоступен. Изменения нельзя сохранить, пока он не вернётся.", + "RETRY": "Повторить", + "DELETE_ALL_WARNING": "Плейлисты будут удалены с сервера для всех устройств." + }, "THEMES": { "DARK_THEME": "Тёмная", "LIGHT_THEME": "Светлая", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7eda1bf19..a320e6986 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "Uzaktan", "NAV_DATA": "Veri", "NAV_BACKUP": "Yedekleme ve veriler", + "NAV_SERVER": "Sunucu", + "SERVER_DESCRIPTION": "Oynatma listeleri, favoriler ve izleme ilerlemesi bu sunucuda saklanır ve onu açan her cihazla paylaşılır.", + "SERVER_CONNECTION": "Bağlantı", + "SERVER_CONNECTION_DESCRIPTION": "Bu uygulamanın verileri kaydettiği IPTVnator sunucusu.", + "SERVER_CONNECTED": "Bağlı", + "SERVER_OFFLINE": "Kullanılamıyor", + "SERVER_STORAGE": "Depolama", + "SERVER_STORAGE_DESCRIPTION": "Her şey sunucudaki tek bir veritabanı dosyasında tutulur. Başka bir yerde kopya saklamak için yedeği dışa aktarın.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} oynatma listesi · {{size}}", + "SERVER_EXPORT_BACKUP": "Yedeği dışa aktar", + "SERVER_IMPORT_BROWSER": "Bu tarayıcıdaki oynatma listeleri", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Bu tarayıcı sunucuyu kullanmadan önce burada kaydedilmiş oynatma listeleri. Her cihazın görmesi için bunları taşıyın; sunucuda zaten olan listeler birleştirilir.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} oynatma listesi yalnızca bu tarayıcıda saklanıyor.", + "SERVER_ACCESS": "Erişim", + "SERVER_ACCESS_DESCRIPTION": "Bu sunucunun kitaplığını kimlerin açabileceği.", + "SERVER_ACCESS_OPEN": "Bu adrese ulaşabilen herkes kullanabilir. Belirteç istemek için sunucuda IPTVNATOR_ACCESS_TOKEN ayarlayın.", + "SERVER_ACCESS_TOKEN": "Bu sunucu bir erişim belirteci gerektiriyor.", + "SERVER_SIGN_OUT": "Oturumu kapat", "NAV_ABOUT": "Hakkında", "NAV_GROUP_APP": "Uygulama", "NAV_GROUP_LIBRARY": "Kitaplık", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "Kilitlendi" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Sunucuya kaydedilemedi. Bağlantıyı kontrol edip tekrar deneyin.", + "TOO_LARGE": "Bu oynatma listesi sunucunun kabul ettiğinden büyük.", + "MIGRATION_TITLE": "Bu tarayıcıda {{count}} oynatma listesi bulundu", + "MIGRATION_DESCRIPTION": "Bunlar yalnızca bu tarayıcıda saklanıyor. Bu adresi açan her cihazın görmesi için sunucuya taşıyın. Bu tarayıcıdan hiçbir şey silinmez.", + "MIGRATION_LATER_HINT": "Bunu daha sonra Ayarlar → Sunucu bölümünden yapabilirsiniz.", + "MIGRATION_NOT_NOW": "Şimdi değil", + "MIGRATION_MOVE": "Sunucuya taşı", + "MIGRATION_DONE": "Taşınan: {{imported}}, birleştirilen: {{merged}}, başarısız: {{failed}}.", + "MIGRATION_FAILED": "Taşıma başarısız oldu. Oynatma listeleriniz hâlâ bu tarayıcıda; Ayarlar → Sunucu bölümünden tekrar deneyin.", + "MIGRATION_RELOAD": "Yeniden yükle", + "SIGN_IN_TITLE": "Erişim belirteci gerekli", + "SIGN_IN_DESCRIPTION": "Bu IPTVnator sunucusu oynatma listelerinizi göstermeden önce bir erişim belirteci istiyor.", + "SIGN_IN_TOKEN_LABEL": "Erişim belirteci", + "SIGN_IN_HINT": "Sunucunun IPTVNATOR_ACCESS_TOKEN ayarında bulabilirsiniz.", + "SIGN_IN_REJECTED": "Belirteç reddedildi. Kontrol edip tekrar deneyin.", + "SIGN_IN_UNREACHABLE": "Sunucuya ulaşılamıyor. Bağlantıyı kontrol edip tekrar deneyin.", + "SIGN_IN_STORAGE": "Bu tarayıcı IPTVnator'ın belirteci saklamasına izin vermiyor. Bu sayfa için site verilerine izin verip tekrar deneyin.", + "SIGN_IN_CONTINUE": "Devam", + "STATUS_ONLINE": "Sunucu bağlı", + "STATUS_OFFLINE": "Sunucu kullanılamıyor", + "STATUS_UNAUTHORIZED": "Sunucu erişim belirteci istiyor", + "OFFLINE_BANNER": "Sunucu kullanılamıyor. Sunucu geri gelene kadar değişiklikler kaydedilemez.", + "RETRY": "Tekrar dene", + "DELETE_ALL_WARNING": "Bu işlem oynatma listelerini tüm cihazlar için sunucudan kaldırır." + }, "THEMES": { "DARK_THEME": "Koyu", "LIGHT_THEME": "Açık", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ce66b3a47..60342d357 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "远程", "NAV_DATA": "数据", "NAV_BACKUP": "备份与数据", + "NAV_SERVER": "服务器", + "SERVER_DESCRIPTION": "播放列表、收藏和观看进度保存在此服务器上,所有打开它的设备共享这些数据。", + "SERVER_CONNECTION": "连接", + "SERVER_CONNECTION_DESCRIPTION": "此应用保存数据的 IPTVnator 服务器。", + "SERVER_CONNECTED": "已连接", + "SERVER_OFFLINE": "不可用", + "SERVER_STORAGE": "存储", + "SERVER_STORAGE_DESCRIPTION": "所有数据都保存在服务器上的一个数据库文件中。导出备份可在其他位置保留一份副本。", + "SERVER_STORAGE_SUMMARY": "{{playlists}} 个播放列表 · {{size}}", + "SERVER_EXPORT_BACKUP": "导出备份", + "SERVER_IMPORT_BROWSER": "此浏览器中的播放列表", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "此浏览器在使用服务器之前保存的播放列表。移到服务器后,所有设备都能看到;服务器上已有的播放列表会被合并。", + "SERVER_IMPORT_BROWSER_COUNT": "有 {{count}} 个播放列表仅保存在此浏览器中。", + "SERVER_ACCESS": "访问", + "SERVER_ACCESS_DESCRIPTION": "谁可以打开此服务器的媒体库。", + "SERVER_ACCESS_OPEN": "任何能访问此地址的人都可以使用。在服务器上设置 IPTVNATOR_ACCESS_TOKEN 即可要求令牌。", + "SERVER_ACCESS_TOKEN": "此服务器需要访问令牌。", + "SERVER_SIGN_OUT": "退出登录", "NAV_ABOUT": "关于", "NAV_GROUP_APP": "应用", "NAV_GROUP_LIBRARY": "媒体库", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "已锁定" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "无法保存到服务器。请检查连接后重试。", + "TOO_LARGE": "此播放列表超出服务器可接受的大小。", + "MIGRATION_TITLE": "在此浏览器中找到 {{count}} 个播放列表", + "MIGRATION_DESCRIPTION": "它们仅保存在此浏览器中。移到服务器后,所有打开此地址的设备都能看到。此浏览器中的内容不会被删除。", + "MIGRATION_LATER_HINT": "您也可以稍后在“设置 → 服务器”中完成。", + "MIGRATION_NOT_NOW": "暂不", + "MIGRATION_MOVE": "移到服务器", + "MIGRATION_DONE": "已移动 {{imported}} 个,已合并 {{merged}} 个,失败 {{failed}} 个。", + "MIGRATION_FAILED": "移动失败。您的播放列表仍在此浏览器中;请在“设置 → 服务器”中重试。", + "MIGRATION_RELOAD": "重新加载", + "SIGN_IN_TITLE": "需要访问令牌", + "SIGN_IN_DESCRIPTION": "此 IPTVnator 服务器在显示您的播放列表前需要访问令牌。", + "SIGN_IN_TOKEN_LABEL": "访问令牌", + "SIGN_IN_HINT": "可在服务器的 IPTVNATOR_ACCESS_TOKEN 设置中找到。", + "SIGN_IN_REJECTED": "令牌被拒绝。请检查后重试。", + "SIGN_IN_UNREACHABLE": "无法连接到服务器。请检查连接后重试。", + "SIGN_IN_STORAGE": "此浏览器不允许 IPTVnator 保存令牌。请允许此页面的网站数据后重试。", + "SIGN_IN_CONTINUE": "继续", + "STATUS_ONLINE": "服务器已连接", + "STATUS_OFFLINE": "服务器不可用", + "STATUS_UNAUTHORIZED": "服务器需要访问令牌", + "OFFLINE_BANNER": "服务器不可用。服务器恢复之前无法保存更改。", + "RETRY": "重试", + "DELETE_ALL_WARNING": "这会从服务器上删除播放列表,所有设备都会受影响。" + }, "THEMES": { "DARK_THEME": "深色", "LIGHT_THEME": "浅色", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4352c0b5c..987f5d256 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -316,6 +316,24 @@ "NAV_REMOTE": "遠端", "NAV_DATA": "資料", "NAV_BACKUP": "備份與資料", + "NAV_SERVER": "伺服器", + "SERVER_DESCRIPTION": "播放清單、我的最愛和觀看進度會儲存在此伺服器上,並由所有開啟它的裝置共用。", + "SERVER_CONNECTION": "連線", + "SERVER_CONNECTION_DESCRIPTION": "此應用程式儲存資料的 IPTVnator 伺服器。", + "SERVER_CONNECTED": "已連線", + "SERVER_OFFLINE": "無法使用", + "SERVER_STORAGE": "儲存空間", + "SERVER_STORAGE_DESCRIPTION": "所有資料都保存在伺服器上的一個資料庫檔案中。匯出備份即可在其他地方保留一份副本。", + "SERVER_STORAGE_SUMMARY": "{{playlists}} 個播放清單 · {{size}}", + "SERVER_EXPORT_BACKUP": "匯出備份", + "SERVER_IMPORT_BROWSER": "此瀏覽器中的播放清單", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "此瀏覽器在使用伺服器之前儲存的播放清單。移到伺服器後,所有裝置都能看到;伺服器上已有的播放清單會合併。", + "SERVER_IMPORT_BROWSER_COUNT": "有 {{count}} 個播放清單只儲存在此瀏覽器中。", + "SERVER_ACCESS": "存取", + "SERVER_ACCESS_DESCRIPTION": "誰可以開啟此伺服器的媒體庫。", + "SERVER_ACCESS_OPEN": "任何能連到此位址的人都可以使用。在伺服器上設定 IPTVNATOR_ACCESS_TOKEN 即可要求權杖。", + "SERVER_ACCESS_TOKEN": "此伺服器需要存取權杖。", + "SERVER_SIGN_OUT": "登出", "NAV_ABOUT": "關於", "NAV_GROUP_APP": "應用程式", "NAV_GROUP_LIBRARY": "媒體庫", @@ -643,6 +661,32 @@ "LOCKED_TOAST": "已鎖定" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "無法儲存到伺服器。請檢查連線後再試一次。", + "TOO_LARGE": "此播放清單超出伺服器可接受的大小。", + "MIGRATION_TITLE": "在此瀏覽器中找到 {{count}} 個播放清單", + "MIGRATION_DESCRIPTION": "它們只儲存在此瀏覽器中。移到伺服器後,所有開啟此位址的裝置都能看到。此瀏覽器中的內容不會被刪除。", + "MIGRATION_LATER_HINT": "您也可以稍後在「設定 → 伺服器」中完成。", + "MIGRATION_NOT_NOW": "暫時不要", + "MIGRATION_MOVE": "移到伺服器", + "MIGRATION_DONE": "已移動 {{imported}} 個,已合併 {{merged}} 個,失敗 {{failed}} 個。", + "MIGRATION_FAILED": "移動失敗。您的播放清單仍在此瀏覽器中;請在「設定 → 伺服器」中再試一次。", + "MIGRATION_RELOAD": "重新載入", + "SIGN_IN_TITLE": "需要存取權杖", + "SIGN_IN_DESCRIPTION": "此 IPTVnator 伺服器在顯示您的播放清單前需要存取權杖。", + "SIGN_IN_TOKEN_LABEL": "存取權杖", + "SIGN_IN_HINT": "可在伺服器的 IPTVNATOR_ACCESS_TOKEN 設定中找到。", + "SIGN_IN_REJECTED": "權杖遭拒。請檢查後再試一次。", + "SIGN_IN_UNREACHABLE": "無法連線到伺服器。請檢查連線後再試一次。", + "SIGN_IN_STORAGE": "此瀏覽器不允許 IPTVnator 儲存權杖。請允許此頁面的網站資料後再試一次。", + "SIGN_IN_CONTINUE": "繼續", + "STATUS_ONLINE": "伺服器已連線", + "STATUS_OFFLINE": "伺服器無法使用", + "STATUS_UNAUTHORIZED": "伺服器需要存取權杖", + "OFFLINE_BANNER": "伺服器無法使用。伺服器恢復之前無法儲存變更。", + "RETRY": "重試", + "DELETE_ALL_WARNING": "這會從伺服器上刪除播放清單,所有裝置都會受影響。" + }, "THEMES": { "DARK_THEME": "深色", "LIGHT_THEME": "淺色", diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 15ea472b4..6ff4dd907 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,8 +1,12 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces'; import { registerAppDateLocale } from './app/app-date-locales'; import { AppComponent } from './app/app.component'; import { appConfig, getInitialLanguage } from './app/app.config'; +import { probeServerInstance } from './app/server-storage/server-instance-probe'; +import { resolveServerStorageStart } from './app/server-storage/server-storage-start'; +import { getRuntimeBackendUrl } from './app/services/runtime-config'; // A reloaded packaged renderer arrives on index.html with the route it was // on carried in the query string (the Electron main process recovers the @@ -16,12 +20,33 @@ if (restoredHref !== null) { window.history.replaceState(window.history.state, '', restoredHref); } +/** + * Adds server storage when the self-hosted backend offers it, or when this + * browser has used it before and it does not answer right now. + */ +async function resolveAppConfig(): Promise { + const baseUrl = getRuntimeBackendUrl(); + const start = resolveServerStorageStart( + await probeServerInstance(), + baseUrl + ); + if (!start) { + return appConfig; + } + const { provideServerStorage } = + await import('./app/server-storage/server-storage.providers'); + return mergeApplicationConfig(appConfig, { + providers: provideServerStorage(start, baseUrl), + }); +} + // Angular ships only English locale data; the preferred language's data is a // lazy chunk. It is awaited before bootstrapping because the first route // renders dates with that locale, and a locale without data throws. English -// (the default) resolves immediately. -registerAppDateLocale(getInitialLanguage()) - .then(() => bootstrapApplication(AppComponent, appConfig)) +// (the default) resolves immediately. The server-storage probe runs in +// parallel and only when the deployment provides a runtime backend URL. +Promise.all([registerAppDateLocale(getInitialLanguage()), resolveAppConfig()]) + .then(([, config]) => bootstrapApplication(AppComponent, config)) .then(() => { // Splash is rendered eagerly by index.html so the user sees something // immediately instead of a blank Material-grey background. Once Angular diff --git a/docker/README.md b/docker/README.md index 897cd7634..e1f73b586 100644 --- a/docker/README.md +++ b/docker/README.md @@ -18,9 +18,12 @@ The Docker image runs the browser PWA, not the Electron desktop application. That means: - EPG/XMLTV panels and multi-EPG views are not available in the self-hosted PWA. -- Playlist metadata is stored in the browser through IndexedDB, and Xtream user - data is stored by the PWA data source in localStorage. The PWA does not use - the Electron SQLite database or DB worker. +- With server storage (the image default) playlists, M3U favorites and + history, and Xtream favorites, recently viewed items and playback positions + live on the server, and every browser that opens the instance shares them. + Settings, parental locks, the global favorites order and UI preferences stay + per browser. With `IPTVNATOR_STORAGE=none` each browser keeps its own data + in IndexedDB and localStorage. - Docker cannot launch local desktop players or Embedded MPV. If a stream does not play inline in the browser, use the in-app copy URL action and open the stream manually in an external player such as MPV, VLC, or IINA. @@ -100,7 +103,7 @@ safe local defaults shown below. | `NODE_EXTRA_CA_CERTS` | unset | Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path. | | `IPTVNATOR_STORAGE` | `server` in the image | `server` opens the server storage database under `IPTVNATOR_DATA_DIR` and its `/api/v1` routes; `none` keeps the backend stateless. | | `IPTVNATOR_DATA_DIR` | `/data` in the image | Data root; the database is `/databases/iptvnator.db`. Mount a volume here. | -| `IPTVNATOR_ACCESS_TOKEN` | unset | When set, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer `, proxy routes included. The browser app cannot send a token yet: leave it unset until it can. | +| `IPTVNATOR_ACCESS_TOKEN` | unset | When set, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer `, proxy routes included. Each browser asks for the token once and keeps it. Needs `IPTVNATOR_STORAGE=server`; the backend refuses to start otherwise. | | `IPTVNATOR_MAX_DOCUMENT_MB` | `64` | Largest playlist document the backend accepts. Keep the nginx `client_max_body_size` in step. | | `IPTVNATOR_BACKEND_START_TIMEOUT` | `300` | Seconds the entrypoint waits for the backend's `/health` before giving up. Database migrations run before the backend listens. | @@ -136,16 +139,21 @@ the `restart: unless-stopped` policy. The image opens a server storage database on the `/data` volume (`IPTVNATOR_STORAGE=server`, `IPTVNATOR_DATA_DIR=/data`) and serves its API -under `/api/v1`. It is the backend half of keeping playlists, favorites, -history and playback positions on the server for every device; the PWA -starts using it in a follow-up change. Until then the volume holds an empty -database and each browser keeps its own data. +under `/api/v1`. The PWA finds it on start and keeps playlists, favorites, +history and playback positions there, so every phone, TV and computer that +opens the instance sees the same library. Settings → Server shows the +connection, the database size and path, and exports a backup. -Provider credentials will be stored unencrypted in that database, as the +The first browser that opens an instance with an empty database offers to +move the playlists it already kept, with their Xtream favorites and +progress; nothing is deleted from the browser. Later browsers can merge +theirs from Settings → Server. + +Provider credentials are stored unencrypted in that database, as the desktop app does. Do not expose an instance with server storage to the -internet without authentication in your reverse proxy. The backend already -supports `IPTVNATOR_ACCESS_TOKEN`, but the browser app cannot send the -token yet, so setting it now makes the app unusable. Back up the volume like any database: stop the container, or copy +internet without authentication. Set `IPTVNATOR_ACCESS_TOKEN` to require a +token: each browser then asks for it once on a sign-in page. Back up the +volume like any database: stop the container, or copy `databases/iptvnator.db` together with its `-wal` file. ## IPv6, VPNs, And Connection Fallback diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ccfca27b0..fff635513 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,6 +16,8 @@ services: # Server storage database on the /data volume. Set to "none" to keep # the backend stateless. IPTVNATOR_STORAGE: server + # Ask every browser for this token once before it shows the library. + # IPTVNATOR_ACCESS_TOKEN: change-me volumes: - iptvnator-data:/data healthcheck: diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 6e8014478..72d58abbe 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -252,6 +252,12 @@ self-hosted server storage is covered too. browser tracks neither. - An unreadable map (storage error, malformed JSON, a playlist entry that is not a list) fails the export instead of being written as an empty list. + So does server mode when it cannot read the server's user data: the + export reads it again (`UserDataStorage.ready(true)`, as another device + may have changed it since this page's first read) and fails when that + read does not succeed. A restore does the same and stays parked, since its + replacement lists would otherwise merge into the server's; it also stays + parked until the server took that playlist's lists. - A pending restore snapshot still wins over the stored maps, as on desktop. - Import parks the restore state like the desktop does, then applies it at once through `XtreamPendingRestoreService.applyAndConsume`: the browser has diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index f055674bf..54d1cb2f0 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -73,8 +73,8 @@ The current self-hosted PWA uses these `apps/web-backend` routes: - `GET /xtream?targetId=&username=&password=

&action=` - `GET /stalker?targetId=&macAddress=&action=` -Server storage adds versioned routes under `/v1`; the PWA does not call them -yet: +Server storage adds versioned routes under `/v1`, which the PWA uses in +[server storage mode](#server-storage-mode): - `GET /v1/instance` (always public): `{ service, apiVersion, instanceId, storage: 'server' | 'none', auth: 'none' | 'token' }` @@ -83,10 +83,19 @@ yet: `DELETE /v1/playlists`: whole playlist documents; `GET` sends an `ETag` and answers `304` to a matching `If-None-Match`, and `PUT ...?keepPayload=1` keeps the stored `playlist`, `items` and - `header` fields (`409 payload_missing` without a stored row) + `header` fields (`409 payload_missing` without a stored row); every `PUT` + answers with the new `ETag` and `X-Replaced-ETag`, the version it + replaced, and one with `If-Match` answers `412 conflict` when another + write replaced that version, one with `If-None-Match: *` when the + playlist exists already - `GET /v1/user-state`, `PUT|DELETE /v1/user-state/:namespace/:playlistId`: Xtream favorites, recently viewed, collection snapshots and playback - positions, one JSON value per namespace and playlist + positions, one JSON value per namespace and playlist; `GET` adds a + content tag per entry (`tags`), and a write with `If-Match: ""` or + `If-None-Match: *` answers `412` with the current value and tag when the + entry changed since, and a `PUT` for a playlist the server does not have + answers `404 playlist_missing` (the client drops that edit), so an edit + queued on another device cannot bring a deleted playlist's state back - `GET|PUT /v1/app-state/:key` Server storage lives in `apps/web-backend/src/app/storage/`. With @@ -102,8 +111,10 @@ its database. Storage errors are logged by error name and code only, because Drizzle messages can carry bound values such as portal credentials. With `IPTVNATOR_ACCESS_TOKEN`, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer `, the proxy -routes included. The PWA cannot send that token yet, so the variable must -stay unset until the browser side lands. Browser app state written through +routes included; its 401 carries `WWW-Authenticate: Bearer +realm="iptvnator"`, which a provider error passed on by the proxy never +does; the PWA sends it once its sign-in page has stored it. +Browser app state written through `/v1/app-state` is stored under a `client:` key prefix, apart from server-owned keys such as the instance id. @@ -119,7 +130,11 @@ reached only `pnpm`/`nx`, and the orphaned server kept port 3333 after the run. launches the backend through Nx or its `env` drifts from the `serve` target. `WEB_BACKEND_PORT` (default 3333) relocates the E2E backend and the `self-hosted.e2e.ts` URLs, like `MOCK_PORT` does for the mocks. Use it when -another worktree holds 3333. The `web:serve` entry can stay on Nx: +another worktree holds 3333. `server-storage.e2e.ts` talks to a second +backend from the same config, launched with `IPTVNATOR_STORAGE=server` on +`STORAGE_BACKEND_PORT` (default 3334) and a data directory named after the +Playwright process, so every run starts with an empty store; the spec +accepts at most that one extra launch. The `web:serve` entry can stay on Nx: `@angular/build:dev-server` runs inside the Nx process, so the group kill stops it. Playwright launches that command itself even outside CI: the web-e2e targets carry no Nx `serve` dependency, because Nx refuses a @@ -160,6 +175,235 @@ MPV and VLC preload launch methods (`openInMpv` and `openInVlc`), and (`setMpvPlayerPath` and `setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only actions in the PWA/shared UI. +## Server Storage Mode + +`main.ts` probes `GET /v1/instance` before bootstrapping, and only when the +deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__` +(the Docker entrypoint does; hosted builds and Electron never probe). On +`storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and +merges `provideServerStorage()` into the app config, and remembers the +instance under `iptvnator-server:instance`. When the probe gets no answer +(network error, timeout, a 5xx such as nginx's 502 while the backend +restarts, or a refusal other than 404, such as a proxy's 429), a browser that remembers this backend still starts in server +mode, offline, and reloads once the server answers; it never falls back to +an empty browser library. Until that reload it sends no writes +(`holdWrites`) and no token anywhere (`ServerStorageStatusService.accessToken()` +is null for an unverified instance): the address may lead to another +server or database by then, which the reload's probe finds out. An answer without server storage, or a 404 from +an older backend, forgets it (`server-storage-start.ts`). Everything else of the +client lives behind the `@iptvnator/services/server-storage` sub-entry, so +other builds never load it; `ServerStorageStatusService` and the token +helpers stay in the main `@iptvnator/services` barrel because the rail, +settings and guards read them in every build. + +- **Playlists.** `ServerStorageSession.start()` (an app initializer) plugs + `ServerPlaylistDocumentStore` into `PlaylistStorageBackend` (kinds + `electron`, `server`, `indexeddb`). The store caches full documents by + ETag and uploads a document whose payload it served unchanged with + `keepPayload=1`, so a favorite or a rename does not resend a large M3U. + A mutation carries `If-Match` with the version it was read at (of its + payload, else of the served copy it was built from, as for portal + documents): when + another device wrote in between, the server answers `412`, the store + throws `PlaylistWriteConflict`, and `PlaylistsService`'s per-playlist + write queue runs the mutation again on a fresh read (a retry on the same, + unread copy writes unconditionally, as before). Imports and start-up + migrations (`dbUpsertAppPlaylists`) write unconditionally, and the store + refuses a document without an id. "Remove all" first lets queued + playlist writes land, and writes issued meanwhile wait until it is done. + An M3U favorite + toggle or reorder goes through `transformPlaylistFavorites`: a toggle adds + or removes that one channel, a reorder applies its order, on the + favorites read at write time. After a write the store + keeps its copy only when `X-Replaced-ETag` names the version its payload + came from. +- **Xtream user data.** `USER_DATA_STORAGE` becomes `ServerUserDataStorage`: + the four synced namespaces are cached per server instance, under + `iptvnator-server:cache::` (the legacy unprefixed + keys are never written in server mode; a tab still open on another + server never writes over this one's cache) and + each change sends only the playlist entries that differ, debounced, with a + `keepalive` flush when the page hides. A change is compared with the map + the caller read: when the cache changed since (another tab, a re-read), + only the entries the caller changed are applied to it. Batches go out one after another + (the page-hide flush waits for a batch still travelling, and a flush + scheduled before it waits for the page-hide batch it sent at once), a failed write, + including a refused `keepalive` one or one the server took and never + answered (it ends after the request timeout), stays queued for the next + flush (a write saved whose answer was cut off counts as saved on an + unknown version: only later edits replay on the next conflict), and + every unconfirmed write (queued or travelling) is saved in `localStorage` + per page load (`iptvnator-server:unsent-writes::`). The + tab remembers its page load in `sessionStorage`, so a reload takes its + writes over and sends them first (the old copy goes only once the new one + is saved, at the latest with this page's next saved copy; until then the + tab's pointer names the kept page loads too, so another reload finds + them even without their registry records; a duplicate of a tab that + still runs leaves them, like that tab's page load, to it). A page marks + its record closed on `pagehide`, and it stays closed when a page-hide + write failing afterwards rewrites it (not into the back-forward cache, where + its record goes stale, as in a background tab the browser suspends; + restored or shown again after another tab adopted it, the page reloads + rather than send those writes again, and until then it neither sends nor + saves them, also when its retry timer runs first), and + only a closed, stale or missing record is taken over: a duplicated tab, + which copies `sessionStorage`, leaves the running tab its writes. A page + load that still looked live (or whose close flag storage refused) is + checked again every 30 s and, once it does not, adopted like a closed + tab's: its edits go ahead of this page's newer ones, and without a + server version this page read they are sent so that they conflict and + replay; the writes of a tab closed before it + could send them are queued, ahead of its own, by another tab once that + tab's record has gone stale (at its next re-read, or its 30 s check when + it has nothing to sync and never re-reads); the result replays the + closed tab's edits first and this tab's newer ones last. An adopted edit + changes only what is still as its page saw it (`EditStep.yields`), also + at every later conflict: an item or key changed since, by this tab or + another device, keeps its newer value. 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. Startup waits for the first read only a few seconds; + `UserDataStorage.ready()` waits for a first read in flight (it starts + none, so an offline page is not held up) and tells whether one + succeeded. The Xtream data source's favorites, favorite checks, history + and position reads wait for it, so a new device does not show empty + lists or start playback without its resume position. A backup export or + restore (also its replay when the playlist opens) reads the server again + first (`ready(true)`: another device may have changed the lists since) and + fails when that read does not succeed: unread maps would export as empty lists, and a restore's lists + would merge into the server's instead of replacing them (the restore + stays parked). A restore also replaces nothing while this page's own + writes for the playlist are unsent (they would join the replacement) and + counts only once the server took its lists (`UserDataStorage.flushed`) + as they are: a conflict that merged another device's edit in leaves it + parked to run again on a fresh read. Replayed edits keep recently viewed lists newest first, + as the app cuts them at their limit from the end. Also, + re-reads run one at a time and keep entries edited while they were in + flight or not yet saved, and entries another tab saved meanwhile or had + unconfirmed when the read went out (the answer predates them; their next + write here conflicts and replays). A response body cut off after its headers counts + as offline. Each write is sent on the tag of the entry it was made + against (`UserDataWriteQueue`); after a `412` the edit (before → after) is + replayed on the server's current value, so another device's change to the + same playlist's entry is kept. A queued write keeps its edits as steps + (before → after, also in the saved writes): an edit continuing the last + one joins it; one made behind a travelling write, after a write whose + answer was lost, or on another tab's change is kept apart; and a + conflict replays them in order. So a lost + write is not dropped, and adding and then removing an item still removes + it when the addition landed. When two tabs edit one entry before either + write lands, a removal of the other tab's unsent item holds if that tab's + write lands first, as it does when a tab is left (its writes go out on + hide); otherwise the item can come back. An edit made on another tab's unsent change, and an entry a + re-read keeps from the cache, are sent on a version no entry has + (`UNKNOWN_TAG`), so they conflict and are replayed too. Page-hide writes report their + new tag or conflict like any other, and one behind a travelling write is + taken from the queue only when that one is answered. While a playlist is + being deleted its edits wait; the delete drops them only when it + succeeds, and a re-read sent before it skips the deleted playlist. 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. On this tab's recovered unsent + write they are kept as their own steps after it. They are saved per page load too + (`iptvnator-server:deferred-edits::`). Records in + `localStorage` (`iptvnator-server:unconfirmed::`, one + key per page load so tabs never overwrite each other's, renewed every + 30 s while it has unsent writes, stale after ten minutes) let a re-read + in one tab keep the cache values another tab is still sending, and a + stale record marks a closed tab whose writes another tab adopts (the + storage adapter lists its keys for this). Saved writes without any record + are left to the tab's own reload: that tab may still run. A value too big for `localStorage`, or any + value when the browser refuses storage access, 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. +- **Connection state.** `ServerStorageStatusService` is `online`, `offline` + (a request did not reach the server, or a proxy answered 502, 503 or 504 + for it; retried every 30 s and when the tab returns, as are writes the + server refused and a first read that failed while it is `online`) or + `unauthorized` (a 401 clears the stored token; only a reload after a new + token leaves it, so a late answer cannot undo sign-out). The rail shows a status + shortcut, an offline banner sits above the workspace content, and failed + writes raise a snackbar (`SERVER_STORAGE.WRITE_FAILED`, or `TOO_LARGE` for + a 413), the same one at most every five minutes. +- **Access token.** With `IPTVNATOR_ACCESS_TOKEN` the instance reports + `auth: 'token'` (the backend refuses a token without server storage: the + PWA has no sign-in flow for a stateless backend); without a stored token, or after a 401, the guards route to + `/server-sign-in` (the start-up screen lets it through although the + playlists could not load), which checks the token against `/v1/instance/status` + before it stores it under `iptvnator-server:access-token` and reloads (a + browser that refuses to store it is told so instead). The token is stored + with the instance id it was given for, and a page sends it only to that + instance: a tab still open on the old backend after `BACKEND_URL` changed + does not send the new server's token there, and goes to sign-in instead. + `ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the + backend, and `PwaService` adds it to its two raw `fetch` calls only while + server mode runs on a verified instance and only when the URL resolves + to the backend (`isBackendUrl`); it joins paths to the backend URL + without its trailing slashes, so `BACKEND_URL=/` never yields a + protocol-relative `//…` URL that names another host. The browser forgets the token + when the backend answers without server storage, or as another server or + database (backend URL or instance id) than the one it remembers; sign-in + therefore remembers the server along with the token, or reports storage + trouble. A page started from memory first checks that the instance at the + address is the remembered one (`ensureVerified`), and reloads if not + (a reload after a server answered late gives the next start probe as + long as requests take, once, so a slow server does not reload forever), + before sign-in sends the token. So does a page that went offline (any + backend call can tell: the storage API with its page-hide `keepalive` + writes, `HttpClient` calls through the interceptor and `PwaService`'s raw + fetches report no answer, or a + gateway's 502/503/504 that is not the backend's own JSON, as offline) + before its next read: until then it sends neither the token nor writes (a + replaced volume at the same address is another database), and it checks + the instance as soon as it goes offline, so a blip costs no retry + interval (only the instance, and once per retry interval: a read or a + check that keeps failing waits for the retry; the retry also runs while a + late answer set an unchecked page online). Sign-out always navigates to sign-in, also when a guard kept the + page the first time. Both turn + the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`, + as the storage API client does with any 401, so a token changed on the + server sends an open page back to sign-in; a late 401 for a token + replaced since (a new sign-in) changes nothing, nor does one for a page + that holds its token back until the instance is checked. +- **Moving browser data.** `ServerMigrationService` offers, once per server + instance (marker `iptvnator-server:migration:`), to move the + playlists this browser kept in IndexedDB (`planServerMigration`), Stalker + rows from before the portal-mode flag normalized first (server rows get + the same Stalker metadata migration once per server, as a desktop + database copied there can predate it: each row read again and written as + a conditional mutation, a conflict retried at the next start). A + playlist the server already has, by id or by source (Xtream server and + user, Stalker portal and MAC, M3U URL), merges into the server's copy; the + others upload with their ids, create-only (`If-None-Match: *`): one that + another tab or device moved first is merged into instead of overwritten. A merged playlist gains the browser + document's favorites and history (M3U favorites name channels by stream + URL, as the app looks them up; older ones by a channel id that is random + per import, which becomes its URL on both sides; history entries keep + their URL id and get the server's channel id as `channel_id`; recently viewed newest first by `added_at`, + one per item with its newest view; Stalker items by id, category and series + flag, since one provider category can hold a movie and a series of one + id; 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`; 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; recently viewed items newest first and one per item, + its newest view, since the app cuts history from the end) and merges + maps, the server's value winning on a + conflict. Parental locks stay in the browser but move to the server + playlist id with it; while the lock store or a browser user-data map + cannot be read the move counts as failed. A parked Xtream restore (a + browser backup import that could not finish) is copied to the server + playlist id it merged into, unless that one has its own. Browser data is never + deleted, the move does not start while user-data writes are unsent, and + the marker is written only when nothing failed, so Settings → Server + keeps offering a partial move. + +What stays per device: settings, parental lock PINs and locked groups, +the global favorites order and other UI preferences in browser storage. + ## Runtime Limitations The self-hosted build is the browser PWA, not the Electron desktop app. Keep @@ -172,7 +416,9 @@ these limitations explicit in UI, troubleshooting, and release notes: metadata uses `PlaylistsService` with IndexedDB, which `PlaylistStorageBackend` selects when no SQLite bridge exists; Xtream favorites, recently viewed items, playback positions, and cached collection - snapshots use `PwaXtreamDataSource` browser storage. + snapshots use `PwaXtreamDataSource` browser storage. In + [server storage mode](#server-storage-mode) both live on the server + instead. - Browser playlist deletion must go through `PlaylistsService`, not `DatabaseService`. `PlaylistsService.deletePlaylist()` runs registered cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does @@ -271,8 +517,8 @@ and sidecar state in localStorage: `PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the `USER_DATA_STORAGE` token from `@iptvnator/services` (default: -`localStorage`), never through `localStorage` directly, so a deployment can -substitute a server-synced storage. The key names are shared as +`localStorage`), never through `localStorage` directly, so server storage +mode can substitute `ServerUserDataStorage`. The key names are shared as `XTREAM_USER_DATA_KEYS` with the playlist backup, which exports and restores the favorites, recent items, positions and snapshots through the same token (`PwaXtreamBackupStateService`; see diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 90997a8e2..99d8a96d4 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -242,7 +242,12 @@ barrier and row reservation before its persisted-source preflight; contention or unavailable PWA locking declines repair without discovery, while an acquired reservation stays held through its conditional row transform. The reservation blocks every new authentication (including a URL edit with the same normalized -fingerprint) and repair, and drains any work already in flight. +fingerprint) and repair, and drains any work already in flight. Delete All +drains the playlist writes queued before it, then takes the exclusive reset; +later writes wait for the delete before they join their playlist's queue, so +no queue holds work blocked on the reset, except a write under a reservation +this tab holds (`holdsPlaylistAuthority`), which joins at once since the +reset waits for that reservation. When Save follows a lazy repair in the same tab, Edit publishes its local authentication owner first, drains that repair through the actual Web Lock request completion, and only then requests the row reservation itself. Repair diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 450894004..b8f415422 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -89,7 +89,10 @@ The shell is intentionally split into four persistent regions: recently viewed. The routed global-search rail link is Electron-only because its data source is the SQLite worker bridge. 2. Provider-aware context links derived from the active or current playlist. - 3. Settings remains a persistent footer shortcut in the rail. + 3. Settings remains a persistent footer shortcut in the rail. In + [server storage mode](pwa-self-hosted.md#server-storage-mode) a server + status shortcut sits above it: its dot shows online, offline or + sign-in needed, and it opens Settings → Server. 4. No brand mark: it only repeated the first workspace link (Dashboard, or Sources when the dashboard is off). 2. Top header: @@ -103,7 +106,9 @@ The shell is intentionally split into four persistent regions: 6. Downloads shortcut in Electron. 3. Main body: 1. Optional left context panel. - 2. Main router outlet content. + 2. Main router outlet content. In server storage mode an offline banner + with Retry takes the first row of `.workspace-content-frame`, above + the scroller, so full-height pages keep their height. 4. Optional footer: 1. External playback session bar when a docked session is visible. diff --git a/docs/superpowers/specs/2026-10-08-server-storage-design.md b/docs/superpowers/specs/2026-10-08-server-storage-design.md index 75f928dd9..66d0a847a 100644 --- a/docs/superpowers/specs/2026-10-08-server-storage-design.md +++ b/docs/superpowers/specs/2026-10-08-server-storage-design.md @@ -79,9 +79,16 @@ on the server. Adding a playlist stays the ordinary "Add playlist" flow. this instance): a dialog offers "Move to server". Playlists are copied with their ids, then the four Xtream user-data maps for those ids. - Any later device (server already has playlists): Settings → Server - offers "Import this browser's playlists", which runs the existing backup - export (browser) and import (server) with fingerprint merging, so the - same provider added on two devices is not duplicated. + offers "Import this browser's playlists". A browser playlist the server + already has (same id, or same source: Xtream server and user, Stalker + portal and MAC, M3U URL) merges into the server's copy, so the same + provider added on two devices is not duplicated; the others upload with + their ids. A merged playlist gains the browser's favorites and history, + and the Xtream maps follow under the server id, combined with what the + server has (lists unioned, maps merged, the server winning conflicts). The backup format is not used: its + PWA export carries no Xtream user data. + - The decision marker is written only when nothing failed, so a partial + move stays on offer and can run again. - Browser data is never deleted. The server-mode cache uses its own localStorage key prefix, so legacy keys stay intact. 5. Offline: reads and writes fail with a visible state. The rail shows an @@ -125,8 +132,8 @@ All JSON. Errors: `{ message, status, code? }`. | `DELETE /v1/playlists/:id` | Delete the row and its user-state entries. | | `DELETE /v1/playlists` | Delete all playlists and all user state. | | `GET/PUT /v1/app-state/:key` | `{ value }`. | -| `GET /v1/user-state` | `{ entries: { [namespace]: { [playlistId]: value } } }`. | -| `PUT/DELETE /v1/user-state/:namespace/:playlistId` | One entry. Namespaces are an allow-list. | +| `GET /v1/user-state` | `{ entries: { [namespace]: { [playlistId]: value } }, tags }`, a content tag per entry. | +| `PUT/DELETE /v1/user-state/:namespace/:playlistId` | One entry. Namespaces are an allow-list. `If-Match: ""` or `If-None-Match: *` makes it conditional: `412` with the current value and tag. A `PUT` for a playlist the server does not have answers `404 playlist_missing`. | ETags are `"-"` from an in-memory per-playlist revision counter; the server is the only writer of its database. @@ -143,8 +150,7 @@ closes it on `SIGTERM`. Storage errors are logged by name and route only. - `PlaylistStorageBackend` (libs/services) resolves the document store: server store in server mode, Electron bridge when `supportsSqlite`, else - IndexedDB. It can switch to browser storage temporarily for the migration - export. + IndexedDB. - `@iptvnator/services/server-storage` (sub-entry, kept out of the eager barrel): API client, document store, user-data storage, session, migration. - `ServerStorageStatusService` (main barrel, tiny): connection state for the @@ -170,7 +176,7 @@ https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD | Situation | Behavior | | --- | --- | -| Probe fails or times out (2.5 s) | Browser mode, no server UI. | +| Probe gets no answer (network error, 2.5 s timeout, 5xx) | Server mode, offline, if this browser used this backend before (`iptvnator-server:instance`); the page reloads once the server answers. Otherwise browser mode. An answer without server storage, or a 404, forgets the server. | | Request fails after boot | Status `offline`, banner, write failures show a snackbar; retry on `visibilitychange` and every 30 s. | | `401` | Status `unauthorized`, stored token cleared, redirect to sign-in. | | `413` | Snackbar names the limit. | @@ -203,9 +209,12 @@ https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD ## 11. Risks -- **Concurrent edits on two devices**: last write wins per document or per - Xtream map entry. Every playlist mutation re-reads first, so the window is - one round trip. +- **Concurrent edits on two devices**: a playlist mutation is sent with + `If-Match` on the version it was read at; on a `412` the per-playlist + write queue runs the mutation again on a fresh read. Imports and start-up + migrations stay last write wins. + Xtream map entries are written on the tag they were read at and replayed + on the server's value after a `412`, so both devices' edits are kept. - **Large payloads**: M3U global favorites and backup export read all full documents, as IndexedDB does today. - **Open instances**: storing credentials raises the cost of an unprotected diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 8615b1f57..efe97c0ce 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -89,9 +89,21 @@ export class PlaylistEffects { this.store.select(selectActivePlaylistId) ), filter(([, , playlistId]) => !!playlistId), - switchMap(([, favorites, playlistId]) => - this.playlistsService.updateFavorites(playlistId, favorites) - ), + // The toggle, applied to the favorites as stored at write + // time: another device's changes to other channels stay, also + // when a conflict makes the write run again on a fresh read. + mergeMap(([{ channel }, favorites, playlistId]) => { + const added = favorites.includes(channel.url); + return this.playlistsService.transformPlaylistFavorites( + playlistId, + (current) => { + const others = current.filter( + (favorite) => favorite !== channel.url + ); + return added ? [...others, channel.url] : others; + } + ); + }), tap(() => { this.snackBar.open( this.translate.instant('CHANNELS.FAVORITES_UPDATED'), @@ -110,10 +122,13 @@ export class PlaylistEffects { ofType(FavoritesActions.setFavorites), withLatestFrom(this.store.select(selectActivePlaylistId)), filter(([, playlistId]) => !!playlistId), - switchMap(([action, playlistId]) => - this.playlistsService.setFavorites( + // The new order, applied to the favorites as stored at write + // time: another device's additions stay, its removals hold. + mergeMap(([action, playlistId]) => + this.playlistsService.transformPlaylistFavorites( playlistId, - action.channelIds + (current) => + reorderFavorites(current, action.channelIds) ) ) ); @@ -582,3 +597,17 @@ export class PlaylistEffects { return normalizeEpgUrls(this.settingsStore.getSettings().epgUrl ?? []); } } + +/** + * `current` in the order of `order`: entries `order` no longer has stay out + * of its part, entries it never had (added elsewhere) follow it. + */ +export function reorderFavorites( + current: readonly T[], + order: readonly T[] +): T[] { + const present = new Set(current); + const ordered = order.filter((id) => present.has(id)); + const placed = new Set(ordered); + return [...ordered, ...current.filter((id) => !placed.has(id))]; +} diff --git a/libs/m3u-state/src/lib/favorites.effect.spec.ts b/libs/m3u-state/src/lib/favorites.effect.spec.ts new file mode 100644 index 000000000..aa94b3962 --- /dev/null +++ b/libs/m3u-state/src/lib/favorites.effect.spec.ts @@ -0,0 +1,108 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Actions } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { PlaylistsService, SettingsStore } from '@iptvnator/services'; +import type { Channel } from '@iptvnator/shared/interfaces'; +import { of, Subject } from 'rxjs'; +import { FavoritesActions } from './actions'; +import { PlaylistEffects } from './effects'; +import { selectActivePlaylistId, selectFavorites } from './selectors'; + +describe('PlaylistEffects updateFavorites$', () => { + let actions$: Subject; + let transformPlaylistFavorites: jest.Mock; + let storedFavorites: string[]; + + function setup(afterToggle: string[]) { + actions$ = new Subject(); + transformPlaylistFavorites = jest.fn( + (_id: string, transform: (current: string[]) => string[]) => { + storedFavorites = transform(storedFavorites); + return of(undefined); + } + ); + const injector = Injector.create({ + parent: { get: () => ({}) } as unknown as Injector, + providers: [ + { provide: Actions, useValue: new Actions(actions$) }, + { + provide: Store, + useValue: { + select: (selector: unknown) => + of( + selector === selectFavorites + ? afterToggle + : selector === selectActivePlaylistId + ? 'p1' + : undefined + ), + }, + }, + { + provide: PlaylistsService, + useValue: { transformPlaylistFavorites }, + }, + { provide: EpgService, useValue: { fetchEpg: jest.fn() } }, + { provide: Router, useValue: {} }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + { + provide: SettingsStore, + useValue: { getSettings: () => ({ epgUrl: [] }) }, + }, + ], + }); + const effects = runInInjectionContext( + injector, + () => new PlaylistEffects() + ); + effects.updateFavorites$.subscribe(); + effects.setFavorites$.subscribe(); + } + + const channel = { url: 'http://a' } as Channel; + + it("adds the toggled channel to the stored favorites, keeping others'", () => { + // This device's state did not know about 'http://b' yet. + storedFavorites = ['http://b']; + setup(['http://a']); + + actions$.next(FavoritesActions.updateFavorites({ channel })); + + expect(transformPlaylistFavorites).toHaveBeenCalledWith( + 'p1', + expect.any(Function) + ); + expect(storedFavorites).toEqual(['http://b', 'http://a']); + }); + + it('removes only the toggled channel', () => { + storedFavorites = ['http://a', 'http://b']; + setup([]); + + actions$.next(FavoritesActions.updateFavorites({ channel })); + + expect(storedFavorites).toEqual(['http://b']); + }); + + it('applies a reorder to the stored favorites', () => { + // Another device added 'http://new' and removed 'http://gone'. + storedFavorites = ['http://a', 'http://b', 'http://new']; + setup([]); + + actions$.next( + FavoritesActions.setFavorites({ + channelIds: ['http://b', 'http://gone', 'http://a'], + }) + ); + + expect(storedFavorites).toEqual(['http://b', 'http://a', 'http://new']); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 21756bda8..39ba106de 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -953,5 +953,247 @@ describe('PwaXtreamDataSource', () => { ); expect(localStorage.getItem('xtream-favorites')).toBeNull(); }); + + it('reads positions once the first server read settled', async () => { + const values = new Map(); + let firstRead = (): void => undefined; + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + // Server mode: the server's data lands in the cache later. + ready: () => + new Promise((resolve) => { + firstRead = () => { + values.set( + 'xtream-playback-positions', + JSON.stringify({ + p1: [ + { + contentXtreamId: 501, + contentType: 'vod', + positionSeconds: 120, + }, + ], + }) + ); + resolve(true); + }; + }), + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { provide: USER_DATA_STORAGE, useValue: storage }, + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamApiService, useValue: apiService }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }); + const source = TestBed.inject(PwaXtreamDataSource); + + const position = source.getPlaybackPosition('p1', 501, 'vod'); + // The read is still running while other work goes on. + for (let turn = 0; turn < 5; turn += 1) { + await Promise.resolve(); + } + firstRead(); + + await expect(position).resolves.toMatchObject({ + positionSeconds: 120, + }); + }); + + it('keeps a restore parked while the server user data is unread', async () => { + const values = new Map([ + ['xtream-favorites', JSON.stringify({ p1: ['live:1'] })], + ]); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + ready: jest.fn(async () => false), + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { provide: USER_DATA_STORAGE, useValue: storage }, + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamApiService, useValue: apiService }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }); + const source = TestBed.inject(PwaXtreamDataSource); + + await expect( + source.restoreUserData('p1', { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + } as never) + ).rejects.toThrow(/restore waits/); + // A fresh read: the restore replaces the current lists. + expect(storage.ready).toHaveBeenCalledWith(true); + expect(values.get('xtream-favorites')).toBe( + JSON.stringify({ p1: ['live:1'] }) + ); + }); + + it('keeps a restore parked while the server refuses its lists', async () => { + const values = new Map(); + // Nothing unsent before; the restored lists are refused. + const flushed = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + ready: async () => true, + flushed, + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { provide: USER_DATA_STORAGE, useValue: storage }, + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamApiService, useValue: apiService }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }); + const source = TestBed.inject(PwaXtreamDataSource); + + await expect( + source.restoreUserData('p1', { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + } as never) + ).rejects.toThrow(/did not take the restored lists/); + expect(flushed).toHaveBeenCalledWith('p1'); + }); + + it('replaces the lists, and runs again when a conflict merged them', async () => { + const values = new Map([ + ['xtream-favorites', JSON.stringify({ p1: ['live:1'] })], + ]); + let merge = false; + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + ready: async () => true, + flushed: jest.fn(async () => { + if (merge) { + // Another device added one meanwhile: replayed in. + values.set( + 'xtream-favorites', + JSON.stringify({ p1: ['live:9', 'movie:5'] }) + ); + } + return true; + }), + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { provide: USER_DATA_STORAGE, useValue: storage }, + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamApiService, useValue: apiService }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }); + const source = TestBed.inject(PwaXtreamDataSource); + const state = { + hiddenCategories: [], + favorites: [{ xtreamId: 5, contentType: 'movie' }], + recentlyViewed: [], + playbackPositions: [], + } as never; + + await source.restoreUserData('p1', state); + expect(JSON.parse(values.get('xtream-favorites') ?? '{}')).toEqual({ + p1: ['movie:5'], + }); + // A saved restore may list one favorite twice: reads drop the + // copy on both sides of the comparison. + await expect( + source.restoreUserData('p1', { + ...(state as object), + favorites: [ + { xtreamId: 5, contentType: 'movie' }, + { xtreamId: 5, contentType: 'movie' }, + ], + } as never) + ).resolves.toBeUndefined(); + + merge = true; + await expect(source.restoreUserData('p1', state)).rejects.toThrow( + /changed elsewhere/ + ); + }); + + it('replaces nothing while earlier changes to the playlist are unsent', async () => { + const values = new Map([ + ['xtream-favorites', JSON.stringify({ p1: ['live:1'] })], + ]); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + ready: async () => true, + flushed: jest.fn().mockResolvedValueOnce(false), + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { provide: USER_DATA_STORAGE, useValue: storage }, + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamApiService, useValue: apiService }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }); + const source = TestBed.inject(PwaXtreamDataSource); + + await expect( + source.restoreUserData('p1', { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + } as never) + ).rejects.toThrow(/not on the server yet/); + expect(values.get('xtream-favorites')).toBe( + JSON.stringify({ p1: ['live:1'] }) + ); + }); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index af61c2906..23f77d2c2 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -695,6 +695,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // ========================================================================= async getFavorites(playlistId: string): Promise { + await this.userDataRead(); await this.getCollectionItemsWithHydration( playlistId, this.collectionStorage.getFavoritesFromStorage()[playlistId] || [] @@ -766,6 +767,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { contentId: XtreamCollectionRef, playlistId: string ): Promise { + await this.userDataRead(); const normalizedContentId = await this.resolveCollectionKey( playlistId, contentId @@ -820,6 +822,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { contentXtreamId: number, contentType: 'vod' | 'episode' ): Promise { + await this.userDataRead(); const allPositions = this.getPlaybackPositionsFromStorage(); const playlistPositions = allPositions[playlistId] || []; @@ -836,6 +839,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, seriesXtreamId: number ): Promise { + await this.userDataRead(); const allPositions = this.getPlaybackPositionsFromStorage(); const playlistPositions = allPositions[playlistId] || []; @@ -850,6 +854,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, limit?: number ): Promise { + await this.userDataRead(); const allPositions = this.getPlaybackPositionsFromStorage(); const playlistPositions = allPositions[playlistId] || []; @@ -866,6 +871,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getAllPlaybackPositions( playlistId: string ): Promise { + await this.userDataRead(); const allPositions = this.getPlaybackPositionsFromStorage(); return allPositions[playlistId] || []; } @@ -941,6 +947,17 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.savePlaybackPositionsToStorage(allPositions); } + /** + * Server mode: favorites, history and positions show the server's data + * once its first read settled, which startup does not wait for beyond a + * few seconds. A read before it would show a new device empty lists. + * Whether a read succeeded (always true outside server mode); `fresh` + * reads again first, for a restore that replaces the current lists. + */ + private async userDataRead(fresh = false): Promise { + return (await this.userDataStorage.ready?.(fresh)) ?? true; + } + private getPlaybackPositionsFromStorage(): Record< string, PlaybackPositionData[] @@ -975,6 +992,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // ========================================================================= async getRecentItems(playlistId: string): Promise { + await this.userDataRead(); await this.getCollectionItemsWithHydration( playlistId, ( @@ -1388,6 +1406,20 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; + // Its lists replace the server's: before the server's are read they + // would merge into them instead. The restore stays parked. + if (!(await this.userDataRead(true))) { + throw new Error( + "The server's user data could not be read yet; the restore waits." + ); + } + // This page's unsent writes for the playlist would join the + // replacement: they go first, or the restore waits. + if ((await this.userDataStorage.flushed?.(playlistId)) === false) { + throw new Error( + 'Earlier changes to this playlist are not on the server yet; the restore waits.' + ); + } // Restore favorites const favorites = this.collectionStorage.getFavoritesFromStorage(); favorites[playlistId] = restoreState.favorites @@ -1426,6 +1458,28 @@ export class PwaXtreamDataSource implements IXtreamDataSource { }) ); this.savePlaybackPositionsToStorage(playbackPositions); + // The lists as reads return them (deduplicated, normalized). + const restoredLists = () => + JSON.stringify([ + this.collectionStorage.getFavoritesFromStorage()[playlistId], + this.collectionStorage.getRecentItemsFromStorage()[playlistId], + this.getPlaybackPositionsFromStorage()[playlistId], + ]); + const restored = restoredLists(); + // Server mode: done once the server took the lists, else it stays + // parked for the next open. + if ((await this.userDataStorage.flushed?.(playlistId)) === false) { + throw new Error( + 'The server did not take the restored lists; the restore waits.' + ); + } + // A conflict merges an edit another device made meanwhile into the + // lists: the restore replaces them, so it runs again on a fresh read. + if (restoredLists() !== restored) { + throw new Error( + 'The lists changed elsewhere during the restore; it runs again.' + ); + } } // ========================================================================= diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 6b73236e3..9cd5bb1a0 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -33,3 +33,6 @@ export * from './lib/source-health-evidence.service'; export * from './lib/source-activity.service'; export * from './lib/translation-tick'; export * from './lib/user-data-storage'; +export * from './lib/server-storage-status.service'; +export * from './lib/server-access-token'; +export * from './lib/server-slow-start'; diff --git a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts index 7a1599452..1ba082396 100644 --- a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts @@ -269,6 +269,46 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { await expect(service.exportBackup()).rejects.toThrow('denied'); }); + + it('waits for server user data before it reads the lists', async () => { + // Server mode: the maps hold the server's data once it was read. + const ready = jest.fn(async () => { + seed(); + return true; + }); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: (key) => localStorage.removeItem(key), + ready, + }), + }); + + const { manifest } = await service.exportBackup(); + + // A fresh read: another device may have changed them since. + expect(ready).toHaveBeenCalledWith(true); + expect(ready).toHaveBeenCalledTimes(1); + expect(xtreamEntry(manifest).userState.favorites).not.toEqual([]); + }); + + it('refuses to export server user data it could not read', async () => { + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: () => null, + setItem: jest.fn(), + removeItem: jest.fn(), + ready: async () => false, + }), + }); + + await expect(service.exportBackup()).rejects.toThrow( + /user data could not be read/ + ); + }); }); describe('import', () => { @@ -472,6 +512,130 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { }); }); + it('keeps the restore parked until server user data was read', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + localStorage.setItem( + KEYS.favorites, + JSON.stringify({ 'xtream-1': ['live:1'] }) + ); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: (key) => localStorage.removeItem(key), + ready: async () => false, + }), + }); + + const summary = await service.importBackup(xtreamOnly(manifest)); + + expect(summary.failed).toBe(1); + // Nothing merged into lists the server may still replace. + expect(read(KEYS.favorites)).toEqual({ 'xtream-1': ['live:1'] }); + expect( + pendingRestore(service).getOrThrow('xtream-1') + ).not.toBeNull(); + }); + + it('keeps the restore parked when a conflict merged other lists in', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + // Taken, but merged with an edit another device made meanwhile. + const flushed = jest + .fn() + .mockResolvedValueOnce(true) + .mockImplementation(async () => { + const map = JSON.parse( + localStorage.getItem(KEYS.favorites) ?? '{}' + ); + map['xtream-1'] = [...map['xtream-1'], 'live:9']; + localStorage.setItem(KEYS.favorites, JSON.stringify(map)); + return true; + }); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: (key) => localStorage.removeItem(key), + ready: async () => true, + flushed, + }), + }); + + const summary = await service.importBackup(xtreamOnly(manifest)); + + expect(summary.failed).toBe(1); + expect(summary.errors[0]).toContain('changed elsewhere'); + expect( + pendingRestore(service).getOrThrow('xtream-1') + ).not.toBeNull(); + }); + + it('replaces nothing while earlier changes to the playlist are unsent', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + localStorage.setItem( + KEYS.favorites, + JSON.stringify({ 'xtream-1': ['live:1'] }) + ); + // A write of this page for the playlist that the server refused. + const flushed = jest.fn().mockResolvedValueOnce(false); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: (key) => localStorage.removeItem(key), + ready: async () => true, + flushed, + }), + }); + + const summary = await service.importBackup(xtreamOnly(manifest)); + + expect(summary.failed).toBe(1); + // Its edits would have joined the replacement lists. + expect(read(KEYS.favorites)).toEqual({ 'xtream-1': ['live:1'] }); + expect( + pendingRestore(service).getOrThrow('xtream-1') + ).not.toBeNull(); + }); + + it('keeps the restore parked until the server took the lists', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + // Nothing unsent before; the restored lists are refused. + const flushed = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: (key) => localStorage.removeItem(key), + ready: async () => true, + flushed, + }), + }); + + const summary = await service.importBackup(xtreamOnly(manifest)); + + expect(flushed).toHaveBeenCalledWith('xtream-1'); + expect(summary.failed).toBe(1); + expect( + pendingRestore(service).getOrThrow('xtream-1') + ).not.toBeNull(); + // Titles are merged regardless: the replay carries none. + expect(read(KEYS.collectionItems)).toEqual({ + 'xtream-1': { 'movie:501': heat, 'series:77': legacyShow }, + }); + }); + it('replaces a damaged title cache but refuses an unreadable storage', async () => { const manifest = await exportSeeded(); localStorage.setItem(KEYS.collectionItems, '{"xtream-1": {'); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 85498b79b..87ac6a48e 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -91,6 +91,9 @@ export class PlaylistBackupService { 'The parental lock store could not be read; the backup would omit its locks.' ); } + if (!this.runtime.supportsXtreamSqliteDataSource) { + await this.pwaXtream.ready(); + } const playlists = await firstValueFrom( this.playlistsService.getAllData() ); diff --git a/libs/services/src/lib/playlist-cross-context-lock.ts b/libs/services/src/lib/playlist-cross-context-lock.ts index 3461e7830..49d52ad53 100644 --- a/libs/services/src/lib/playlist-cross-context-lock.ts +++ b/libs/services/src/lib/playlist-cross-context-lock.ts @@ -69,6 +69,17 @@ export async function runWithPlaylistAuthorityReset( ); } +/** Row reservations this browsing context holds now, per playlist. */ +const heldHere = new Map(); + +/** + * Whether this context holds a reservation on the playlist: a store-wide + * reset waits for it, so its own writes must not wait for the reset. + */ +export function holdsPlaylistAuthority(playlistId: string): boolean { + return (heldHere.get(playlistId) ?? 0) > 0; +} + /** Tries to hold one origin-wide row reservation until its owner releases it. */ export async function reservePlaylistAuthority( playlistId: string @@ -120,11 +131,18 @@ export async function reservePlaylistAuthority( } let released = false; + heldHere.set(playlistId, (heldHere.get(playlistId) ?? 0) + 1); return { status: 'acquired', release: () => { if (!released) { released = true; + const count = (heldHere.get(playlistId) ?? 1) - 1; + if (count > 0) { + heldHere.set(playlistId, count); + } else { + heldHere.delete(playlistId); + } releaseHeldLock(); } }, diff --git a/libs/services/src/lib/playlist-storage-backend.spec.ts b/libs/services/src/lib/playlist-storage-backend.spec.ts index dadffd2f3..57da94822 100644 --- a/libs/services/src/lib/playlist-storage-backend.spec.ts +++ b/libs/services/src/lib/playlist-storage-backend.spec.ts @@ -41,4 +41,15 @@ describe('PlaylistStorageBackend', () => { expect(backend.kind()).toBe('indexeddb'); expect(backend.documentStore()).toBeNull(); }); + + it('prefers the server store once it is set', () => { + testWindow.electron = undefined; + const backend = create(false); + const store = { dbGetAppPlaylists: jest.fn() } as never; + + backend.useServerStore(store); + + expect(backend.kind()).toBe('server'); + expect(backend.documentStore()).toBe(store); + }); }); diff --git a/libs/services/src/lib/playlist-storage-backend.ts b/libs/services/src/lib/playlist-storage-backend.ts index ce1653b9d..5766176f6 100644 --- a/libs/services/src/lib/playlist-storage-backend.ts +++ b/libs/services/src/lib/playlist-storage-backend.ts @@ -28,20 +28,44 @@ export type PlaylistDocumentStore = Pick< > >; -export type PlaylistStorageKind = 'electron' | 'indexeddb'; +/** + * The server refused a write built on a playlist version another device has + * replaced since; the write queue runs the mutation again on a fresh read. + */ +export class PlaylistWriteConflict extends Error { + constructor(readonly playlistId: string) { + super(`Playlist ${playlistId} changed since it was read.`); + this.name = 'PlaylistWriteConflict'; + } +} + +export type PlaylistStorageKind = 'electron' | 'server' | 'indexeddb'; /** Single place that decides where `PlaylistsService` persists playlists. */ @Injectable({ providedIn: 'root' }) export class PlaylistStorageBackend { private readonly runtime = inject(RuntimeCapabilitiesService); + private serverStore: PlaylistDocumentStore | null = null; + + /** Server mode: set once by the server storage session at startup. */ + useServerStore(store: PlaylistDocumentStore): void { + this.serverStore = store; + } kind(): PlaylistStorageKind { + if (this.serverStore) { + return 'server'; + } return this.runtime.supportsSqlite ? 'electron' : 'indexeddb'; } /** The document store for `kind()`, or `null` for IndexedDB. */ documentStore(): PlaylistDocumentStore | null { - if (this.kind() !== 'electron' || typeof window === 'undefined') { + const kind = this.kind(); + if (kind === 'server') { + return this.serverStore; + } + if (kind !== 'electron' || typeof window === 'undefined') { return null; } return (window.electron as PlaylistDocumentStore | undefined) ?? null; diff --git a/libs/services/src/lib/playlists.service.inventory-read.spec.ts b/libs/services/src/lib/playlists.service.inventory-read.spec.ts index 492b6e000..bc75bcd08 100644 --- a/libs/services/src/lib/playlists.service.inventory-read.spec.ts +++ b/libs/services/src/lib/playlists.service.inventory-read.spec.ts @@ -54,6 +54,8 @@ describe('PlaylistsService inventory reads', () => { }, electronMigrationPromise: null, playlistWriteQueues: new Map(), + deletingAll: Promise.resolve(), + resets: 0, }); const settle = async (index: number, playlists: Playlist[]) => { // The read starts after the memoized migration's awaits. diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index f59e131a4..3570f8182 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1,5 +1,7 @@ import { EMPTY, firstValueFrom, from, of } from 'rxjs'; import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { reservePlaylistAuthority } from './playlist-cross-context-lock'; +import { PlaylistWriteConflict } from './playlist-storage-backend'; import { PlaylistsService, resolvePlaylistParser } from './playlists.service'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; @@ -66,6 +68,8 @@ describe('PlaylistsService', () => { electronMigrationPromise: null, indexedDbMigrationPromise: null, playlistWriteQueues: new Map(), + deletingAll: Promise.resolve(), + resets: 0, playlistDeleteCleanups: [], }); @@ -73,7 +77,10 @@ describe('PlaylistsService', () => { } it('announces a committed SQLite connection edit', async () => { - const playlist = { _id: 'source', url: 'https://old.test/list' } as Playlist; + const playlist = { + _id: 'source', + url: 'https://old.test/list', + } as Playlist; testWindow.electron = { dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(), @@ -84,8 +91,22 @@ describe('PlaylistsService', () => { const service = createService(); const next = jest.fn(); Object.assign(service, { healthEvidence: { connections: { next } } }); - await firstValueFrom(service.updatePlaylistMeta({ ...playlist, title: 'Source', count: 0, importDate: '', autoRefresh: false, serverUrl: 'https://new.test' })); - expect(next).toHaveBeenCalledWith({ id: 'source', playlist: expect.objectContaining({ serverUrl: 'https://new.test' }) }); + await firstValueFrom( + service.updatePlaylistMeta({ + ...playlist, + title: 'Source', + count: 0, + importDate: '', + autoRefresh: false, + serverUrl: 'https://new.test', + }) + ); + expect(next).toHaveBeenCalledWith({ + id: 'source', + playlist: expect.objectContaining({ + serverUrl: 'https://new.test', + }), + }); }); it('announces committed deletion but does not retire health after a failed write', async () => { @@ -95,9 +116,15 @@ describe('PlaylistsService', () => { await firstValueFrom(service.deletePlaylist('retired')); expect(next).toHaveBeenCalledWith({ id: 'retired' }); next.mockClear(); - const failed = createService({ delete: jest.fn(() => { throw new Error('write failed'); }) }); + const failed = createService({ + delete: jest.fn(() => { + throw new Error('write failed'); + }), + }); Object.assign(failed, { healthEvidence: { connections: { next } } }); - await expect(firstValueFrom(failed.deletePlaylist('kept'))).rejects.toThrow(); + await expect( + firstValueFrom(failed.deletePlaylist('kept')) + ).rejects.toThrow(); expect(next).not.toHaveBeenCalled(); }); @@ -554,6 +581,85 @@ describe('PlaylistsService', () => { ); }); + describe('legacy Stalker portal flags on the server', () => { + // A desktop database copied to the server, from before the flag. + const copied = { + _id: 'stalker-1', + title: 'Copied Stalker', + count: 0, + importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(), + autoRefresh: false, + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'http://example.com/stalker_portal/c/', + } as Playlist; + + function serverService( + fresh: Playlist, + upsert: jest.Mock = jest.fn(async () => ({ success: true })) + ) { + const appState = new Map(); + const server = { + dbGetAppPlaylists: jest.fn(async () => [copied]), + // Another device may have changed it since the list read. + dbGetAppPlaylist: jest.fn(async () => fresh), + dbGetAppState: jest.fn( + async (key: string) => appState.get(key) ?? null + ), + dbSetAppState: jest.fn(async (key: string, value: string) => { + appState.set(key, value); + }), + dbUpsertAppPlaylist: upsert, + dbUpsertAppPlaylists: jest.fn(), + dbRecoverLegacyPlaylists: jest.fn(), + }; + testWindow.electron = undefined; + const service = createService(); + Object.assign(service as object, { + storageBackend: { + kind: () => 'server', + documentStore: () => server, + }, + }); + return { service, server, appState }; + } + + it('are written per row on a fresh read, as conditional mutations', async () => { + const edited = { ...copied, title: 'Renamed elsewhere' }; + const { service, server, appState } = serverService(edited); + + await firstValueFrom(service.getAllPlaylists()); + + // Never the stale list copy, never an unconditional bulk write. + expect(server.dbUpsertAppPlaylists).not.toHaveBeenCalled(); + expect(server.dbUpsertAppPlaylist).toHaveBeenCalledWith({ + ...edited, + isFullStalkerPortal: true, + }); + expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe( + '1' + ); + // The desktop-only steps stay with the desktop. + expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled(); + }); + + it('stay unflagged for the next start after a conflict', async () => { + const { service, appState } = serverService( + copied, + jest.fn(async () => { + throw new PlaylistWriteConflict('stalker-1'); + }) + ); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await firstValueFrom(service.getAllPlaylists()); + + expect( + appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) + ).toBeUndefined(); + }); + }); + it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => { const electron = { dbGetAppPlaylists: jest.fn(async () => []), @@ -1109,7 +1215,9 @@ describe('PlaylistsService', () => { ] as Playlist[]; const dbService = { getAll: jest.fn(() => of([])), - getByID: jest.fn((_store: string, id: string) => of(playlists.find((p) => p._id === id))), + getByID: jest.fn((_store: string, id: string) => + of(playlists.find((p) => p._id === id)) + ), update: jest.fn((_storeName: string, playlist: Playlist) => of(playlist) ), @@ -1142,19 +1250,32 @@ describe('PlaylistsService', () => { ); }); - it.each([false, true])('does not resurrect a deleted source when startup refresh finishes (SQLite=%s)', async (sqlite) => { - const upsert = jest.fn(); - testWindow.electron = sqlite ? { - dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(), - dbGetAppPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(null), - dbUpsertAppPlaylist: upsert, - } : undefined; - const update = jest.fn(() => of(undefined)); - const service = createService({ update }); - expect(await firstValueFrom(service.updateManyPlaylists([{ _id: 'deleted', autoRefresh: true } as Playlist]))).toEqual([]); - expect(update).not.toHaveBeenCalled(); - expect(upsert).not.toHaveBeenCalled(); - }); + it.each([false, true])( + 'does not resurrect a deleted source when startup refresh finishes (SQLite=%s)', + async (sqlite) => { + const upsert = jest.fn(); + testWindow.electron = sqlite + ? { + dbGetAppState: jest.fn().mockResolvedValue('1'), + dbSetAppState: jest.fn(), + dbGetAppPlaylists: jest.fn().mockResolvedValue([]), + dbGetAppPlaylist: jest.fn().mockResolvedValue(null), + dbUpsertAppPlaylist: upsert, + } + : undefined; + const update = jest.fn(() => of(undefined)); + const service = createService({ update }); + expect( + await firstValueFrom( + service.updateManyPlaylists([ + { _id: 'deleted', autoRefresh: true } as Playlist, + ]) + ) + ).toEqual([]); + expect(update).not.toHaveBeenCalled(); + expect(upsert).not.toHaveBeenCalled(); + } + ); it('short-circuits updateManyPlaylists when no playlists are provided', async () => { const dbService = { @@ -1635,6 +1756,273 @@ describe('PlaylistsService', () => { ); }); + it('deletes everything only after queued writes, before later ones', async () => { + const order: string[] = []; + let finishWrite: () => void = () => undefined; + const electron = { + dbGetAppPlaylist: jest.fn(async () => { + order.push('read'); + return { _id: 'p9', title: 'P', favorites: [] }; + }), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async (key: string) => + key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null + ), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn( + () => + new Promise((resolve) => { + order.push('write'); + finishWrite = () => resolve(); + }) + ), + dbUpsertAppPlaylists: jest.fn(), + dbDeleteAllPlaylists: jest.fn(async () => { + order.push('delete all'); + }), + }; + testWindow.electron = electron; + const service = createService(); + // Microtasks only: a real timer would let other tests' late errors in. + const settle = async () => { + for (let i = 0; i < 50; i += 1) { + await Promise.resolve(); + } + }; + + const first = firstValueFrom( + service.transformPlaylistFavorites('p9', (f) => [...f, 'a']) + ); + await settle(); + const removed = firstValueFrom(service.removeAll()); + const later = firstValueFrom( + service.transformPlaylistFavorites('p9', (f) => [...f, 'b']) + ); + await settle(); + expect(order).toEqual(['read', 'write']); + + finishWrite(); + await first; + await removed; + await settle(); + finishWrite(); + await later; + + expect(order).toEqual(['read', 'write', 'delete all', 'read', 'write']); + }); + + describe('Delete All with cross-context locks', () => { + /** Web Locks as far as these tests need: the barrier and free rows. */ + function fakeLocks() { + let shared = 0; + let exclusive = false; + const waiting: (() => void)[] = []; + const changed = () => waiting.splice(0).forEach((wake) => wake()); + const until = async (free: () => boolean) => { + while (!free()) { + await new Promise((wake) => waiting.push(wake)); + } + }; + const request = jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => { + const lock = { + name, + mode: options.mode ?? 'exclusive', + } as Lock; + if (name !== 'iptvnator:playlist-authority') { + return callback(lock); + } + if (options.mode === 'shared') { + await until(() => !exclusive); + shared += 1; + try { + return await callback(lock); + } finally { + shared -= 1; + changed(); + } + } + await until(() => !exclusive && shared === 0); + exclusive = true; + try { + return await callback(lock); + } finally { + exclusive = false; + changed(); + } + } + ); + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { request }, + }); + } + + let finishWrite: () => void = () => undefined; + function store(slowWrites = false) { + const electron = { + dbGetAppPlaylist: jest.fn(async () => ({ + _id: 'p9', + title: 'P', + favorites: [], + })), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async (key: string) => + key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null + ), + dbSetAppState: jest.fn(), + // Only the first write is slow. + dbUpsertAppPlaylist: jest.fn(() => { + if (!slowWrites) { + return Promise.resolve(); + } + slowWrites = false; + return new Promise( + (resolve) => (finishWrite = resolve) + ); + }), + dbUpsertAppPlaylists: jest.fn(), + dbDeletePlaylist: jest.fn(async () => undefined), + dbDeleteAllPlaylists: jest.fn(async () => undefined), + }; + testWindow.electron = electron; + return electron; + } + + const settle = async () => { + for (let i = 0; i < 100; i += 1) { + await Promise.resolve(); + } + }; + + it('lets a repair holding its reservation write first', async () => { + fakeLocks(); + const electron = store(); + const service = createService(); + const reservation = await reservePlaylistAuthority('p9'); + if (reservation.status !== 'acquired') { + throw new Error('expected a reservation'); + } + + const removed = firstValueFrom(service.removeAll()); + // The repair's conditional row write, under its reservation. + let written = false; + const repair = firstValueFrom( + service.transformPlaylistFavorites('p9', (f) => [...f, 'a']) + ).then(() => (written = true)); + await settle(); + + expect(written).toBe(true); + expect(electron.dbDeleteAllPlaylists).not.toHaveBeenCalled(); + reservation.release(); + await repair; + await removed; + expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled(); + }); + + it('lets a repair write past later writes waiting for the reset', async () => { + fakeLocks(); + const electron = store(true); + const service = createService(); + // An earlier slow write that Delete All waits for. + const slow = firstValueFrom( + service.transformPlaylistFavorites('q1', (f) => [...f, 'a']) + ); + await settle(); + const removed = firstValueFrom(service.removeAll()); + // A later write for P, which waits for the reset. + const later = firstValueFrom( + service.transformPlaylistFavorites('p9', (f) => [...f, 'b']) + ); + // A repair reserves P before the reset takes its lock. + const reservation = await reservePlaylistAuthority('p9'); + if (reservation.status !== 'acquired') { + throw new Error('expected a reservation'); + } + let repaired = false; + const repair = firstValueFrom( + service.transformPlaylistFavorites('p9', (f) => [...f, 'r']) + ).then(() => { + repaired = true; + reservation.release(); + }); + await settle(); + + expect(repaired).toBe(true); + finishWrite(); + await Promise.all([slow, repair, removed, later]); + expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled(); + }); + + it('lets a deletion queued behind a slow write finish first', async () => { + fakeLocks(); + const electron = store(true); + const service = createService(); + + const slow = firstValueFrom( + service.transformPlaylistFavorites('p9', (f) => [...f, 'a']) + ); + await settle(); + const deleted = firstValueFrom(service.deletePlaylist('p9')); + let done = false; + const removed = firstValueFrom(service.removeAll()).then( + () => (done = true) + ); + await settle(); + finishWrite(); + await settle(); + + // The deletion needs the shared lock: it cannot wait for a + // reset that waits for it. + expect(done).toBe(true); + await slow; + await deleted; + await removed; + expect(electron.dbDeletePlaylist).toHaveBeenCalledWith('p9'); + expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled(); + }); + }); + + it('runs a mutation again on a fresh read when the server refused it', async () => { + const versions = [ + { _id: 'p9', title: 'P', favorites: ['a'] }, + { _id: 'p9', title: 'P', favorites: ['a', 'theirs'] }, + ] as unknown as Playlist[]; + let reads = 0; + const electron = { + dbGetAppPlaylist: jest.fn( + async () => versions[Math.min(reads++, 1)] + ), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async (key: string) => + key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null + ), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest + .fn() + .mockRejectedValueOnce(new PlaylistWriteConflict('p9')) + .mockResolvedValue(undefined), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + await firstValueFrom( + createService().transformPlaylistFavorites('p9', (favorites) => [ + ...favorites, + 'mine', + ]) + ); + + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); + expect(electron.dbUpsertAppPlaylist).toHaveBeenLastCalledWith( + expect.objectContaining({ favorites: ['a', 'theirs', 'mine'] }) + ); + }); + it('short-circuits removeFromPlaylistRecentlyViewedBatch when identities is empty', async () => { const existingPlaylist: Playlist = { _id: 'playlist-5', @@ -1813,19 +2201,30 @@ describe('PlaylistsService', () => { ); }); - it.each([false, true])('does not recreate a removed source on singular refresh (SQLite: %s)', async (sqlite) => { - const upsert = jest.fn(); - testWindow.electron = sqlite ? { - dbGetAllPlaylists: jest.fn().mockResolvedValue([]), - dbGetAppPlaylist: jest.fn().mockResolvedValue(null), - dbUpsertAppPlaylist: upsert, - } as unknown as typeof testWindow.electron : undefined; - const update = jest.fn(() => of(undefined)); - const service = createService({ update }); - await expect(firstValueFrom(service.updatePlaylist('deleted', { _id: 'deleted' } as Playlist))).rejects.toThrow('Playlist no longer exists'); - expect(update).not.toHaveBeenCalled(); - expect(upsert).not.toHaveBeenCalled(); - }); + it.each([false, true])( + 'does not recreate a removed source on singular refresh (SQLite: %s)', + async (sqlite) => { + const upsert = jest.fn(); + testWindow.electron = sqlite + ? ({ + dbGetAllPlaylists: jest.fn().mockResolvedValue([]), + dbGetAppPlaylist: jest.fn().mockResolvedValue(null), + dbUpsertAppPlaylist: upsert, + } as unknown as typeof testWindow.electron) + : undefined; + const update = jest.fn(() => of(undefined)); + const service = createService({ update }); + await expect( + firstValueFrom( + service.updatePlaylist('deleted', { + _id: 'deleted', + } as Playlist) + ) + ).rejects.toThrow('Playlist no longer exists'); + expect(update).not.toHaveBeenCalled(); + expect(upsert).not.toHaveBeenCalled(); + } + ); it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => { const existingPlaylist: Playlist = { _id: 'playlist-2', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 6a138d144..1de4240d4 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -28,7 +28,6 @@ import { Channel, DbStores, extractStalkerItemId, - isFullStalkerPortalUrl, isM3uRecentlyViewedItem, M3uFavoriteChannel, M3uRecentlyViewedItem, @@ -38,15 +37,18 @@ import { PlaylistUpdateState, StalkerPortalItem, normalizeStalkerDate, + withExplicitStalkerPortalFlag, } from '@iptvnator/shared/interfaces'; import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token'; import { runWithPlaylistAuthorityMutation, + holdsPlaylistAuthority, runWithPlaylistAuthorityReset, } from './playlist-cross-context-lock'; import { PlaylistDocumentStore, PlaylistStorageBackend, + PlaylistWriteConflict, } from './playlist-storage-backend'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; @@ -83,7 +85,9 @@ export function resolvePlaylistParser(parserModule: PlaylistParserModule) { providedIn: 'root', }) export class PlaylistsService { - private readonly healthEvidence = inject(SourceHealthEvidenceService, { optional: true }); + private readonly healthEvidence = inject(SourceHealthEvidenceService, { + optional: true, + }); private readonly dbService = inject(NgxIndexedDBService); private readonly databaseService = inject(DatabaseService); private readonly snackBar = inject(MatSnackBar); @@ -100,6 +104,10 @@ export class PlaylistsService { private startupMetas?: Promise | null; private indexedDbMigrationPromise: Promise | null = null; private readonly playlistWriteQueues = new Map>(); + /** "Remove all" in progress: every playlist write waits for it. */ + private deletingAll: Promise = Promise.resolve(); + /** Delete All runs in progress (see serializePlaylistWrite). */ + private resets = 0; /** Electron bridge or server store; `null` means IndexedDB. */ private get documentStore(): PlaylistDocumentStore | null { @@ -128,14 +136,18 @@ export class PlaylistsService { } private async ensureElectronPlaylistMigrations(): Promise { - if (!this.usesElectronStorage) { + if (!this.usesDocumentStore) { return; } if (!this.electronMigrationPromise) { this.electronMigrationPromise = (async () => { - await this.migrateIndexedDbPlaylistsToSqlite(); - await this.documentStore?.dbRecoverLegacyPlaylists?.(); + if (this.usesElectronStorage) { + await this.migrateIndexedDbPlaylistsToSqlite(); + await this.documentStore?.dbRecoverLegacyPlaylists?.(); + } + // Server rows too, once per server: a desktop database + // copied to the server can predate this migration. await this.migrateStalkerPlaylistMetadataInSqlite(); })().catch((error) => { this.electronMigrationPromise = null; @@ -241,19 +253,7 @@ export class PlaylistsService { } private withExplicitLegacyStalkerPortalFlag(playlist: Playlist): Playlist { - if ( - !playlist?.macAddress || - playlist.isFullStalkerPortal !== undefined - ) { - return playlist; - } - - const portalUrl = playlist.portalUrl ?? playlist.url ?? ''; - - return { - ...playlist, - isFullStalkerPortal: isFullStalkerPortalUrl(portalUrl), - }; + return withExplicitStalkerPortalFlag(playlist); } private collectStalkerMetadataMigrationUpdates( @@ -307,7 +307,20 @@ export class PlaylistsService { const updates = this.collectStalkerMetadataMigrationUpdates(storedPlaylists); - if (updates.length > 0) { + if (this.storageBackend.kind() === 'server') { + // Shared with other devices: each row is read again and + // written on that version, never over a newer copy. A + // conflict leaves the flag unset, so the next start retries. + for (const { _id } of updates) { + const fresh = await store.dbGetAppPlaylist(_id); + const [update] = fresh + ? this.collectStalkerMetadataMigrationUpdates([fresh]) + : []; + if (update) { + await store.dbUpsertAppPlaylist(update); + } + } + } else if (updates.length > 0) { await store.dbUpsertAppPlaylists(updates); } @@ -384,7 +397,10 @@ export class PlaylistsService { } else { await store.dbUpsertAppPlaylist(playlist, operationId); } - this.healthEvidence?.connections.next({ id: playlist._id, playlist }); + this.healthEvidence?.connections.next({ + id: playlist._id, + playlist, + }); return playlist; }); } @@ -398,7 +414,12 @@ export class PlaylistsService { this.startupMetas = null; await store.dbUpsertAppPlaylists(playlists); - playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist })); + playlists.forEach((playlist) => + this.healthEvidence?.connections.next({ + id: playlist._id, + playlist, + }) + ); return playlists; }); } @@ -411,9 +432,21 @@ export class PlaylistsService { operation: () => Promise ): Observable { return defer(() => { - const previous = - this.playlistWriteQueues.get(playlistId) ?? Promise.resolve(); - const next = previous.then(() => operation()); + // While Delete All runs, a write waits for it before it joins + // its playlist's queue, so no queue holds work blocked on the + // reset; a write under a reservation held here (a Stalker repair + // or Edit) joins at once, as the reset waits for that reservation. + if (this.resets > 0 && !holdsPlaylistAuthority(playlistId)) { + return this.deletingAll.then(() => + firstValueFrom( + this.serializePlaylistWrite(playlistId, operation) + ) + ); + } + const previous = Promise.resolve( + this.playlistWriteQueues.get(playlistId) + ); + const next = previous.then(() => retryOnConflict(operation)); const tail = next.then( () => undefined, () => undefined @@ -572,7 +605,9 @@ export class PlaylistsService { ); return delete$.pipe( - tap(() => this.healthEvidence?.connections.next({ id: playlistId })), + tap(() => + this.healthEvidence?.connections.next({ id: playlistId }) + ), switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))), map((cleanupWarnings) => cleanupWarnings @@ -1039,7 +1074,9 @@ export class PlaylistsService { return nextPlaylist; }) ) - ).pipe(map((rows) => rows.filter((row): row is Playlist => row !== null))); + ).pipe( + map((rows) => rows.filter((row): row is Playlist => row !== null)) + ); } getFavoriteChannels(playlistId: string) { @@ -1318,23 +1355,37 @@ export class PlaylistsService { } removeAll(): Observable { - return defer(() => - runWithPlaylistAuthorityReset(async () => { - if (this.usesDocumentStore) { - await this.ensureElectronPlaylistMigrations(); - const store = this.documentStore; - if (store) { - this.startupMetas = null; - await store.dbDeleteAllPlaylists(); - this.healthEvidence?.connections.next({}); - } - return undefined; - } + // Writes queued so far land first, outside the reset (some take the + // shared side of its lock); new ones wait for the delete before they + // queue, except those under a reservation held here. + return defer(() => { + const pending = [...this.playlistWriteQueues.values()]; + this.resets += 1; + const run = Promise.all(pending) + .then(() => + runWithPlaylistAuthorityReset(() => this.deleteAllRows()) + ) + .finally(() => (this.resets -= 1)); + this.deletingAll = run.then( + () => undefined, + () => undefined + ); + return run; + }); + } - await firstValueFrom(this.dbService.clear(DbStores.Playlists)); - return undefined; - }) - ); + private async deleteAllRows(): Promise { + if (this.usesDocumentStore) { + await this.ensureElectronPlaylistMigrations(); + const store = this.documentStore; + if (store) { + this.startupMetas = null; + await store.dbDeleteAllPlaylists(); + this.healthEvidence?.connections.next({}); + } + return; + } + await firstValueFrom(this.dbService.clear(DbStores.Playlists)); } private normalizePortalRecentIdentity(value: unknown): string { @@ -1553,3 +1604,19 @@ export class PlaylistsService { return this.clearPlaylistRecentlyViewed(playlistId); } } + +/** + * A mutation the server refused as built on an older version (another device + * wrote since) runs again: it reads the playlist afresh first. + */ +async function retryOnConflict(operation: () => Promise): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (!(error instanceof PlaylistWriteConflict) || attempt >= 3) { + throw error; + } + } + } +} diff --git a/libs/services/src/lib/pwa-xtream-backup-state.service.ts b/libs/services/src/lib/pwa-xtream-backup-state.service.ts index 36287dbbd..5946bda36 100644 --- a/libs/services/src/lib/pwa-xtream-backup-state.service.ts +++ b/libs/services/src/lib/pwa-xtream-backup-state.service.ts @@ -31,6 +31,22 @@ const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series']; export class PwaXtreamBackupStateService { private readonly storage = inject(USER_DATA_STORAGE); + /** + * Before an export or a restore: in server mode the maps show the + * server's data only after the first read, which may still run or have + * failed at start. An export would write unread lists as empty, and a + * restore's replacement lists would merge into the server's instead. + */ + async ready(): Promise { + // Read again: another device may have changed them since this + // page's first read, and a restore must replace the current lists. + if ((await this.storage.ready?.(true)) === false) { + throw new Error( + "The server's user data could not be read yet; try again once the server answers." + ); + } + } + /** * The entry with the playlist's stored lists and snapshots filled in; * hidden categories and pins, which the browser build does not track, @@ -103,13 +119,28 @@ export class PwaXtreamBackupStateService { state: XtreamPendingRestoreState, entry: XtreamPlaylistBackupEntry ): Promise { + await this.ready(); // A user-supplied file: the field may be anything. const archived: unknown = entry.userState.collectionItems; const snapshots = snapshotsByKey( Array.isArray(archived) ? archived : [] ); + // The lists replace what the server has now: this page's own unsent + // writes for the playlist would join them, so they go first or the + // restore waits (titles merged regardless, as below). + if ((await this.storage.flushed?.(playlistId)) === false) { + try { + this.mergeSnapshots(playlistId, snapshots); + } catch { + // The unsent writes are the failure to report. + } + throw new Error( + 'Earlier changes to this playlist are not on the server yet; the restore waits.' + ); + } + let written: Map; try { - this.replaceLists(playlistId, state); + written = this.replaceLists(playlistId, state); } catch (error) { if (!isQuotaExceeded(error)) { try { @@ -120,17 +151,46 @@ export class PwaXtreamBackupStateService { } throw error; } + // Server mode: the lists count once the server took them; a refused + // one keeps the restore parked (titles merged regardless, as above). + const saved = (await this.storage.flushed?.(playlistId)) !== false; + // A conflict merges an edit another device made meanwhile into the + // lists: the backup replaces them, so it waits and runs again on a + // fresh read. + const replaced = + saved && + [...written].every( + ([name, list]) => + JSON.stringify(this.listFor(name, playlistId)) === + JSON.stringify(list) + ); this.mergeSnapshots(playlistId, snapshots); + if (!saved) { + throw new Error( + 'The server did not take the restored lists; the restore waits.' + ); + } + if (!replaced) { + throw new Error( + 'The lists changed elsewhere during the restore; it runs again.' + ); + } } + /** The lists it wrote, by map. */ private replaceLists( playlistId: string, state: XtreamPendingRestoreState - ): void { + ): Map { + const written = new Map(); + const write = (name: UserDataMap, list: unknown[]) => { + this.writeFor(name, playlistId, list); + written.set(name, list); + }; const favorites = state.favorites .map((item) => typedKey(item.contentType, item.xtreamId)) .filter((key): key is string => key !== null); - this.writeFor('favorites', playlistId, [...new Set(favorites)]); + write('favorites', [...new Set(favorites)]); const recent: StoredMap[] = []; for (const item of state.recentlyViewed) { @@ -143,18 +203,18 @@ export class PwaXtreamBackupStateService { }); } } - this.writeFor('recentItems', playlistId, recent); + write('recentItems', recent); const now = new Date().toISOString(); - this.writeFor( + write( 'playbackPositions', - playlistId, state.playbackPositions.filter(isPlaybackPosition).map((item) => ({ ...item, playlistId, updatedAt: item.updatedAt ?? now, })) ); + return written; } /** diff --git a/libs/services/src/lib/server-access-token.spec.ts b/libs/services/src/lib/server-access-token.spec.ts new file mode 100644 index 000000000..2cd738527 --- /dev/null +++ b/libs/services/src/lib/server-access-token.spec.ts @@ -0,0 +1,54 @@ +import { + clearServerAccessToken, + readServerAccessToken, + SERVER_ACCESS_TOKEN_OWNER_KEY, + SERVER_ACCESS_TOKEN_STORAGE_KEY, + writeServerAccessToken, +} from './server-access-token'; + +describe('server access token', () => { + afterEach(() => localStorage.clear()); + + it('has no token until one is stored', () => { + expect(readServerAccessToken()).toBeNull(); + expect(readServerAccessToken('i-1')).toBeNull(); + }); + + it('stores a trimmed token for the instance it was given for', () => { + writeServerAccessToken(' s3cret ', 'i-1'); + + expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe( + 's3cret' + ); + expect(readServerAccessToken('i-1')).toBe('s3cret'); + expect(readServerAccessToken()).toBe('s3cret'); + + clearServerAccessToken(); + expect(readServerAccessToken()).toBeNull(); + expect(localStorage.getItem(SERVER_ACCESS_TOKEN_OWNER_KEY)).toBeNull(); + }); + + it("never hands another instance's token out", () => { + writeServerAccessToken('new-server', 'i-2'); + expect(readServerAccessToken('i-1')).toBeNull(); + + // A token stored without its instance belongs to none. + localStorage.removeItem(SERVER_ACCESS_TOKEN_OWNER_KEY); + expect(readServerAccessToken('i-2')).toBeNull(); + }); + + it('reports a token browser storage refuses', () => { + const refuse = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new DOMException('full', 'QuotaExceededError'); + }); + try { + expect(writeServerAccessToken('s3cret', 'i-1')).toBe(false); + expect(readServerAccessToken()).toBeNull(); + } finally { + refuse.mockRestore(); + } + expect(writeServerAccessToken('s3cret', 'i-1')).toBe(true); + }); +}); diff --git a/libs/services/src/lib/server-access-token.ts b/libs/services/src/lib/server-access-token.ts new file mode 100644 index 000000000..1613eca4c --- /dev/null +++ b/libs/services/src/lib/server-access-token.ts @@ -0,0 +1,103 @@ +import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces'; + +export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token'; + +/** + * A gateway's answer for a backend that is down (nginx's 502/503/504 page), + * not the backend's own JSON error for a provider that failed. + */ +export function isGatewayOutage( + status: number, + contentType: string | null | undefined +): boolean { + return ( + [502, 503, 504].includes(status) && + !contentType?.includes('application/json') + ); +} + +/** A 401 from the backend's access guard, not one a provider sent. */ +export function isServerAccessChallenge( + status: number, + challenge: string | null +): boolean { + return status === 401 && challenge === SERVER_ACCESS_CHALLENGE; +} + +/** The instance the stored token was given for. */ +export const SERVER_ACCESS_TOKEN_OWNER_KEY = + 'iptvnator-server:access-token-instance'; + +/** + * The stored token; with `instanceId`, only when it was given for that + * instance: a tab still open on another server (a changed backend URL) + * never sends it there. + */ +export function readServerAccessToken( + instanceId?: string | null +): string | null { + try { + const token = localStorage + .getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY) + ?.trim(); + return token && + (instanceId === undefined || + localStorage.getItem(SERVER_ACCESS_TOKEN_OWNER_KEY) === + (instanceId ?? '')) + ? token + : null; + } catch { + return null; + } +} + +/** False when browser storage refused it (private mode, full). */ +export function writeServerAccessToken( + token: string, + instanceId: string | null +): boolean { + try { + // The owner goes last: a token stored without one is sent nowhere. + localStorage.removeItem(SERVER_ACCESS_TOKEN_OWNER_KEY); + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, token.trim()); + localStorage.setItem(SERVER_ACCESS_TOKEN_OWNER_KEY, instanceId ?? ''); + return true; + } catch { + return false; + } +} + +export function clearServerAccessToken(): void { + try { + localStorage.removeItem(SERVER_ACCESS_TOKEN_STORAGE_KEY); + localStorage.removeItem(SERVER_ACCESS_TOKEN_OWNER_KEY); + } catch { + // Nothing stored. + } +} + +/** + * Whether `url` goes to the backend: the same origin as the backend URL and + * a path inside it. A plain prefix check fails for `BACKEND_URL=/`, where + * every URL, other origins included, would carry the token. + */ +export function isBackendUrl( + url: string, + baseUrl: string, + pageUrl = globalThis.location?.href ?? 'http://localhost/' +): boolean { + try { + const base = new URL(baseUrl, pageUrl); + const target = new URL(url, pageUrl); + const root = base.pathname.endsWith('/') + ? base.pathname + : `${base.pathname}/`; + return ( + target.origin === base.origin && + (target.pathname === base.pathname || + target.pathname.startsWith(root)) + ); + } catch { + return false; + } +} diff --git a/libs/services/src/lib/server-slow-start.ts b/libs/services/src/lib/server-slow-start.ts new file mode 100644 index 000000000..8d3b25ef2 --- /dev/null +++ b/libs/services/src/lib/server-slow-start.ts @@ -0,0 +1,26 @@ +/** + * A page started from memory reloads once the server answers + * (`ServerStorageSession`), but a server slower than the start probe would + * time it out again and reload forever: the reloaded page's probe waits as + * long as requests do, once. + */ +const SLOW_START_KEY = 'iptvnator-server:slow-start'; + +export function markSlowStart(): void { + try { + sessionStorage.setItem(SLOW_START_KEY, '1'); + } catch { + // Without session storage the next probe keeps its short wait. + } +} + +/** Whether this page load follows such a reload; asks only once. */ +export function takeSlowStart(): boolean { + try { + const slow = sessionStorage.getItem(SLOW_START_KEY) !== null; + sessionStorage.removeItem(SLOW_START_KEY); + return slow; + } catch { + return false; + } +} diff --git a/libs/services/src/lib/server-storage-status.service.spec.ts b/libs/services/src/lib/server-storage-status.service.spec.ts new file mode 100644 index 000000000..4707e0029 --- /dev/null +++ b/libs/services/src/lib/server-storage-status.service.spec.ts @@ -0,0 +1,106 @@ +import { TestBed } from '@angular/core/testing'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import { + readServerAccessToken, + SERVER_ACCESS_TOKEN_STORAGE_KEY, + writeServerAccessToken, +} from './server-access-token'; +import { ServerStorageStatusService } from './server-storage-status.service'; + +const instance: ServerInstanceInfo = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'none', +}; + +describe('ServerStorageStatusService', () => { + let status: ServerStorageStatusService; + + beforeEach(() => { + status = TestBed.inject(ServerStorageStatusService); + }); + + it('stays inactive and ignores reports until activated', () => { + status.reportOffline(); + status.reportUnauthorized(); + + expect(status.state()).toBe('inactive'); + expect(status.isActive()).toBe(false); + }); + + it('tracks online, offline and unauthorized after activation', () => { + const retry = jest.fn(); + status.activate(instance, retry); + expect(status.state()).toBe('online'); + expect(status.instance()).toEqual(instance); + + status.reportOffline(); + expect(status.state()).toBe('offline'); + status.retry(); + expect(retry).toHaveBeenCalledTimes(1); + + status.reportSuccess(); + expect(status.state()).toBe('online'); + expect(status.lastSuccessAt()).toBeInstanceOf(Date); + + status.reportUnauthorized(); + expect(status.state()).toBe('unauthorized'); + + // Late answers to requests sent with the old token. + status.reportSuccess(); + status.reportOffline(); + expect(status.state()).toBe('unauthorized'); + }); + + it('ignores a refusal of a token replaced since', () => { + status.activate(instance, jest.fn()); + writeServerAccessToken('new', 'i-1'); + + status.reportAccessDenied('old'); + expect(status.state()).toBe('online'); + expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe( + 'new' + ); + + status.reportAccessDenied('new'); + expect(status.state()).toBe('unauthorized'); + expect( + localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY) + ).toBeNull(); + }); + + it("sends no other instance's token and asks for this one's", () => { + status.activate(instance, jest.fn()); + // Another tab signed in to the server a new backend URL leads to. + writeServerAccessToken('new-server', 'i-2'); + + expect(status.accessToken()).toBeNull(); + status.reportAccessDenied(null); + + expect(status.state()).toBe('unauthorized'); + expect(readServerAccessToken('i-2')).toBe('new-server'); + }); + + it('keeps the token of a page that holds it back on a refusal', () => { + status.activate(instance, jest.fn(), false); + writeServerAccessToken('kept', 'i-1'); + + expect(status.accessToken()).toBeNull(); + status.reportAccessDenied(null); + + expect(status.state()).toBe('online'); + expect(readServerAccessToken('i-1')).toBe('kept'); + }); + + it('checks the instance again after an outage on the sign-in page', () => { + status.activate(instance, jest.fn()); + status.reportUnauthorized(); + + status.reportOffline(); + + expect(status.state()).toBe('unauthorized'); + expect(status.isVerified()).toBe(false); + }); +}); diff --git a/libs/services/src/lib/server-storage-status.service.ts b/libs/services/src/lib/server-storage-status.service.ts new file mode 100644 index 000000000..830399abc --- /dev/null +++ b/libs/services/src/lib/server-storage-status.service.ts @@ -0,0 +1,124 @@ +import { computed, Injectable, signal } from '@angular/core'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import { + clearServerAccessToken, + readServerAccessToken, +} from './server-access-token'; + +export type ServerConnectionState = + 'inactive' | 'online' | 'offline' | 'unauthorized'; + +/** Where server mode asks for the access token. */ +export const SERVER_SIGN_IN_PATH = '/server-sign-in'; + +/** + * Connection state of the self-hosted server storage. `inactive` in Electron + * and in a PWA without server storage; the server session activates it. + */ +@Injectable({ providedIn: 'root' }) +export class ServerStorageStatusService { + private readonly stateSignal = signal('inactive'); + private readonly instanceSignal = signal(null); + private readonly lastSuccessSignal = signal(null); + private retryHandler: (() => void) | null = null; + private verified = true; + + readonly state = this.stateSignal.asReadonly(); + readonly instance = this.instanceSignal.asReadonly(); + readonly lastSuccessAt = this.lastSuccessSignal.asReadonly(); + readonly isActive = computed(() => this.stateSignal() !== 'inactive'); + + /** + * `verified` is false for a start from a remembered instance that did + * not answer: the address may lead to another server by now. So it is + * after going offline, until the instance is checked again. + */ + activate( + instance: ServerInstanceInfo, + retry: () => void, + verified = true + ): void { + this.instanceSignal.set(instance); + this.retryHandler = retry; + this.verified = verified; + this.stateSignal.set('online'); + } + + /** The instance at the address turned out to be the remembered one. */ + markVerified(): void { + this.verified = true; + } + + /** Whether the token and writes may go out (see `activate`). */ + isVerified(): boolean { + return this.verified; + } + + /** + * The stored token, only while server mode runs on an instance this + * start verified: a token must never reach a server it was not given + * for. + */ + accessToken(): string | null { + const instance = this.instanceSignal(); + return instance && this.isActive() && this.verified + ? readServerAccessToken(instance.instanceId) + : null; + } + + reportSuccess(): void { + // A late answer to a request sent with the old token must not undo + // sign-out; only a reload after a new token leaves this state. + if (!this.isActive() || this.stateSignal() === 'unauthorized') { + return; + } + this.stateSignal.set('online'); + this.lastSuccessSignal.set(new Date()); + } + + reportOffline(): void { + if (!this.isActive()) { + return; + } + // It may come back as another database (a replaced volume at the + // same address): no token, no writes until it is checked, also + // from the sign-in page. + this.verified = false; + // A late failure after sign-out must not unlock the workspace. + if (this.stateSignal() !== 'unauthorized') { + this.stateSignal.set('offline'); + } + } + + reportUnauthorized(): void { + if (this.isActive()) { + this.stateSignal.set('unauthorized'); + } + } + + /** + * The access guard refused a request sent with `sentToken`: the token + * stored for this instance is stale or missing, ask for a new one. + */ + reportAccessDenied(sentToken: string | null): void { + const instance = this.instanceSignal(); + // A late answer to a token replaced since (a new sign-in) is moot, + // and so is one to a page that held its token back until the + // instance is checked. + if ( + !instance || + !this.isActive() || + readServerAccessToken(instance.instanceId) !== sentToken + ) { + return; + } + if (sentToken) { + clearServerAccessToken(); + } + this.stateSignal.set('unauthorized'); + } + + retry(): void { + this.retryHandler?.(); + } +} diff --git a/libs/services/src/lib/server-storage/index.ts b/libs/services/src/lib/server-storage/index.ts new file mode 100644 index 000000000..8d321c337 --- /dev/null +++ b/libs/services/src/lib/server-storage/index.ts @@ -0,0 +1,7 @@ +export * from './server-storage.config'; +export * from './server-api-client'; +export * from './server-playlist-document-store'; +export * from './server-user-data-storage'; +export * from './server-access-token.interceptor'; +export * from './server-storage-session'; +export * from './server-migration.service'; diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts new file mode 100644 index 000000000..71835f760 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts @@ -0,0 +1,190 @@ +import { + HTTP_INTERCEPTORS, + HttpClient, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { + SERVER_ACCESS_TOKEN_STORAGE_KEY, + writeServerAccessToken, +} from '../server-access-token'; +import { ServerStorageStatusService } from '../server-storage-status.service'; +import { + isBackendUrl, + ServerAccessTokenInterceptor, +} from './server-access-token.interceptor'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; + +describe('ServerAccessTokenInterceptor', () => { + beforeEach(() => { + writeServerAccessToken('t', 'i'); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { + provide: HTTP_INTERCEPTORS, + useClass: ServerAccessTokenInterceptor, + multi: true, + }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { baseUrl: '/api', instance: {} }, + }, + ], + }); + }); + + afterEach(() => localStorage.clear()); + + const activate = (verified = true) => + TestBed.inject(ServerStorageStatusService).activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'token', + }, + () => undefined, + verified + ); + + it('adds the token to backend calls only', () => { + activate(); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + http.get('/api/parse').subscribe(); + http.get('https://provider.example/list.m3u').subscribe(); + + expect( + controller + .expectOne('/api/parse') + .request.headers.get('Authorization') + ).toBe('Bearer t'); + expect( + controller + .expectOne('https://provider.example/list.m3u') + .request.headers.has('Authorization') + ).toBe(false); + }); + + it('withholds the token from an instance not checked yet', () => { + activate(false); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + http.get('/api/v1/instance').subscribe(); + + expect( + controller + .expectOne('/api/v1/instance') + .request.headers.has('Authorization') + ).toBe(false); + controller.verify(); + }); + + it('asks for a new token when the backend refuses the stored one', () => { + const status = TestBed.inject(ServerStorageStatusService); + status.activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'token', + }, + () => undefined + ); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + // A provider's own 401 passed on by the proxy changes nothing. + http.get('/api/xtream').subscribe({ error: () => undefined }); + controller + .expectOne('/api/xtream') + .flush({ message: 'bad login' }, { status: 401, statusText: 'x' }); + expect(status.state()).toBe('online'); + + http.get('/api/xtream').subscribe({ error: () => undefined }); + controller.expectOne('/api/xtream').flush( + { code: 'unauthorized' }, + { + status: 401, + statusText: 'Unauthorized', + headers: { 'WWW-Authenticate': 'Bearer realm="iptvnator"' }, + } + ); + expect(status.state()).toBe('unauthorized'); + expect( + localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY) + ).toBeNull(); + }); + + it('reports a backend that does not answer as offline, not a provider failing', () => { + const status = TestBed.inject(ServerStorageStatusService); + activate(); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + // The backend's own JSON error for a provider that failed. + http.get('/api/xtream').subscribe({ error: () => undefined }); + controller.expectOne('/api/xtream').flush( + { message: 'provider down' }, + { + status: 502, + statusText: 'Bad Gateway', + headers: { 'Content-Type': 'application/json' }, + } + ); + expect(status.state()).toBe('online'); + expect(status.isVerified()).toBe(true); + + // No answer at all: it may come back as another instance. + http.get('/api/parse').subscribe({ error: () => undefined }); + controller + .expectOne('/api/parse') + .error(new ProgressEvent('error'), { status: 0 }); + expect(status.state()).toBe('offline'); + expect(status.accessToken()).toBeNull(); + }); +}); + +describe('isBackendUrl', () => { + const page = 'http://nas.local:4333/workspace'; + + it('matches the backend path and nothing else', () => { + expect(isBackendUrl('/api/xtream', '/api', page)).toBe(true); + expect(isBackendUrl('/api', '/api', page)).toBe(true); + expect(isBackendUrl('/apiary', '/api', page)).toBe(false); + expect(isBackendUrl('https://other.test/api/x', '/api', page)).toBe( + false + ); + }); + + it('keeps other origins out for a root deployment', () => { + expect(isBackendUrl('/v1/playlists', '/', page)).toBe(true); + expect( + isBackendUrl('https://api.github.com/repos/x/releases', '/', page) + ).toBe(false); + }); + + it('handles an absolute backend URL', () => { + expect( + isBackendUrl( + 'http://nas.local:3000/parse', + 'http://nas.local:3000', + page + ) + ).toBe(true); + expect(isBackendUrl('/parse', 'http://nas.local:3000', page)).toBe( + false + ); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts new file mode 100644 index 000000000..ddf6ca1e2 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts @@ -0,0 +1,74 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable, tap } from 'rxjs'; +import { + isBackendUrl, + isGatewayOutage, + isServerAccessChallenge, +} from '../server-access-token'; + +// Lives with the token helpers so the startup bundle can check URLs +// without loading the server storage code. +export { isBackendUrl } from '../server-access-token'; +import { ServerStorageStatusService } from '../server-storage-status.service'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; + +/** + * Adds the server access token to `HttpClient` calls to the backend, sends + * the user to sign-in when the backend refuses it (a token changed on the + * server while this page was open), and reports a backend that does not + * answer as offline, like the storage API client does. + */ +@Injectable() +export class ServerAccessTokenInterceptor implements HttpInterceptor { + private readonly status = inject(ServerStorageStatusService); + private readonly baseUrl = inject(SERVER_STORAGE_CONFIG).baseUrl; + + intercept( + request: HttpRequest, + next: HttpHandler + ): Observable> { + if (!isBackendUrl(request.url, this.baseUrl)) { + return next.handle(request); + } + const token = this.status.accessToken(); + const sent = token + ? request.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + }) + : request; + return next.handle(sent).pipe( + tap({ + error: (error: unknown) => { + if (!(error instanceof HttpErrorResponse)) { + return; + } + if ( + error.status === 0 || + isGatewayOutage( + error.status, + error.headers.get('Content-Type') + ) + ) { + // No answer: it may come back as another instance, + // checked before the token goes out again. + this.status.reportOffline(); + } else if ( + isServerAccessChallenge( + error.status, + error.headers.get('WWW-Authenticate') + ) + ) { + this.status.reportAccessDenied(token); + } + }, + }) + ); + } +} diff --git a/libs/services/src/lib/server-storage/server-api-client.spec.ts b/libs/services/src/lib/server-storage/server-api-client.spec.ts new file mode 100644 index 000000000..1e68b58d0 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-api-client.spec.ts @@ -0,0 +1,273 @@ +import { ServerApiClient, ServerApiError } from './server-api-client'; + +// jsdom has Headers but no Response or fetch. +function response(status: number, body?: unknown) { + return { + status, + ok: status >= 200 && status < 300, + statusText: '', + headers: new Headers(), + json: async () => body, + } as unknown as Response; +} + +describe('ServerApiClient', () => { + const status = { + reportSuccess: jest.fn(), + reportOffline: jest.fn(), + reportAccessDenied: jest.fn(), + }; + let token: string | null = null; + const onWriteFailure = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + token = null; + }); + + function client(fetchFn: jest.Mock) { + return new ServerApiClient({ + baseUrl: '/api/', + status, + onWriteFailure, + fetchFn: fetchFn as unknown as typeof fetch, + accessToken: () => token, + }); + } + + it('calls /v1 with the token and reports success', async () => { + token = 't'; + const fetchFn = jest + .fn() + .mockResolvedValue(response(200, { ok: true })); + + await expect(client(fetchFn).getJson('/instance')).resolves.toEqual({ + ok: true, + }); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe('/api/v1/instance'); + expect(init.headers).toMatchObject({ Authorization: 'Bearer t' }); + expect(status.reportSuccess).toHaveBeenCalled(); + }); + + it('reports offline on a network error and announces failed writes', async () => { + const fetchFn = jest + .fn() + .mockRejectedValue(new TypeError('Failed to fetch')); + + await expect( + client(fetchFn).sendJson('PUT', '/playlists/a', {}) + ).rejects.toMatchObject({ status: 0, code: 'network' }); + expect(status.reportOffline).toHaveBeenCalled(); + expect(onWriteFailure).toHaveBeenCalledTimes(1); + }); + + it('reports offline when a proxy answers for a down backend', async () => { + for (const code of [502, 503, 504]) { + const fetchFn = jest + .fn() + .mockResolvedValue(response(code, 'Bad Gateway')); + + await expect( + client(fetchFn).getJson('/user-state') + ).rejects.toMatchObject({ status: code }); + } + expect(status.reportOffline).toHaveBeenCalledTimes(3); + + // A backend error is not an outage. + status.reportOffline.mockClear(); + await expect( + client( + jest.fn().mockResolvedValue(response(500, { code: 'x' })) + ).getJson('/user-state') + ).rejects.toMatchObject({ status: 500 }); + expect(status.reportOffline).not.toHaveBeenCalled(); + }); + + it('gives up on a keepalive write the server never answers', async () => { + const hanging = jest.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('Timed out', 'TimeoutError')) + ); + }) + ); + const slow = new ServerApiClient({ + baseUrl: '/api', + status, + fetchFn: hanging as unknown as typeof fetch, + accessToken: () => null, + timeoutMs: 10, + }); + + // Later writes queue behind it: it must end, as a failed one. + await expect( + slow.sendKeepalive('PUT', '/user-state/a/b', { value: 1 }) + ).resolves.toEqual({ status: 'failed' }); + // An outage: the instance is checked before the next write. + expect(status.reportOffline).toHaveBeenCalledTimes(1); + }); + + it('reports a keepalive answer cut off after its headers as offline', async () => { + const cutOff = jest.fn().mockResolvedValue({ + ...response(200), + json: async () => { + throw new TypeError('body cut off'); + }, + }); + + // Saved, on a version it cannot tell (see the write queue). + await expect( + client(cutOff).sendKeepalive('PUT', '/user-state/a/b', { + value: 1, + }) + ).resolves.toEqual({ status: 'saved', tag: undefined }); + expect(status.reportOffline).toHaveBeenCalled(); + }); + + it('reports a keepalive write refused by the access guard', async () => { + token = 'old'; + const refused = jest.fn().mockResolvedValue(response(401)); + + await expect( + client(refused).sendKeepalive('PUT', '/user-state/a/b', { + value: 1, + }) + ).resolves.toEqual({ status: 'failed' }); + // With the token it sent: one replaced since is not refused. + expect(status.reportAccessDenied).toHaveBeenCalledWith('old'); + }); + + it('reports a gateway answering for a down backend on a keepalive write', async () => { + const down = jest.fn().mockResolvedValue(response(502)); + + await expect( + client(down).sendKeepalive('PUT', '/user-state/a/b', { value: 1 }) + ).resolves.toEqual({ status: 'failed' }); + expect(status.reportOffline).toHaveBeenCalled(); + }); + + it('reports how a keepalive write ended', async () => { + const ok = jest.fn().mockResolvedValue(response(200, { tag: 't2' })); + await expect( + client(ok).sendKeepalive('PUT', '/user-state/a/b', { value: 1 }) + ).resolves.toEqual({ status: 'saved', tag: 't2' }); + expect(ok.mock.calls[0][1]).toMatchObject({ keepalive: true }); + + const conflict = jest.fn().mockResolvedValue( + response(412, { + code: 'conflict', + message: 'changed', + current: { value: [2], tag: 't3' }, + }) + ); + await expect( + client(conflict).sendKeepalive('PUT', '/user-state/a/b', { + value: 1, + }) + ).resolves.toEqual({ + status: 'conflict', + current: { value: [2], tag: 't3' }, + }); + + const gone = jest + .fn() + .mockResolvedValue( + response(404, { code: 'playlist_missing', message: 'gone' }) + ); + await expect( + client(gone).sendKeepalive('PUT', '/user-state/a/b', { value: 1 }) + ).resolves.toEqual({ status: 'gone' }); + + const refused = jest + .fn() + .mockRejectedValue(new TypeError('body too large')); + await expect( + client(refused).sendKeepalive('DELETE', '/user-state/a/b') + ).resolves.toEqual({ status: 'failed' }); + }); + + it('holds writes, not reads, until the instance is checked', async () => { + const fetchFn = jest.fn().mockResolvedValue(response(200, {})); + const held = new ServerApiClient({ + baseUrl: '/api', + status, + fetchFn: fetchFn as unknown as typeof fetch, + accessToken: () => null, + holdWrites: () => true, + }); + + await expect( + held.sendJson('PUT', '/user-state/a/b', { value: 1 }) + ).rejects.toMatchObject({ status: 0, code: 'network' }); + await expect( + held.sendKeepalive('DELETE', '/user-state/a/b') + ).resolves.toEqual({ status: 'failed' }); + await held.getJson('/instance'); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance'); + }); + + it('reports offline when a body is cut off after the headers', async () => { + const cut = { + ...response(200), + json: async () => { + throw new TypeError('network error'); + }, + } as unknown as Response; + + await expect( + client(jest.fn().mockResolvedValue(cut)).getJson('/user-state') + ).rejects.toMatchObject({ status: 0, code: 'network' }); + expect(status.reportOffline).toHaveBeenCalled(); + + // Malformed JSON is an answer, not an outage. + status.reportOffline.mockClear(); + const malformed = { + ...response(200), + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + } as unknown as Response; + await expect( + client(jest.fn().mockResolvedValue(malformed)).getJson('/x') + ).rejects.toBeInstanceOf(SyntaxError); + expect(status.reportOffline).not.toHaveBeenCalled(); + }); + + it('reports the refused token on 401', async () => { + token = 'old'; + const fetchFn = jest + .fn() + .mockResolvedValue(response(401, { code: 'unauthorized' })); + + await expect( + client(fetchFn).getJson('/playlists') + ).rejects.toBeInstanceOf(ServerApiError); + // The status decides: it ignores a token replaced since. + expect(status.reportAccessDenied).toHaveBeenCalledWith('old'); + expect(status.reportOffline).not.toHaveBeenCalled(); + }); + + it('passes 304 through and maps error bodies', async () => { + const fetchFn = jest + .fn() + .mockResolvedValueOnce(response(304)) + .mockResolvedValueOnce( + response(409, { message: 'x', code: 'payload_missing' }) + ); + const api = client(fetchFn); + + expect((await api.request('/playlists/a')).status).toBe(304); + await expect( + api.sendJson('PUT', '/playlists/a', {}) + ).rejects.toMatchObject({ + status: 409, + code: 'payload_missing', + }); + expect(onWriteFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-api-client.ts b/libs/services/src/lib/server-storage/server-api-client.ts new file mode 100644 index 000000000..5b969efb4 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-api-client.ts @@ -0,0 +1,266 @@ +import type { + ServerStorageErrorBody, + ServerUserStateCurrent, + ServerUserStateWriteResponse, +} from '@iptvnator/shared/interfaces'; +import type { ServerStorageStatusService } from '../server-storage-status.service'; + +const REQUEST_TIMEOUT_MS = 20_000; + +/** A user-state write for a playlist the server no longer has. */ +export const PLAYLIST_MISSING = 'playlist_missing'; + +/** nginx answers these while the backend behind it is down. */ +const GATEWAY_UNAVAILABLE = new Set([502, 503, 504]); + +export class ServerApiError extends Error { + constructor( + readonly status: number, + readonly code: string | undefined, + message: string, + /** A `412` conflict's user-state entry as the server has it. */ + readonly current?: ServerUserStateCurrent + ) { + super(message); + this.name = 'ServerApiError'; + } +} + +/** How a page-hide write ended, as far as the page could see. */ +export type KeepaliveOutcome = + | { readonly status: 'saved'; readonly tag?: string | null } + | { readonly status: 'conflict'; readonly current: ServerUserStateCurrent } + | { readonly status: 'gone' } + | { readonly status: 'failed' }; + +export interface ServerApiClientOptions { + readonly baseUrl: string; + readonly status: Pick< + ServerStorageStatusService, + 'reportSuccess' | 'reportOffline' | 'reportAccessDenied' + >; + /** Failed writes the user should hear about: network, 413, 5xx. */ + readonly onWriteFailure?: (error: ServerApiError) => void; + readonly fetchFn?: typeof fetch; + readonly timeoutMs?: number; + /** + * Refuse writes as if offline while true: an instance not checked yet + * (a start from memory, or one that went offline), whose address may + * lead to another database by the time it answers. + */ + readonly holdWrites?: () => boolean; + /** The token to send (`ServerStorageStatusService.accessToken`). */ + readonly accessToken: () => string | null; +} + +/** `fetch` for `/v1`: token, timeout, error mapping, connection state. */ +export class ServerApiClient { + private readonly root: string; + + constructor(private readonly options: ServerApiClientOptions) { + this.root = `${options.baseUrl.replace(/\/+$/, '')}/v1`; + } + + private token(): string | null { + return this.options.accessToken(); + } + + url(path: string): string { + return `${this.root}${path}`; + } + + async request(path: string, init: RequestInit = {}): Promise { + const isWrite = (init.method ?? 'GET') !== 'GET'; + const token = this.token(); + let response: Response; + try { + if (isWrite && this.options.holdWrites?.()) { + throw new Error('Writes wait for the instance to answer.'); + } + response = await this.fetchFn()(this.url(path), { + ...init, + cache: 'no-store', + headers: { + ...bearer(token), + ...(init.headers as Record | undefined), + }, + signal: init.signal ?? this.timeoutSignal(), + }); + } catch { + this.options.status.reportOffline(); + const error = new ServerApiError( + 0, + 'network', + 'The server is not reachable.' + ); + if (isWrite) { + this.options.onWriteFailure?.(error); + } + throw error; + } + + if (response.status === 401) { + this.options.status.reportAccessDenied(token); + throw new ServerApiError( + 401, + 'unauthorized', + 'The server needs an access token.' + ); + } + + if (GATEWAY_UNAVAILABLE.has(response.status)) { + // A proxy in front of a backend that is down or restarting. + this.options.status.reportOffline(); + } + + if (!response.ok && response.status !== 304) { + const body = await readErrorBody(response); + const error = new ServerApiError( + response.status, + body.code, + body.message, + body.current + ); + if ( + isWrite && + (response.status === 413 || response.status >= 500) + ) { + this.options.onWriteFailure?.(error); + } + throw error; + } + + this.options.status.reportSuccess(); + return response; + } + + async getJson(path: string): Promise { + return this.readJson(await this.request(path)); + } + + /** + * A body cut off after the headers arrived is an outage too; malformed + * JSON is not. + */ + async readJson(response: Response): Promise { + try { + return (await response.json()) as T; + } catch (error) { + if (error instanceof SyntaxError) { + throw error; + } + this.options.status.reportOffline(); + throw new ServerApiError( + 0, + 'network', + 'The server is not reachable.' + ); + } + } + + sendJson( + method: 'PUT' | 'POST', + path: string, + body: unknown, + headers: Record = {} + ): Promise { + return this.request(path, { + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json', ...headers }, + }); + } + + /** + * A write that survives the page. Browsers reject keepalive bodies + * above 64 KiB, which ends as `failed`. + */ + async sendKeepalive( + method: 'PUT' | 'DELETE', + path: string, + body?: unknown, + headers: Record = {} + ): Promise { + if (this.options.holdWrites?.()) { + return { status: 'failed' }; + } + const token = this.token(); + try { + const response = await this.fetchFn()(this.url(path), { + method, + keepalive: true, + headers: { + ...bearer(token), + ...headers, + ...(body === undefined + ? {} + : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + // A server that takes the request and never answers would + // hold every later write behind it. + signal: this.timeoutSignal(), + }); + if (response.ok) { + const body = (await response.json().catch(() => { + // Saved, on a version it cannot tell: cut off after its + // headers, which counts as offline, as for requests. + this.options.status.reportOffline(); + return {}; + })) as Partial; + return { status: 'saved', tag: body?.tag }; + } + if (GATEWAY_UNAVAILABLE.has(response.status)) { + // As for requests: the backend may come back as another + // instance, checked before the next write goes out. + this.options.status.reportOffline(); + } + if (response.status === 401) { + // As for requests: a token replaced since is not refused. + this.options.status.reportAccessDenied(token); + } + const { code, current } = + response.status === 412 || response.status === 404 + ? await readErrorBody(response) + : {}; + if (code === PLAYLIST_MISSING) { + return { status: 'gone' }; + } + return current + ? { status: 'conflict', current } + : { status: 'failed' }; + } catch { + this.options.status.reportOffline(); + return { status: 'failed' }; + } + } + + private fetchFn(): typeof fetch { + return this.options.fetchFn ?? globalThis.fetch.bind(globalThis); + } + + private timeoutSignal(): AbortSignal | undefined { + return typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout(this.options.timeoutMs ?? REQUEST_TIMEOUT_MS) + : undefined; + } +} + +async function readErrorBody( + response: Response +): Promise> { + try { + const body = (await response.json()) as Partial; + return { + message: body.message ?? response.statusText, + code: body.code, + current: body.current, + }; + } catch { + return { message: response.statusText }; + } +} + +function bearer(token: string | null): Record { + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/libs/services/src/lib/server-storage/server-migration-merge.spec.ts b/libs/services/src/lib/server-storage/server-migration-merge.spec.ts new file mode 100644 index 000000000..9bcd7cbd8 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration-merge.spec.ts @@ -0,0 +1,172 @@ +import { + combineUserData, + remapChannelReferences, + retargetUserData, + unionItems, +} from './server-migration-merge'; + +describe('combineUserData', () => { + it('unions lists, server entries first', () => { + expect( + combineUserData([ + ['live:1', 'live:2'], + ['live:2', 'live:3'], + ]) + ).toEqual(['live:1', 'live:2', 'live:3']); + }); + + it('merges maps and keeps the first value of a key', () => { + expect( + combineUserData([ + { a: { position: 10 } }, + { a: { position: 99 }, b: { position: 5 } }, + ]) + ).toEqual({ a: { position: 10 }, b: { position: 5 } }); + }); + + it('ignores missing values and keeps the first of mixed kinds', () => { + expect(combineUserData([undefined, ['x']])).toEqual(['x']); + expect(combineUserData([undefined])).toBeUndefined(); + expect(combineUserData([['x'], { a: 1 }])).toEqual(['x']); + }); +}); + +describe('unionItems', () => { + it('identifies items by content id or URL', () => { + expect( + unionItems( + [{ id: 1, title: 'Server' }, { url: 'http://a' }], + [{ id: 1, title: 'Browser' }, { url: 'http://b' }] + ) + ).toEqual([ + { id: 1, title: 'Server' }, + { url: 'http://a' }, + { url: 'http://b' }, + ]); + }); +}); + +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' }, + ]); + }); + + it('keep a movie and a series of one provider category apart', () => { + expect( + unionItems>( + [{ id: 7, category_id: '12', name: 'Movie' }], + [ + { id: 7, category_id: '12', is_series: 1, name: 'Show' }, + { + id: 7, + category_id: '12', + is_series: true, + name: 'Again', + }, + ] + ) + ).toEqual([ + { id: 7, category_id: '12', name: 'Movie' }, + { id: 7, category_id: '12', is_series: 1, name: 'Show' }, + ]); + }); +}); + +describe('playback positions', () => { + it('keep the server position of the same item', () => { + expect( + combineUserData([ + [ + { + contentType: 'vod', + contentXtreamId: 7, + positionSeconds: 60, + }, + ], + [ + { + contentType: 'vod', + contentXtreamId: 7, + positionSeconds: 900, + }, + { + contentType: 'episode', + contentXtreamId: 7, + positionSeconds: 5, + }, + ], + ]) + ).toEqual([ + { contentType: 'vod', contentXtreamId: 7, positionSeconds: 60 }, + { contentType: 'episode', contentXtreamId: 7, positionSeconds: 5 }, + ]); + }); + + it('name the server playlist after a move', () => { + expect( + retargetUserData( + [{ contentXtreamId: 1, playlistId: 'b' }, 'live:1'], + 'b', + 's' + ) + ).toEqual([{ contentXtreamId: 1, playlistId: 's' }, 'live:1']); + const same = [{ playlistId: 'b' }]; + expect(retargetUserData(same, 'b', 'b')).toBe(same); + }); +}); + +describe('remapChannelReferences', () => { + const browser = [ + { id: 'b1', url: 'http://s/1' }, + { id: 'b2', url: 'http://s/2' }, + { id: 'b3', url: 'http://s/gone' }, + ]; + const server = [ + { id: 's1', url: 'http://s/1' }, + { id: 's2', url: 'http://s/2' }, + ]; + + it('turns legacy channel-id favorites into their stream URL', () => { + expect( + remapChannelReferences(['b2', 'b1', 'http://s/1'], browser, server) + ).toEqual(['http://s/2', 'http://s/1', 'http://s/1']); + // The server's own legacy ids, against its own channels. + expect(remapChannelReferences(['s1'], server, server)).toEqual([ + 'http://s/1', + ]); + }); + + it('keeps URL favorites and anything it cannot resolve', () => { + expect( + remapChannelReferences( + ['http://elsewhere/9', 'b9', 'b3'], + browser, + server + ) + ).toEqual(['http://elsewhere/9', 'b9', 'http://s/gone']); + }); + + it("keeps a history entry's URL id and names the server's channel", () => { + const portal = { id: 7, category_id: 'itv' }; + const entry = { + source: 'm3u', + id: 'http://s/1', + url: 'http://s/1', + channel_id: 'b1', + }; + expect( + remapChannelReferences([entry, portal], browser, server) + ).toEqual([{ ...entry, channel_id: 's1' }, portal]); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-migration-merge.ts b/libs/services/src/lib/server-storage/server-migration-merge.ts new file mode 100644 index 000000000..5901344be --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration-merge.ts @@ -0,0 +1,177 @@ +import { isStalkerSeriesFlag } from '@iptvnator/shared/interfaces'; + +/** + * How the move to the server combines user data that two places hold for + * one playlist: the server's value comes first and wins, the browser only + * adds. Lists are unioned, maps keep the first value of each key. + */ +export function combineUserData(values: readonly unknown[]): unknown { + const present = values.filter((value) => value !== undefined); + if (present.length === 0) { + return undefined; + } + if (present.every(Array.isArray)) { + return unionItems(...(present as unknown[][])); + } + if (present.every(isPlainObject)) { + const merged: Record = {}; + for (const value of present as Record[]) { + for (const [key, entry] of Object.entries(value)) { + if (!(key in merged)) { + merged[key] = entry; + } + } + } + return merged; + } + return present[0]; +} + +/** Items in order of first appearance, without duplicates. */ +export function unionItems(...lists: readonly (readonly T[])[]): T[] { + const seen = new Set(); + const result: T[] = []; + for (const list of lists) { + for (const item of list) { + const key = itemKey(item); + if (!seen.has(key)) { + seen.add(key); + result.push(item); + } + } + } + return result; +} + +/** + * Content ids or URLs identify favorites and history entries; a playback + * position is identified by its content type and Xtream id. + */ +export function itemKey(item: unknown): string { + if (!isPlainObject(item)) { + return `value:${JSON.stringify(item)}`; + } + if ( + typeof item['contentType'] === 'string' && + (typeof item['contentXtreamId'] === 'number' || + typeof item['contentXtreamId'] === 'string') + ) { + return `position:${item['contentType']}:${item['contentXtreamId']}`; + } + // Stalker ids repeat across content types; the category tells them + // apart, and the series flag where a provider category holds both. + const category = item['category_id']; + const scope = + (typeof category === 'string' || typeof category === 'number' + ? `|category:${category}` + : '') + (isStalkerSeriesFlag(item['is_series']) ? '|series' : ''); + 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}${scope}`; + } + } + return `json:${JSON.stringify(item)}`; +} + +interface ChannelRef { + readonly id?: unknown; + readonly url?: unknown; +} + +/** + * M3U favorites and history as the app reads them, moved to the server's + * copy of a playlist. Favorites name channels by stream URL; older ones by + * a channel id that every import makes up anew, which becomes that + * channel's URL here (`ownChannels`: the playlist the favorite came from). + * History entries keep their URL id and get the server's channel id as + * `channel_id`. Anything else passes through, never dropped. + */ +export function remapChannelReferences( + references: readonly T[], + ownChannels: readonly ChannelRef[], + serverChannels: readonly ChannelRef[] +): T[] { + const urlById = new Map( + ownChannels.map((channel) => [channel.id, channel.url]) + ); + const idByUrl = new Map( + serverChannels.map((channel) => [channel.url, channel.id]) + ); + return references.map((reference) => { + if (typeof reference === 'string') { + const url = urlById.get(reference); + return (typeof url === 'string' ? url : reference) as T; + } + if (isPlainObject(reference) && reference['source'] === 'm3u') { + const channelId = idByUrl.get(reference['url']); + return ( + typeof channelId === 'string' + ? { ...reference, channel_id: channelId } + : reference + ) as T; + } + return reference; + }); +} + +/** + * A browser value moved under another playlist id: list items that name + * their playlist (playback positions do) name the server's instead. + */ +export function retargetUserData( + value: unknown, + browserId: string, + serverId: string +): unknown { + if (browserId === serverId || !Array.isArray(value)) { + return value; + } + return value.map((item) => + isPlainObject(item) && item['playlistId'] === browserId + ? { ...item, playlistId: serverId } + : item + ); +} + +/** + * Recently viewed items as the app keeps them: newest first, since it cuts + * the list at its limit from the end, and one per item, its newest view + * with that view's artwork. Undated items follow the dated ones in order. + */ +export function combineRecentViews(values: readonly unknown[]): unknown { + const present = values.filter((value) => value !== undefined); + if (present.length === 0 || !present.every(Array.isArray)) { + return combineUserData(values); + } + return unionItems( + newestViewsFirst(([] as unknown[]).concat(...(present as unknown[][]))) + ); +} + +/** + * Recently viewed items newest first, by `viewedAt` (Xtream maps) or + * `added_at` (playlist documents: an ISO date or epoch milliseconds); + * stable, undated items after dated ones. + */ +export function newestViewsFirst( + items: readonly T[], + field: 'viewedAt' | 'added_at' = 'viewedAt' +): T[] { + const viewedAt = (item: unknown) => { + const value = isPlainObject(item) ? item[field] : undefined; + const at = + typeof value === 'number' ? value : Date.parse(String(value)); + return Number.isNaN(at) ? 0 : at; + }; + return [...items].sort((a, b) => viewedAt(b) - viewedAt(a)); +} + +/** Whether a list holds recently viewed items, which stay newest first. */ +export function isViewHistory(items: readonly unknown[]): boolean { + return items.some((item) => isPlainObject(item) && 'viewedAt' in item); +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/libs/services/src/lib/server-storage/server-migration-plan.spec.ts b/libs/services/src/lib/server-storage/server-migration-plan.spec.ts new file mode 100644 index 000000000..5ad8fbe35 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration-plan.spec.ts @@ -0,0 +1,79 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { planServerMigration, sourceIdentity } from './server-migration-plan'; + +const playlist = (fields: Partial & { _id: string }) => + ({ title: fields._id, ...fields }) as Playlist; + +describe('planServerMigration', () => { + it('uploads everything to an empty server', () => { + const browser = [ + playlist({ _id: 'm', url: 'http://a.test/list.m3u' }), + playlist({ _id: 'file' }), + ]; + + expect(planServerMigration(browser, [])).toEqual([ + { kind: 'upload', playlist: browser[0] }, + { kind: 'upload', playlist: browser[1] }, + ]); + }); + + it('merges by id first, then by source', () => { + const server = [ + playlist({ _id: 'moved' }), + playlist({ + _id: 's-x', + serverUrl: 'HTTP://Panel.test/', + username: 'u', + }), + playlist({ + _id: 's-st', + portalUrl: 'http://portal.test/c', + macAddress: '00:1A:79:AA:BB:CC', + }), + playlist({ _id: 's-m', url: 'http://a.test/list.m3u' }), + ]; + const browser = [ + playlist({ _id: 'moved' }), + playlist({ + _id: 'b-x', + serverUrl: 'http://panel.test', + username: 'u', + }), + playlist({ + _id: 'b-st', + url: 'http://portal.test/c/', + macAddress: '00:1a:79:aa:bb:cc', + }), + playlist({ _id: 'b-m', url: 'http://a.test/list.m3u' }), + playlist({ + _id: 'b-other', + serverUrl: 'http://panel.test', + username: 'other', + }), + ]; + + expect(planServerMigration(browser, server)).toEqual([ + { kind: 'merge', browserId: 'moved', serverId: 'moved' }, + { kind: 'merge', browserId: 'b-x', serverId: 's-x' }, + { kind: 'merge', browserId: 'b-st', serverId: 's-st' }, + { kind: 'merge', browserId: 'b-m', serverId: 's-m' }, + { kind: 'upload', playlist: browser[4] }, + ]); + }); + + it('collapses browser duplicates of one source onto the first', () => { + const browser = [ + playlist({ _id: 'one', url: 'http://a.test/list.m3u' }), + playlist({ _id: 'two', url: 'http://A.test/list.m3u' }), + ]; + + expect(planServerMigration(browser, [])).toEqual([ + { kind: 'upload', playlist: browser[0] }, + { kind: 'merge', browserId: 'two', serverId: 'one' }, + ]); + }); + + it('has no source for a file or pasted M3U', () => { + expect(sourceIdentity(playlist({ _id: 'file' }))).toBeNull(); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-migration-plan.ts b/libs/services/src/lib/server-storage/server-migration-plan.ts new file mode 100644 index 000000000..f353bc79a --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration-plan.ts @@ -0,0 +1,94 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; + +/** Where one browser playlist goes on the server. */ +export type ServerMigrationStep = + | { + readonly kind: 'upload'; + readonly playlist: Playlist; + } + | { + /** The server already has this source; it keeps its document. */ + readonly kind: 'merge'; + readonly browserId: string; + readonly serverId: string; + }; + +/** + * Matches browser playlists to the server's: the same id first (a playlist + * moved before), then the same source (Xtream server and user, Stalker + * portal and MAC, M3U URL). A file or pasted M3U has no source to compare + * and is uploaded unless its id is taken. Browser duplicates of one source + * collapse onto the first. + */ +export function planServerMigration( + browser: readonly Playlist[], + server: readonly Playlist[] +): ServerMigrationStep[] { + const serverIds = new Set(server.map((playlist) => playlist._id)); + const bySource = new Map(); + for (const playlist of server) { + const source = sourceIdentity(playlist); + if (source && !bySource.has(source)) { + bySource.set(source, playlist._id); + } + } + + const steps: ServerMigrationStep[] = []; + for (const playlist of browser) { + const source = sourceIdentity(playlist); + const match = serverIds.has(playlist._id) + ? playlist._id + : source + ? bySource.get(source) + : undefined; + if (match) { + steps.push({ + kind: 'merge', + browserId: playlist._id, + serverId: match, + }); + continue; + } + steps.push({ kind: 'upload', playlist }); + serverIds.add(playlist._id); + if (source) { + bySource.set(source, playlist._id); + } + } + return steps; +} + +/** The provider account or URL a playlist reads from, if it has one. */ +export function sourceIdentity(playlist: Playlist): string | null { + if (playlist.serverUrl && playlist.username) { + return [ + 'xtream', + normalizeUrl(playlist.serverUrl), + playlist.username.trim(), + ].join('|'); + } + const portalUrl = playlist.portalUrl ?? playlist.url; + if (playlist.macAddress && portalUrl) { + return [ + 'stalker', + normalizeUrl(portalUrl), + playlist.macAddress.trim().toLowerCase(), + ].join('|'); + } + return playlist.url ? `m3u|${normalizeUrl(playlist.url)}` : null; +} + +/** Same rules as the backup import's fingerprints. */ +function normalizeUrl(value: string): string { + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + const pathname = + url.pathname === '/' + ? '/' + : url.pathname.replace(/\/+$/, '') || '/'; + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${pathname}${url.search}${url.hash}`; + } catch { + return trimmed.toLowerCase(); + } +} diff --git a/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts b/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts new file mode 100644 index 000000000..3634c1b8a --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts @@ -0,0 +1,84 @@ +import { combineUserData } from './server-migration-merge'; +import { typeLegacyXtreamRefs } from './server-migration-xtream'; + +describe('typeLegacyXtreamRefs', () => { + it('types bare ids from the same copy snapshots', () => { + expect( + typeLegacyXtreamRefs({ + 'xtream-favorites': [42, 'movie:7', 99], + 'xtream-recent-items': [{ id: 42, viewedAt: 't' }], + 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'live', title: 'News' }, + 'movie:7': { xtream_id: 7, type: 'movie' }, + }, + }) + ).toEqual({ + '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' }, + }, + }); + }); + + it('keeps a live and a movie favorite that share an id apart', () => { + const browser = typeLegacyXtreamRefs({ + 'xtream-favorites': [42], + 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'live' }, + }, + }); + const server = typeLegacyXtreamRefs({ + 'xtream-favorites': [42], + 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'movie' }, + }, + }); + + expect( + combineUserData([ + server['xtream-favorites'], + browser['xtream-favorites'], + ]) + ).toEqual(['movie:42', 'live:42']); + expect( + combineUserData([ + server['xtream-collection-items'], + 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' }, + }); + }); + + it('types numeric-string ids too', () => { + expect( + typeLegacyXtreamRefs({ + 'xtream-favorites': ['42'], + 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'movie' }, + }, + })['xtream-favorites'] + ).toEqual(['movie:42']); + }); + + it('keeps a typed snapshot newer than its legacy alias', () => { + const typed = typeLegacyXtreamRefs({ + 'xtream-favorites': ['movie:42'], + 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'movie', backdrop: 'old' }, + 'movie:42': { xtream_id: 42, type: 'movie', backdrop: 'new' }, + }, + }); + + expect(typed['xtream-collection-items']).toEqual({ + '42': { xtream_id: 42, type: 'movie', backdrop: 'old' }, + 'movie:42': { xtream_id: 42, type: 'movie', backdrop: 'new' }, + }); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-migration-xtream.ts b/libs/services/src/lib/server-storage/server-migration-xtream.ts new file mode 100644 index 000000000..00454f547 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration-xtream.ts @@ -0,0 +1,66 @@ +import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; + +/** One playlist's Xtream user data from one place (browser or server). */ +export type XtreamUserData = Partial>; + +const SNAPSHOTS = 'xtream-collection-items'; + +/** + * Older favorites and history name content by its bare Xtream id, which + * 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. Snapshots gain the typed key and + * keep the bare one. + */ +export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData { + const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {}; + const typed = (key: unknown): unknown => { + // Bare ids were stored as numbers and, older still, numeric strings. + const id = + typeof key === 'number' + ? key + : typeof key === 'string' && /^\d+$/.test(key) + ? Number(key) + : null; + if (id === null) { + return key; + } + const snapshot = snapshots[String(id)]; + return isRecord(snapshot) && + snapshot['xtream_id'] === id && + typeof snapshot['type'] === 'string' + ? `${snapshot['type']}:${id}` + : key; + }; + + const result: XtreamUserData = { ...data }; + const favorites = data['xtream-favorites']; + if (Array.isArray(favorites)) { + result['xtream-favorites'] = favorites.map(typed); + } + const recent = data['xtream-recent-items']; + if (Array.isArray(recent)) { + result['xtream-recent-items'] = recent.map((item) => + isRecord(item) ? { ...item, id: typed(item['id']) } : item + ); + } + if (isRecord(data[SNAPSHOTS])) { + // 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. A typed snapshot that exists is newer. + const aliased: Record = { ...snapshots }; + for (const [key, snapshot] of Object.entries(snapshots)) { + const typedKey = String(typed(Number(key))); + if (/^\d+$/.test(key) && !(typedKey in snapshots)) { + aliased[typedKey] = snapshot; + } + } + result[SNAPSHOTS] = aliased; + } + return result; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/libs/services/src/lib/server-storage/server-migration.service.spec.ts b/libs/services/src/lib/server-storage/server-migration.service.spec.ts new file mode 100644 index 000000000..90f2b7394 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration.service.spec.ts @@ -0,0 +1,589 @@ +import { TestBed } from '@angular/core/testing'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { of } from 'rxjs'; +import { getXtreamPendingRestoreStorageKey } from '@iptvnator/shared/interfaces'; +import { ParentalLockService } from '../parental-lock/parental-lock.service'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; +import { ServerStorageSession } from './server-storage-session'; +import { ServerMigrationService } from './server-migration.service'; + +describe('ServerMigrationService', () => { + const browserPlaylists = [ + { _id: 'm', title: 'Family', url: 'http://lists.test/family.m3u' }, + { + _id: 'x', + title: 'Provider', + serverUrl: 'http://panel.test', + username: 'user', + }, + ]; + let session: { + fetchStatus: jest.Mock; + documentStore: { + dbGetAppPlaylist: jest.Mock; + dbGetAppPlaylistMetas: jest.Mock; + dbUpsertAppPlaylist: jest.Mock; + dbCreateAppPlaylist: jest.Mock; + }; + api: { getJson: jest.Mock; sendJson: jest.Mock }; + userData: { + flush: jest.Mock; + hasUnsentWrites: jest.Mock; + hydrate: jest.Mock; + }; + }; + + const emptyLocks = () => ({ xtream: [], stalker: [], m3u: [] }); + let parentalLock: { + ensureLocksReadable: jest.Mock; + locksFor: jest.Mock; + replacePlaylistLocks: jest.Mock; + }; + + beforeEach(() => { + parentalLock = { + ensureLocksReadable: jest.fn().mockResolvedValue(true), + locksFor: jest.fn(() => emptyLocks()), + replacePlaylistLocks: jest.fn().mockResolvedValue(true), + }; + session = { + fetchStatus: jest.fn().mockResolvedValue({ playlists: 0 }), + documentStore: { + dbGetAppPlaylist: jest.fn().mockResolvedValue(null), + dbGetAppPlaylistMetas: jest.fn().mockResolvedValue([]), + dbUpsertAppPlaylist: jest + .fn() + .mockResolvedValue({ success: true }), + dbCreateAppPlaylist: jest.fn().mockResolvedValue(true), + }, + api: { + getJson: jest.fn().mockResolvedValue({ entries: {} }), + sendJson: jest.fn().mockResolvedValue(undefined), + }, + userData: { + flush: jest.fn().mockResolvedValue(undefined), + hasUnsentWrites: jest.fn(() => false), + hydrate: jest.fn().mockResolvedValue(undefined), + }, + }; + TestBed.configureTestingModule({ + providers: [ + { + provide: NgxIndexedDBService, + useValue: { getAll: () => of(browserPlaylists) }, + }, + { provide: ServerStorageSession, useValue: session }, + { provide: ParentalLockService, useValue: parentalLock }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { + baseUrl: '/api', + instance: { instanceId: 'i-1' }, + }, + }, + ], + }); + }); + + afterEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + }); + + it('offers the move on an empty server and summarizes browser playlists', async () => { + const offer = await TestBed.inject( + ServerMigrationService + ).offerOnStartup(); + + expect(offer).toEqual([ + { id: 'm', title: 'Family', kind: 'm3u' }, + { id: 'x', title: 'Provider', kind: 'xtream' }, + ]); + }); + + it('does not offer after a decision or when the server has playlists', async () => { + const migration = TestBed.inject(ServerMigrationService); + session.fetchStatus.mockResolvedValueOnce({ playlists: 3 }); + expect(await migration.offerOnStartup()).toBeNull(); + + migration.skip(); + expect(await migration.offerOnStartup()).toBeNull(); + }); + + it('copies playlists with their ids and their Xtream user data to an empty server', async () => { + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ x: ['live:1'], other: ['live:2'] }) + ); + const migration = TestBed.inject(ServerMigrationService); + + const result = await migration.moveBrowserData(); + + expect(result).toEqual({ imported: 2, merged: 0, failed: 0 }); + expect(session.documentStore.dbCreateAppPlaylist).toHaveBeenCalledTimes( + 2 + ); + expect(session.api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/x', + { value: ['live:1'] }, + {} + ); + expect(session.api.sendJson).toHaveBeenCalledTimes(1); + // Queued edits were sent before the combined values were read. + expect(session.userData.flush.mock.invocationCallOrder[0]).toBeLessThan( + session.api.getJson.mock.invocationCallOrder[0] + ); + expect(session.userData.hydrate).toHaveBeenCalled(); + expect(migration.browserDataMoved()).toBe(true); + }); + + it('merges into the server copy and combines Xtream data under its id', async () => { + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { + _id: 'server-x', + title: 'Provider', + serverUrl: 'http://panel.test/', + username: 'user', + }, + { + _id: 'server-m', + title: 'Family', + url: 'http://lists.test/family.m3u', + }, + ]); + session.api.getJson.mockResolvedValue({ + entries: { + 'xtream-recent-items': { 'server-x': ['vod:9'] }, + }, + }); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ x: ['live:1'] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ x: ['vod:1', 'vod:9'] }) + ); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result).toEqual({ imported: 0, merged: 2, failed: 0 }); + expect( + session.documentStore.dbUpsertAppPlaylist + ).not.toHaveBeenCalled(); + expect(session.api.sendJson.mock.calls).toEqual([ + [ + 'PUT', + '/user-state/xtream-favorites/server-x', + { value: ['live:1'] }, + {}, + ], + [ + 'PUT', + '/user-state/xtream-recent-items/server-x', + { value: ['vod:9', 'vod:1'] }, + {}, + ], + ]); + }); + + it("puts the browser's newer views ahead of the server's history", async () => { + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { ...browserPlaylists[1], _id: 'server-x' }, + ]); + const view = (id: string, day: number) => ({ + id, + viewedAt: `2026-10-0${day}T20:00:00.000Z`, + }); + session.api.getJson.mockResolvedValue({ + entries: { + 'xtream-recent-items': { + 'server-x': [view('vod:9', 2), view('vod:8', 1)], + }, + }, + }); + // vod:8 was watched again in this browser, with other artwork. + const again = { ...view('vod:8', 4), backdropUrl: 'b.jpg' }; + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ x: [again, view('vod:1', 3)] }) + ); + + await TestBed.inject(ServerMigrationService).moveBrowserData(); + + // The app cuts history from the end: the newest view leads, and an + // item keeps its newest view. + expect(session.api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-recent-items/server-x', + { value: [again, view('vod:1', 3), view('vod:9', 2)] }, + {} + ); + }); + + it('combines browser duplicates of one source in one write', async () => { + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([ + browserPlaylists[1], + { ...browserPlaylists[1], _id: 'x2' }, + ]), + }, + }); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ x: ['live:1'], x2: ['live:2'] }) + ); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result).toEqual({ imported: 1, merged: 1, failed: 0 }); + expect(session.api.sendJson.mock.calls).toEqual([ + [ + 'PUT', + '/user-state/xtream-favorites/x', + { value: ['live:1', 'live:2'] }, + {}, + ], + ]); + }); + + it("adds a browser document's favorites to the matching server playlist", async () => { + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([ + { + ...browserPlaylists[0], + favorites: ['b1', 'b2'], + playlist: { + items: [ + { id: 'b1', url: 'http://s/1' }, + { id: 'b2', url: 'http://s/2' }, + ], + }, + }, + ]), + }, + }); + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { _id: 'server-m', title: 'Family', url: browserPlaylists[0].url }, + ]); + // The server imported the same list: same URLs, other channel ids. + const stored = { + _id: 'server-m', + title: 'Family', + url: browserPlaylists[0].url, + favorites: ['s1'], + recentlyViewed: [], + playlist: { + items: [ + { id: 's1', url: 'http://s/1' }, + { id: 's2', url: 'http://s/2' }, + ], + }, + }; + session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result).toEqual({ imported: 0, merged: 1, failed: 0 }); + // By stream URL, as the app looks favorites up. + expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({ + ...stored, + favorites: ['http://s/1', 'http://s/2'], + recentlyViewed: [], + }); + }); + + it("keeps a document's newest view of an item both copies hold", async () => { + const view = (day: number, extra = {}) => ({ + source: 'm3u', + id: 'http://s/1', + url: 'http://s/1', + title: 'One', + channel_id: 'b1', + category_id: 'live', + added_at: `2026-10-0${day}T20:00:00.000Z`, + ...extra, + }); + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([ + { + ...browserPlaylists[0], + recentlyViewed: [ + view(3, { poster_url: 'new.png' }), + ], + playlist: { + items: [{ id: 'b1', url: 'http://s/1' }], + }, + }, + ]), + }, + }); + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { _id: 'server-m', title: 'Family', url: browserPlaylists[0].url }, + ]); + const stored = { + _id: 'server-m', + title: 'Family', + url: browserPlaylists[0].url, + favorites: [], + // The same channel, watched there before this browser's view. + recentlyViewed: [view(1, { channel_id: 's1' })], + playlist: { items: [{ id: 's1', url: 'http://s/1' }] }, + }; + session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored); + + await TestBed.inject(ServerMigrationService).moveBrowserData(); + + // Not skipped for an unchanged length: the newer view replaces it. + expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({ + ...stored, + recentlyViewed: [ + view(3, { channel_id: 's1', poster_url: 'new.png' }), + ], + }); + }); + + it('merges into a copy another tab or device uploaded first', async () => { + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([{ ...browserPlaylists[0], favorites: ['http://s/2'] }]), + }, + }); + // The id was free when planned, taken when uploaded. + session.documentStore.dbCreateAppPlaylist.mockResolvedValue(false); + const first = { + ...browserPlaylists[0], + favorites: ['http://s/1'], + recentlyViewed: [], + }; + session.documentStore.dbGetAppPlaylist.mockResolvedValue(first); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result).toEqual({ imported: 0, merged: 1, failed: 0 }); + // Never overwritten: this browser's favorites join the first copy's. + expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({ + ...first, + favorites: ['http://s/1', 'http://s/2'], + }); + }); + + it('takes a parked Xtream restore along to the server playlist id', async () => { + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { ...browserPlaylists[1], _id: 'server-x' }, + ]); + // A browser backup import that could not finish. + const parked = JSON.stringify({ + hiddenCategories: [], + favorites: [{ xtreamId: 5, contentType: 'movie' }], + recentlyViewed: [], + playbackPositions: [], + }); + localStorage.setItem(getXtreamPendingRestoreStorageKey('x'), parked); + + await TestBed.inject(ServerMigrationService).moveBrowserData(); + + expect( + localStorage.getItem(getXtreamPendingRestoreStorageKey('server-x')) + ).toBe(parked); + }); + + it('keeps the move on offer when a playlist could not be uploaded', async () => { + session.documentStore.dbCreateAppPlaylist + .mockRejectedValueOnce(new Error('413')) + .mockResolvedValue(true); + const migration = TestBed.inject(ServerMigrationService); + + const result = await migration.moveBrowserData(); + + expect(result).toEqual({ imported: 1, merged: 0, failed: 1 }); + expect(migration.browserDataMoved()).toBe(false); + + // A second run merges the moved one and retries the other. + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + browserPlaylists[1], + ]); + await expect(migration.moveBrowserData()).resolves.toEqual({ + imported: 1, + merged: 1, + failed: 0, + }); + expect(migration.browserDataMoved()).toBe(true); + }); + + it('does not start while user-data writes are still unsent', async () => { + session.userData.hasUnsentWrites.mockReturnValue(true); + const migration = TestBed.inject(ServerMigrationService); + + await expect(migration.moveBrowserData()).rejects.toThrow('Unsent'); + expect( + session.documentStore.dbCreateAppPlaylist + ).not.toHaveBeenCalled(); + expect(session.api.sendJson).not.toHaveBeenCalled(); + expect(migration.browserDataMoved()).toBe(false); + }); + + it('counts a duplicate of a failed upload as failed, not merged', async () => { + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([ + browserPlaylists[1], + { ...browserPlaylists[1], _id: 'x2' }, + ]), + }, + }); + session.documentStore.dbCreateAppPlaylist.mockRejectedValue( + new Error('500') + ); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ x: ['live:1'], x2: ['live:2'] }) + ); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result).toEqual({ imported: 0, merged: 0, failed: 2 }); + expect(session.api.sendJson).not.toHaveBeenCalled(); + }); + + it('takes parental locks along to the server playlist id', async () => { + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { + _id: 'server-x', + title: 'Provider', + serverUrl: 'http://panel.test', + username: 'user', + }, + ]); + parentalLock.locksFor.mockImplementation((id: string) => + id === 'x' + ? { + xtream: [{ categoryType: 'live', xtreamId: 5 }], + stalker: [], + m3u: [], + } + : { + xtream: [{ categoryType: 'vod', xtreamId: 9 }], + stalker: [], + m3u: [], + } + ); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result.failed).toBe(0); + expect(parentalLock.replacePlaylistLocks).toHaveBeenCalledWith( + 'server-x', + { + xtream: [ + { categoryType: 'vod', xtreamId: 9 }, + { categoryType: 'live', xtreamId: 5 }, + ], + stalker: [], + m3u: [], + } + ); + }); + + it('keeps the move on offer while parental locks cannot be read', async () => { + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { + _id: 'server-x', + title: 'Provider', + serverUrl: 'http://panel.test', + username: 'user', + }, + ]); + parentalLock.ensureLocksReadable.mockResolvedValue(false); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result.failed).toBeGreaterThan(0); + expect(parentalLock.replacePlaylistLocks).not.toHaveBeenCalled(); + }); + + it('keeps the move on offer when browser favorites cannot be read', async () => { + localStorage.setItem('xtream-favorites', '{not json'); + const migration = TestBed.inject(ServerMigrationService); + + const result = await migration.moveBrowserData(); + + expect(result.failed).toBeGreaterThan(0); + expect(migration.browserDataMoved()).toBe(false); + }); + + it('uploads a legacy Stalker row with its portal flag set', async () => { + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([ + { + _id: 's1', + title: 'Portal', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.test/stalker_portal/c', + }, + ]), + }, + }); + + await TestBed.inject(ServerMigrationService).moveBrowserData(); + + expect(session.documentStore.dbCreateAppPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ _id: 's1', isFullStalkerPortal: true }) + ); + }); + + it("does not take an inherited name for a playlist id's data", async () => { + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([{ _id: 'toString', title: 'Odd id', url: 'http://x' }]), + }, + }); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ toString: ['live:1'] }) + ); + // The server has the namespace, but no entry for that id. + session.api.getJson.mockResolvedValue({ + entries: { 'xtream-favorites': {} }, + tags: { 'xtream-favorites': {} }, + }); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(result.failed).toBe(0); + expect(session.api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/toString', + { value: ['live:1'] }, + { 'If-None-Match': '*' } + ); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-migration.service.ts b/libs/services/src/lib/server-storage/server-migration.service.ts new file mode 100644 index 000000000..f3f50b831 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration.service.ts @@ -0,0 +1,446 @@ +import { inject, Injectable } from '@angular/core'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { firstValueFrom } from 'rxjs'; +import { + DbStores, + getXtreamPendingRestoreStorageKey, + SERVER_USER_STATE_NAMESPACES, + type Playlist, + type ServerUserStateNamespace, + type ServerUserStateResponse, + withExplicitStalkerPortalFlag, +} from '@iptvnator/shared/interfaces'; +import { + combineRecentViews, + combineUserData, + newestViewsFirst, + remapChannelReferences, + retargetUserData, + unionItems, +} from './server-migration-merge'; +import { planServerMigration } from './server-migration-plan'; +import { withoutDeleted } from './server-user-data-edit'; +import { idMap, preconditionHeaders } from './server-user-data-entries'; +import { + typeLegacyXtreamRefs, + type XtreamUserData, +} from './server-migration-xtream'; +import { ParentalLockService } from '../parental-lock/parental-lock.service'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; +import { ServerStorageSession } from './server-storage-session'; + +const MARKER_PREFIX = 'iptvnator-server:migration:'; + +export interface ServerMigrationPlaylist { + readonly id: string; + readonly title: string; + readonly kind: 'm3u' | 'xtream' | 'stalker'; +} + +export interface ServerMigrationResult { + readonly imported: number; + readonly merged: number; + readonly failed: number; +} + +/** + * Moves this browser's playlists to the server. New playlists keep their + * ids; one the server already has (same id, or same provider source) is + * merged into the server's copy, adding the browser's favorites and + * history to it. The legacy Xtream favorites, history, collections and + * playback positions follow under the server id, combined with what is + * there (`combineUserData`). Browser data is never deleted, and the move + * can run again after a failure. + */ +@Injectable({ providedIn: 'root' }) +export class ServerMigrationService { + private readonly dbService = inject(NgxIndexedDBService); + private readonly session = inject(ServerStorageSession); + private readonly parentalLock = inject(ParentalLockService); + private readonly instanceId = inject(SERVER_STORAGE_CONFIG).instance + .instanceId; + + async browserPlaylists(): Promise { + const playlists = await this.readBrowserPlaylists(); + return playlists.map((playlist) => ({ + id: playlist._id, + title: playlist.title, + kind: playlist.serverUrl + ? 'xtream' + : playlist.macAddress + ? 'stalker' + : 'm3u', + })); + } + + /** The startup offer: only for an empty server and an undecided browser. */ + async offerOnStartup(): Promise { + if (this.readMarker() !== null) { + return null; + } + const playlists = await this.browserPlaylists(); + if (playlists.length === 0) { + return null; + } + const { playlists: serverCount } = await this.session.fetchStatus(); + return serverCount === 0 ? playlists : null; + } + + browserDataMoved(): boolean { + return this.readMarker() === 'done'; + } + + skip(): void { + this.writeMarker('skipped'); + } + + async moveBrowserData(): Promise { + // An edit still unsent could land after the combined values below. + await this.session.userData.flush(); + if (this.session.userData.hasUnsentWrites()) { + throw new Error('Unsent user data; try again when online'); + } + const [browser, server] = await Promise.all([ + this.readBrowserPlaylists(), + this.session.documentStore.dbGetAppPlaylistMetas(), + ]); + const byId = new Map( + browser.map((playlist) => [playlist._id, playlist]) + ); + /** Browser playlist id → its id on the server. */ + const targets = new Map(); + let imported = 0; + let merged = 0; + let failed = 0; + + const onServer = new Set(server.map((playlist) => playlist._id)); + for (const step of planServerMigration(browser, server)) { + try { + if (step.kind === 'merge') { + // A duplicate of a browser playlist whose upload failed. + if (!onServer.has(step.serverId)) { + throw new Error('Merge target was not uploaded'); + } + const playlist = byId.get(step.browserId); + if (playlist) { + await this.mergeDocumentUserData( + playlist, + step.serverId + ); + } + merged += 1; + targets.set(step.browserId, step.serverId); + } else { + // Create-only: another tab or device that moved the + // same playlist first keeps its copy, merged into. + const created = + await this.session.documentStore.dbCreateAppPlaylist( + step.playlist + ); + if (created) { + imported += 1; + } else { + await this.mergeDocumentUserData( + step.playlist, + step.playlist._id + ); + merged += 1; + } + onServer.add(step.playlist._id); + targets.set(step.playlist._id, step.playlist._id); + } + } catch { + failed += 1; + } + } + + failed += await this.copyXtreamUserData(targets); + failed += await this.moveParentalLocks(targets); + failed += movePendingRestores(targets); + await this.session.userData.hydrate(); + if (failed === 0) { + // A partial move stays on offer, in Settings → Server. + this.writeMarker('done'); + } + return { imported, merged, failed }; + } + + /** Adds a browser document's favorites and history to the server's. */ + private async mergeDocumentUserData( + playlist: Playlist, + serverId: string + ): Promise { + const favorites = playlist.favorites ?? []; + const recentlyViewed = playlist.recentlyViewed ?? []; + if (favorites.length === 0 && recentlyViewed.length === 0) { + return; + } + const stored = + await this.session.documentStore.dbGetAppPlaylist(serverId); + if (!stored) { + throw new Error('The server playlist disappeared'); + } + const browserChannels = channelsOf(playlist); + const serverChannels = channelsOf(stored); + const next = { + ...stored, + // By stream URL on both sides, so one channel is one favorite. + favorites: unionItems( + remapChannelReferences( + stored.favorites ?? [], + serverChannels, + serverChannels + ), + remapChannelReferences( + favorites, + browserChannels, + serverChannels + ) + ), + // Newest first, one per item: its newest view (the server's on + // a tie), as the app keeps the list. + recentlyViewed: unionItems( + newestViewsFirst( + [ + ...(stored.recentlyViewed ?? []), + ...remapChannelReferences( + recentlyViewed, + browserChannels, + serverChannels + ), + ], + 'added_at' + ) + ), + }; + // Also when only a newer view of an item it has changed. + if ( + JSON.stringify(next.favorites) !== + JSON.stringify(stored.favorites ?? []) || + JSON.stringify(next.recentlyViewed) !== + JSON.stringify(stored.recentlyViewed ?? []) + ) { + await this.session.documentStore.dbUpsertAppPlaylist(next); + } + } + + /** + * Copies the legacy maps, one write per server entry: each copy's + * legacy ids are typed first, then the browser copies of one source + * are combined with each other and with the server's value. Returns + * how many writes failed. + */ + private async copyXtreamUserData( + targets: ReadonlyMap + ): Promise { + if (targets.size === 0) { + return 0; + } + // Maps keyed by playlist id without a prototype: an id such as + // `toString` is not mistaken for an entry. + const { entries, tags } = withoutDeleted( + await this.session.api.getJson( + '/user-state' + ), + [] + ); + // A map this browser has but cannot read fails the move, so it + // stays on offer instead of being marked done without it. + let unreadable = 0; + const legacy = new Map< + ServerUserStateNamespace, + Record + >(); + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const map = readLegacyMap(namespace); + if (map) { + legacy.set(namespace, map); + } else { + unreadable += 1; + } + } + const copies = new Map(); + for (const [browserId, serverId] of targets) { + const copy: XtreamUserData = {}; + for (const [namespace, map] of legacy) { + if (browserId in map) { + copy[namespace] = retargetUserData( + map[browserId], + browserId, + serverId + ); + } + } + if (Object.keys(copy).length > 0) { + copies.set(serverId, [...(copies.get(serverId) ?? []), copy]); + } + } + + let failed = unreadable; + for (const [serverId, browserCopies] of copies) { + const stored: XtreamUserData = {}; + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const value = entries[namespace]?.[serverId]; + if (value !== undefined) { + stored[namespace] = value; + } + } + const typed = [stored, ...browserCopies].map(typeLegacyXtreamRefs); + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const value = ( + namespace === 'xtream-recent-items' + ? combineRecentViews + : combineUserData + )(typed.map((copy) => copy[namespace])); + if ( + value === undefined || + JSON.stringify(value) === JSON.stringify(stored[namespace]) + ) { + continue; + } + try { + // On the version read above: a device that changed it + // meanwhile wins, and the move stays on offer. + await this.session.api.sendJson( + 'PUT', + `/user-state/${namespace}/${encodeURIComponent(serverId)}`, + { value }, + tags + ? preconditionHeaders( + tags[namespace]?.[serverId] ?? null + ) + : {} + ); + } catch { + failed += 1; + } + } + } + return failed; + } + + /** + * Parental locks stay in this browser, keyed by playlist id: a playlist + * merged under another id takes its locks along, or its locked + * categories would show after the move. Returns how many failed. + */ + private async moveParentalLocks( + targets: ReadonlyMap + ): Promise { + const moving = [...targets].filter(([from, to]) => from !== to); + // An unreadable lock store reads as empty: keep the move on offer. + if ( + moving.length > 0 && + !(await this.parentalLock.ensureLocksReadable()) + ) { + return moving.length; + } + let failed = 0; + for (const [browserId, serverId] of targets) { + const from = this.parentalLock.locksFor(browserId); + if ( + browserId === serverId || + from.xtream.length + from.stalker.length + from.m3u.length === 0 + ) { + continue; + } + const to = this.parentalLock.locksFor(serverId); + const moved = await this.parentalLock.replacePlaylistLocks( + serverId, + { + xtream: unionItems(to.xtream, from.xtream), + stalker: unionItems(to.stalker, from.stalker), + m3u: unionItems(to.m3u, from.m3u), + } + ); + if (!moved) { + failed += 1; + } + } + return failed; + } + + private async readBrowserPlaylists(): Promise { + const playlists = await firstValueFrom( + this.dbService.getAll(DbStores.Playlists) + ); + // A browser that skipped the Stalker metadata migration still has + // rows without the explicit portal flag. + return playlists.map(withExplicitStalkerPortalFlag); + } + + private readMarker(): string | null { + return readLocal(MARKER_PREFIX + this.instanceId); + } + + private writeMarker(value: 'done' | 'skipped'): void { + try { + localStorage.setItem(MARKER_PREFIX + this.instanceId, value); + } catch { + // Without storage the offer simply returns next time. + } + } +} + +/** + * A parked Xtream restore (a browser backup import that could not finish) + * follows a playlist merged under another id, where it is applied and + * exported; one the server playlist has parked already wins. Returns how + * many could not be copied. + */ +function movePendingRestores(targets: ReadonlyMap): number { + let failed = 0; + for (const [browserId, serverId] of targets) { + if (browserId === serverId) { + continue; + } + try { + const parked = localStorage.getItem( + getXtreamPendingRestoreStorageKey(browserId) + ); + const target = getXtreamPendingRestoreStorageKey(serverId); + if (parked !== null && localStorage.getItem(target) === null) { + localStorage.setItem(target, parked); + } + } catch { + failed += 1; + } + } + return failed; +} + +/** The M3U channels of a full playlist document. */ +function channelsOf(playlist: Playlist): { id?: unknown; url?: unknown }[] { + const items = (playlist as { playlist?: { items?: unknown } }).playlist + ?.items; + return Array.isArray(items) ? items : []; +} + +function readLocal(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** A browser map, `{}` when absent, `null` when it cannot be read. */ +function readLegacyMap(key: string): Record | null { + let value: string | null; + try { + value = localStorage.getItem(key); + } catch { + return null; + } + if (value === null) { + return idMap(); + } + try { + const parsed: unknown = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? idMap(parsed as Record) + : null; + } catch { + return null; + } +} diff --git a/libs/services/src/lib/server-storage/server-page-pointer.ts b/libs/services/src/lib/server-storage/server-page-pointer.ts new file mode 100644 index 000000000..18aaed9bd --- /dev/null +++ b/libs/services/src/lib/server-storage/server-page-pointer.ts @@ -0,0 +1,64 @@ +import type { UserDataStorage } from '../user-data-storage'; + +/** + * The tab's pointer to its page load, in `sessionStorage` (one per tab, + * kept on reload): a reload finds the writes the page loads before it + * saved. + */ +export const PAGE_KEY = 'iptvnator-server:page'; + +/** + * Records `page` as the tab's current page load and returns the ones + * before it: the last page load (a reload, whose saved writes this page + * takes over), then, oldest first, older ones whose copies a page load + * kept because storage refused its own (`keepPages`). + */ +export function swapPage( + tabStore: UserDataStorage, + instanceId: string, + page: string +): string[] { + try { + const previous = tabStore.getItem(`${PAGE_KEY}:${instanceId}`); + tabStore.setItem(`${PAGE_KEY}:${instanceId}`, page); + return previous ? pageList(previous) : []; + } catch { + return []; + } +} + +/** + * The tab's pointer names `page` and the older page loads whose saved + * copies it still keeps: a reload before this page saved its own copy must + * find them too, as their registry records may be missing. + */ +export function keepPages( + tabStore: UserDataStorage, + instanceId: string, + page: string, + kept: readonly string[] +): void { + try { + tabStore.setItem( + `${PAGE_KEY}:${instanceId}`, + kept.length > 0 ? JSON.stringify([page, ...kept]) : page + ); + } catch { + // Session storage refused: the registry records remain. + } +} + +/** A plain page id, or a page and the older ones it keeps (`keepPages`). */ +function pageList(value: string): string[] { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.filter( + (page): page is string => typeof page === 'string' + ); + } + } catch { + // A plain page id. + } + return [value]; +} 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..3a71fe0d7 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts @@ -0,0 +1,301 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistWriteConflict } from '../playlist-storage-backend'; +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; +} + +function putResponse(etag: string, replaced: string) { + return { + status: 200, + ok: true, + headers: new Headers({ etag, 'x-replaced-etag': replaced }), + json: async () => ({ success: true }), + } 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(), + readJson: jest.fn((response: Response) => response.json()), + 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"' }, + }); + // structuredClone drops the served-version symbol, as JSON does. + expect(structuredClone(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(JSON.parse(JSON.stringify(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); + }); + + describe('after a keepPayload write', () => { + async function readThenEdit(replaced: string) { + const { api, store } = setup(); + // nginx gzip weakens the read's ETag. + api.request.mockResolvedValueOnce( + fakeResponse(200, doc, 'W/"b.0.1"') + ); + const served = await store.dbGetAppPlaylist('p1'); + if (!served) throw new Error('expected a document'); + api.sendJson.mockResolvedValueOnce( + putResponse('"b.0.2"', replaced) + ); + await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] }); + api.request.mockResolvedValueOnce( + fakeResponse(200, doc, '"b.0.3"') + ); + await store.dbGetAppPlaylist('p1'); + return api.request.mock.calls[1][1]; + } + + it('keeps its copy when it replaced the version it read', async () => { + await expect(readThenEdit('"b.0.1"')).resolves.toEqual({ + headers: { 'If-None-Match': '"b.0.2"' }, + }); + }); + + it('drops its copy when another device wrote in between', async () => { + // The server kept a newer payload than the one this client has. + await expect(readThenEdit('"b.0.4"')).resolves.toEqual({}); + }); + }); + + it('writes an edit on the version it read and reports a refusal', 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(412, 'conflict', 'changed') + ); + + await expect( + store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] }) + ).rejects.toBeInstanceOf(PlaylistWriteConflict); + expect(api.sendJson.mock.calls[0][3]).toEqual({ + 'If-Match': '"b.0.1"', + }); + + // A retry on the same copy, not read again, writes as before. + api.sendJson.mockResolvedValueOnce(putResponse('"b.0.3"', '"b.0.2"')); + await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] }); + expect(api.sendJson.mock.calls[1][3]).toEqual({}); + }); + + it('creates a playlist only while the server has none of that id', async () => { + const { api, store } = setup(); + api.sendJson.mockResolvedValueOnce(putResponse('"b.0.1"', '')); + + await expect(store.dbCreateAppPlaylist(doc)).resolves.toBe(true); + expect(api.sendJson.mock.calls[0][3]).toEqual({ 'If-None-Match': '*' }); + + api.sendJson.mockRejectedValueOnce( + new ServerApiError(412, 'conflict', 'exists') + ); + await expect(store.dbCreateAppPlaylist(doc)).resolves.toBe(false); + }); + + it('writes a portal document on the version it read', async () => { + const { api, store } = setup(); + const portal = { _id: 'p2', title: 'Portal', macAddress: 'm' }; + api.request.mockResolvedValueOnce(fakeResponse(200, portal, '"b.0.1"')); + const served = await store.dbGetAppPlaylist('p2'); + if (!served) throw new Error('expected a document'); + api.sendJson.mockResolvedValueOnce(fakeResponse(200, {}, '"b.0.2"')); + + await store.dbUpsertAppPlaylist({ ...served, title: 'Renamed' }); + + expect(api.sendJson.mock.calls[0][1]).toBe('/playlists/p2'); + expect(api.sendJson.mock.calls[0][3]).toEqual({ + 'If-Match': '"b.0.1"', + }); + }); + + it('names the version of the copy a mutation was built on', async () => { + const { api, store } = setup(); + const portal = { _id: 'p2', title: 'Portal', macAddress: 'm' }; + api.request + .mockResolvedValueOnce(fakeResponse(200, portal, '"b.0.1"')) + // Another device wrote; a later read sees its version. + .mockResolvedValueOnce( + fakeResponse(200, { ...portal, title: 'Theirs' }, '"b.0.2"') + ); + const older = await store.dbGetAppPlaylist('p2'); + await store.dbGetAppPlaylist('p2'); + if (!older) throw new Error('expected a document'); + api.sendJson.mockResolvedValueOnce(fakeResponse(200, {}, '"b.0.3"')); + + await store.dbUpsertAppPlaylist({ ...older, title: 'Mine' }); + + expect(api.sendJson.mock.calls[0][3]).toEqual({ + 'If-Match': '"b.0.1"', + }); + }); + + it("keeps checking a playlist after another one's refusal", async () => { + const { api, store } = setup(); + // Both playlists are at revision 1 of the same boot: one ETag. + for (const id of ['p1', 'p2']) { + api.request.mockResolvedValueOnce( + fakeResponse(200, { ...doc, _id: id }, '"b.0.1"') + ); + } + const first = await store.dbGetAppPlaylist('p1'); + const second = await store.dbGetAppPlaylist('p2'); + if (!first || !second) throw new Error('expected documents'); + api.sendJson.mockRejectedValueOnce( + new ServerApiError(412, 'conflict', 'changed') + ); + await expect( + store.dbUpsertAppPlaylist({ ...first, title: 'A' }) + ).rejects.toBeInstanceOf(PlaylistWriteConflict); + + api.sendJson.mockResolvedValueOnce(putResponse('"b.0.2"', '"b.0.1"')); + await store.dbUpsertAppPlaylist({ ...second, title: 'B' }); + + expect(api.sendJson.mock.calls[1][3]).toEqual({ + 'If-Match': '"b.0.1"', + }); + }); + + it('refuses a document without an id', async () => { + const { api, store } = setup(); + + await expect( + store.dbUpsertAppPlaylist({ title: 'Orphan' } as Playlist) + ).rejects.toThrow('without an id'); + expect(api.sendJson).not.toHaveBeenCalled(); + }); + + it('writes imports as they are', async () => { + const { api, store } = setup(); + api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"')); + await store.dbGetAppPlaylist('p1'); + api.sendJson.mockResolvedValue(fakeResponse(200, {}, '"b.0.2"')); + + await store.dbUpsertAppPlaylists([{ ...doc, title: 'Imported' }]); + + expect(api.sendJson.mock.calls[0][3]).toEqual({}); + }); + + it('runs deletes through the user-data hold', async () => { + const order: string[] = []; + const api = { + request: jest.fn(async (path: string) => { + order.push(`DELETE ${path}`); + return fakeResponse(200, { success: true }); + }), + getJson: jest.fn(), + readJson: jest.fn(), + sendJson: jest.fn(), + }; + const store = new ServerPlaylistDocumentStore( + api as never, + async (playlistId, remove) => { + order.push(`hold ${playlistId}`); + await remove(); + order.push(`release ${playlistId}`); + } + ); + + await store.dbDeletePlaylist('p1'); + await store.dbDeleteAllPlaylists(); + + expect(order).toEqual([ + 'hold p1', + 'DELETE /playlists/p1', + 'release p1', + 'hold undefined', + 'DELETE /playlists', + 'release undefined', + ]); + }); +}); 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..4e9bb7228 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-playlist-document-store.ts @@ -0,0 +1,313 @@ +import { + SERVER_PAYLOAD_FIELDS, + SERVER_REPLACED_ETAG_HEADER, + type ElectronBridgeCountResult, + type ElectronBridgeResult, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import { + type PlaylistDocumentStore, + PlaylistWriteConflict, +} from '../playlist-storage-backend'; +import { ServerApiError, type ServerApiClient } from './server-api-client'; + +/** The version a served copy came from; JSON never sends it. */ +const SERVED_VERSION = Symbol('servedVersion'); + +function servedVersion(playlist: Playlist): string | null { + const version = (playlist as unknown as Record)[ + SERVED_VERSION + ]; + return typeof version === 'string' ? version : null; +} + +interface CachedDocument { + readonly etag: string; + readonly document: Playlist; +} + +type ServerApi = Pick< + ServerApiClient, + 'request' | 'getJson' | 'readJson' | 'sendJson' +>; + +/** + * `PlaylistDocumentStore` over the server storage API. Full documents are + * cached by ETag; every read hands out a private copy and remembers which + * ETag its payload objects came from, 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(); + /** `id|version` a write was refused on (another device wrote since). */ + private readonly refused = new Set(); + /** Served payload object → the ETag of the document it came from. */ + private readonly servedPayloads = new WeakMap(); + + constructor( + private readonly api: ServerApi, + /** + * Runs the server delete of a playlist (without an id, of every + * playlist), so user-data writes cannot land after it. + */ + private readonly deleting: ( + playlistId: string | undefined, + remove: () => Promise + ) => Promise = async (_, remove) => void (await remove()) + ) {} + + 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, cached.etag); + } + const document = await this.api.readJson(response); + const etag = this.remember(playlistId, response, document); + return this.serve(document, etag); + } + + /** A mutation: built on the version it read, refused if it changed. */ + dbUpsertAppPlaylist(playlist: Playlist): Promise { + return this.upsert(playlist, true); + } + + /** Imports and migrations: written as they are, outside any retry. */ + async dbUpsertAppPlaylists( + playlists: Playlist[] + ): Promise { + for (const playlist of playlists) { + await this.upsert(playlist, false); + } + return { success: true, count: playlists.length }; + } + + async dbDeletePlaylist(playlistId: string): Promise { + await this.deleting(playlistId, () => + this.api.request(`/playlists/${encodeURIComponent(playlistId)}`, { + method: 'DELETE', + }) + ); + this.cache.delete(playlistId); + return { success: true }; + } + + async dbDeleteAllPlaylists(): Promise { + await this.deleting(undefined, () => + 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 async upsert( + playlist: Playlist, + conditional: boolean + ): Promise { + const id = playlist?._id; + if (!id) { + // A mutation that ran after its playlist was deleted. + throw new Error('A playlist without an id cannot be stored.'); + } + const path = `/playlists/${encodeURIComponent(id)}`; + const payloadEtag = this.servedPayloadEtag(playlist); + // The version it was read at: of its payload, else of the copy it + // was built from (portal documents have no payload). A retry on the + // same copy, not read again after a refusal, writes as before. + const read = payloadEtag ?? servedVersion(playlist); + const headers: Record = + conditional && read && !this.refused.has(`${id}|${read}`) + ? { 'If-Match': read } + : {}; + try { + if (payloadEtag !== null) { + const response = await this.keepPayload( + playlist, + payloadEtag, + headers + ); + if (response) { + return { success: true }; + } + } + const response = await this.api.sendJson( + 'PUT', + path, + playlist, + payloadEtag === null ? headers : {} + ); + this.remember(id, response, playlist); + return { success: true }; + } catch (error) { + if (error instanceof ServerApiError && error.status === 412) { + this.refused.add(`${id}|${read}`); + this.cache.delete(id); + throw new PlaylistWriteConflict(id); + } + throw error; + } + } + + /** + * A create-only upload (the move from browser storage): false when the + * server has a playlist with that id already, uploaded meanwhile by + * another tab or device, which the caller merges into instead. + */ + async dbCreateAppPlaylist(playlist: Playlist): Promise { + try { + const response = await this.api.sendJson( + 'PUT', + `/playlists/${encodeURIComponent(playlist._id)}`, + playlist, + { 'If-None-Match': '*' } + ); + this.remember(playlist._id, response, playlist); + return true; + } catch (error) { + if (error instanceof ServerApiError && error.status === 412) { + return false; + } + throw error; + } + } + + /** `null` when the server has no payload to keep (write it in full). */ + private async keepPayload( + playlist: Playlist, + payloadEtag: string, + headers: Record + ): Promise { + let response: Response; + try { + response = await this.api.sendJson( + 'PUT', + `/playlists/${encodeURIComponent(playlist._id)}?keepPayload=1`, + withoutPayload(playlist), + headers + ); + } catch (error) { + if ( + error instanceof ServerApiError && + error.code === 'payload_missing' + ) { + return null; + } + throw error; + } + // The server kept its stored payload; it is ours only if nothing + // replaced it since we read it. + const replaced = response.headers.get(SERVER_REPLACED_ETAG_HEADER); + if (replaced && sameEtag(replaced, payloadEtag)) { + this.remember(playlist._id, response, playlist); + } else { + this.cache.delete(playlist._id); + } + return response; + } + + private remember( + id: string, + response: Response, + document: Playlist + ): string | null { + const etag = response.headers.get('etag'); + if (etag) { + this.cache.set(id, { etag, document }); + } else { + this.cache.delete(id); + } + return etag; + } + + private serve(document: Playlist, etag: string | null): Playlist { + const copy = structuredClone(document); + if (!etag) { + return copy; + } + // Spreads copy it along, so a mutation built on this copy names it. + (copy as unknown as Record)[SERVED_VERSION] = etag; + 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.set(value, etag); + } + } + return copy; + } + + /** The ETag every payload field of `playlist` was served under, if any. */ + private servedPayloadEtag(playlist: Playlist): string | null { + const record = playlist as unknown as Record; + const etags = new Set(); + for (const field of SERVER_PAYLOAD_FIELDS) { + const value = record[field]; + if (value === undefined || value === null) { + continue; + } + etags.add( + typeof value === 'object' + ? this.servedPayloads.get(value) + : undefined + ); + } + const [etag] = etags; + return etags.size === 1 && etag !== undefined ? etag : null; + } +} + +/** nginx's gzip turns ETags weak; the revision stays the same. */ +function sameEtag(left: string, right: string): boolean { + return left.replace(/^W\//, '') === right.replace(/^W\//, ''); +} + +function withoutPayload(playlist: Playlist): Record { + const copy = { ...playlist } as Record; + for (const field of SERVER_PAYLOAD_FIELDS) { + delete copy[field]; + } + return copy; +} diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts new file mode 100644 index 000000000..333bb355a --- /dev/null +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -0,0 +1,457 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideRouter, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; +import { writeServerAccessToken } from '../server-access-token'; +import { takeSlowStart } from '../server-slow-start'; +import { ServerStorageStatusService } from '../server-storage-status.service'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; +import { ServerStorageSession } from './server-storage-session'; + +describe('ServerStorageSession', () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + // start() arms a 3 s startup race and a 30 s retry interval. + jest.useFakeTimers(); + (globalThis as { fetch: unknown }).fetch = fetchMock; + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ entries: {} }), + }); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + fetchMock.mockReset(); + localStorage.clear(); + sessionStorage.clear(); + TestBed.resetTestingModule(); + }); + + function setup(auth: 'none' | 'token', startedOffline = false) { + TestBed.configureTestingModule({ + providers: [ + provideRouter([{ path: 'server-sign-in', children: [] }]), + { + provide: RuntimeCapabilitiesService, + useValue: { supportsSqlite: false }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { + baseUrl: '/api', + startedOffline, + instance: { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth, + }, + }, + }, + ], + }); + return { + session: TestBed.inject(ServerStorageSession), + backend: TestBed.inject(PlaylistStorageBackend), + status: TestBed.inject(ServerStorageStatusService), + }; + } + + it('switches playlists to the server and hydrates user data', async () => { + const { session, backend, status } = setup('none'); + + await session.start(); + + expect(backend.kind()).toBe('server'); + expect(status.state()).toBe('online'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/user-state', + expect.anything() + ); + }); + + it('stops at unauthorized when a token is required but missing', async () => { + const { session, status } = setup('token'); + + await session.start(); + + expect(status.state()).toBe('unauthorized'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('marks the page closed on page hide also while sign-in is pending', async () => { + const { session } = setup('token'); + const close = jest.spyOn(session.userData, 'close'); + + await session.start(); + window.dispatchEvent(new Event('pagehide')); + + expect(close).toHaveBeenCalled(); + }); + + it('starts with a stored token', async () => { + writeServerAccessToken('t', 'i-1'); + const { session, status } = setup('token'); + + await session.start(); + + expect(status.state()).toBe('online'); + }); + + it("asks for a token when the stored one is another instance's", async () => { + writeServerAccessToken('t', 'i-2'); + const { session, status } = setup('token'); + + await session.start(); + + expect(status.state()).toBe('unauthorized'); + }); + + it('retries a write the server refused while it stays online', async () => { + const { session, status } = setup('none'); + await session.start(); + const ok = fetchMock.getMockImplementation(); + fetchMock.mockImplementation( + async (url: string, init?: { method?: string }) => + init?.method === 'PUT' + ? { + status: 500, + ok: false, + headers: new Headers(), + json: async () => ({ code: 'x', message: 'down' }), + } + : ok?.(url, init) + ); + + session.userData.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['x'] }) + ); + await session.userData.flush(); + expect(status.state()).toBe('online'); + expect(session.userData.hasUnsentWrites()).toBe(true); + + fetchMock.mockImplementation(async () => ({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ entries: {}, tag: 't' }), + })); + await jest.advanceTimersByTimeAsync(30_000); + + expect(session.userData.hasUnsentWrites()).toBe(false); + }); + + it('reloads a page back from the back-forward cache whose writes another tab took', async () => { + const { session } = setup('none'); + const reload = jest + .spyOn(session as unknown as { reloadPage(): void }, 'reloadPage') + .mockImplementation(() => undefined); + await session.start(); + const resumed = jest + .spyOn(session.userData, 'resumed') + .mockReturnValue(true); + const pageshow = (persisted: boolean) => + window.dispatchEvent( + Object.assign(new Event('pageshow'), { persisted }) + ); + + pageshow(false); + pageshow(true); + expect(resumed).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + + resumed.mockReturnValue(false); + pageshow(true); + expect(reload).toHaveBeenCalledTimes(1); + + // So does a background tab the browser suspended as long. + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); + document.dispatchEvent(new Event('visibilitychange')); + expect(reload).toHaveBeenCalledTimes(2); + delete (document as { visibilityState?: unknown }).visibilityState; + + // And its retry timer, which may run before it is shown again. + jest.advanceTimersByTime(30_000); + expect(reload).toHaveBeenCalledTimes(3); + }); + + it('goes to sign-in on every sign-out, also when already signed out', async () => { + const { session } = setup('none'); + await session.start(); + const navigate = jest + .spyOn(TestBed.inject(Router), 'navigateByUrl') + .mockResolvedValue(false); + + session.signOut(); + // A guard kept the page (unsaved Settings); the user signs out again. + session.signOut(); + + expect(navigate).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenLastCalledWith('/server-sign-in'); + }); + + describe('after losing the server', () => { + const answer = (body: unknown) => ({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => body, + }); + + it('checks the instance before the token and writes go out again', async () => { + writeServerAccessToken('t', 'i-1'); + const { session, status } = setup('token'); + await session.start(); + expect(status.accessToken()).toBe('t'); + + status.reportOffline(); + + expect(status.accessToken()).toBeNull(); + fetchMock.mockClear(); + await expect( + session.api.sendJson('PUT', '/user-state/a/b', { value: 1 }) + ).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + + fetchMock.mockResolvedValue( + answer({ instanceId: 'i-1', entries: {} }) + ); + await session.refresh(); + + expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/instance'); + expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty( + 'Authorization' + ); + expect(status.accessToken()).toBe('t'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/user-state', + expect.anything() + ); + }); + + it('checks only the instance at once when it goes offline', async () => { + const { session, status } = setup('none'); + await session.start(); + // The instance answers, its user data does not. + fetchMock.mockImplementation(async (url: string) => + url.endsWith('/instance') + ? answer({ instanceId: 'i-1' }) + : { status: 503, ok: false, headers: new Headers() } + ); + fetchMock.mockClear(); + + status.reportOffline(); + TestBed.tick(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + + expect(status.isVerified()).toBe(true); + // A failing read waits for the retry interval, not a loop. + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + '/api/v1/instance', + ]); + }); + + it('checks at once only once per retry interval', async () => { + const { session, status } = setup('none'); + await session.start(); + // Headers arrive, the body is cut off: success, then offline. + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => { + throw new TypeError('body cut off'); + }, + }); + fetchMock.mockClear(); + + status.reportOffline(); + for (let round = 0; round < 5; round += 1) { + TestBed.tick(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('keeps checking an unverified page a late answer set online', async () => { + writeServerAccessToken('t', 'i-1'); + const { session, status } = setup('token'); + await session.start(); + fetchMock.mockRejectedValue(new TypeError('offline')); + status.reportOffline(); + TestBed.tick(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + // A request sent before the outage is answered late. + status.reportSuccess(); + expect(status.state()).toBe('online'); + expect(status.isVerified()).toBe(false); + fetchMock.mockReset(); + fetchMock.mockResolvedValue( + answer({ instanceId: 'i-1', entries: {} }) + ); + + jest.advanceTimersByTime(30_000); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + + expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/instance'); + expect(status.isVerified()).toBe(true); + }); + + it('reloads when another instance answers at the address', async () => { + const { session, status } = setup('none'); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + await session.start(); + status.reportOffline(); + fetchMock.mockClear(); + fetchMock.mockResolvedValue(answer({ instanceId: 'other' })); + + await session.refresh(); + + expect(reload).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalledWith( + '/api/v1/user-state', + expect.anything() + ); + }); + }); + + describe('after an offline start', () => { + it('stays offline until the server answers, then reloads once', async () => { + const { session, status } = setup('none', true); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await session.start(); + expect(status.state()).toBe('offline'); + expect(reload).not.toHaveBeenCalled(); + + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ entries: {} }), + }); + await session.refresh(); + await session.refresh(); + + expect(reload).toHaveBeenCalledTimes(1); + // The reloaded page's probe waits as long as this request did. + expect(takeSlowStart()).toBe(true); + }); + + it('sends no write before the reload checks the instance', async () => { + // A write this tab's last page load could not send. + sessionStorage.setItem('iptvnator-server:page:i-1', 'last'); + localStorage.setItem( + 'iptvnator-server:unsent-writes:i-1:last', + JSON.stringify([ + { + namespace: 'xtream-favorites', + playlistId: 'p1', + value: ['x'], + }, + ]) + ); + const { session } = setup('none', true); + jest.spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ).mockImplementation(() => undefined); + + await session.start(); + session.userData.flushWithKeepalive(); + await session.userData.flush(); + + expect( + fetchMock.mock.calls.map(([url, init]) => [ + url, + init?.method ?? 'GET', + ]) + ).toEqual([['/api/v1/instance', 'GET']]); + expect(session.userData.hasUnsentWrites()).toBe(true); + }); + + it('checks the instance before a token may go out', async () => { + const { session, status } = setup('token', true); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + await session.start(); + writeServerAccessToken('t', 'i-1'); + expect(status.accessToken()).toBeNull(); + + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ instanceId: 'other' }), + }); + await expect(session.ensureVerified()).rejects.toThrow(); + expect(reload).toHaveBeenCalled(); + expect(status.accessToken()).toBeNull(); + + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ instanceId: 'i-1' }), + }); + await session.ensureVerified(); + expect(status.accessToken()).toBe('t'); + }); + + it('never reloads after a normal start', async () => { + const { session } = setup('none'); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + + await session.start(); + await session.refresh(); + + expect(reload).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts new file mode 100644 index 000000000..70a623c60 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -0,0 +1,262 @@ +import { effect, inject, Injectable, untracked } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import type { + ServerInstanceInfo, + ServerStorageStatusResponse, +} from '@iptvnator/shared/interfaces'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { + clearServerAccessToken, + readServerAccessToken, +} from '../server-access-token'; +import { + SERVER_SIGN_IN_PATH, + ServerStorageStatusService, +} from '../server-storage-status.service'; +import { + createBrowserSessionStorage, + createBrowserUserDataStorage, +} from '../user-data-storage'; +import { markSlowStart } from '../server-slow-start'; +import { ServerApiClient, type ServerApiError } from './server-api-client'; +import { ServerPlaylistDocumentStore } from './server-playlist-document-store'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; +import { ServerUserDataStorage } from './server-user-data-storage'; + +const RETRY_INTERVAL_MS = 30_000; +const STARTUP_HYDRATE_WAIT_MS = 3_000; +const NOTICE_REPEAT_MS = 5 * 60_000; + +/** Server mode for this page: created only when the probe found server storage. */ +@Injectable({ providedIn: 'root' }) +export class ServerStorageSession { + private readonly config = inject(SERVER_STORAGE_CONFIG); + private readonly status = inject(ServerStorageStatusService); + private readonly backend = inject(PlaylistStorageBackend); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + private readonly router = inject(Router); + private reloading = false; + private lastQuickCheck = 0; + private lastNotice = { key: '', at: 0 }; + + readonly api = new ServerApiClient({ + baseUrl: this.config.baseUrl, + status: this.status, + onWriteFailure: (error) => this.announceWriteFailure(error), + // A remembered instance, or one that went offline, may answer as + // another database: writes and the token wait until it is checked. + holdWrites: () => !this.status.isVerified(), + accessToken: () => this.status.accessToken(), + }); + readonly documentStore = new ServerPlaylistDocumentStore( + this.api, + (playlistId, remove) => this.userData.deleting(playlistId, remove) + ); + readonly userData = new ServerUserDataStorage( + this.api, + // Lazy: a browser that denies storage must not stop the app here. + createBrowserUserDataStorage(), + () => this.announceWriteFailure(), + this.config.instance.instanceId ?? undefined, + createBrowserSessionStorage() + ); + + constructor() { + effect(() => { + if ( + this.status.state() === 'unauthorized' && + !this.router.url.startsWith(SERVER_SIGN_IN_PATH) + ) { + void this.router.navigateByUrl(SERVER_SIGN_IN_PATH); + } + }); + effect(() => { + if ( + this.status.state() === 'offline' && + !this.config.startedOffline + ) { + untracked(() => this.checkSoon()); + } + }); + } + + /** App initializer: must run before the first playlist read. */ + async start(): Promise { + this.backend.useServerStore(this.documentStore); + this.status.activate( + this.config.instance, + () => void this.refresh(), + !this.config.startedOffline + ); + // Also while sign-in is pending: this page may already hold another + // page load's writes, so it must mark itself closed on page hide. + this.listen(); + if ( + this.config.instance.auth === 'token' && + !readServerAccessToken(this.config.instance.instanceId) + ) { + this.status.reportUnauthorized(); + return; + } + await Promise.race([ + this.refresh(), + new Promise((resolve) => + setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS) + ), + ]); + } + + /** + * Gone offline, the token and writes wait for the instance check: + * after a blip it passes at once, not at the next retry. Only the + * check, and once per retry interval: a read or a check that keeps + * failing (a body cut off after its headers) waits for the retry. + */ + private checkSoon(): void { + const now = Date.now(); + if (now - this.lastQuickCheck < RETRY_INTERVAL_MS) { + return; + } + this.lastQuickCheck = now; + void this.ensureVerified().catch(() => undefined); + } + + /** Re-reads synced user data; also the offline retry. */ + async refresh(): Promise { + if (this.config.startedOffline) { + await this.reloadOnceReachable(); + return; + } + try { + // Back from offline: the instance first, as the read flushes. + await this.ensureVerified(); + await this.userData.hydrate(); + } catch { + // The API client already reported offline or unauthorized. + } + } + + /** + * Nothing was read at start: once the server answers, boot again, so + * its instance is checked before anything is sent. + */ + private async reloadOnceReachable(): Promise { + try { + await this.api.getJson('/instance'); + } catch { + return; + } + if (!this.reloading) { + this.reloading = true; + // It answers, maybe slower than the start probe waits. + markSlowStart(); + this.reloadPage(); + } + } + + /** + * Before a token or a write goes out from a page started from memory, + * or one that went offline: the instance answering at the address must + * be this page's. Another one reloads the page, which starts against it. + */ + async ensureVerified(): Promise { + if (this.status.isVerified()) { + return; + } + const { instanceId } = + await this.api.getJson('/instance'); + if (instanceId !== this.config.instance.instanceId) { + this.reloadPage(); + throw new Error('Another server answers at this address.'); + } + this.status.markVerified(); + } + + fetchStatus(): Promise { + return this.api.getJson( + '/instance/status' + ); + } + + signOut(): void { + clearServerAccessToken(); + this.status.reportUnauthorized(); + // Also when signed out already: a guard (unsaved Settings) may have + // kept the page here the first time. + void this.router.navigateByUrl(SERVER_SIGN_IN_PATH); + } + + private listen(): void { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.userData.flushWithKeepalive(); + } else if (!this.userData.resumed()) { + // Suspended long enough for another tab to take its writes. + this.reloadPage(); + } else { + void this.refresh(); + } + }); + window.addEventListener('pageshow', (event) => { + // Back from the back-forward cache: another tab may have taken + // its writes over while it was frozen; then this page reloads. + if (event.persisted && !this.userData.resumed()) { + this.reloadPage(); + } + }); + window.addEventListener('pagehide', (event) => { + this.userData.flushWithKeepalive(); + // Not into the back-forward cache: the page is gone for good. + if (!event.persisted) { + this.userData.close(); + } + }); + setInterval(() => { + // A suspended tab's timers can run before it is shown again. + if (!this.userData.resumed()) { + this.reloadPage(); + return; + } + this.userData.touch(); + // Also online: a write the server refused (a 5xx) or a first + // read that failed is not retried by anything else. + // Online but unverified too: a late answer to a request sent + // before the outage may have set it online again. + const state = this.status.state(); + if ( + state === 'offline' || + (state === 'online' && + (this.userData.needsSync() || !this.status.isVerified())) + ) { + void this.refresh(); + } + }, RETRY_INTERVAL_MS); + } + + /** Separate so tests can observe it. */ + protected reloadPage(): void { + window.location.reload(); + } + + private announceWriteFailure(error?: ServerApiError): void { + const key = + error?.status === 413 + ? 'SERVER_STORAGE.TOO_LARGE' + : 'SERVER_STORAGE.WRITE_FAILED'; + // Retries must not repeat the same notice every 30 s. + const now = Date.now(); + if ( + key === this.lastNotice.key && + now - this.lastNotice.at < NOTICE_REPEAT_MS + ) { + return; + } + this.lastNotice = { key, at: now }; + this.snackBar.open(this.translate.instant(key), undefined, { + duration: 5000, + }); + } +} diff --git a/libs/services/src/lib/server-storage/server-storage.config.ts b/libs/services/src/lib/server-storage/server-storage.config.ts new file mode 100644 index 000000000..fdeb8ce18 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-storage.config.ts @@ -0,0 +1,17 @@ +import { InjectionToken } from '@angular/core'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; + +export interface ServerStorageConfig { + readonly instance: ServerInstanceInfo; + /** Backend base URL as the PWA resolves it, e.g. `/api`. */ + readonly baseUrl: string; + /** + * The start-up probe got no answer and the instance came from this + * browser's memory of the server: the page reloads once it answers. + */ + readonly startedOffline?: boolean; +} + +export const SERVER_STORAGE_CONFIG = new InjectionToken( + 'SERVER_STORAGE_CONFIG' +); diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.spec.ts b/libs/services/src/lib/server-storage/server-unsent-writes.spec.ts new file mode 100644 index 000000000..ebb997939 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-unsent-writes.spec.ts @@ -0,0 +1,41 @@ +import { + markUnconfirmed, + othersUnconfirmed, + UNCONFIRMED_TTL_MS, +} from './server-unsent-writes'; + +function memoryStorage() { + const values = new Map(); + 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), + keys: () => [...values.keys()], + }; +} + +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); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.ts b/libs/services/src/lib/server-storage/server-unsent-writes.ts new file mode 100644 index 000000000..a2f61012b --- /dev/null +++ b/libs/services/src/lib/server-storage/server-unsent-writes.ts @@ -0,0 +1,453 @@ +import { + isServerUserStateNamespace, + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, +} from '@iptvnator/shared/interfaces'; +import type { UserDataStorage } from '../user-data-storage'; +import { cacheKey } from './server-user-data-cache'; + +/** + * User-data writes the server has not confirmed, kept in `localStorage` + * per page load: a failed write, or a page-hide write whose answer the page + * did not live to see, is sent again after a reload (by the same tab) or + * after the tab closed (by another, see `orphanedPages`), instead of being + * replaced by the server's older value. + */ +export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes'; +/** Which server database the cached user data belongs to. */ +export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner'; +/** The tab's current page load, in the tab's own storage. */ + +const writesKey = (instanceId: string, page: string) => + `${UNSENT_WRITES_KEY}:${instanceId}:${page}`; +/** + * Which entries each page load has not had confirmed yet, so a re-read in + * one tab keeps the cache values another tab is still sending. Each page + * load writes only its own key, so tabs never overwrite each other's + * record. A live tab renews it; one 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 recordKey = (instanceId: string, page: string) => + `${UNCONFIRMED_KEY}:${instanceId}:${page}`; + +/** 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, page: string) => + `${DEFERRED_EDITS_KEY}:${instanceId}:${page}`; + +/** + * Another database behind this address (a new volume, another backend): + * its cache and unsent writes belong to the old one and must not be sent + * to or shown for this one. + */ +export function claimCache(local: UserDataStorage, instanceId: string): void { + try { + const owner = local.getItem(CACHE_OWNER_KEY); + if (owner === instanceId) { + return; + } + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + // Also the unscoped keys of earlier builds. + local.removeItem(`iptvnator-server:${namespace}`); + if (owner) { + local.removeItem(cacheKey(owner, namespace)); + } + } + if (owner) { + for (const prefix of PAGE_PREFIXES) { + for (const page of pagesUnder(local, `${prefix}:${owner}:`)) { + local.removeItem(`${prefix}:${owner}:${page}`); + } + } + } + local.setItem(CACHE_OWNER_KEY, instanceId); + } catch { + // Storage unavailable: nothing cached to mix up. + } +} + +/** Saved edits: JSON drops an undefined side, which reads back as such. */ +type Steps = readonly { + readonly before: unknown; + readonly after: unknown; + readonly yields?: true; +}[]; + +export interface UnsentWrite { + readonly namespace: ServerUserStateNamespace; + readonly playlistId: string; + /** `undefined` deletes the entry. */ + readonly value: unknown; + /** The server version it is based on (see `PendingEntry.tag`). */ + readonly tag?: string | null; + /** The edits since that version (see `PendingEntry.steps`). */ + readonly steps: Steps; +} + +interface StoredWrite { + readonly namespace: string; + readonly playlistId: string; + readonly value?: unknown; + readonly remove?: true; + readonly tag?: string | null; + readonly steps?: unknown; +} + +function readSteps(stored: unknown): Steps { + return Array.isArray(stored) + ? stored + .filter((step) => step && typeof step === 'object') + .map( + (step: { + before?: unknown; + after?: unknown; + yields?: unknown; + }) => ({ + before: step.before, + after: step.after, + ...(step.yields === true + ? { yields: true as const } + : {}), + }) + ) + : []; +} + +const saveSteps = (steps: Steps) => + steps.map(({ before, after, yields }) => ({ before, after, yields })); + +/** + * Page loads whose record went stale: closed tabs nobody sends for. Saved + * writes without any record are left alone: a tab that could save them but + * not its record may still run, and only its own reload takes them over. + */ +export function orphanedPages( + local: UserDataStorage, + instanceId: string, + page: string, + now = Date.now() +): string[] { + return Object.entries(readRegistry(local, instanceId)) + .filter( + ([owner, record]) => + owner !== page && + !(now - Number(record.at) < UNCONFIRMED_TTL_MS) + ) + .map(([owner]) => owner); +} + +/** Forgets a page load's saved writes, early edits and record. */ +export function removePage( + local: UserDataStorage, + instanceId: string, + page: string +): void { + try { + local.removeItem(writesKey(instanceId, page)); + local.removeItem(deferredKey(instanceId, page)); + } catch { + // Storage unavailable: nothing was saved there either. + } + markUnconfirmed(local, instanceId, page, []); +} + +export function readUnsentWrites( + local: UserDataStorage, + instanceId: string, + page: string +): UnsentWrite[] { + let stored: unknown; + try { + stored = JSON.parse(local.getItem(writesKey(instanceId, page)) ?? '[]'); + } catch { + return []; + } + const writes: UnsentWrite[] = []; + for (const write of Array.isArray(stored) + ? (stored as StoredWrite[]) + : []) { + if ( + write && + isServerUserStateNamespace(write.namespace) && + typeof write.playlistId === 'string' + ) { + writes.push({ + namespace: write.namespace, + playlistId: write.playlistId, + value: write.remove ? undefined : write.value, + tag: write.tag, + steps: readSteps(write.steps), + }); + } + } + return writes; +} + +export function saveUnsentWrites( + local: UserDataStorage, + instanceId: string, + page: string, + writes: readonly UnsentWrite[] +): boolean { + try { + if (writes.length === 0) { + local.removeItem(writesKey(instanceId, page)); + return true; + } + const stored: StoredWrite[] = writes.map( + ({ namespace, playlistId, value, tag, steps }) => ({ + namespace, + playlistId, + ...(value === undefined ? { remove: true } : { value }), + ...(tag === undefined ? {} : { tag }), + steps: saveSteps(steps), + }) + ); + local.setItem(writesKey(instanceId, page), JSON.stringify(stored)); + return true; + } catch { + // Storage unavailable or full: the queue stays in memory only. + return false; + } +} + +/** One write per entry, the newest by edit number. */ +export function newestWrites( + writes: readonly T[] +): T[] { + const newest = new Map(); + for (const write of writes) { + const key = `${write.namespace}|${write.playlistId}`; + const known = newest.get(key); + if (!known || write.edit > known.edit) { + newest.set(key, write); + } + } + return [...newest.values()]; +} + +export interface SavedDeferredEdit { + readonly namespace: ServerUserStateNamespace; + readonly playlistId: string; + readonly steps: Steps; +} + +export function readDeferredEdits( + local: UserDataStorage, + instanceId: string, + page: string +): SavedDeferredEdit[] { + let stored: unknown; + try { + stored = JSON.parse( + local.getItem(deferredKey(instanceId, page)) ?? '[]' + ); + } catch { + return []; + } + const edits: SavedDeferredEdit[] = []; + for (const edit of Array.isArray(stored) + ? (stored as Record[]) + : []) { + const namespace = edit?.['namespace']; + const playlistId = edit?.['playlistId']; + if ( + typeof namespace === 'string' && + isServerUserStateNamespace(namespace) && + typeof playlistId === 'string' + ) { + edits.push({ + namespace, + playlistId, + steps: readSteps(edit['steps']), + }); + } + } + return edits; +} + +export function saveDeferredEdits( + local: UserDataStorage, + instanceId: string, + page: string, + edits: readonly SavedDeferredEdit[] +): boolean { + try { + if (edits.length === 0) { + local.removeItem(deferredKey(instanceId, page)); + return true; + } + local.setItem( + deferredKey(instanceId, page), + JSON.stringify( + edits.map((edit) => ({ + namespace: edit.namespace, + playlistId: edit.playlistId, + steps: saveSteps(edit.steps), + })) + ) + ); + return true; + } catch { + // Storage unavailable or full: the edits stay in memory only. + return false; + } +} + +/** `closed`: the page unloaded (not into the back-forward cache). */ +interface PageRecord { + readonly at: number; + readonly keys: string[]; + readonly closed?: boolean; +} + +/** Key prefixes of what a page load keeps, each followed by `::`. */ +const PAGE_PREFIXES = [UNCONFIRMED_KEY, UNSENT_WRITES_KEY, DEFERRED_EDITS_KEY]; + +/** The page loads of keys that start with `prefix`. */ +function pagesUnder(local: UserDataStorage, prefix: string): string[] { + try { + return (local.keys?.() ?? []) + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)); + } catch { + return []; + } +} + +function readRecord( + local: UserDataStorage, + instanceId: string, + page: string +): PageRecord | null { + try { + const parsed = JSON.parse( + local.getItem(recordKey(instanceId, page)) ?? 'null' + ) as PageRecord | null; + return parsed && Array.isArray(parsed.keys) ? parsed : null; + } catch { + return null; + } +} + +function readRegistry( + local: UserDataStorage, + instanceId: string +): Record { + const registry: Record = Object.create(null); + for (const page of pagesUnder(local, `${UNCONFIRMED_KEY}:${instanceId}:`)) { + const record = readRecord(local, instanceId, page); + if (record) { + registry[page] = record; + } + } + return registry; +} + +/** Marks a page load's record as unloaded: a reload may take it over. */ +export function markClosed( + local: UserDataStorage, + instanceId: string, + page: string +): void { + const record = readRecord(local, instanceId, page); + if (!record) { + return; + } + try { + local.setItem( + recordKey(instanceId, page), + JSON.stringify({ ...record, closed: true }) + ); + } catch { + // Storage full: the record goes stale instead. + } +} + +/** + * Whether a page load still runs: it has a fresh record and did not + * unload. A duplicated tab copies the tab's page load but must not take + * over its saved writes. + */ +export function pageIsLive( + local: UserDataStorage, + instanceId: string, + page: string, + now = Date.now() +): boolean { + const record = readRecord(local, instanceId, page); + return ( + !!record && + !record.closed && + now - Number(record.at) < UNCONFIRMED_TTL_MS + ); +} + +/** + * Writes only this page load's record (none without keys); true when one + * now stands for it. A closed record stays closed: a page-hide write that + * fails after `pagehide` rewrites it, and the reload must still see a page + * that unloaded, not one that seems to run. + */ +export function markUnconfirmed( + local: UserDataStorage, + instanceId: string, + tabId: string, + keys: readonly string[], + now = Date.now() +): boolean { + try { + if (keys.length === 0) { + local.removeItem(recordKey(instanceId, tabId)); + return false; + } + const closed = readRecord(local, instanceId, tabId)?.closed === true; + local.setItem( + recordKey(instanceId, tabId), + JSON.stringify({ + at: now, + keys: [...keys], + ...(closed ? { closed } : {}), + }) + ); + return true; + } catch { + // Storage full: re-reads in other tabs lose this protection only. + return false; + } +} + +/** Whether a page load's record still stands (another tab may remove it). */ +export function hasRecord( + local: UserDataStorage, + instanceId: string, + page: string +): boolean { + try { + return local.getItem(recordKey(instanceId, page)) !== null; + } catch { + return true; + } +} + +/** Entry keys other live tabs have not had confirmed yet. */ +export function othersUnconfirmed( + local: UserDataStorage, + instanceId: string, + tabId: string, + now = Date.now() +): Set { + const keys = new Set(); + for (const [owner, record] of Object.entries( + readRegistry(local, instanceId) + )) { + if (owner !== tabId && now - Number(record.at) < UNCONFIRMED_TTL_MS) { + for (const key of record.keys) { + keys.add(String(key)); + } + } + } + return keys; +} diff --git a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts new file mode 100644 index 000000000..1150224f1 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts @@ -0,0 +1,729 @@ +import { ServerApiError } from './server-api-client'; +import { keepPages } from './server-page-pointer'; +import { + markClosed, + markUnconfirmed, + saveUnsentWrites, + UNCONFIRMED_TTL_MS, +} from './server-unsent-writes'; +import { ServerUserDataStorage } from './server-user-data-storage'; +import { fakeServer, memoryStorage } from './server-user-data.test-helpers'; + +/** Lets microtasks run until `condition` holds (a mocked call started). */ +async function until(condition: () => boolean): Promise { + for (let turn = 0; turn < 1000 && !condition(); turn += 1) { + await Promise.resolve(); + } + if (!condition()) { + throw new Error('The awaited call never started'); + } +} + +describe('ServerUserDataStorage taking over other page loads', () => { + it("keeps progress this tab saved over a closed tab's older edit", async () => { + const at = (seconds: number) => [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: seconds, + }, + ]; + const server = fakeServer({ + 'xtream-playback-positions': { p1: at(10) }, + }); + const shared = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const closed = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await closed.hydrate(); + closed.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(20) }) + ); + await closed.flush(); + closed.close(); + + // This tab saves newer progress before it adopts the closed one. + const tab = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await tab.hydrate(); + tab.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(30) }) + ); + // Sent on the server's value: the closed tab's write is unconfirmed. + await tab.flush(); + await tab.flush(); + expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30)); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + await tab.hydrate(); + await tab.flush(); + await tab.flush(); + clock.mockRestore(); + + expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30)); + expect( + JSON.parse(tab.getItem('xtream-playback-positions') ?? '{}')['p1'] + ).toEqual(at(30)); + }); + + it("sends a closed tab's saved write from an idle tab", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + // Read, and with nothing to sync it never reads again. + const idle = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await idle.hydrate(); + const closed = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await closed.hydrate(); + closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await closed.flush(); + closed.close(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + expect(idle.needsSync()).toBe(false); + idle.touch(); + await idle.flush(); + await idle.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + + it('lets a page back from the back-forward cache give up writes another tab took', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const page = () => + new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn(() => + Promise.reject( + new ServerApiError(0, 'network', 'offline') + ) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + // Two pages frozen in the cache with a write each, not closed. + const adopted = page(); + await adopted.hydrate(); + adopted.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await adopted.flush(); + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + const other = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await other.hydrate(); + await other.flush(); + await other.flush(); + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + const kept = page(); + await kept.hydrate(); + kept.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X', 'Y'] }) + ); + await kept.flush(); + clock.mockRestore(); + + // Sent by the other tab: sending it again could undo newer edits. + expect(adopted.resumed()).toBe(false); + expect(kept.resumed()).toBe(true); + }); + + it("keeps a closed tab's edit giving way through repeated conflicts", async () => { + const at = (id: number, seconds: number) => ({ + contentType: 'vod', + contentXtreamId: id, + positionSeconds: seconds, + }); + const server = fakeServer({ + 'xtream-playback-positions': { p1: [at(1, 10), at(2, 5)] }, + }); + const positions = () => server.state['xtream-playback-positions']['p1']; + const shared = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const closed = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await closed.hydrate(); + closed.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: [at(1, 20), at(2, 5)] }) + ); + await closed.flush(); + closed.close(); + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + const tab = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + + // Adopted; another device moves movie 2 before it is sent. + await tab.hydrate(); + server.state['xtream-playback-positions']['p1'] = [at(1, 10), at(2, 6)]; + await tab.flush(); + // And movie 1 before the replayed write lands. + server.state['xtream-playback-positions']['p1'] = [at(1, 30), at(2, 6)]; + await tab.flush(); + await tab.flush(); + clock.mockRestore(); + + expect(positions()).toEqual([at(1, 30), at(2, 6)]); + }); + + it("keeps what another tab saved while this tab's read travelled", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const a = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await a.hydrate(); + let answer = (): void => undefined; + let asked = false; + const b = new ServerUserDataStorage( + { + ...server.api, + // Answered with the state of when it was asked. + getJson: jest.fn(() => { + asked = true; + const old = server.api.getJson(); + return new Promise((resolve) => { + answer = () => resolve(old); + }); + }), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const reading = b.hydrate(); + // Its read is out before the other tab's write lands. + await until(() => asked); + a.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await a.flush(); + answer(); + await reading; + + // Tab A edits the list as it reads it now. + const read = JSON.parse(a.getItem('xtream-favorites') ?? '{}'); + a.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...read.p1, 'Y'] }) + ); + await a.flush(); + await a.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X', 'Y']); + }); + + it('sends nothing more once another tab took its writes over', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + let online = false; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const suspended = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online + ? send(...args) + : Promise.reject( + new ServerApiError(0, 'network', 'offline') + ) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await suspended.hydrate(); + suspended.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + await suspended.flush(); + // Suspended past the record lifetime: another tab takes it over, + // sends it, and the user removes X there afterwards. + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + const other = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await other.hydrate(); + await other.flush(); + await other.flush(); + other.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + await other.flush(); + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + + // Its retry timer runs before the tab is shown again. + online = true; + suspended.touch(); + await suspended.flush(); + await suspended.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + expect(suspended.resumed()).toBe(false); + }); + + it("tells whether one playlist's writes were all taken", async () => { + const server = fakeServer({ 'xtream-favorites': {} }); + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const tab = new ServerUserDataStorage( + { + ...server.api, + // The server refuses p2's entry as too large. + sendJson: jest.fn(async (...args: Parameters) => + args[1].endsWith('/p2') + ? Promise.reject( + new ServerApiError(413, 'too_large', 'big') + ) + : send(...args) + ), + } as never, + memoryStorage(), + jest.fn(), + 'default', + memoryStorage() + ); + await tab.hydrate(); + tab.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1'], p2: ['live:2'] }) + ); + + await expect(tab.flushed('p1')).resolves.toBe(true); + await expect(tab.flushed('p2')).resolves.toBe(false); + await expect(tab.flushed()).resolves.toBe(false); + expect(server.state['xtream-favorites']['p1']).toEqual(['live:1']); + }); + + it('stays closed when a page-hide write fails after pagehide', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const page = new ServerUserDataStorage( + { + ...server.api, + // Offline: the page-hide write fails at once. + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + sendKeepalive: jest.fn(async () => ({ + status: 'failed' as const, + })), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await page.hydrate(); + page.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + + // pagehide: flush, then close; the failure is answered after. + page.flushWithKeepalive(); + page.close(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + + // The reload takes its writes over at once, not after ten minutes. + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + await reloaded.flush(); + await reloaded.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + + it('keeps a write another tab confirmed while this read travelled', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + let land = (): void => undefined; + const a = new ServerUserDataStorage( + { + ...server.api, + // Tab A's write is still on its way when B asks. + sendJson: jest.fn( + (...args: Parameters) => + new Promise((resolve) => { + land = () => resolve(send(...args)); + }) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await a.hydrate(); + a.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const sending = a.flush(); + let answer = (): void => undefined; + let asked = false; + const b = new ServerUserDataStorage( + { + ...server.api, + getJson: jest.fn(() => { + asked = true; + const old = server.api.getJson(); + return new Promise((resolve) => { + answer = () => resolve(old); + }); + }), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const reading = b.hydrate(); + // Its read is out before the other tab's write lands. + await until(() => asked); + // A's write lands (its record is cleared), then B's old answer. + land(); + await sending; + answer(); + await reading; + + // Tab A removes S from the list as it reads it now. + const read = JSON.parse(a.getItem('xtream-favorites') ?? '{}'); + a.setItem( + 'xtream-favorites', + JSON.stringify({ p1: read.p1.filter((id: string) => id !== 'S') }) + ); + land = () => undefined; + const removing = a.flush(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + land(); + await removing; + + expect(server.state['xtream-favorites']['p1']).toEqual(['X']); + }); + + it('finds a kept page load again on a second reload', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + let online = false; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const api = { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online + ? send(...args) + : Promise.reject( + new ServerApiError(0, 'network', 'offline') + ) + ), + } as never; + const first = new ServerUserDataStorage( + api, + local, + jest.fn(), + 'default', + tabStore + ); + await first.hydrate(); + first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await first.flush(); + first.close(); + // Its registry record is gone too: only this tab can find it. + for (const key of local.keys()) { + if (key.startsWith('iptvnator-server:unconfirmed:')) { + local.removeItem(key); + } + } + // Storage is full: the reloads cannot save their own copies. + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key.startsWith('iptvnator-server:unsent-writes:')) { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + const second = new ServerUserDataStorage( + api, + local, + jest.fn(), + 'default', + tabStore + ); + expect(second.hasUnsentWrites()).toBe(true); + second.close(); + + // Reloaded again before any copy was saved. + const third = new ServerUserDataStorage( + api, + local, + jest.fn(), + 'default', + tabStore + ); + expect(third.hasUnsentWrites()).toBe(true); + local.setItem = setItem; + online = true; + await third.hydrate(); + await third.flush(); + await third.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + + it("leaves a running tab's kept page loads to it in a duplicate", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const api = { + ...server.api, + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + } as never; + const page = (store: ReturnType) => + new ServerUserDataStorage(api, local, jest.fn(), 'default', store); + const first = page(tabStore); + await first.hydrate(); + first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await first.flush(); + first.close(); + // The reload cannot save its copy: it keeps the first one's. + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key.startsWith('iptvnator-server:unsent-writes:')) { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + const running = page(tabStore); + expect(running.hasUnsentWrites()).toBe(true); + + // A duplicate copies the tab's session storage, pointer and all. + const copied = memoryStorage(); + for (const key of tabStore.keys()) { + copied.setItem(key, tabStore.getItem(key) ?? ''); + } + const duplicate = page(copied); + + // The running tab owns those writes and may hold newer ones. + expect(duplicate.hasUnsentWrites()).toBe(false); + running.close(); + duplicate.touch(); + expect(duplicate.hasUnsentWrites()).toBe(true); + }); + + describe('a page load and the older one it kept', () => { + // The older page added X; the newer one took that over and then + // removed X again; neither copy could replace the other. + function journals() { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tag = `t${JSON.stringify(['S']).replace(/"/g, "'")}`; + const entry = (steps: { before: unknown; after: unknown }[]) => [ + { + namespace: 'xtream-favorites' as const, + playlistId: 'p1', + value: steps[steps.length - 1].after, + tag, + steps, + }, + ]; + saveUnsentWrites( + local, + 'default', + 'older', + entry([{ before: ['S'], after: ['S', 'X'] }]) + ); + saveUnsentWrites( + local, + 'default', + 'newer', + entry([ + { before: ['S'], after: ['S', 'X'] }, + { before: ['S', 'X'], after: ['S'] }, + ]) + ); + // Another device adds Y meanwhile: the replay decides. + server.state['xtream-favorites']['p1'] = ['S', 'Y']; + return { server, local }; + } + + async function send(page: ServerUserDataStorage) { + await page.hydrate(); + await page.flush(); + await page.flush(); + } + + it('replays them oldest first on a reload', async () => { + const { server, local } = journals(); + const tabStore = memoryStorage(); + keepPages(tabStore, 'default', 'newer', ['older']); + + await send( + new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ) + ); + + // X stays removed. + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Y']); + }); + + it('replays them oldest first when a duplicate adopts them', async () => { + const { server, local } = journals(); + const tabStore = memoryStorage(); + keepPages(tabStore, 'default', 'newer', ['older']); + // The newer page load still runs when the duplicate opens. + markUnconfirmed(local, 'default', 'newer', ['xtream-favorites|p1']); + const duplicate = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + markClosed(local, 'default', 'newer'); + duplicate.touch(); + + await send(duplicate); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Y']); + }); + }); + + it('lands a page-hide batch before a flush scheduled in the same turn', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const keepalive = server.api.sendKeepalive.getMockImplementation(); + if (!keepalive) { + throw new Error('The fake server answers sendKeepalive'); + } + let land = (): void => undefined; + let started = false; + const tab = new ServerUserDataStorage( + { + ...server.api, + // The page-hide write is still on its way. + sendKeepalive: jest.fn( + (...args: Parameters) => + new Promise((resolve) => { + started = true; + land = () => resolve(keepalive(...args)); + }) + ), + } as never, + memoryStorage(), + jest.fn(), + 'default', + memoryStorage() + ); + await tab.hydrate(); + tab.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const flushing = tab.flush(); + tab.flushWithKeepalive(); + // Removed again before either batch was answered. + tab.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + await until(() => started); + // The scheduled flush has its turn while the page-hide write travels. + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + land(); + await flushing; + await tab.flush(); + await tab.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-user-data-adopt.ts b/libs/services/src/lib/server-storage/server-user-data-adopt.ts new file mode 100644 index 000000000..d8b047c7f --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-adopt.ts @@ -0,0 +1,183 @@ +import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import { + behind, + type EditStep, + splitEditKey, + uncertain, + UNKNOWN_TAG, + yielding, +} from './server-user-data-entries'; +import { replaySteps } from './server-user-data-edit'; +import type { UserDataWriteQueue } from './server-user-data-queue'; +import type { UserDataStorage } from '../user-data-storage'; +import type { DeferredEdit } from './server-user-data-edit'; +import { + othersUnconfirmed, + readDeferredEdits, + readUnsentWrites, +} from './server-unsent-writes'; +import type { UserDataCache } from './server-user-data-cache'; + +/** + * A closed tab's saved edits, ahead of this tab's own (which may have been + * made on them): sent with them, replayed first on a conflict, and only + * where the entry is still as that tab saw it (`yields`): a change made + * since, here or elsewhere, is newer. Returns the value to show. + */ +export function adoptEdits( + writes: UserDataWriteQueue, + namespace: ServerUserStateNamespace, + playlistId: string, + current: unknown, + steps: readonly EditStep[], + foreign: boolean +): unknown { + const key = `${namespace}|${playlistId}`; + const adopted = yielding(steps); + const queued = writes.isQueued(key) ? writes.unsent(key) : undefined; + // Without a server version this page read, the write must conflict and + // be replayed on the server's value, never overwrite it blindly. + const unknown = + foreign || + (queued ? queued.tag : writes.tags.of(namespace, playlistId)) === + undefined; + if (queued) { + // This tab's queued edits are newer: they replay last. + const all = [...adopted, ...queued.steps]; + const value = replaySteps(queued.steps[0]?.before, all); + writes.put({ + ...queued, + value, + steps: all, + tag: unknown ? UNKNOWN_TAG : queued.tag, + }); + return value; + } + const value = replaySteps(current, adopted); + writes.queue(namespace, playlistId, value, value, { foreign: unknown }); + const entry = writes.unsent(key); + if (entry) { + writes.put({ ...entry, steps: [...adopted, ...entry.steps] }); + } + return value; +} + +/** + * Early edits made on this tab's own unsent write: kept as their own steps + * after it, so a conflict replays them in order. + */ +export function extendUnsent( + writes: UserDataWriteQueue, + key: string, + steps: readonly EditStep[] +): void { + const entry = writes.unsent(key); + if (entry) { + writes.put({ + ...entry, + value: replaySteps(entry.value, steps), + steps: [ + ...(writes.isQueued(key) ? entry.steps : behind(entry)), + ...steps, + ], + }); + } +} + +/** + * A reload of this tab: its last page load's saved writes are queued and + * its early edits kept, both as this page's own. + */ +export function restorePage( + writes: UserDataWriteQueue, + deferred: Map, + local: UserDataStorage, + instanceId: string, + page: string +): void { + for (const write of readUnsentWrites(local, instanceId, page)) { + const key = `${write.namespace}|${write.playlistId}`; + const queued = writes.isQueued(key) ? writes.unsent(key) : undefined; + if (queued) { + // An older page load's copy came first: these steps follow it. + writes.put({ + ...queued, + value: write.value, + steps: [...queued.steps, ...uncertain(write.steps)], + }); + continue; + } + writes.queue( + write.namespace, + write.playlistId, + write.value, + undefined, + { + tag: write.tag, + steps: write.steps, + } + ); + } + for (const edit of readDeferredEdits(local, instanceId, page)) { + const key = `${edit.namespace}|${edit.playlistId}`; + deferred.set(key, { + ...edit, + steps: [...(deferred.get(key)?.steps ?? []), ...edit.steps], + }); + } +} + +/** + * Flushes until nothing (of `playlistId`) is queued or travelling, a few + * rounds for conflict replays: false when some stays unsent (refused, or + * offline). + */ +export async function settleWrites( + writes: UserDataWriteQueue, + playlistId?: string +): Promise { + const unsent = () => + writes + .unconfirmedKeys() + .some( + (key) => + playlistId === undefined || + splitEditKey(key)[1] === playlistId + ); + for (let round = 0; round < 3 && unsent(); round += 1) { + await writes.flush(); + } + return !unsent(); +} + +/** + * Another page load's saved writes and early edits, shown on what this tab + * shows now and queued with its writes (also when the cache shows them). + */ +export function adoptPageEdits( + writes: UserDataWriteQueue, + cache: UserDataCache, + from: { + readonly local: UserDataStorage; + readonly instanceId: string; + readonly tabId: string; + readonly page: string; + } +): void { + const { local, instanceId, tabId, page } = from; + const foreign = othersUnconfirmed(local, instanceId, tabId); + for (const { namespace, playlistId, steps } of [ + ...readDeferredEdits(local, instanceId, page), + ...readUnsentWrites(local, instanceId, page), + ]) { + const value = adoptEdits( + writes, + namespace, + playlistId, + cache.map(namespace)[playlistId], + steps, + foreign.has(`${namespace}|${playlistId}`) + ); + cache.setEntry(namespace, playlistId, value); + } +} diff --git a/libs/services/src/lib/server-storage/server-user-data-cache.ts b/libs/services/src/lib/server-storage/server-user-data-cache.ts new file mode 100644 index 000000000..b63710a9e --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-cache.ts @@ -0,0 +1,122 @@ +import { + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, +} from '@iptvnator/shared/interfaces'; +import type { UserDataStorage } from '../user-data-storage'; +import { changedEntries } from './server-user-data-edit'; +import { parseMap } from './server-user-data-entries'; + +/** Server-mode cache keys; the legacy unprefixed keys are never touched. */ +export const SERVER_CACHE_PREFIX = 'iptvnator-server:cache'; + +/** + * One namespace's cache of one server instance: a tab still open on + * another server (a changed backend URL) never writes over it. + */ +export function cacheKey( + instanceId: string, + namespace: ServerUserStateNamespace +): string { + return `${SERVER_CACHE_PREFIX}:${instanceId}:${namespace}`; +} + +/** + * The synced namespaces as this browser shows them: in browser storage, + * shared by the tabs, or in memory when it is full or refuses access, so + * callers and the diff baseline always read the same map. + */ +export class UserDataCache { + private readonly overflow = new Map(); + + constructor( + private readonly local: UserDataStorage, + private readonly instanceId: string + ) {} + + read(namespace: ServerUserStateNamespace): string | null { + const kept = this.overflow.get(namespace); + if (kept !== undefined) { + return kept; + } + try { + return this.local.getItem(cacheKey(this.instanceId, namespace)); + } catch { + return null; + } + } + + /** `null` empties the namespace. */ + write(namespace: ServerUserStateNamespace, value: string | null): void { + try { + const key = cacheKey(this.instanceId, namespace); + if (value === null) { + this.local.removeItem(key); + } else { + this.local.setItem(key, value); + } + this.overflow.delete(namespace); + } catch { + this.overflow.set(namespace, value ?? '{}'); + } + } + + /** What every namespace holds now, to compare with later. */ + snapshot(): Map { + return new Map( + SERVER_USER_STATE_NAMESPACES.map((namespace) => [ + namespace, + this.read(namespace), + ]) + ); + } + + /** Entries (`namespace|playlistId`) changed since `snapshot`. */ + changedSince( + snapshot: ReadonlyMap + ): string[] { + const keys: string[] = []; + for (const [namespace, before] of snapshot) { + if (this.read(namespace) !== before) { + for (const [playlistId] of changedEntries( + parseMap(before ?? '{}'), + this.map(namespace) + )) { + keys.push(`${namespace}|${playlistId}`); + } + } + } + return keys; + } + + map(namespace: ServerUserStateNamespace): Record { + return parseMap(this.read(namespace) ?? '{}'); + } + + /** After a delete: one playlist's entries, without an id all of them. */ + forget(playlistId?: string): void { + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const map = this.map(namespace); + if (playlistId === undefined) { + this.write(namespace, null); + } else if (playlistId in map) { + delete map[playlistId]; + this.write(namespace, JSON.stringify(map)); + } + } + } + + /** `undefined` removes the playlist's entry. */ + setEntry( + namespace: ServerUserStateNamespace, + playlistId: string, + value: unknown + ): void { + const map = this.map(namespace); + if (value === undefined) { + delete map[playlistId]; + } else { + map[playlistId] = value; + } + this.write(namespace, JSON.stringify(map)); + } +} diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts b/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts new file mode 100644 index 000000000..b179f7079 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts @@ -0,0 +1,147 @@ +import { applyEdit, withoutDeleted } from './server-user-data-edit'; + +describe('applyEdit', () => { + it('adds the new favorite to what the server already has', () => { + expect(applyEdit(['live:1', 'live:2'], [], ['live:9'])).toEqual([ + 'live:1', + 'live:2', + 'live:9', + ]); + expect(applyEdit(['live:1'], undefined, ['live:9'])).toEqual([ + 'live:1', + 'live:9', + ]); + }); + + it('removes only what the edit removed and replaces changed items', () => { + expect( + applyEdit( + [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: 5, + }, + 'other', + ], + [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: 5, + }, + ], + [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: 60, + }, + ] + ) + ).toEqual([ + { contentType: 'vod', contentXtreamId: 1, positionSeconds: 60 }, + 'other', + ]); + expect(applyEdit(['a', 'b'], ['a'], [])).toEqual(['b']); + // Another device removed 'a'; this edit only added 'c'. + expect(applyEdit(['b'], ['a', 'b'], ['a', 'b', 'c'])).toEqual([ + 'b', + 'c', + ]); + }); + + it('merges maps by key and passes deletes through', () => { + expect(applyEdit({ a: 1, b: 2 }, { a: 1 }, { c: 3 })).toEqual({ + b: 2, + c: 3, + }); + expect(applyEdit(['x'], ['x'], undefined)).toBeUndefined(); + }); +}); + +describe('applyEdit of recently viewed items', () => { + it('puts a replayed view ahead of older ones', () => { + const view = (id: string, day: number) => ({ + id, + viewedAt: `2026-10-0${day}T20:00:00.000Z`, + }); + // Another device added a view meanwhile; this one is newer still. + expect( + applyEdit( + [view('b', 2), view('a', 1)], + [view('a', 1)], + [view('c', 3), view('a', 1)] + ) + ).toEqual([view('c', 3), view('b', 2), view('a', 1)]); + }); +}); + +describe('applyEdit of a gone page (yields)', () => { + it('changes only what is still as that page saw it', () => { + // Map keys: `a` changed since, `b` did not. + expect( + applyEdit({ a: 3, b: 1 }, { a: 1, b: 1 }, { a: 2, b: 2 }, true) + ).toEqual({ a: 3, b: 2 }); + // List items by key: a changed item keeps its newer state. + const item = (seconds: number) => ({ + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: seconds, + }); + expect(applyEdit([item(30)], [item(10)], [item(20)], true)).toEqual([ + item(30), + ]); + expect(applyEdit([item(10)], [item(10)], [item(20)], true)).toEqual([ + item(20), + ]); + // Adds and removes still apply where nothing changed. + expect(applyEdit(['a', 'b'], ['a', 'b'], ['b', 'c'], true)).toEqual([ + 'b', + 'c', + ]); + }); + + it('keeps a value or entry replaced since', () => { + expect(applyEdit('new', 'old', 'mine', true)).toBe('new'); + expect(applyEdit('old', 'old', 'mine', true)).toBe('mine'); + expect(applyEdit(['x', 'y'], ['x'], undefined, true)).toEqual([ + 'x', + 'y', + ]); + expect(applyEdit(['x'], ['x'], undefined, true)).toBeUndefined(); + }); +}); + +describe('withoutDeleted', () => { + it('drops deleted playlists and keeps every other id', () => { + const read = withoutDeleted( + { + entries: { + 'xtream-favorites': JSON.parse( + '{"gone":["a"],"__proto__":["b"],"kept":["c"]}' + ), + 'xtream-recent-items': {}, + 'xtream-collection-items': {}, + 'xtream-playback-positions': {}, + }, + tags: { + 'xtream-favorites': JSON.parse( + '{"gone":"t1","__proto__":"t2","kept":"t3"}' + ), + 'xtream-recent-items': {}, + 'xtream-collection-items': {}, + 'xtream-playback-positions': {}, + }, + }, + ['gone'] + ); + + expect(Object.keys(read.entries['xtream-favorites'])).toEqual([ + '__proto__', + 'kept', + ]); + expect(read.entries['xtream-favorites']['__proto__']).toEqual(['b']); + expect(read.tags?.['xtream-favorites']['__proto__']).toBe('t2'); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.ts b/libs/services/src/lib/server-storage/server-user-data-edit.ts new file mode 100644 index 000000000..1dc704c42 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-edit.ts @@ -0,0 +1,218 @@ +import { + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, + type ServerUserStateResponse, +} from '@iptvnator/shared/interfaces'; +import { + isViewHistory, + itemKey, + newestViewsFirst, +} from './server-migration-merge'; +import { type EditStep, idMap } from './server-user-data-entries'; + +/** Edits made before the first read, applied once the server's value is known. */ +export interface DeferredEdit { + readonly namespace: ServerUserStateNamespace; + readonly playlistId: string; + readonly steps: readonly EditStep[]; +} + +/** + * One namespace's cache after a read: the server's map, except entries the + * server does not have yet (`keepCached`, from the cache) and edits made + * before the first read (applied to the server's value). + */ +export function refreshedNamespace( + server: Readonly>, + cached: Readonly>, + keepCached: ReadonlySet, + deferred: readonly DeferredEdit[], + /** What an early edit was made on; the server's entry by default. */ + baseOf: (playlistId: string) => unknown = (playlistId) => server[playlistId] +): Record { + const value = idMap(server); + for (const playlistId of keepCached) { + if (playlistId in cached) { + value[playlistId] = cached[playlistId]; + } else { + delete value[playlistId]; + } + } + for (const edit of deferred) { + const merged = replaySteps(baseOf(edit.playlistId), edit.steps); + if (merged === undefined) { + delete value[edit.playlistId]; + } else { + value[edit.playlistId] = merged; + } + } + return value; +} + +/** + * Applies one local edit (`before` → `after`, as the user made it against + * an older or empty cache) to the server's current value: list items the + * edit added or changed are set, items it removed are dropped, everything + * else the server has stays. Maps work the same by key. A `yields` edit + * (a gone page's) changes only what is still as it was `before`. + */ +export function applyEdit( + server: unknown, + before: unknown, + after: unknown, + yields = false +): unknown { + const free = (now: unknown, was: unknown) => !yields || same(now, was); + if (after === undefined) { + return free(server, before) ? undefined : server; + } + if ( + Array.isArray(after) && + isListOrMissing(server) && + isListOrMissing(before) + ) { + const was = new Map( + (before ?? []).map((item) => [itemKey(item), item]) + ); + const now = new Map(after.map((item) => [itemKey(item), item])); + const result = (server ?? []).filter((item) => { + const key = itemKey(item); + return !(was.has(key) && !now.has(key) && free(item, was.get(key))); + }); + for (const [key, item] of now) { + // Untouched by the edit: the server's state of it wins. + if (same(was.get(key), item)) { + continue; + } + const index = result.findIndex((entry) => itemKey(entry) === key); + if (!free(index < 0 ? undefined : result[index], was.get(key))) { + continue; + } + if (index < 0) { + result.push(item); + } else { + result[index] = item; + } + } + // History is cut at its limit from the end: a replayed view must + // not land behind older ones. + return isViewHistory(result) ? newestViewsFirst(result) : result; + } + if (isMap(after) && isMapOrMissing(server) && isMapOrMissing(before)) { + const was = before ?? {}; + const result: Record = { ...(server ?? {}) }; + for (const key of Object.keys(was)) { + if (!(key in after) && free(result[key], was[key])) { + delete result[key]; + } + } + for (const [key, value] of Object.entries(after)) { + if (!same(was[key], value) && free(result[key], was[key])) { + result[key] = value; + } + } + return result; + } + return free(server, before) ? after : server; +} + +function same(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function isMap(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isMapOrMissing( + value: unknown +): value is Record | undefined { + return value === undefined || isMap(value); +} + +function isListOrMissing(value: unknown): value is unknown[] | undefined { + return value === undefined || Array.isArray(value); +} + +/** Edits replayed on `server` in order. */ +export function replaySteps( + server: unknown, + steps: readonly EditStep[] +): unknown { + let value = server; + for (const step of steps) { + value = applyEdit(value, step.before, step.after, step.yields); + } + return value; +} + +/** The playlist entries `next` changed against `previous`; removed ones as `undefined`. */ +export function changedEntries( + previous: Readonly>, + next: Readonly> +): [string, unknown][] { + const changed: [string, unknown][] = []; + for (const [playlistId, value] of Object.entries(next)) { + if (JSON.stringify(previous[playlistId]) !== JSON.stringify(value)) { + changed.push([playlistId, value]); + } + } + for (const playlistId of Object.keys(previous)) { + if (!(playlistId in next)) { + changed.push([playlistId, undefined]); + } + } + return changed; +} + +/** The entries `next` changed against `read`, applied to `current`. */ +export function mergeEdits( + current: Record, + read: Record, + next: Record +): Record { + const merged = idMap(current); + for (const id of new Set([...Object.keys(read), ...Object.keys(next)])) { + if (JSON.stringify(read[id]) !== JSON.stringify(next[id])) { + const value = applyEdit(current[id], read[id], next[id]); + if (value === undefined) { + delete merged[id]; + } else { + merged[id] = value; + } + } + } + return merged; +} + +/** + * A server read without the playlists deleted after it was sent (an id of + * `undefined` stands for every playlist), its maps as `idMap`s. + */ +export function withoutDeleted( + read: ServerUserStateResponse, + deleted: readonly (string | undefined)[] +): ServerUserStateResponse { + const keep = (map: Record = {}): Record => { + const kept = idMap(); + for (const [id, value] of Object.entries(map)) { + if (!deleted.some((gone) => gone === undefined || gone === id)) { + kept[id] = value; + } + } + return kept; + }; + const entries = {} as Record>; + const tags = {} as Record>; + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + entries[namespace] = keep(read.entries[namespace]); + tags[namespace] = keep(read.tags?.[namespace]) as Record< + string, + string + >; + } + return { + entries: entries as ServerUserStateResponse['entries'], + ...(read.tags ? { tags: tags as ServerUserStateResponse['tags'] } : {}), + }; +} diff --git a/libs/services/src/lib/server-storage/server-user-data-entries.ts b/libs/services/src/lib/server-storage/server-user-data-entries.ts new file mode 100644 index 000000000..794c63d02 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-entries.ts @@ -0,0 +1,130 @@ +import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; + +/** One playlist entry of a synced user-data map, on its way to the server. */ +export interface PendingEntry { + readonly namespace: ServerUserStateNamespace; + readonly playlistId: string; + /** `undefined` deletes the entry. */ + readonly value: unknown; + /** The edit this value came from; a retry never replaces a newer one. */ + readonly edit: number; + /** + * The server version `value` is based on: a tag, `null` for an entry + * seen absent, `undefined` when unknown (an unconditional write). + */ + readonly tag?: string | null; + /** + * The edits since that version, oldest first: a conflict replays them + * on the server's value in order. Kept apart where they cannot be one + * before → after (another tab's change in between, or a write that may + * or may not have landed), so adding and then removing an item is + * never a no-op. + */ + readonly steps: readonly EditStep[]; +} + +/** One edit as it was made: the entry before and after it. */ +export interface EditStep { + readonly before: unknown; + readonly after: unknown; + /** + * The travelling write that carries it, until that one is answered; + * `0` once a write that carried it was lost or saved for a later page: + * it may have landed, so a later edit never joins it. + */ + readonly sent?: number; + /** + * Made on a page that is gone (a closed tab's saved edit): it changes + * only what is still as that page saw it, never a change made since. + */ + readonly yields?: true; +} + +/** A travelling write's steps, marked as carried by it. */ +export function behind(entry: PendingEntry): EditStep[] { + return entry.steps.map((step) => ({ ...step, sent: entry.edit })); +} + +/** Steps whose write may or may not have landed. */ +export function uncertain(steps: readonly EditStep[]): EditStep[] { + return steps.map((step) => ({ ...step, sent: 0 })); +} + +/** A gone page's steps: uncertain, and giving way to later changes. */ +export function yielding(steps: readonly EditStep[]): EditStep[] { + return steps.map((step) => ({ ...step, sent: 0, yields: true })); +} + +/** + * `steps` plus the edit `before` → `after`: joined with the last step when + * it continues it, kept apart otherwise. + */ +export function addStep( + steps: readonly EditStep[], + before: unknown, + after: unknown +): EditStep[] { + const last = steps[steps.length - 1]; + if ( + last && + last.sent === undefined && + !last.yields && + JSON.stringify(last.after) === JSON.stringify(before) + ) { + return [...steps.slice(0, -1), { before: last.before, after }]; + } + return [...steps, { before, after }]; +} + +/** + * A version no entry ever has: a write based on what the cache shows but + * the server may not have (another tab's unsent change) always conflicts + * and is replayed on the server's value. + */ +export const UNKNOWN_TAG = '~unknown'; + +/** `If-Match` / `If-None-Match` for a write based on `tag`. */ +export function preconditionHeaders( + tag: string | null | undefined +): Record { + if (tag === undefined) { + return {}; + } + return tag === null ? { 'If-None-Match': '*' } : { 'If-Match': `"${tag}"` }; +} + +export function entryKey(entry: PendingEntry): string { + return `${entry.namespace}|${entry.playlistId}`; +} + +export function splitEditKey(key: string): [string, string] { + const separator = key.indexOf('|'); + return [key.slice(0, separator), key.slice(separator + 1)]; +} + +export function entryPath(entry: PendingEntry): string { + return `/user-state/${entry.namespace}/${encodeURIComponent(entry.playlistId)}`; +} + +/** + * A map keyed by playlist id, without a prototype: an id such as + * `__proto__` is an ordinary key, both to set and to read when absent. + */ +export function idMap( + source: Readonly> = {} +): Record { + return Object.assign(Object.create(null) as Record, source); +} + +export function parseMap(value: string): Record { + try { + const parsed: unknown = JSON.parse(value); + return idMap( + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + ); + } catch { + return idMap(); + } +} diff --git a/libs/services/src/lib/server-storage/server-user-data-queue.ts b/libs/services/src/lib/server-storage/server-user-data-queue.ts new file mode 100644 index 000000000..ccefcf604 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-queue.ts @@ -0,0 +1,499 @@ +import type { + ServerUserStateCurrent, + ServerUserStateNamespace, + ServerUserStateWriteResponse, +} from '@iptvnator/shared/interfaces'; +import { + PLAYLIST_MISSING, + ServerApiError, + type ServerApiClient, +} from './server-api-client'; +import { + entryKey, + entryPath, + addStep, + behind, + type EditStep, + type PendingEntry, + preconditionHeaders, + uncertain, + UNKNOWN_TAG, +} from './server-user-data-entries'; +import { replaySteps } from './server-user-data-edit'; +import { UserStateTags } from './server-user-data-tags'; +import { newestWrites } from './server-unsent-writes'; + +const FLUSH_DELAY_MS = 500; + +/** What the queue needs of the API client. */ +export type ServerApi = Pick< + ServerApiClient, + 'readJson' | 'sendJson' | 'request' | 'sendKeepalive' +>; + +export interface UserDataQueueHooks { + /** The queue or the writes travelling changed: persist them. */ + readonly changed: () => void; + /** A conflict replay changed what an entry should show. */ + readonly show: ( + namespace: ServerUserStateNamespace, + playlistId: string, + value: unknown + ) => void; + readonly writeFailed: (error: unknown) => void; + /** False while nothing may go out (another tab took the writes over). */ + readonly maySend?: () => boolean; +} + +/** + * The writes of `ServerUserDataStorage`: one entry per playlist and + * namespace, sent in batches one after another, each based on the server + * version (tag) it was read at. A `412` replays the edit (before → after) + * on the server's current value; a failure keeps it queued unless a newer + * edit replaced it. + */ +export class UserDataWriteQueue { + /** Server version tag per entry; absent until the first read. */ + readonly tags = new UserStateTags(); + private readonly pending = new Map(); + /** Writes still travelling (normal or page-hide), by entry key. */ + private readonly inFlight = new Map(); + private flushTimer: ReturnType | null = null; + /** Batches go out one at a time, so an older write never lands last. */ + private sending: Promise = Promise.resolve(); + /** A page-hide batch sent at once, which a scheduled flush waits for. */ + private keepaliveRun: Promise = Promise.resolve(); + private writesInFlight = 0; + /** Edit counter per `namespace|playlistId`, for re-reads to keep them. */ + private readonly edits = new Map(); + private editCount = 0; + /** Entries a delete is waiting on: they stay queued and are not sent. */ + private readonly held: ((key: string) => boolean)[] = []; + + constructor( + private readonly api: ServerApi, + private readonly hooks: UserDataQueueHooks + ) {} + + get lastEdit(): number { + return this.editCount; + } + + hasUnsent(): boolean { + return this.pending.size > 0 || this.inFlight.size > 0; + } + + /** What the server has not confirmed, the newest write per entry. */ + unsentWrites(): PendingEntry[] { + return newestWrites([ + ...this.inFlight.values(), + ...this.pending.values(), + ]); + } + + unconfirmedKeys(): string[] { + return [...this.pending.keys(), ...this.inFlight.keys()]; + } + + /** Entries a re-read must not replace: unconfirmed or edited since. */ + unsavedSince(edit: number): Set { + const keys = new Set(this.unconfirmedKeys()); + for (const [key, made] of this.edits) { + if (made > edit) { + keys.add(key); + } + } + return keys; + } + + /** Forgets edit numbers a re-read has covered. */ + settledUpTo(edit: number): void { + for (const [key, made] of this.edits) { + if ( + made <= edit && + !this.pending.has(key) && + !this.inFlight.has(key) + ) { + this.edits.delete(key); + } + } + } + + /** + * Queues an edit (`before` → `value`). A newer edit of an entry still + * queued joins it; one behind a travelling write keeps that write's + * steps (marked as sent) and tag. A `foreign` edit was made on another + * tab's unsent change: its write must conflict and be replayed. + */ + queue( + namespace: ServerUserStateNamespace, + playlistId: string, + value: unknown, + before: unknown, + base: { + readonly tag?: string | null; + readonly steps?: readonly EditStep[]; + readonly foreign?: boolean; + } = {} + ): void { + const key = `${namespace}|${playlistId}`; + const queued = this.pending.get(key); + const travelling = this.inFlight.get(key); + const edit = ++this.editCount; + let entry: PendingEntry; + if (queued) { + entry = { + ...queued, + value, + edit, + steps: addStep(queued.steps, before, value), + }; + } else if (travelling) { + entry = { + ...travelling, + value, + edit, + steps: [...behind(travelling), { before, after: value }], + }; + } else { + entry = { + namespace, + playlistId, + value, + edit, + tag: + base.tag !== undefined + ? base.tag + : this.tags.of(namespace, playlistId), + steps: base.steps + ? uncertain(base.steps) + : [{ before, after: value }], + }; + } + if (base.foreign) { + entry = { ...entry, tag: UNKNOWN_TAG }; + } + this.pending.set(key, entry); + this.edits.set(key, edit); + } + + /** The write of an entry: queued, else travelling. */ + unsent(key: string): PendingEntry | undefined { + return this.pending.get(key) ?? this.inFlight.get(key); + } + + isQueued(key: string): boolean { + return this.pending.has(key); + } + + /** Queues `entry` as this tab's newest edit of it. */ + put(entry: Omit): void { + const key = entryKey(entry as PendingEntry); + const edit = ++this.editCount; + this.pending.set(key, { ...entry, edit }); + this.edits.set(key, edit); + } + + scheduleFlush(): void { + if (this.pending.size === 0) { + return; + } + // Saved at once: another tab's re-read must not replace them. + this.hooks.changed(); + if (this.flushTimer !== null) { + return; + } + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flush(); + }, FLUSH_DELAY_MS); + } + + /** Sends the queued entries after any batch still travelling. */ + flush(): Promise { + const run = this.sending.then(async () => { + // A page-hide batch that went out at once in the meantime is + // not chained on this one: its writes land first. + await this.keepaliveRun; + const batch = this.takePending(); + if (batch.length === 0) { + return; + } + for (const entry of batch) { + this.inFlight.set(entryKey(entry), entry); + } + this.writesInFlight += 1; + try { + await Promise.all(batch.map((entry) => this.send(entry))); + } finally { + this.writesInFlight -= 1; + } + }); + this.sending = run.then( + () => undefined, + () => undefined + ); + return this.sending; + } + + /** + * Page hide: `keepalive` requests survive the page. They go out at once + * unless a write is still travelling, which they must not overtake, and + * later writes wait for them in turn. + */ + flushWithKeepalive(): void { + // Saved first: the page may be gone before anything is answered. + this.hooks.changed(); + if (this.pending.size === 0) { + return; + } + // Idle: send synchronously, while the hiding page still runs. Behind + // a travelling write the entries stay queued until it is answered, + // so the version it created can still become their baseline. + const now = this.writesInFlight === 0; + const run = now + ? this.sendKeepaliveBatch() + : this.sending.then(() => this.sendKeepaliveBatch()); + this.sending = run.then( + () => undefined, + () => undefined + ); + if (now) { + this.keepaliveRun = this.sending; + } + } + + /** + * Keeps the entries whose key `matches` queued but unsent until the + * returned release is called (a delete is running). + */ + hold(matches: (key: string) => boolean): () => void { + this.held.push(matches); + return () => { + this.held.splice(this.held.indexOf(matches), 1); + this.scheduleFlush(); + }; + } + + /** Resolves once the writes travelling now have been answered. */ + whenSent(): Promise { + return this.sending; + } + + /** + * After a delete: forgets the queued edits whose key `matches`, their + * edit numbers and their tags (all tags without a playlist id). + */ + drop(matches: (key: string) => boolean, playlistId?: string): void { + for (const store of [this.pending, this.edits]) { + for (const key of [...store.keys()]) { + if (matches(key)) { + store.delete(key); + } + } + } + this.tags.forget(playlistId); + this.hooks.changed(); + } + + private takePending(): PendingEntry[] { + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.hooks.maySend?.() === false) { + return []; + } + const batch: PendingEntry[] = []; + for (const [key, entry] of this.pending) { + if (!this.held.some((matches) => matches(key))) { + batch.push(entry); + this.pending.delete(key); + } + } + return batch; + } + + /** + * Back into the queue after a failed write, for the offline retry or + * the next edit, unless the entry was edited since it was sent. + */ + private requeue(entry: PendingEntry): void { + const key = entryKey(entry); + if (this.edits.get(key) === entry.edit && !this.pending.has(key)) { + this.pending.set(key, { ...entry, steps: uncertain(entry.steps) }); + } + } + + /** A newer write of the same entry may have replaced it meanwhile. */ + private retireInFlight(entry: PendingEntry): void { + const key = entryKey(entry); + if (this.inFlight.get(key)?.edit === entry.edit) { + this.inFlight.delete(key); + } + } + + private async sendKeepaliveBatch(): Promise { + const entries = this.takePending(); + if (entries.length === 0) { + return; + } + for (const entry of entries) { + this.inFlight.set(entryKey(entry), entry); + } + this.hooks.changed(); + this.writesInFlight += 1; + try { + await Promise.all( + entries.map((entry) => this.sendKeepalive(entry)) + ); + } finally { + this.writesInFlight -= 1; + } + } + + private async sendKeepalive(entry: PendingEntry): Promise { + const path = entryPath(entry); + const headers = preconditionHeaders(entry.tag); + const outcome = await (entry.value === undefined + ? this.api.sendKeepalive('DELETE', path, undefined, headers) + : this.api.sendKeepalive( + 'PUT', + path, + { value: entry.value }, + headers + )); + this.retireInFlight(entry); + if (outcome.status === 'saved') { + this.confirmed(entry, outcome.tag); + } else if (outcome.status === 'conflict') { + this.replayOnConflict(entry, outcome.current); + } else if (outcome.status === 'gone') { + this.gone(entry); + } else { + this.requeue(entry); + } + this.hooks.changed(); + } + + private async send(entry: PendingEntry): Promise { + const headers = preconditionHeaders(entry.tag); + try { + const response = + entry.value === undefined + ? await this.api.request(entryPath(entry), { + method: 'DELETE', + headers, + }) + : await this.api.sendJson( + 'PUT', + entryPath(entry), + { value: entry.value }, + headers + ); + const { tag } = await this.api + .readJson>(response) + .catch(() => ({ tag: undefined })); + this.retireInFlight(entry); + this.confirmed(entry, tag); + } catch (error) { + this.retireInFlight(entry); + if ( + error instanceof ServerApiError && + error.status === 412 && + error.current + ) { + this.replayOnConflict(entry, error.current); + } else if ( + error instanceof ServerApiError && + error.code === PLAYLIST_MISSING + ) { + this.gone(entry); + } else { + this.requeue(entry); + this.hooks.writeFailed(error); + } + } + this.hooks.changed(); + } + + /** The playlist was deleted elsewhere: its edit goes with it. */ + private gone(entry: PendingEntry): void { + const key = entryKey(entry); + if (this.edits.get(key) === entry.edit) { + this.edits.delete(key); + this.hooks.show(entry.namespace, entry.playlistId, undefined); + } + } + + /** The server took `entry`: the edit queued behind it builds on it. */ + /** + * `entry` landed. Without a readable tag (a body cut off after its + * headers) a conditional write leaves the version unknown: a newer edit + * drops the landed steps all the same and conflicts, so only the edits + * made after it replay. An unconditional one stays unconditional. + */ + private confirmed( + entry: PendingEntry, + read: string | null | undefined + ): void { + const tag = + read !== undefined || entry.tag === undefined ? read : UNKNOWN_TAG; + if (tag !== undefined) { + this.tags.update(entry.namespace, entry.playlistId, tag); + } + const key = entryKey(entry); + const next = this.pending.get(key); + if (next?.steps.some((step) => step.sent === entry.edit)) { + // A foreign edit stays unknown: its baseline is not this value. + this.pending.set(key, { + ...next, + steps: next.steps.filter((step) => step.sent !== entry.edit), + tag: + next.tag === UNKNOWN_TAG || tag === undefined + ? next.tag + : tag, + }); + } + } + + /** + * Another device changed the entry and `entry` did not land: replay its + * steps on the server's current value (with the newer queued edit that + * carries them, if any), show the result and send it on that version. + */ + private replayOnConflict( + entry: PendingEntry, + current: ServerUserStateCurrent + ): void { + this.tags.update(entry.namespace, entry.playlistId, current.tag); + const key = entryKey(entry); + const latest = + this.pending.get(key) ?? + (this.edits.get(key) === entry.edit ? entry : undefined); + if (!latest) { + return; + } + const merged = replaySteps(current.value, latest.steps); + this.hooks.show(entry.namespace, entry.playlistId, merged); + if ( + latest === entry && + JSON.stringify(merged) === JSON.stringify(current.value) + ) { + return; + } + // A closed tab's steps keep giving way to newer changes at the next + // conflict; this page's own become one edit on top of them. + const yielding = latest.steps.filter((step) => step.yields); + this.pending.set(key, { + ...latest, + value: merged, + tag: current.tag, + steps: [ + ...yielding, + { before: replaySteps(current.value, yielding), after: merged }, + ], + }); + this.scheduleFlush(); + } +} diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts b/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts new file mode 100644 index 000000000..7f8a4b7ce --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts @@ -0,0 +1,784 @@ +import type { KeepaliveOutcome } from './server-api-client'; +import { ServerUserDataStorage } from './server-user-data-storage'; + +const FAVORITES_CACHE = 'iptvnator-server:cache:default:xtream-favorites'; + +function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + keys: () => [...values.keys()], + }; +} + +/** Lets chained promises run without advancing the fake timers. */ +/** The saved writes of the one page load that has any, for `instanceId`. */ +function savedWrites( + local: ReturnType, + instanceId = 'default' +): unknown[] { + const prefix = `iptvnator-server:unsent-writes:${instanceId}:`; + const key = [...local.values.keys()].find((name) => + name.startsWith(prefix) + ); + return key ? JSON.parse(local.values.get(key) ?? '[]') : []; +} + +async function settle(): Promise { + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } +} + +describe('ServerUserDataStorage', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function setup() { + const local = memoryStorage(); + const api = { + getJson: jest.fn().mockResolvedValue({ + entries: { + 'xtream-favorites': { p1: ['live:1'] }, + 'xtream-recent-items': {}, + 'xtream-collection-items': {}, + 'xtream-playback-positions': {}, + }, + }), + sendJson: jest.fn().mockResolvedValue(undefined), + request: jest.fn().mockResolvedValue(undefined), + sendKeepalive: jest.fn().mockResolvedValue({ status: 'saved' }), + readJson: jest.fn().mockResolvedValue({}), + }; + const onWriteFailure = jest.fn(); + const storage = new ServerUserDataStorage( + api as never, + local, + onWriteFailure + ); + return { api, local, storage, onWriteFailure }; + } + + it('hydrates synced keys into a prefixed cache', async () => { + const { local, storage } = setup(); + + await storage.hydrate(); + + expect(storage.getItem('xtream-favorites')).toBe('{"p1":["live:1"]}'); + expect(local.values.get(FAVORITES_CACHE)).toBe('{"p1":["live:1"]}'); + expect(local.values.has('xtream-favorites')).toBe(false); + }); + + it('sends only changed and removed playlist entries', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + + storage.setItem('xtream-favorites', JSON.stringify({ p2: ['live:9'] })); + await jest.runOnlyPendingTimersAsync(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p2', + { value: ['live:9'] }, + {} + ); + expect(api.request).toHaveBeenCalledWith( + '/user-state/xtream-favorites/p1', + { method: 'DELETE', headers: {} } + ); + expect(api.sendJson).toHaveBeenCalledTimes(1); + }); + + it('passes other keys to plain storage', () => { + const { api, local, storage } = setup(); + + storage.setItem('xtream-playlists', '[]'); + + expect(local.values.get('xtream-playlists')).toBe('[]'); + expect(api.sendJson).not.toHaveBeenCalled(); + }); + + it('flushes pending entries with keepalive', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + + storage.setItem('xtream-recent-items', JSON.stringify({ p1: [] })); + storage.flushWithKeepalive(); + + expect(api.sendKeepalive).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-recent-items/p1', + { value: [] }, + {} + ); + await jest.runOnlyPendingTimersAsync(); + expect(api.sendJson).not.toHaveBeenCalled(); + }); + + it('reports failed sends', async () => { + const { api, storage, onWriteFailure } = setup(); + await storage.hydrate(); + api.sendJson.mockRejectedValueOnce(new Error('offline')); + + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['live:2'] })); + await jest.runOnlyPendingTimersAsync(); + + expect(onWriteFailure).toHaveBeenCalled(); + }); + + it('keeps an edit made while a refresh is reading', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + let answer: (value: unknown) => void = () => undefined; + api.getJson.mockReturnValueOnce( + new Promise((resolve) => (answer = resolve)) + ); + + const refresh = storage.hydrate(); + await settle(); + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1'], p2: ['live:2'] }) + ); + // The server's older snapshot arrives after the edit. + answer({ entries: { 'xtream-favorites': { p1: ['live:1'] } } }); + await refresh; + + expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({ + p1: ['live:1'], + p2: ['live:2'], + }); + await jest.runOnlyPendingTimersAsync(); + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p2', + { value: ['live:2'] }, + {} + ); + + // The next edit does not send a delete for the kept entry. + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1', 'live:3'], p2: ['live:2'] }) + ); + await jest.runOnlyPendingTimersAsync(); + expect(api.request).not.toHaveBeenCalledWith( + '/user-state/xtream-favorites/p2', + { method: 'DELETE', headers: {} } + ); + }); + + it('sends a newer batch only after the older one finished', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + const order: string[] = []; + let finishFirst: () => void = () => undefined; + api.sendJson + .mockImplementationOnce( + (_method: string, _path: string, body: { value: unknown }) => { + order.push(`start ${JSON.stringify(body.value)}`); + return new Promise((resolve) => { + finishFirst = () => { + order.push('end first'); + resolve(); + }; + }); + } + ) + .mockImplementation( + (_method: string, _path: string, body: { value: unknown }) => { + order.push(`start ${JSON.stringify(body.value)}`); + return Promise.resolve(); + } + ); + + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['a'] })); + const first = storage.flush(); + await settle(); + // A newer edit while the first batch is still travelling. + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['b'] })); + const second = storage.flush(); + await settle(); + expect(order).toEqual(['start ["a"]']); + + finishFirst(); + await first; + await second; + expect(order).toEqual(['start ["a"]', 'end first', 'start ["b"]']); + }); + + it('keeps a failed write for the next flush and through a re-read', async () => { + const { api, storage, onWriteFailure } = setup(); + await storage.hydrate(); + api.sendJson.mockRejectedValueOnce(new Error('offline')); + + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1', 'live:2'] }) + ); + await storage.flush(); + expect(onWriteFailure).toHaveBeenCalled(); + + // Back online: the re-read sends it first, so the server has it. + api.getJson.mockResolvedValueOnce({ + entries: { 'xtream-favorites': { p1: ['live:1', 'live:2'] } }, + }); + await storage.hydrate(); + expect(api.sendJson).toHaveBeenLastCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['live:1', 'live:2'] }, + {} + ); + expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({ + p1: ['live:1', 'live:2'], + }); + }); + + it('keeps an unsent edit when a re-read succeeds while writes fail', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + api.sendJson.mockRejectedValue(new Error('500')); + + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1', 'live:2'] }) + ); + await storage.hydrate(); + + expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({ + p1: ['live:1', 'live:2'], + }); + }); + + it('holds keepalive writes until a travelling batch finished', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + let finish: () => void = () => undefined; + api.sendJson.mockReturnValueOnce( + new Promise((resolve) => (finish = resolve)) + ); + + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['a'] })); + const travelling = storage.flush(); + await settle(); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['b'] })); + storage.flushWithKeepalive(); + expect(api.sendKeepalive).not.toHaveBeenCalled(); + + finish(); + await travelling; + await settle(); + expect(api.sendKeepalive).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['b'] }, + {} + ); + }); + + it('queues a page-hide write again when the server did not take it', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + api.sendKeepalive.mockResolvedValueOnce({ status: 'failed' }); + + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['big'] })); + storage.flushWithKeepalive(); + await settle(); + + // Back on the page: the re-read sends it normally and keeps it. + api.getJson.mockResolvedValueOnce({ + entries: { 'xtream-favorites': { p1: ['big'] } }, + }); + await storage.hydrate(); + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['big'] }, + {} + ); + }); + + it('runs re-reads one after another', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + let answerFirst: (value: unknown) => void = () => undefined; + api.getJson.mockReturnValueOnce( + new Promise((resolve) => (answerFirst = resolve)) + ); + + const first = storage.hydrate(); + const second = storage.hydrate(); + await settle(); + expect(api.getJson).toHaveBeenCalledTimes(2); + + answerFirst({ entries: {} }); + await first; + 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: (outcome: KeepaliveOutcome) => void = () => undefined; + api.sendKeepalive.mockReturnValueOnce( + new Promise((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({ status: 'saved' }); + await newer; + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['new'] }, + {} + ); + }); + + it('applies an edit made before the first read to the server value', async () => { + const { api, local, storage } = setup(); + // The cache from the last visit; no read from the server yet. + local.setItem( + FAVORITES_CACHE, + JSON.stringify({ p1: ['a'], p2: ['b'] }) + ); + + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['a', 'c'], p2: ['b'] }) + ); + await storage.flush(); + expect(api.sendJson).not.toHaveBeenCalled(); + + // Meanwhile another device added 'live:1' and 'z'. + api.getJson.mockResolvedValueOnce({ + entries: { + 'xtream-favorites': { p1: ['live:1', 'a'], p2: ['b', 'z'] }, + }, + }); + await storage.hydrate(); + await storage.flush(); + + expect(api.sendJson.mock.calls).toEqual([ + [ + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['live:1', 'a', 'c'] }, + {}, + ], + ]); + expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({ + p1: ['live:1', 'a', 'c'], + p2: ['b', 'z'], + }); + }); + + it('keeps the server favorites when a new device adds one early', async () => { + const { api, storage } = setup(); + let answer: (value: unknown) => void = () => undefined; + api.getJson.mockReturnValueOnce( + new Promise((resolve) => (answer = resolve)) + ); + + const firstRead = storage.hydrate(); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['live:9'] })); + answer({ + entries: { 'xtream-favorites': { p1: ['live:1', 'live:2'] } }, + }); + await firstRead; + await storage.flush(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['live:1', 'live:2', 'live:9'] }, + {} + ); + }); + + it('never retries an older page-hide write over a newer one', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + const acks: ((outcome: KeepaliveOutcome) => void)[] = []; + api.sendKeepalive.mockImplementation( + () => new Promise((resolve) => acks.push(resolve)) + ); + + for (const value of ['A', 'B', 'C']) { + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [value] }) + ); + storage.flushWithKeepalive(); + await settle(); + // Each page-hide write waits for the previous one. + acks[acks.length - 1]({ + status: value === 'B' ? 'failed' : 'saved', + }); + await settle(); + } + + await storage.flush(); + expect(api.sendJson).not.toHaveBeenCalled(); + }); + + 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. + api.getJson.mockResolvedValueOnce({ + entries: { + 'xtream-favorites': { p1: ['live:1'], p2: ['live:2'] }, + }, + }); + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key === FAVORITES_CACHE) { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + await storage.hydrate(); + + // 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({ ...current, p1: ['live:1', 'live:3'] }) + ); + await storage.flush(); + local.setItem = setItem; + + expect(api.request).not.toHaveBeenCalled(); + 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 () => { + const first = setup(); + await first.storage.hydrate(); + first.api.sendJson.mockRejectedValue(new Error('offline')); + first.storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1', 'live:9'] }) + ); + await first.storage.flush(); + expect(first.storage.hasUnsentWrites()).toBe(true); + expect(savedWrites(first.local)).toMatchObject([ + { + namespace: 'xtream-favorites', + playlistId: 'p1', + value: ['live:1', 'live:9'], + }, + ]); + + // Reload: the page unloads, a new instance over the same storage. + first.storage.close(); + const api = { + ...first.api, + sendJson: jest.fn().mockResolvedValue(undefined), + getJson: jest.fn().mockResolvedValue({ + entries: { 'xtream-favorites': { p1: ['live:1', 'live:9'] } }, + }), + }; + const reloaded = new ServerUserDataStorage( + api as never, + first.local, + jest.fn() + ); + await reloaded.hydrate(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['live:1', 'live:9'] }, + {} + ); + expect(reloaded.hasUnsentWrites()).toBe(false); + expect(savedWrites(first.local)).toEqual([]); + }); + + it('saves a write still travelling when the page hides', async () => { + const { api, local, storage } = setup(); + await storage.hydrate(); + api.sendJson.mockReturnValueOnce(new Promise(() => undefined)); + + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['x'] })); + void storage.flush(); + await settle(); + storage.flushWithKeepalive(); + + expect(savedWrites(local)).toMatchObject([ + { namespace: 'xtream-favorites', playlistId: 'p1', value: ['x'] }, + ]); + }); + + it('drops cache and saved writes of another server database', () => { + const local = memoryStorage(); + local.setItem('iptvnator-server:cache-owner', 'old-db'); + local.setItem( + 'iptvnator-server:cache:old-db:xtream-favorites', + '{"p1":["a"]}' + ); + // An earlier build's unscoped key. + local.setItem('iptvnator-server:xtream-favorites', '{"p1":["a"]}'); + local.setItem( + 'iptvnator-server:unconfirmed:old-db:tab', + JSON.stringify({ at: 0, keys: ['xtream-favorites|p1'] }) + ); + local.setItem( + 'iptvnator-server:unsent-writes:old-db:tab', + JSON.stringify([ + { + namespace: 'xtream-favorites', + playlistId: 'p1', + value: ['a'], + }, + ]) + ); + + const storage = new ServerUserDataStorage( + {} as never, + local, + jest.fn(), + 'new-db' + ); + + expect(storage.hasUnsentWrites()).toBe(false); + expect(local.values.has('iptvnator-server:xtream-favorites')).toBe( + false + ); + expect( + local.values.has('iptvnator-server:cache:old-db:xtream-favorites') + ).toBe(false); + expect(savedWrites(local, 'old-db')).toEqual([]); + expect( + local.values.has('iptvnator-server:unconfirmed:old-db:tab') + ).toBe(false); + expect(local.values.get('iptvnator-server:cache-owner')).toBe('new-db'); + }); + + it('tells, once a first read in flight settled, whether it read the server', async () => { + const { api, storage } = setup(); + api.getJson.mockRejectedValueOnce(new Error('offline')); + + const failed = storage.hydrate().catch(() => undefined); + await expect(storage.ready()).resolves.toBe(false); + await failed; + // It starts no read of its own: an offline page is not held up. + expect(api.getJson).toHaveBeenCalledTimes(1); + + const read = storage.hydrate(); + await expect(storage.ready()).resolves.toBe(true); + await read; + await expect(storage.ready()).resolves.toBe(true); + expect(api.getJson).toHaveBeenCalledTimes(2); + }); + + it('reads the server again when a backup asks for fresh data', async () => { + const { api, storage } = setup(); + await storage.hydrate(); + api.getJson.mockClear(); + + // Another device may have changed it since: read again. + await expect(storage.ready(true)).resolves.toBe(true); + expect(api.getJson).toHaveBeenCalledTimes(1); + + api.getJson.mockRejectedValueOnce(new Error('offline')); + await expect(storage.ready(true)).resolves.toBe(false); + // Without `fresh` an earlier read is enough. + await expect(storage.ready()).resolves.toBe(true); + expect(api.getJson).toHaveBeenCalledTimes(2); + }); + + it("never writes over another server instance's cache", async () => { + const local = memoryStorage(); + const tab = (instanceId: string, favorites: unknown) => + new ServerUserDataStorage( + { + getJson: jest.fn().mockResolvedValue({ + entries: { 'xtream-favorites': favorites }, + }), + } as never, + local, + jest.fn(), + instanceId + ); + // The tab of the old backend stays open after a new one started. + const old = tab('old-db', { p1: ['old'] }); + const current = tab('new-db', { p1: ['new'] }); + await current.hydrate(); + + await old.hydrate(); + + expect(current.getItem('xtream-favorites')).toBe('{"p1":["new"]}'); + }); + + it('keeps a newer travelling write when an older one is answered', async () => { + const { api, local, storage } = setup(); + await storage.hydrate(); + let answerOlder: () => void = () => undefined; + api.sendJson.mockReturnValueOnce( + new Promise((resolve) => (answerOlder = resolve)) + ); + api.sendKeepalive.mockReturnValueOnce(new Promise(() => undefined)); + + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['old'] })); + const older = storage.flush(); + await settle(); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['new'] })); + storage.flushWithKeepalive(); + answerOlder(); + await older; + await settle(); + + expect(storage.hasUnsentWrites()).toBe(true); + expect(savedWrites(local)).toMatchObject([ + { namespace: 'xtream-favorites', playlistId: 'p1', value: ['new'] }, + ]); + }); + + it('keeps two early additions to an empty entry', async () => { + const { api, storage } = setup(); + let answer: (value: unknown) => void = () => undefined; + api.getJson.mockReturnValueOnce( + new Promise((resolve) => (answer = resolve)) + ); + + const firstRead = storage.hydrate(); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A'] })); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A', 'B'] })); + answer({ entries: { 'xtream-favorites': { p1: ['S'] } } }); + await firstRead; + await storage.flush(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['S', 'A', 'B'] }, + {} + ); + }); + + it('keeps early edits across a reload before the first read', async () => { + const first = setup(); + first.api.getJson.mockReturnValueOnce(new Promise(() => undefined)); + void first.storage.hydrate(); + first.storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['mine'] }) + ); + first.storage.close(); + + const api = { + ...first.api, + sendJson: jest.fn().mockResolvedValue(undefined), + getJson: jest.fn().mockResolvedValue({ + entries: { 'xtream-favorites': { p1: ['theirs'] } }, + }), + }; + const reloaded = new ServerUserDataStorage( + api as never, + first.local, + jest.fn() + ); + await reloaded.hydrate(); + await reloaded.flush(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['theirs', 'mine'] }, + {} + ); + }); + + it("does not replace another tab's unconfirmed edit on a re-read", async () => { + 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({ status: 'saved' }), + readJson: jest.fn().mockResolvedValue({}), + }; + 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'] }) + ); + await tabB.hydrate(); + + expect(JSON.parse(tabB.getItem('xtream-favorites') ?? '')).toEqual({ + p1: ['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(FAVORITES_CACHE, '{"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 === FAVORITES_CACHE) { + 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'] }, + {} + ); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts new file mode 100644 index 000000000..4970b20e7 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -0,0 +1,534 @@ +import { + isServerUserStateNamespace, + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, + type ServerUserStateResponse, +} from '@iptvnator/shared/interfaces'; +import type { UserDataStorage } from '../user-data-storage'; +import type { ServerApiClient } from './server-api-client'; +import { + addStep, + idMap, + parseMap, + splitEditKey, + UNKNOWN_TAG, +} from './server-user-data-entries'; +import { + adoptPageEdits, + extendUnsent, + restorePage, + settleWrites, +} from './server-user-data-adopt'; +import { keepPages, swapPage } from './server-page-pointer'; +import { UserDataCache } from './server-user-data-cache'; +import { + changedEntries, + type DeferredEdit, + mergeEdits, + refreshedNamespace, + withoutDeleted, +} from './server-user-data-edit'; +import { type ServerApi, UserDataWriteQueue } from './server-user-data-queue'; +import { + claimCache, + hasRecord, + markClosed, + markUnconfirmed, + orphanedPages, + pageIsLive, + othersUnconfirmed, + removePage, + saveDeferredEdits, + saveUnsentWrites, +} from './server-unsent-writes'; + +/** + * Write-through `USER_DATA_STORAGE` for server mode. Synced namespaces are + * maps keyed by playlist id; a write compares the new map with the one the + * caller read and sends only the playlist entries that changed, each based + * on the server version it was read at. When another device changed the + * entry meanwhile, the server answers `412` and the edit is replayed on its + * current value (`UserDataWriteQueue`). Other keys use plain storage. + */ +export class ServerUserDataStorage implements UserDataStorage { + private readonly writes: UserDataWriteQueue; + private unsentSaved = false; + /** + * Edits made before the first successful read: the cache they were + * made against may be empty or old, so they are replayed on the + * server's value once it is known (`applyEdit`). + */ + private readonly deferred = new Map(); + private hydrated = false; + /** The previous page load in this tab, while it still looks live. */ + private awaited: string[] = []; + /** A record stands for this page: a stale one is adopted elsewhere. */ + private recorded = false; + /** Playlists deleted here, in order: a read sent earlier skips them. */ + private readonly deleted: (string | undefined)[] = []; + /** Closed tabs' page loads this one took over (once each). */ + private readonly adopted = new Set(); + /** Page loads taken over whose copy stays until this one saves. */ + private readonly retained = new Set(); + /** This page load: names its saved writes and its registry record. */ + private readonly tabId = Math.random().toString(36).slice(2); + private readonly cache: UserDataCache; + /** + * What `getItem` last handed out (or a write left) per namespace: the + * map a caller edits, so its write is compared with that, not with + * changes another tab or a re-read made to the cache since. + */ + private readonly handedOut = new Map< + ServerUserStateNamespace, + string | null + >(); + /** Re-reads run one at a time, so an older one never lands last. */ + private hydration: Promise = Promise.resolve(); + + constructor( + private readonly api: ServerApi & Pick, + private readonly local: UserDataStorage, + onWriteFailure: (error: unknown) => void, + /** Scopes the cache and saved writes to one server database. */ + private readonly instanceId = 'default', + /** + * The tab's own storage (`sessionStorage` in the app): remembers + * its page load, so a reload takes over the writes it saved. + */ + private readonly tabStore: UserDataStorage = local + ) { + this.cache = new UserDataCache(local, instanceId); + this.writes = new UserDataWriteQueue(api, { + changed: () => this.saveUnsent(), + show: (namespace, playlistId, value) => + this.cache.setEntry(namespace, playlistId, value), + writeFailed: onWriteFailure, + maySend: () => !this.superseded(), + }); + claimCache(local, instanceId); + const [previous, ...kept] = swapPage(tabStore, instanceId, this.tabId); + // Older page loads whose copies a reload kept come first. + const pages = previous ? [...kept, previous] : kept; + // A duplicated tab copies the page load of a tab that still runs, + // and the ones it keeps; so does a reload whose close flag could not + // be saved: `touch` adopts them once it no longer looks live. + if (previous && pageIsLive(local, instanceId, previous)) { + this.awaited = pages; + } else { + pages.forEach((page) => this.takeOver(page)); + } + } + + /** The page unloads: a reload of this tab may take its writes over. */ + close(): void { + markClosed(this.local, this.instanceId, this.tabId); + } + + /** + * A reload of this tab: the writes its last page load could not confirm + * go out with the first flush, and the first read keeps them. + */ + private takeOver(page: string): void { + restorePage( + this.writes, + this.deferred, + this.local, + this.instanceId, + page + ); + // Saved under this page load before the old one is forgotten; if + // storage refuses the copy, the old one stays. + const saved = [this.saveDeferred(), this.saveUnsent()].every(Boolean); + this.publishUnconfirmed(); + this.release(page, saved); + } + + /** + * Saved writes and early edits of closed tabs: shown on what this tab + * shows now and queued with its writes, then forgotten there. + */ + private adoptClosedTabs(): void { + for (const page of orphanedPages( + this.local, + this.instanceId, + this.tabId + ).filter((page) => !this.adopted.has(page))) { + this.adoptPage(page); + } + } + + /** Its edits go ahead of this tab's own, which may be newer. */ + private adoptPage(page: string): void { + adoptPageEdits(this.writes, this.cache, { + local: this.local, + instanceId: this.instanceId, + tabId: this.tabId, + page, + }); + this.adopted.add(page); + // The other page's copy goes only once this tab saved its own. + this.release(page, this.saveUnsent()); + this.writes.scheduleFlush(); + } + + /** Tells other tabs this one is alive and still sending its writes. */ + touch(): void { + const pages = this.awaited; + const owner = pages[pages.length - 1]; + if (owner && !pageIsLive(this.local, this.instanceId, owner)) { + // This page may have read and edited since: adopt, not restore. + // Each adopted page goes ahead of what is queued: newest first. + this.awaited = []; + [...pages].reverse().forEach((page) => this.adoptPage(page)); + } + // A tab with nothing to sync never re-reads, which is where closed + // tabs' saved writes are otherwise picked up. + if (this.hydrated) { + this.adoptClosedTabs(); + } + if (this.hasUnsentWrites()) { + this.publishUnconfirmed(); + } + } + + /** + * Back from the back-forward cache or a suspended background tab, where + * its record could go stale: false when another tab took its writes + * over meanwhile, which this page must then not send again (it reloads + * instead). + */ + resumed(): boolean { + this.publishUnconfirmed(); + return !this.superseded(); + } + + /** + * Its record is gone: another tab took this page's writes over, so + * nothing more of them goes out or is saved here. + */ + private superseded(): boolean { + return ( + this.recorded && !hasRecord(this.local, this.instanceId, this.tabId) + ); + } + + /** Not read yet, or holding writes the server has not taken. */ + needsSync(): boolean { + return !this.hydrated || this.hasUnsentWrites(); + } + + /** + * Once a first read in flight settled: whether reads show the server's + * data. Starts no read, so an offline page is not held up; `fresh` + * (backups) reads the server again and tells whether that succeeded. + */ + async ready(fresh = false): Promise { + if (fresh) { + return this.hydrate().then( + () => true, + () => false + ); + } + await (this.hydrated ? undefined : this.hydration); + return this.hydrated; + } + + /** Whether a write is queued, being retried, or still travelling. */ + hasUnsentWrites(): boolean { + return this.writes.hasUnsent() || this.deferred.size > 0; + } + + getItem(key: string): string | null { + if (!isServerUserStateNamespace(key)) { + return this.local.getItem(key); + } + const value = this.cache.read(key); + this.handedOut.set(key, value); + return value; + } + + setItem(key: string, value: string): void { + this.write(key, value); + } + + removeItem(key: string): void { + this.write(key, null); + } + + /** + * A caller's new map. When the cache changed since the caller read it + * (another tab, a re-read), only the entries it changed are applied to + * the cache as it is now; the rest stays. Other keys use plain storage. + */ + private write(namespace: string, value: string | null): void { + if (!isServerUserStateNamespace(namespace)) { + if (value === null) { + this.local.removeItem(namespace); + } else { + this.local.setItem(namespace, value); + } + return; + } + const current = this.cache.read(namespace); + const before = parseMap(current ?? '{}'); + const read = this.handedOut.has(namespace) + ? (this.handedOut.get(namespace) ?? null) + : current; + let next = parseMap(value ?? '{}'); + if (read !== current) { + next = mergeEdits(before, parseMap(read ?? '{}'), next); + value = JSON.stringify(next); + } + this.cache.write(namespace, value); + this.handedOut.set(namespace, value); + this.queueChanges(namespace, next, before); + } + + /** + * Replaces the cache with the server's data, after sending pending + * writes. Entries edited while the read was in flight, and entries whose + * write failed, keep their local value: the server does not have it yet. + */ + hydrate(): Promise { + const run = this.hydration.then(() => this.hydrateNow()); + this.hydration = run.then( + () => undefined, + () => undefined + ); + return run; + } + + /** Sends the queued entries after any batch still travelling. */ + readonly flush = (): Promise => this.writes.flush(); + + /** See `UserDataStorage.flushed`. */ + readonly flushed = (playlistId?: string) => + settleWrites(this.writes, playlistId); + + /** Page hide: sends the queue with requests that survive the page. */ + flushWithKeepalive(): void { + this.writes.flushWithKeepalive(); + } + + /** + * Runs the server delete of a playlist (without an id, of every + * playlist). Its user-data edits wait meanwhile and writes already + * travelling land first, so none recreates deleted data; a delete that + * succeeded drops its queued and early edits and its cache, a failed + * one leaves them to be sent as before. + */ + async deleting( + playlistId: string | undefined, + remove: () => Promise + ): Promise { + const matches = (key: string) => + playlistId === undefined || splitEditKey(key)[1] === playlistId; + const release = this.writes.hold(matches); + try { + await this.writes.whenSent(); + await remove(); + this.deleted.push(playlistId); + for (const key of [...this.deferred.keys()].filter(matches)) { + this.deferred.delete(key); + } + this.saveDeferred(); + this.writes.drop(matches, playlistId); + this.cache.forget(playlistId); + } finally { + release(); + } + } + + private async hydrateNow(): Promise { + // Edits from here on may miss the read: queued, or still travelling. + const editsBefore = this.writes.lastEdit; + const deletesBefore = this.deleted.length; + await this.writes.flush(); + const sent = this.cache.snapshot(); + const foreign = () => + othersUnconfirmed(this.local, this.instanceId, this.tabId); + const foreignBefore = foreign(); + const { entries, tags } = withoutDeleted( + await this.api.getJson('/user-state'), + this.deleted.slice(deletesBefore) + ); + const unsaved = this.writes.unsavedSince(editsBefore); + // In the shared cache: other tabs' writes unconfirmed when this read + // went out or now (one confirmed meanwhile is newer than its answer), + // and what a tab saved while it travelled. + for (const key of [ + ...foreignBefore, + ...foreign(), + ...this.cache.changedSince(sent), + ]) { + unsaved.add(key); + } + const deferred = [...this.deferred.values()]; + // This tab's own unsent writes (recovered after a reload): an early + // edit was made on them, not on the server's value. + const own = new Set(this.writes.unconfirmedKeys()); + const base = new Map(); + const refreshed = new Map>(); + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const keepCached = new Set(); + for (const key of unsaved) { + const [editedNamespace, playlistId] = splitEditKey(key); + if (editedNamespace === namespace) { + keepCached.add(playlistId); + } + } + const cached = this.cache.map(namespace); + const early = deferred.filter( + (edit) => edit.namespace === namespace + ); + for (const { playlistId } of early) { + const key = `${namespace}|${playlistId}`; + base.set( + key, + own.has(key) + ? this.writes.unsent(key)?.value + : entries[namespace]?.[playlistId] + ); + } + const value = refreshedNamespace( + entries[namespace] ?? {}, + cached, + keepCached, + early, + (playlistId) => base.get(`${namespace}|${playlistId}`) + ); + refreshed.set(namespace, value); + // A value too big for browser storage stays readable in memory, + // so callers and the diff baseline see the same map. + this.cache.write(namespace, JSON.stringify(value)); + if (tags) { + // A kept value is not what the server's tag describes: its + // next write must conflict and be replayed on the server's. + const fresh = idMap(tags[namespace]); + for (const playlistId of keepCached) { + fresh[playlistId] = UNKNOWN_TAG; + } + this.writes.tags.set(namespace, fresh); + } + } + this.writes.settledUpTo(editsBefore); + if (!this.hydrated) { + // From here edits go straight to the queue. + this.hydrated = true; + this.deferred.clear(); + for (const edit of deferred) { + const key = `${edit.namespace}|${edit.playlistId}`; + if (own.has(key)) { + extendUnsent(this.writes, key, edit.steps); + } else { + this.writes.queue( + edit.namespace, + edit.playlistId, + refreshed.get(edit.namespace)?.[edit.playlistId], + base.get(key) + ); + } + } + this.saveDeferred(); + this.writes.scheduleFlush(); + } + this.adoptClosedTabs(); + } + + private queueChanges( + namespace: ServerUserStateNamespace, + next: Record, + previous: Record + ): void { + // The cache shows other tabs' unsent changes too. + const foreign = this.hydrated + ? othersUnconfirmed(this.local, this.instanceId, this.tabId) + : new Set(); + const change = (playlistId: string, value: unknown) => { + if (this.hydrated) { + this.writes.queue( + namespace, + playlistId, + value, + previous[playlistId], + { foreign: foreign.has(`${namespace}|${playlistId}`) } + ); + return; + } + const key = `${namespace}|${playlistId}`; + this.deferred.set(key, { + namespace, + playlistId, + steps: addStep( + this.deferred.get(key)?.steps ?? [], + previous[playlistId], + value + ), + }); + this.saveDeferred(); + this.publishUnconfirmed(); + }; + for (const [playlistId, value] of changedEntries(previous, next)) { + change(playlistId, value); + } + this.writes.scheduleFlush(); + } + + /** Persists what the server has not confirmed; skips idle rewrites. */ + /** False when browser storage refused the copy. */ + private saveUnsent(): boolean { + if (this.superseded()) { + return false; + } + // A newer edit can be queued while an older one is still travelling. + const writes = this.writes.unsentWrites(); + if (writes.length === 0 && !this.unsentSaved) { + return true; + } + const saved = saveUnsentWrites( + this.local, + this.instanceId, + this.tabId, + writes + ); + this.unsentSaved = writes.length > 0 || !saved; + this.publishUnconfirmed(); + if (saved && this.retained.size > 0 && this.saveDeferred()) { + // This page's copy now stands for the ones it kept. + for (const page of this.retained) { + removePage(this.local, this.instanceId, page); + } + this.retained.clear(); + keepPages(this.tabStore, this.instanceId, this.tabId, []); + } + return saved; + } + + /** Forgets another page load's copy, or keeps it until this one saves. */ + private release(page: string, saved: boolean): void { + if (saved) { + removePage(this.local, this.instanceId, page); + } else { + this.retained.add(page); + keepPages(this.tabStore, this.instanceId, this.tabId, [ + ...this.retained, + ]); + } + } + + private saveDeferred(): boolean { + return saveDeferredEdits(this.local, this.instanceId, this.tabId, [ + ...this.deferred.values(), + ]); + } + + private publishUnconfirmed(): void { + // Superseded, it stays so: the record is not written again. + this.recorded = + this.superseded() || + markUnconfirmed(this.local, this.instanceId, this.tabId, [ + ...this.writes.unconfirmedKeys(), + ...this.deferred.keys(), + ]); + } +} diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts new file mode 100644 index 000000000..c2af32173 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -0,0 +1,1295 @@ +import { ServerApiError } from './server-api-client'; +import { UNCONFIRMED_TTL_MS } from './server-unsent-writes'; +import { ServerUserDataStorage } from './server-user-data-storage'; +import { fakeServer, memoryStorage } from './server-user-data.test-helpers'; + +const favorites = (storage: ServerUserDataStorage) => + JSON.parse(storage.getItem('xtream-favorites') ?? '{}'); + +describe('ServerUserDataStorage with other devices', () => { + it('replays an edit on what another device saved meanwhile', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['live:1'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + + // The TV adds live:2 after the phone read the entry. + server.state['xtream-favorites']['p1'] = ['live:1', 'live:2']; + phone.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['live:1', 'live:9'] }) + ); + await phone.flush(); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual([ + 'live:1', + 'live:2', + 'live:9', + ]); + expect(favorites(phone)).toEqual({ + p1: ['live:1', 'live:2', 'live:9'], + }); + }); + + it('replays a newer queued edit together with the refused one', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + server.state['xtream-favorites']['p1'] = ['S', 'Z']; + + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const first = phone.flush(); + phone.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X', 'Y'] }) + ); + await first; + await phone.flush(); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual([ + 'S', + 'Z', + 'X', + 'Y', + ]); + }); + + it('builds a queued edit on the version its predecessor created', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + + let answer: () => void = () => undefined; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + server.api.sendJson.mockImplementationOnce( + (...args: Parameters) => + new Promise( + (resolve) => (answer = () => resolve(send(...args))) + ) + ); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const first = phone.flush(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The first write is travelling when the second edit comes in. + phone.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X', 'Y'] }) + ); + answer(); + await first; + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X', 'Y']); + // No conflict round trip: the second write carried the new tag. + expect(server.api.sendJson).toHaveBeenCalledTimes(2); + }); + + it("never sends another tab's change to an entry it did not edit", async () => { + const server = fakeServer({ + 'xtream-favorites': { p1: ['a'], p2: ['b'] }, + }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ ...favorites(tabB), p1: ['a', 'b1'] }) + ); + tabA.setItem( + 'xtream-favorites', + JSON.stringify({ ...favorites(tabA), p2: ['b', 'a2'] }) + ); + await tabA.flush(); + + expect(server.api.sendJson.mock.calls.map(([, path]) => path)).toEqual([ + '/user-state/xtream-favorites/p2', + ]); + }); + + it('drops queued edits and cache once a playlist is deleted', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['a'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['a', 'b'] })); + + await phone.deleting('p1', async () => { + await phone.flush(); + delete server.state['xtream-favorites']['p1']; + }); + await phone.flush(); + + expect(server.api.sendJson).not.toHaveBeenCalled(); + expect(favorites(phone)).toEqual({}); + expect(phone.hasUnsentWrites()).toBe(false); + }); + + it('keeps the edits of a playlist whose delete failed', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['a'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['a', 'b'] })); + + await expect( + phone.deleting('p1', async () => { + throw new Error('offline'); + }) + ).rejects.toThrow('offline'); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['a', 'b']); + expect(favorites(phone)).toEqual({ p1: ['a', 'b'] }); + }); + + it("re-reads another tab's unsent value without the server's tag", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + // Tab A's favorite is not sent yet when another device saves one. + tabA.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + server.state['xtream-favorites']['p1'] = ['S', 'Z']; + + await tabB.hydrate(); + expect(favorites(tabB)).toEqual({ p1: ['S', 'X'] }); + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X', 'Y'] }) + ); + // Each conflict replays the edit on the newest value: the two tabs + // settle after a few rounds. + for (let round = 0; round < 4; round += 1) { + await tabB.flush(); + await tabA.flush(); + } + + expect(tabA.hasUnsentWrites() || tabB.hasUnsentWrites()).toBe(false); + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X', 'Y', 'Z']); + }); + + it('sends a page-hide edit on the version a travelling write created', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + + let answer: () => void = () => undefined; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + server.api.sendJson.mockImplementationOnce( + (...args: Parameters) => + new Promise( + (resolve) => (answer = () => resolve(send(...args))) + ) + ); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const first = phone.flush(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // X is removed again while its addition is travelling; the page hides. + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + phone.flushWithKeepalive(); + answer(); + await first; + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + expect(phone.hasUnsentWrites()).toBe(false); + }); + + it('removes an item again after a page-hide write added it', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + + let answer: () => void = () => undefined; + const keepalive = server.api.sendKeepalive.getMockImplementation(); + if (!keepalive) { + throw new Error('The fake server answers sendKeepalive'); + } + server.api.sendKeepalive.mockImplementationOnce( + (...args: Parameters) => + new Promise( + (resolve) => (answer = () => resolve(keepalive(...args))) + ) + ); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + phone.flushWithKeepalive(); + // Back on the page, X is removed while the page-hide write travels. + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + answer(); + await phone.flush(); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + expect(phone.hasUnsentWrites()).toBe(false); + }); + + it('recovers a removal queued behind a write whose answer was lost', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const open = () => + new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + const before = open(); + await before.hydrate(); + + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + // The server saves X, but the page reloads before the answer. + server.api.sendJson.mockImplementationOnce( + (...args: Parameters) => { + void send(...args); + return new Promise(() => undefined); + } + ); + before.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + void before.flush(); + await new Promise((resolve) => setTimeout(resolve, 0)); + before.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + + before.close(); + const reloaded = open(); + await reloaded.hydrate(); + await reloaded.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + expect(reloaded.hasUnsentWrites()).toBe(false); + }); + + it('replays a failed write it was queued behind on a conflict', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + + let fail: () => void = () => undefined; + server.api.sendJson.mockImplementationOnce( + () => + new Promise((_, reject) => { + fail = () => + reject(new ServerApiError(0, 'network', 'offline')); + }) + ); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const first = phone.flush(); + await new Promise((resolve) => setTimeout(resolve, 0)); + phone.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X', 'Y'] }) + ); + // X never reached the server; another device saves Z meanwhile. + server.state['xtream-favorites']['p1'] = ['S', 'Z']; + fail(); + await first; + await phone.flush(); + await phone.flush(); + + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X', 'Y', 'Z']); + }); + + it('keeps a removal made after another tab built on the unsent item', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + tabA.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + // Tab B sees X in the shared cache and adds Y next to it. + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...favorites(tabB).p1, 'Y'] }) + ); + // Tab A takes X back before anything was sent. + tabA.setItem( + 'xtream-favorites', + JSON.stringify({ + p1: favorites(tabA).p1.filter((id: string) => id !== 'X'), + }) + ); + for (let round = 0; round < 4; round += 1) { + await tabB.flush(); + await tabA.flush(); + } + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Y']); + }); + + it('sends the saved writes of a tab that was closed offline', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const offline = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue(new ServerApiError(0, 'network', 'offline')), + }; + const closed = new ServerUserDataStorage( + offline as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await closed.hydrate(); + closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await closed.flush(); + // The tab is gone: its record stops being renewed. + for (const [key, value] of shared.values) { + if (key.startsWith('iptvnator-server:unconfirmed:default:')) { + shared.setItem( + key, + JSON.stringify({ ...JSON.parse(value), at: 0 }) + ); + } + } + + const next = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await next.hydrate(); + await next.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + expect( + [...shared.values.keys()].filter((key) => + key.startsWith('iptvnator-server:unsent-writes:') + ) + ).toEqual([]); + }); + + it("keeps a removal of another tab's unsent item", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + tabA.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...favorites(tabB).p1, 'Y'] }) + ); + // Tab A removes the Y it sees from tab B before sending anything; + // tab B's write lands first (leaving a tab sends its writes). + tabA.setItem( + 'xtream-favorites', + JSON.stringify({ + p1: favorites(tabA).p1.filter((id: string) => id !== 'Y'), + }) + ); + await tabB.flush(); + await tabB.flush(); + await tabA.flush(); + await tabA.flush(); + + expect(tabA.hasUnsentWrites() || tabB.hasUnsentWrites()).toBe(false); + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X']); + }); + + it('reads and writes through memory when browser storage refuses', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const denied = () => { + throw new DOMException('denied', 'SecurityError'); + }; + const phone = new ServerUserDataStorage( + server.api as never, + { getItem: denied, setItem: denied, removeItem: denied }, + jest.fn() + ); + + await phone.hydrate(); + expect(favorites(phone)).toEqual({ p1: ['S'] }); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + + it('keeps a removal made after a write whose answer was lost', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + // The server saves X, but the answer never arrives. + server.api.sendJson.mockImplementationOnce( + async (...args: Parameters) => { + await send(...args); + throw new ServerApiError(0, 'network', 'offline'); + } + ); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await phone.flush(); + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + await phone.flush(); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + }); + + it("sends a closed tab's favorite this tab's cache already shows", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const offline = () => + Promise.reject(new ServerApiError(0, 'network', 'offline')); + let online = false; + const tabApi = { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online ? send(...args) : offline() + ), + }; + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const closed = new ServerUserDataStorage( + { ...server.api, sendJson: jest.fn(offline) } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await closed.hydrate(); + closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await closed.flush(); + const tab = new ServerUserDataStorage( + tabApi as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await tab.hydrate(); + tab.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...favorites(tab).p1, 'Y'] }) + ); + await tab.flush(); + + // Later the closed tab's record is stale; this tab is still offline. + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + await tab.hydrate(); + online = true; + await tab.flush(); + await tab.flush(); + clock.mockRestore(); + + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X', 'Y']); + }); + + it('applies only the edits of a caller that read before another tab saved', async () => { + const server = fakeServer({ + 'xtream-favorites': { p1: ['a'], p2: ['b'] }, + }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + const readByA = favorites(tabA); + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ ...favorites(tabB), p2: ['b', 'B2'] }) + ); + await tabB.flush(); + // Tab A writes back the map it read before tab B saved. + tabA.setItem( + 'xtream-favorites', + JSON.stringify({ ...readByA, p1: ['a', 'A1'] }) + ); + await tabA.flush(); + await tabA.flush(); + + expect(server.state['xtream-favorites']).toEqual({ + p1: ['a', 'A1'], + p2: ['b', 'B2'], + }); + expect(favorites(tabA)).toEqual({ p1: ['a', 'A1'], p2: ['b', 'B2'] }); + }); + + it("keeps this tab's newer progress over a closed tab's older one", async () => { + const at = (seconds: number) => [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: seconds, + }, + ]; + const server = fakeServer({ + 'xtream-playback-positions': { p1: at(10) }, + }); + const shared = memoryStorage(); + const offline = () => + Promise.reject(new ServerApiError(0, 'network', 'offline')); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const closed = new ServerUserDataStorage( + { ...server.api, sendJson: jest.fn(offline) } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await closed.hydrate(); + closed.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(20) }) + ); + await closed.flush(); + + // This tab starts while the server cannot be read: its edit waits. + let readable = false; + const tab = new ServerUserDataStorage( + { + ...server.api, + getJson: jest.fn(() => + readable ? server.api.getJson() : offline() + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await expect(tab.hydrate()).rejects.toBeInstanceOf(ServerApiError); + tab.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(30) }) + ); + // Later the closed tab's record is stale and the server answers. + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + readable = true; + await tab.hydrate(); + await tab.flush(); + await tab.flush(); + clock.mockRestore(); + + expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30)); + }); + + it('does not replay a saved edit whose answer was cut off', async () => { + const at = (id: number, seconds: number) => ({ + contentType: 'vod', + contentXtreamId: id, + positionSeconds: seconds, + }); + const server = fakeServer({ + 'xtream-playback-positions': { p1: [at(1, 10), at(2, 5)] }, + }); + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + let cutOff = true; + let answer = (): void => undefined; + const tab = new ServerUserDataStorage( + { + ...server.api, + // Saved, but the body with the new tag never arrives. + sendJson: jest.fn(async (...args: Parameters) => { + if (!cutOff) { + return send(...args); + } + cutOff = false; + await new Promise((resolve) => (answer = resolve)); + await send(...args); + return { + json: async () => { + throw new TypeError('body cut off'); + }, + } as unknown as Response; + }), + } as never, + memoryStorage(), + jest.fn(), + 'default', + memoryStorage() + ); + await tab.hydrate(); + tab.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: [at(1, 20), at(2, 5)] }) + ); + const saving = tab.flush(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + // Movie 2 changes here while that write travels. + tab.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: [at(1, 20), at(2, 6)] }) + ); + answer(); + await saving; + expect(server.state['xtream-playback-positions']['p1']).toEqual([ + at(1, 20), + at(2, 5), + ]); + // Another device saves movie 1 at 30 s. + server.state['xtream-playback-positions']['p1'] = [at(1, 30), at(2, 5)]; + await tab.flush(); + await tab.flush(); + + expect(server.state['xtream-playback-positions']['p1']).toEqual([ + at(1, 30), + at(2, 6), + ]); + }); + + it("keeps the last page load's saved writes when storage refuses the copy", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const offline = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue(new ServerApiError(0, 'network', 'offline')), + }; + const before = new ServerUserDataStorage( + offline as never, + local, + jest.fn(), + 'default', + tabStore + ); + await before.hydrate(); + before.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await before.flush(); + const saved = () => + [...local.values.keys()].filter((key) => + key.startsWith('iptvnator-server:unsent-writes:') + ); + expect(saved()).toHaveLength(1); + + // Storage is full now: the reloaded page cannot save its copy. + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key.startsWith('iptvnator-server:unsent-writes:')) { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + const kept = saved()[0]; + before.close(); + const reloaded = new ServerUserDataStorage( + offline as never, + local, + jest.fn(), + 'default', + tabStore + ); + + expect(reloaded.hasUnsentWrites()).toBe(true); + expect(saved()).toEqual([kept]); + + // Space again: this page's copy replaces the one it kept. + local.setItem = setItem; + reloaded.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...favorites(reloaded).p1, 'Y'] }) + ); + expect(saved()).toHaveLength(1); + expect(saved()).not.toEqual([kept]); + }); + + it('keeps a recovered write under an early edit made on it', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const offline = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue(new ServerApiError(0, 'network', 'offline')), + }; + const before = new ServerUserDataStorage( + offline as never, + local, + jest.fn(), + 'default', + tabStore + ); + await before.hydrate(); + before.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'old'] }) + ); + await before.flush(); + + // Reloaded: the recovered write fails once, the read works. + before.close(); + const send = server.api.sendJson.getMockImplementation(); + const api = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValueOnce( + new ServerApiError(0, 'network', 'offline') + ) + .mockImplementation(send), + }; + const reloaded = new ServerUserDataStorage( + api as never, + local, + jest.fn(), + 'default', + tabStore + ); + reloaded.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...favorites(reloaded).p1, 'new'] }) + ); + await reloaded.hydrate(); + await reloaded.flush(); + await reloaded.flush(); + + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'new', 'old']); + }); + + it('keeps an early removal of an item a recovered write adds', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const before = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue( + new ServerApiError(0, 'network', 'offline') + ), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await before.hydrate(); + before.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await before.flush(); + before.close(); + + const send = server.api.sendJson.getMockImplementation(); + const reloaded = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValueOnce( + new ServerApiError(0, 'network', 'offline') + ) + .mockImplementation(send), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + // X is taken back before the first read; another device adds Z. + reloaded.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] })); + server.state['xtream-favorites']['p1'] = ['S', 'Z']; + await reloaded.hydrate(); + await reloaded.flush(); + await reloaded.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Z']); + }); + + it('drops an edit of a playlist another device deleted', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + // Another device deletes the playlist before this edit is sent. + server.deleted.add('p1'); + delete server.state['xtream-favorites']['p1']; + + await phone.flush(); + await phone.flush(); + + expect(server.state['xtream-favorites']).toEqual({}); + expect(phone.hasUnsentWrites()).toBe(false); + expect(favorites(phone)).toEqual({}); + }); + + it('leaves a still running tab its saved writes when the tab is duplicated', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const offline = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue(new ServerApiError(0, 'network', 'offline')), + }; + const original = new ServerUserDataStorage( + offline as never, + local, + jest.fn(), + 'default', + tabStore + ); + await original.hydrate(); + original.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + await original.flush(); + + // A duplicated tab starts with a copy of the tab's own storage. + const copy = memoryStorage(); + for (const [key, value] of tabStore.values) { + copy.setItem(key, value); + } + const duplicate = new ServerUserDataStorage( + offline as never, + local, + jest.fn(), + 'default', + copy + ); + + expect(duplicate.hasUnsentWrites()).toBe(false); + expect(original.hasUnsentWrites()).toBe(true); + expect( + [...local.values.keys()].filter((key) => + key.startsWith('iptvnator-server:unsent-writes:') + ) + ).toHaveLength(1); + }); + + it('takes over a reload whose close flag was lost once it goes quiet', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const before = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue( + new ServerApiError(0, 'network', 'offline') + ), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await before.hydrate(); + before.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await before.flush(); + // Storage is full when the page unloads: the close flag is lost. + const setItem = local.setItem; + local.setItem = () => { + throw new DOMException('full', 'QuotaExceededError'); + }; + before.close(); + local.setItem = setItem; + + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + // It looks like a duplicated tab of a running one: nothing taken. + expect(reloaded.hasUnsentWrites()).toBe(false); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + expect(reloaded.hasUnsentWrites()).toBe(true); + await reloaded.hydrate(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + + describe('a reload whose close flag was lost', () => { + const at = (seconds: number) => [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: seconds, + }, + ]; + const offline = () => + Promise.reject(new ServerApiError(0, 'network', 'offline')); + + /** The last page load saves its edit, then unloads on a full disk. */ + async function lastPageLoad( + server: ReturnType, + local: ReturnType, + tabStore: ReturnType, + edit: (page: ServerUserDataStorage) => Promise + ) { + const page = new ServerUserDataStorage( + { ...server.api, sendJson: jest.fn(offline) } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await edit(page); + const setItem = local.setItem; + local.setItem = () => { + throw new DOMException('full', 'QuotaExceededError'); + }; + page.close(); + local.setItem = setItem; + } + + it('keeps the newer progress of the page that adopts it later', async () => { + const server = fakeServer({ + 'xtream-playback-positions': { p1: at(10) }, + }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + await page.hydrate(); + page.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(20) }) + ); + await page.flush(); + }); + let online = false; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const reloaded = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online ? send(...args) : offline() + ), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + reloaded.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(30) }) + ); + await reloaded.flush(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + online = true; + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-playback-positions']['p1']).toEqual( + at(30) + ); + }); + + it('sends its early edits after this page has read', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + // Never read: the edit stays an early one. + page.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + }); + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + + it("replays its writes on the server's value when this page never read it", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + await page.hydrate(); + page.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + await page.flush(); + }); + // Another device saves Z; this page's reads keep failing. + server.state['xtream-favorites']['p1'] = ['S', 'Z']; + const reloaded = new ServerUserDataStorage( + { ...server.api, getJson: jest.fn(offline) } as never, + local, + jest.fn(), + 'default', + tabStore + ); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X', 'Z']); + }); + }); + + it('keeps a playlist deleted while a re-read was on its way out of the cache', async () => { + const server = fakeServer({ + 'xtream-favorites': { p1: ['a'], p2: ['b'] }, + }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + const get = server.api.getJson.getMockImplementation(); + if (!get) { + throw new Error('The fake server answers getJson'); + } + let answer: () => void = () => undefined; + server.api.getJson.mockImplementationOnce(async () => { + // The server answers with what it had when the read was sent. + const before = await get(); + return new Promise((resolve) => (answer = () => resolve(before))); + }); + + const reading = phone.hydrate(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await phone.deleting('p1', async () => { + delete server.state['xtream-favorites']['p1']; + }); + answer(); + await reading; + + expect(favorites(phone)).toEqual({ p2: ['b'] }); + }); + + it('keeps a playlist whose id is __proto__', async () => { + const server = fakeServer({ + 'xtream-favorites': JSON.parse('{"__proto__":["a"],"p2":["b"]}'), + }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + expect(Object.keys(favorites(phone))).toEqual(['__proto__', 'p2']); + + const map = favorites(phone); + Object.defineProperty(map, '__proto__', { + value: ['a', 'c'], + enumerable: true, + }); + phone.setItem('xtream-favorites', JSON.stringify(map)); + await phone.flush(); + + expect( + Object.getOwnPropertyDescriptor( + server.state['xtream-favorites'], + '__proto__' + )?.value + ).toEqual(['a', 'c']); + expect(JSON.parse(JSON.stringify(favorites(phone)))).toEqual( + JSON.parse('{"__proto__":["a","c"],"p2":["b"]}') + ); + }); + + it('leaves saved writes without a record to the tab that saved them', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const tabStore = memoryStorage(); + const offline = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue(new ServerApiError(0, 'network', 'offline')), + }; + const open = new ServerUserDataStorage( + offline as never, + shared, + jest.fn(), + 'default', + tabStore + ); + await open.hydrate(); + open.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await open.flush(); + // Storage refused the record but kept the saved writes. + for (const key of [...shared.values.keys()]) { + if (key.startsWith('iptvnator-server:unconfirmed:')) { + shared.removeItem(key); + } + } + + const other = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await other.hydrate(); + await other.flush(); + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + + // The tab's own reload takes them over. + const reloaded = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + await reloaded.flush(); + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-user-data-tags.ts b/libs/services/src/lib/server-storage/server-user-data-tags.ts new file mode 100644 index 000000000..880793a3b --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-tags.ts @@ -0,0 +1,55 @@ +import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import { idMap } from './server-user-data-entries'; + +/** The server version tag of each user-state entry, by namespace. */ +export class UserStateTags { + /** Absent for a namespace until the first read. */ + private readonly tags = new Map< + ServerUserStateNamespace, + Record + >(); + + set( + namespace: ServerUserStateNamespace, + tags: Readonly> + ): void { + this.tags.set(namespace, idMap(tags)); + } + + /** `undefined` until the server's tags are known (or not offered). */ + of( + namespace: ServerUserStateNamespace, + playlistId: string + ): string | null | undefined { + const tags = this.tags.get(namespace); + return tags ? (tags[playlistId] ?? null) : undefined; + } + + /** `null`: the entry is gone. */ + update( + namespace: ServerUserStateNamespace, + playlistId: string, + tag: string | null + ): void { + const tags = this.tags.get(namespace); + if (!tags) { + return; + } + if (tag === null) { + delete tags[playlistId]; + } else { + tags[playlistId] = tag; + } + } + + /** After a delete: one playlist's tags, without an id all of them. */ + forget(playlistId?: string): void { + for (const [namespace, tags] of this.tags) { + if (playlistId === undefined) { + this.tags.set(namespace, {}); + } else { + delete tags[playlistId]; + } + } + } +} diff --git a/libs/services/src/lib/server-storage/server-user-data.test-helpers.ts b/libs/services/src/lib/server-storage/server-user-data.test-helpers.ts new file mode 100644 index 000000000..f946b543e --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data.test-helpers.ts @@ -0,0 +1,116 @@ +import { ServerApiError } from './server-api-client'; + +/** Browser storage in memory, with the `keys` listing tabs use. */ +export function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + keys: () => [...values.keys()], + }; +} + +/** A server that keeps entries, tags them by content and refuses stale tags. */ +export function fakeServer( + initial: Record> = {} +) { + const state: Record> = { + 'xtream-favorites': {}, + 'xtream-recent-items': {}, + 'xtream-collection-items': {}, + 'xtream-playback-positions': {}, + ...structuredClone(initial), + }; + /** Playlists deleted on the server: their user state is refused. */ + const deleted = new Set(); + const tagOf = (value: unknown) => + value === undefined + ? null + : `t${JSON.stringify(value).replace(/"/g, "'")}`; + const answer = (body: unknown) => + ({ json: async () => body }) as unknown as Response; + const write = ( + path: string, + headers: Record, + value?: unknown + ) => { + const [, , namespace, playlistId] = path.split('/'); + if (value !== undefined && deleted.has(playlistId)) { + throw new ServerApiError(404, 'playlist_missing', 'gone'); + } + const current = state[namespace][playlistId]; + const expected = + headers['If-None-Match'] === '*' + ? null + : headers['If-Match']?.replace(/^"|"$/g, ''); + if (expected !== undefined && expected !== tagOf(current)) { + throw new ServerApiError(412, 'conflict', 'changed', { + ...(current === undefined ? {} : { value: current }), + tag: tagOf(current), + }); + } + if (value === undefined) { + delete state[namespace][playlistId]; + } else { + state[namespace][playlistId] = value; + } + return answer({ tag: tagOf(value) }); + }; + const api = { + getJson: jest.fn(async () => { + const tags: Record> = {}; + for (const [namespace, entries] of Object.entries(state)) { + tags[namespace] = Object.fromEntries( + Object.entries(entries).map(([id, value]) => [ + id, + tagOf(value) as string, + ]) + ); + } + return { entries: structuredClone(state), tags }; + }), + sendJson: jest.fn( + async ( + _method: string, + path: string, + body: { value: unknown }, + headers: Record = {} + ) => write(path, headers, body.value) + ), + request: jest.fn( + async (path: string, init: { headers?: Record }) => + write(path, init.headers ?? {}) + ), + readJson: jest.fn((response: Response) => response.json()), + sendKeepalive: jest.fn( + async ( + _method: string, + path: string, + body?: { value: unknown }, + headers: Record = {} + ) => { + try { + const response = write(path, headers, body?.value); + const { tag } = await response.json(); + return { status: 'saved' as const, tag }; + } catch (error) { + if ( + error instanceof ServerApiError && + error.code === 'playlist_missing' + ) { + return { status: 'gone' as const }; + } + return error instanceof ServerApiError && error.current + ? { + status: 'conflict' as const, + current: error.current, + } + : { status: 'failed' as const }; + } + } + ), + }; + return { state, api, deleted }; +} diff --git a/libs/services/src/lib/user-data-storage.ts b/libs/services/src/lib/user-data-storage.ts index 647c2eaba..c36f7bfeb 100644 --- a/libs/services/src/lib/user-data-storage.ts +++ b/libs/services/src/lib/user-data-storage.ts @@ -1,10 +1,21 @@ import { InjectionToken } from '@angular/core'; -/** The slice of the Web Storage API that PWA user-data stores rely on. */ +/** + * The slice of the Web Storage API that PWA user-data stores rely on, plus + * the stored keys where the store can list them, and where writes reach a + * source later (server mode): `ready`, which waits for a first read in + * flight and tells whether one succeeded (`fresh`: reads again, for + * backups that must see the source's current data), and `flushed`, which sends what + * is queued and tells whether all of it (of one playlist) was taken. + */ export type UserDataStorage = Pick< Storage, 'getItem' | 'setItem' | 'removeItem' ->; +> & { + keys?(): string[]; + ready?(fresh?: boolean): Promise; + flushed?(playlistId?: string): Promise; +}; /** * `localStorage` behind an adapter that reads the global only inside each @@ -17,6 +28,26 @@ export function createBrowserUserDataStorage(): UserDataStorage { getItem: (key) => globalThis.localStorage.getItem(key), setItem: (key, value) => globalThis.localStorage.setItem(key, value), removeItem: (key) => globalThis.localStorage.removeItem(key), + keys: () => { + const storage = globalThis.localStorage; + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key !== null) { + keys.push(key); + } + } + return keys; + }, + }; +} + +/** 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), }; } diff --git a/libs/shared/interfaces/src/lib/server-storage.contract.ts b/libs/shared/interfaces/src/lib/server-storage.contract.ts index e4af9e3b7..f1e7359fb 100644 --- a/libs/shared/interfaces/src/lib/server-storage.contract.ts +++ b/libs/shared/interfaces/src/lib/server-storage.contract.ts @@ -36,6 +36,19 @@ export interface ServerInstanceInfo { readonly auth: 'none' | 'token'; } +/** + * `WWW-Authenticate` value of the access guard's 401. A provider error the + * proxy passes on never carries it, so a client can tell the two apart. + */ +export const SERVER_ACCESS_CHALLENGE = 'Bearer realm="iptvnator"'; + +/** + * Sent with every `PUT /v1/playlists/:id`: the document's ETag before that + * write. A client that kept the stored payload (`keepPayload=1`) may cache + * its copy under the new ETag only if its payload came from this one. + */ +export const SERVER_REPLACED_ETAG_HEADER = 'X-Replaced-ETag'; + export interface ServerStorageStatusResponse { readonly playlists: number; readonly databaseBytes: number; @@ -45,10 +58,30 @@ export interface ServerStorageStatusResponse { export interface ServerUserStateResponse { readonly entries: Record>; + /** + * Version tag of every entry. A write sends the tag it is based on + * (`If-Match: ""`, or `If-None-Match: *` for an entry the client + * saw absent); a stale tag gets `412` with the current entry. + */ + readonly tags?: Record>; +} + +/** Answer to a user-state write: the entry's new tag, `null` once deleted. */ +export interface ServerUserStateWriteResponse { + readonly tag: string | null; +} + +/** The entry as the server has it, sent with a `412` conflict. */ +export interface ServerUserStateCurrent { + /** Absent when the entry does not exist. */ + readonly value?: unknown; + readonly tag: string | null; } export interface ServerStorageErrorBody { readonly message: string; readonly status: number; readonly code?: string; + /** With `code: 'conflict'` (412): the user-state entry as it is now. */ + readonly current?: ServerUserStateCurrent; } diff --git a/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts b/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts index 4fcc23362..dbaadc3ec 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts @@ -26,6 +26,29 @@ export function isFullStalkerPortalUrl(url: string): boolean { return url.includes('/stalker_portal') || url.includes('/server/load.php'); } +/** + * A Stalker playlist saved before the explicit flag existed, with the flag + * set from its URL; any other playlist comes back unchanged. + */ +export function withExplicitStalkerPortalFlag< + T extends { + macAddress?: string; + isFullStalkerPortal?: boolean; + portalUrl?: string; + url?: string; + }, +>(playlist: T): T { + if (!playlist?.macAddress || playlist.isFullStalkerPortal !== undefined) { + return playlist; + } + return { + ...playlist, + isFullStalkerPortal: isFullStalkerPortalUrl( + playlist.portalUrl ?? playlist.url ?? '' + ), + }; +} + /** * Whether a playlist should use the full-portal auth lifecycle * (handshake, Bearer token, watchdog). diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html index 04dadb780..a27f49056 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html @@ -4,10 +4,7 @@ @if (primaryContextLinks().length > 0) { -

+
+ @if (serverState() !== 'inactive') { + + {{ serverIcon() }} + + + } { ?.tagName ).toBe('APP-WORKSPACE-SHELL-RAIL-LINKS'); }); + + describe('server status', () => { + const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', + } as const; + const indicator = (): HTMLElement | null => + fixture.nativeElement.querySelector( + '[data-test-id="rail-server-status"]' + ); + + it('hides the indicator without server storage', () => { + fixture.detectChanges(); + + expect(indicator()).toBeNull(); + }); + + it('shows the connection state and links to Settings → Server', () => { + const status = TestBed.inject(ServerStorageStatusService); + status.activate(instance, () => undefined); + fixture.detectChanges(); + + expect(indicator()?.dataset['state']).toBe('online'); + expect(indicator()?.getAttribute('href')).toBe( + '/workspace/settings/server' + ); + + status.reportOffline(); + fixture.detectChanges(); + + expect(indicator()?.dataset['state']).toBe('offline'); + expect(indicator()?.textContent).toContain('cloud_off'); + expect(indicator()?.getAttribute('aria-label')).toBe( + 'SERVER_STORAGE.STATUS_OFFLINE' + ); + }); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts index 980d29bf0..92fce1a2b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts @@ -1,4 +1,10 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; @@ -7,6 +13,10 @@ import { PortalRailLink, PortalRailSection, } from '@iptvnator/portal/shared/util'; +import { + type ServerConnectionState, + ServerStorageStatusService, +} from '@iptvnator/services'; import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/workspace-shell-rail-links.component'; @Component({ @@ -32,4 +42,25 @@ export class WorkspaceShellRailComponent { >(null); readonly railProviderClass = input('rail-context-region'); readonly isSettingsRoute = input(false); + + /** Self-hosted server storage: hidden while `inactive`. */ + readonly serverState = inject(ServerStorageStatusService).state; + readonly serverIcon = computed(() => SERVER_ICONS[this.serverState()]); + readonly serverLabelKey = computed( + () => SERVER_LABEL_KEYS[this.serverState()] + ); } + +const SERVER_ICONS: Record = { + inactive: 'dns', + online: 'dns', + offline: 'cloud_off', + unauthorized: 'lock', +}; + +const SERVER_LABEL_KEYS: Record = { + inactive: 'SERVER_STORAGE.STATUS_ONLINE', + online: 'SERVER_STORAGE.STATUS_ONLINE', + offline: 'SERVER_STORAGE.STATUS_OFFLINE', + unauthorized: 'SERVER_STORAGE.STATUS_UNAUTHORIZED', +}; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.spec.ts new file mode 100644 index 000000000..206c6f273 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { WorkspaceShellServerBannerComponent } from './workspace-shell-server-banner.component'; + +const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', +} as const; + +describe('WorkspaceShellServerBannerComponent', () => { + let fixture: ComponentFixture; + let status: ServerStorageStatusService; + const retry = jest.fn(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + WorkspaceShellServerBannerComponent, + TranslateModule.forRoot(), + ], + }).compileComponents(); + status = TestBed.inject(ServerStorageStatusService); + fixture = TestBed.createComponent(WorkspaceShellServerBannerComponent); + }); + + const banner = (): HTMLElement | null => + fixture.nativeElement.querySelector( + '[data-test-id="server-offline-banner"]' + ); + + it('renders nothing without server storage or while online', () => { + fixture.detectChanges(); + expect(banner()).toBeNull(); + + status.activate(instance, retry); + fixture.detectChanges(); + expect(banner()).toBeNull(); + }); + + it('explains the offline state and retries', () => { + status.activate(instance, retry); + status.reportOffline(); + fixture.detectChanges(); + + fixture.nativeElement + .querySelector('[data-test-id="server-offline-retry"]') + .click(); + + expect(banner()?.textContent).toContain( + 'SERVER_STORAGE.OFFLINE_BANNER' + ); + expect(retry).toHaveBeenCalled(); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts new file mode 100644 index 000000000..958e8d1a7 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; + +/** + * Self-hosted server storage is unreachable: the library on screen is the + * last copy this browser read, and changes cannot be saved. Styled by the + * workspace shell's stylesheet (see there why). + */ +@Component({ + selector: 'app-workspace-shell-server-banner', + imports: [MatButtonModule, MatIcon, TranslatePipe], + template: ` + @if (status.state() === 'offline') { +
+ + {{ + 'SERVER_STORAGE.OFFLINE_BANNER' | translate + }} + +
+ } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkspaceShellServerBannerComponent { + readonly status = inject(ServerStorageStatusService); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index d85be2505..a21cd57ec 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -113,6 +113,9 @@ shadow without clipping; the scroller inside clips to a rectangle (see the stylesheet for why). -->
+ + diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss index 098c0229a..840fe929c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss @@ -93,6 +93,10 @@ app-workspace-shell-context-sidebar { position: relative; display: grid; + // Server banner (empty unless the self-hosted server is offline), then + // the scroller. + grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); min-width: 0; min-height: 0; border-radius: var(--workspace-content-radius) 0 0 0; @@ -139,6 +143,39 @@ app-workspace-shell-context-sidebar { background: var(--app-content-bg, var(--mat-sys-surface)); } +// The offline banner has no stylesheet of its own: a component stylesheet +// adds a