From 94570bc387d85553e2eef585c910bf1fe554ed75 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:03:58 +0200 Subject: [PATCH] refactor(storage): seams for server-backed playlist storage (#1896) * docs(storage): server storage design and implementation plans Design for keeping playlists and user data on the self-hosted server (#305), revised against master, plus three implementation plans: storage seams, the web-backend storage API, and the PWA server mode. Co-Authored-By: Claude Opus 5.5 * refactor(database): share the playlist row codec Move buildPlaylistRow, parseAppPlaylist and the upsert conflict helpers from the Electron database operations into libs/shared/database (@iptvnator/shared/database/app-playlist) so the self-hosted web backend can write the same rows. The Electron modules re-export them; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 * feat(database): read the data root from IPTVNATOR_DATA_DIR Deployments that keep the database on a mounted volume, such as the self-hosted web backend in Docker, can now set the data root. The E2E override still wins; without either variable the root stays ~/.iptvnator. Co-Authored-By: Claude Opus 5.5 * refactor(services): add the playlist storage backend resolver PlaylistStorageBackend becomes the single place that decides whether playlists persist through the Electron bridge or IndexedDB, and defines the PlaylistDocumentStore contract a server store will implement. Co-Authored-By: Claude Opus 5.5 * refactor(services): resolve playlist storage through one backend PlaylistsService asks PlaylistStorageBackend for its document store instead of reading window.electron and supportsSqlite directly. Paths that only make sense on the desktop (legacy IndexedDB import, worker deletion with progress, the favorites-channel query) check for the Electron kind explicitly. Behavior is unchanged: the backend still resolves to the Electron bridge or IndexedDB. Co-Authored-By: Claude Opus 5.5 * refactor(xtream): store PWA user data through USER_DATA_STORAGE PwaXtreamDataSource and PwaCollectionStorage read and write favorites, recently viewed items, collection snapshots and playback positions through the new USER_DATA_STORAGE token (default: localStorage) instead of calling localStorage directly, so a deployment can substitute a server-synced storage. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 * docs(pwa): document the storage seams Co-Authored-By: Claude Opus 5.5 * fix(database): bracket access in the shared playlist codec libs/shared/database compiles with noPropertyAccessFromIndexSignature, which the Electron backend did not enforce on the moved code. Co-Authored-By: Claude Opus 5.5 * build(electron): alias the shared playlist codec in the worker bundle The database worker is bundled by esbuild with an explicit alias table; without the new entry, @iptvnator/shared/database/app-playlist resolved under the library's index file. Co-Authored-By: Claude Opus 5.5 * fix(services): read localStorage lazily in USER_DATA_STORAGE When the browser denies storage access, reading localStorage itself throws. The default factory returned the global, so injecting the token failed and PwaXtreamDataSource could not be constructed. The adapter now reads the global inside each call, where the data source already guards its reads. Co-Authored-By: Claude Opus 5.5 * docs(database): describe /data as an example data root The Docker image does not set IPTVNATOR_DATA_DIR yet; that arrives with the server storage backend. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...2026-10-11-server-storage-phase-1-seams.md | 1305 ++++++ ...6-10-11-server-storage-phase-2a-backend.md | 2033 +++++++++ .../2026-10-11-server-storage-phase-2b-pwa.md | 3790 +++++++++++++++++ .plans/2026-10-11-server-storage.md | 52 + apps/electron-backend/build-worker.js | 4 + .../playlist-server-timezone.operations.ts | 77 +- .../operations/playlist.operations.ts | 257 +- docs/architecture/pwa-self-hosted.md | 12 +- .../specs/2026-10-08-server-storage-design.md | 214 + .../data-sources/pwa-collection-storage.ts | 16 +- .../pwa-xtream-data-source.spec.ts | 40 +- .../data-sources/pwa-xtream-data-source.ts | 32 +- libs/services/src/index.ts | 2 + .../src/lib/playlist-migration.spec.ts | 5 +- .../src/lib/playlist-storage-backend.spec.ts | 44 + .../src/lib/playlist-storage-backend.ts | 49 + .../playlists.service.inventory-read.spec.ts | 5 +- .../src/lib/playlists.service.spec.ts | 39 +- libs/services/src/lib/playlists.service.ts | 163 +- .../src/lib/user-data-storage.spec.ts | 34 + libs/services/src/lib/user-data-storage.ts | 31 + libs/shared/database/README.md | 8 +- libs/shared/database/src/index.ts | 1 + .../src/lib/app-playlist-codec.spec.ts | 111 + .../database/src/lib/app-playlist-codec.ts | 263 ++ .../database/src/lib/app-playlist-conflict.ts | 81 + libs/shared/database/src/lib/app-playlist.ts | 2 + .../database/src/lib/path-utils.spec.ts | 36 + libs/shared/database/src/lib/path-utils.ts | 12 + tsconfig.base.json | 3 + 30 files changed, 8271 insertions(+), 450 deletions(-) create mode 100644 .plans/2026-10-11-server-storage-phase-1-seams.md create mode 100644 .plans/2026-10-11-server-storage-phase-2a-backend.md create mode 100644 .plans/2026-10-11-server-storage-phase-2b-pwa.md create mode 100644 .plans/2026-10-11-server-storage.md create mode 100644 docs/superpowers/specs/2026-10-08-server-storage-design.md create mode 100644 libs/services/src/lib/playlist-storage-backend.spec.ts create mode 100644 libs/services/src/lib/playlist-storage-backend.ts create mode 100644 libs/services/src/lib/user-data-storage.spec.ts create mode 100644 libs/services/src/lib/user-data-storage.ts create mode 100644 libs/shared/database/src/lib/app-playlist-codec.spec.ts create mode 100644 libs/shared/database/src/lib/app-playlist-codec.ts create mode 100644 libs/shared/database/src/lib/app-playlist-conflict.ts create mode 100644 libs/shared/database/src/lib/app-playlist.ts diff --git a/.plans/2026-10-11-server-storage-phase-1-seams.md b/.plans/2026-10-11-server-storage-phase-1-seams.md new file mode 100644 index 000000000..c791957e8 --- /dev/null +++ b/.plans/2026-10-11-server-storage-phase-1-seams.md @@ -0,0 +1,1305 @@ +# Server Storage Phase 1: Storage Seams Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prepare the code for server-backed storage without changing any behavior: share the playlist row codec, let the database root come from `IPTVNATOR_DATA_DIR`, route `PlaylistsService` storage selection through one resolver, and route Xtream PWA user data through an injectable storage. + +**Architecture:** Four independent refactors. (1) The `playlists` row codec moves from `apps/electron-backend` into `libs/shared/database`, re-exported from its old module. (2) `path-utils` learns `IPTVNATOR_DATA_DIR`. (3) A new `PlaylistStorageBackend` in `libs/services` decides between the Electron bridge and IndexedDB; `PlaylistsService` asks it instead of reading `window.electron` and `supportsSqlite`. (4) A `USER_DATA_STORAGE` injection token defaults to `localStorage`; `PwaCollectionStorage` and `PwaXtreamDataSource` use it. + +**Tech Stack:** Angular 22 (signals, `inject`), Drizzle ORM 0.45 on better-sqlite3 13, Jest 30 via `@nx/jest`, Nx 23. + +Spec: `docs/superpowers/specs/2026-10-08-server-storage-design.md`. Overview: `.plans/2026-10-11-server-storage.md`. + +--- + +## File map + +| File | Change | Responsibility | +| --- | --- | --- | +| `libs/shared/database/src/lib/app-playlist-codec.ts` | create | `buildPlaylistRow`, `parseAppPlaylist`, type constants and JSON helpers | +| `libs/shared/database/src/lib/app-playlist-conflict.ts` | create | `playlistConflictUpdate`, `readPayloadServerTimezone` | +| `libs/shared/database/src/lib/app-playlist.ts` | create | Sub-entry barrel for the two modules | +| `libs/shared/database/src/lib/app-playlist-codec.spec.ts` | create | Codec round trip and type inference | +| `libs/shared/database/src/index.ts` | modify | Export the sub-entry | +| `tsconfig.base.json` | modify | Alias `@iptvnator/shared/database/app-playlist` | +| `apps/electron-backend/src/app/database/operations/playlist.operations.ts` | modify | Import the codec, drop the moved code | +| `apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts` | modify | Re-export moved functions | +| `libs/shared/database/src/lib/path-utils.ts` | modify | `IPTVNATOR_DATA_DIR` | +| `libs/shared/database/src/lib/path-utils.spec.ts` | modify | Cover the variable and its precedence | +| `libs/shared/database/README.md` | modify | Document the variable | +| `libs/services/src/lib/playlist-storage-backend.ts` | create | `PlaylistDocumentStore` type, `PlaylistStorageBackend` resolver | +| `libs/services/src/lib/playlist-storage-backend.spec.ts` | create | Resolver tests | +| `libs/services/src/lib/playlists.service.ts` | modify | Use the resolver | +| `libs/services/src/lib/playlists.service.spec.ts`, `playlists.service.inventory-read.spec.ts`, `playlist-migration.spec.ts` | modify | Stub the resolver instead of `runtime` | +| `libs/services/src/lib/user-data-storage.ts` | create | `USER_DATA_STORAGE` token | +| `libs/services/src/index.ts` | modify | Export the two new modules | +| `libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts` | modify | Constructor takes the storage | +| `libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts` | modify | Inject the token | +| `libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts` | modify | Prove the token is used | + +--- + +### Task 1: Prepare the worktree + +**Files:** none. + +- [ ] **Step 1: Install and verify discovery** + +Run from the worktree root: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects | grep -E '^(database|services|electron-backend|portal-xtream-data-access|web-backend)$' +node tools/testing/ensure-electron-binary.mjs +``` + +Expected: install finishes with exit 0, the grep prints all five project names, and the Electron binary check reports the pinned version. + +- [ ] **Step 2: Record the baseline** + +```bash +pnpm nx test database +pnpm nx test services --testPathPatterns=playlist +pnpm nx test electron-backend --testPathPatterns=playlist +``` + +Expected: all pass. If something fails before any change, stop and report it; do not fix unrelated failures in this branch. + +--- + +### Task 2: Move the playlist row codec into `libs/shared/database` + +**Files:** +- Create: `libs/shared/database/src/lib/app-playlist-codec.ts` +- Create: `libs/shared/database/src/lib/app-playlist-conflict.ts` +- Create: `libs/shared/database/src/lib/app-playlist.ts` +- Create: `libs/shared/database/src/lib/app-playlist-codec.spec.ts` +- Modify: `libs/shared/database/src/index.ts` +- Modify: `tsconfig.base.json` (paths block, next to `@iptvnator/shared/database/schema`) +- Modify: `apps/electron-backend/src/app/database/operations/playlist.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts` + +- [ ] **Step 1: Write the failing codec spec** + +Create `libs/shared/database/src/lib/app-playlist-codec.spec.ts`: + +```ts +import { + buildPlaylistRow, + parseAppPlaylist, + PLAYLIST_TYPES, +} from './app-playlist-codec'; +import type { NewPlaylist, Playlist as PlaylistRow } from './schema'; + +/** What SQLite hands back for a row written from `row`. */ +function storedRow(row: NewPlaylist, payload?: string | null): PlaylistRow { + const nullable = Object.fromEntries( + Object.entries(row).map(([key, value]) => [key, value ?? null]) + ); + return { + dateCreated: '2026-10-01 00:00:00', + ...nullable, + payload: payload === undefined ? (row.payload ?? null) : payload, + } as PlaylistRow; +} + +describe('app playlist codec', () => { + const m3uDocument = { + _id: 'm3u-1', + title: 'Family', + url: 'https://example.test/list.m3u', + count: 1, + importDate: '2026-10-01T00:00:00.000Z', + lastUsage: '2026-10-02T00:00:00.000Z', + favorites: ['https://example.test/a'], + recentlyViewed: [], + autoRefresh: true, + hiddenGroupTitles: ['Adult'], + playlist: { + header: { raw: '#EXTM3U' }, + items: [ + { + id: 'a', + url: 'https://example.test/a', + raw: '#EXTINF:-1,A', + }, + ], + }, + }; + + it('round-trips an M3U document through a stored row', () => { + const row = buildPlaylistRow(m3uDocument); + + expect(row).toMatchObject({ + id: 'm3u-1', + name: 'Family', + type: PLAYLIST_TYPES.M3U_URL, + url: 'https://example.test/list.m3u', + autoRefresh: true, + count: 1, + }); + expect(parseAppPlaylist(storedRow(row!))).toMatchObject({ + _id: 'm3u-1', + title: 'Family', + favorites: ['https://example.test/a'], + hiddenGroupTitles: ['Adult'], + playlist: m3uDocument.playlist, + }); + }); + + it('reads metadata from columns when the payload is not selected', () => { + const row = buildPlaylistRow(m3uDocument)!; + const meta = parseAppPlaylist(storedRow(row, null)); + + expect(meta).toMatchObject({ + _id: 'm3u-1', + title: 'Family', + count: 1, + favorites: ['https://example.test/a'], + url: 'https://example.test/list.m3u', + }); + expect(meta['playlist']).toBeUndefined(); + }); + + it('infers the provider type from the document', () => { + expect( + buildPlaylistRow({ _id: 'x', serverUrl: 'http://panel.test' }) + ?.type + ).toBe(PLAYLIST_TYPES.XTREAM); + expect( + buildPlaylistRow({ + _id: 's', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.test/c', + }) + ).toMatchObject({ + type: PLAYLIST_TYPES.STALKER, + url: 'http://portal.test/c', + }); + expect( + buildPlaylistRow({ _id: 'f', filePath: '/tmp/list.m3u' })?.type + ).toBe(PLAYLIST_TYPES.M3U_FILE); + expect(buildPlaylistRow({ _id: 't' })?.type).toBe( + PLAYLIST_TYPES.M3U_TEXT + ); + }); + + it('refuses a document without an id', () => { + expect(buildPlaylistRow({ title: 'No id' })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `pnpm nx test database --testPathPatterns=app-playlist-codec` +Expected: FAIL, `Cannot find module './app-playlist-codec'`. + +- [ ] **Step 3: Create the codec** + +Create `libs/shared/database/src/lib/app-playlist-codec.ts`. The function bodies are moved unchanged from `playlist.operations.ts`; only `PlaylistType` is renamed `AppPlaylistType` and four symbols gain `export`. + +```ts +/** + * Row codec for the `playlists` table. The whole renderer `Playlist` + * document is stored as JSON in `payload`; the fields that metadata reads + * need are projected into columns. The Electron database worker and the + * self-hosted web backend both use this module, so both write the same rows. + */ +import * as schema from './schema'; + +export const PLAYLIST_TYPES = { + XTREAM: 'xtream', + STALKER: 'stalker', + M3U_FILE: 'm3u-file', + M3U_TEXT: 'm3u-text', + M3U_URL: 'm3u-url', +} as const; + +export type AppPlaylistType = + (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES]; + +const PLAYLIST_TYPE_VALUES = new Set( + Object.values(PLAYLIST_TYPES) +); + +function getStringValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function getNumericValue(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; +} + +export function parseJsonValue( + value: string | null | undefined, + fallback: T +): T { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value) as T; + } catch (error) { + console.warn('Failed to parse JSON value from DB:', error); + return fallback; + } +} + +function getStringArrayValue(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +function inferPlaylistType(playlist: Record): AppPlaylistType { + const explicitType = getStringValue(playlist.type); + if ( + explicitType && + PLAYLIST_TYPE_VALUES.has(explicitType as AppPlaylistType) + ) { + return explicitType as AppPlaylistType; + } + + if (getStringValue(playlist.serverUrl)) { + return PLAYLIST_TYPES.XTREAM; + } + + if (getStringValue(playlist.macAddress)) { + return PLAYLIST_TYPES.STALKER; + } + + if (getStringValue(playlist.filePath)) { + return PLAYLIST_TYPES.M3U_FILE; + } + + if (getStringValue(playlist.url)) { + return PLAYLIST_TYPES.M3U_URL; + } + + return PLAYLIST_TYPES.M3U_TEXT; +} + +export function buildPlaylistRow( + playlist: Record +): schema.NewPlaylist | null { + const id = getStringValue(playlist._id) ?? getStringValue(playlist.id); + if (!id) { + return null; + } + + const type = inferPlaylistType(playlist); + const portalUrl = getStringValue(playlist.portalUrl); + const url = getStringValue(playlist.url); + const nowIso = new Date().toISOString(); + const updateDate = getNumericValue(playlist.updateDate); + + return { + id, + name: + getStringValue(playlist.title) ?? + getStringValue(playlist.name) ?? + id, + serverUrl: getStringValue(playlist.serverUrl), + username: getStringValue(playlist.username), + password: getStringValue(playlist.password), + lastUpdated: + typeof updateDate === 'number' + ? new Date(updateDate).toISOString() + : getStringValue(playlist.lastUpdated), + type, + userAgent: getStringValue(playlist.userAgent), + origin: getStringValue(playlist.origin), + referrer: getStringValue(playlist.referrer), + filePath: getStringValue(playlist.filePath), + epgUrls: + playlist.epgUrls !== undefined + ? JSON.stringify(getStringArrayValue(playlist.epgUrls)) + : undefined, + detectedEpgUrls: + playlist.detectedEpgUrls !== undefined + ? JSON.stringify(getStringArrayValue(playlist.detectedEpgUrls)) + : undefined, + manualEpgUrls: + playlist.manualEpgUrls !== undefined + ? JSON.stringify(getStringArrayValue(playlist.manualEpgUrls)) + : undefined, + disabledEpgUrls: + playlist.disabledEpgUrls !== undefined + ? JSON.stringify(getStringArrayValue(playlist.disabledEpgUrls)) + : undefined, + autoRefresh: Boolean(playlist.autoRefresh), + macAddress: getStringValue(playlist.macAddress), + url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url, + portalUrl, + count: getNumericValue(playlist.count), + importDate: getStringValue(playlist.importDate), + updateDate, + position: getNumericValue(playlist.position), + favorites: + playlist.favorites !== undefined + ? JSON.stringify(playlist.favorites) + : undefined, + recentlyViewed: + playlist.recentlyViewed !== undefined + ? JSON.stringify(playlist.recentlyViewed) + : undefined, + payload: JSON.stringify(playlist), + lastUsage: getStringValue(playlist.lastUsage) ?? nowIso, + }; +} + +export function getPlaylistItemCount( + playlist: Record +): number | undefined { + try { + const value = playlist.playlist; + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + +export function parseAppPlaylist( + row: schema.Playlist +): Record { + const payload = parseJsonValue | null>( + row.payload, + null + ); + const base = payload && typeof payload === 'object' ? payload : {}; + const favorites = parseJsonValue(row.favorites, []); + const recentlyViewed = parseJsonValue(row.recentlyViewed, []); + const epgUrls = getStringArrayValue( + row.epgUrls ? parseJsonValue(row.epgUrls, []) : base.epgUrls + ); + const detectedEpgUrls = getStringArrayValue( + row.detectedEpgUrls + ? parseJsonValue(row.detectedEpgUrls, []) + : (base.detectedEpgUrls ?? epgUrls) + ); + const manualEpgUrls = getStringArrayValue( + row.manualEpgUrls + ? parseJsonValue(row.manualEpgUrls, []) + : base.manualEpgUrls + ); + const disabledEpgUrls = getStringArrayValue( + row.disabledEpgUrls + ? parseJsonValue(row.disabledEpgUrls, []) + : base.disabledEpgUrls + ); + const importDate = + row.importDate ?? row.dateCreated ?? new Date().toISOString(); + const portalUrl = + row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null); + const updateDate = + row.updateDate ?? + (row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined); + + return { + ...base, + _id: row.id, + title: + getStringValue(base.title) ?? getStringValue(base.name) ?? row.name, + count: row.count ?? getNumericValue(base.count) ?? 0, + importDate: getStringValue(base.importDate) ?? importDate, + lastUsage: + row.lastUsage ?? + getStringValue(base.lastUsage) ?? + getStringValue(base.importDate) ?? + importDate, + favorites, + recentlyViewed, + autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh), + url: + row.type === PLAYLIST_TYPES.M3U_URL + ? (row.url ?? getStringValue(base.url)) + : getStringValue(base.url), + filePath: row.filePath ?? getStringValue(base.filePath), + epgUrls, + detectedEpgUrls, + manualEpgUrls, + disabledEpgUrls, + userAgent: row.userAgent ?? getStringValue(base.userAgent), + referrer: row.referrer ?? getStringValue(base.referrer), + origin: row.origin ?? getStringValue(base.origin), + updateDate, + position: row.position ?? getNumericValue(base.position), + serverUrl: row.serverUrl ?? getStringValue(base.serverUrl), + username: row.username ?? getStringValue(base.username), + password: row.password ?? getStringValue(base.password), + macAddress: row.macAddress ?? getStringValue(base.macAddress), + portalUrl: portalUrl ?? getStringValue(base.portalUrl), + }; +} +``` + +- [ ] **Step 4: Create the conflict module** + +Create `libs/shared/database/src/lib/app-playlist-conflict.ts`. Bodies move unchanged from `playlist-server-timezone.operations.ts`. + +```ts +/** + * The `DO UPDATE` half of an app-playlist upsert and the payload clock + * reader. See `playlist-server-timezone.operations.ts` in the Electron + * backend for the conditional writers that complete this contract + * (issue #1562). + */ +import { sql, type SQL } from 'drizzle-orm'; +import * as schema from './schema'; + +export type PlaylistConflictUpdate = { + [K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL; +}; + +const stored = schema.playlists; + +/** + * A full upsert is built from a snapshot the caller read earlier; when that + * snapshot carries no panel clock while the stored row does, the stored + * clock is carried over as long as the row still points at the same panel. + * A snapshot that carries its own clock, or moves the source, wins as is. + * Nested CASE, not AND: SQLite may reorder AND terms, and the json_* readers + * raise on a malformed payload unless json_valid ran first. + */ +export function playlistConflictUpdate( + row: schema.NewPlaylist, + playlist: Record +): PlaylistConflictUpdate { + if (readString(playlist.serverTimezone)) { + return row; + } + const excludedPayload = sql.raw(`excluded.${stored.payload.name}`); + const payload = sql`CASE + WHEN json_valid(${stored.payload}) + THEN CASE + WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text' + AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)} + AND ${stored.username} IS excluded.${sql.raw(stored.username.name)} + AND ${stored.password} IS excluded.${sql.raw(stored.password.name)} + THEN json_set( + ${excludedPayload}, + '$.serverTimezone', + json_extract(${stored.payload}, '$.serverTimezone') + ) + ELSE ${excludedPayload} + END + ELSE ${excludedPayload} + END`; + return { ...row, payload }; +} + +/** The clock stored in a raw row's payload. */ +export function readPayloadServerTimezone( + payload: string | null | undefined +): string | undefined { + if (!payload) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(payload); + return parsed && typeof parsed === 'object' + ? readString((parsed as Record).serverTimezone) + : undefined; + } catch { + return undefined; + } +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} +``` + +- [ ] **Step 5: Create the sub-entry and wire the aliases** + +Create `libs/shared/database/src/lib/app-playlist.ts`: + +```ts +export * from './app-playlist-codec'; +export * from './app-playlist-conflict'; +``` + +Append to `libs/shared/database/src/index.ts`: + +```ts +export * from './lib/app-playlist'; +``` + +In `tsconfig.base.json`, add this entry directly after the `"@iptvnator/shared/database/schema"` entry: + +```json +"@iptvnator/shared/database/app-playlist": [ + "libs/shared/database/src/lib/app-playlist.ts" +], +``` + +- [ ] **Step 6: Run the codec spec** + +Run: `pnpm nx test database --testPathPatterns=app-playlist-codec` +Expected: PASS, 4 tests. + +- [ ] **Step 7: Point the Electron operations at the shared codec** + +In `apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts`: + +1. Delete the `PlaylistConflictUpdate` type, `playlistConflictUpdate`, `readPayloadServerTimezone` and `readString` (with their doc comments). +2. Add below the imports: + +```ts +export { + playlistConflictUpdate, + readPayloadServerTimezone, + type PlaylistConflictUpdate, +} from '@iptvnator/shared/database/app-playlist'; +``` + +3. If the `SQL` type import is now unused, change `import { sql, type SQL } from 'drizzle-orm';` to `import { sql } from 'drizzle-orm';`. + +In `apps/electron-backend/src/app/database/operations/playlist.operations.ts`: + +1. Delete the local `PLAYLIST_TYPES` constant, the `PlaylistType` alias, `PLAYLIST_TYPE_VALUES`, and the functions `getStringValue`, `getNumericValue`, `parseJsonValue`, `getStringArrayValue`, `inferPlaylistType`, `buildPlaylistRow`, `getPlaylistItemCount` and `parseAppPlaylist`. Keep `getFavoriteChannelMatch` and the two `*PhaseCapture` interfaces. +2. Add after the existing imports: + +```ts +import { + buildPlaylistRow, + getPlaylistItemCount, + parseAppPlaylist, + parseJsonValue, + PLAYLIST_TYPES, + type AppPlaylistType, +} from '@iptvnator/shared/database/app-playlist'; + +// Kept for existing importers (specs, playlist-migration.operations.ts). +export { + buildPlaylistRow, + parseAppPlaylist, +} from '@iptvnator/shared/database/app-playlist'; +``` + +3. In `createPlaylist`, change `type: playlist.type as PlaylistType,` to `type: playlist.type as AppPlaylistType,`. + +- [ ] **Step 8: Verify nothing else referenced the deleted locals** + +Run: + +```bash +pnpm run typecheck:backend +pnpm nx lint electron-backend +pnpm nx lint database +``` + +Expected: no errors. If ESLint reports an unused import in either Electron file, remove that import. + +- [ ] **Step 9: Run the affected suites** + +```bash +pnpm nx test database +pnpm nx test electron-backend --testPathPatterns="playlist|migration|timezone" +``` + +Expected: PASS, same counts as the Task 1 baseline plus the 4 new codec tests. + +- [ ] **Step 10: Commit** + +```bash +git add libs/shared/database tsconfig.base.json apps/electron-backend/src/app/database/operations/playlist.operations.ts apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts +git commit -m "refactor(database): share the playlist row codec + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: `IPTVNATOR_DATA_DIR` + +**Files:** +- Modify: `libs/shared/database/src/lib/path-utils.ts` +- Modify: `libs/shared/database/src/lib/path-utils.spec.ts` +- Modify: `libs/shared/database/README.md` + +- [ ] **Step 1: Write the failing tests** + +In `path-utils.spec.ts`: + +1. Add `IPTVNATOR_DATA_DIR_ENV` to the import list from `./path-utils`. +2. In `beforeEach`, after the E2E variable is saved and deleted, add: + +```ts + originalDataDirValue = process.env[IPTVNATOR_DATA_DIR_ENV]; + delete process.env[IPTVNATOR_DATA_DIR_ENV]; +``` + +and declare `let originalDataDirValue: string | undefined;` next to `originalEnvValue`. +3. In `afterEach`, before `rmSync`, add: + +```ts + if (originalDataDirValue === undefined) { + delete process.env[IPTVNATOR_DATA_DIR_ENV]; + } else { + process.env[IPTVNATOR_DATA_DIR_ENV] = originalDataDirValue; + } +``` + +4. Add these tests inside the `describe`: + +```ts + it('uses and creates IPTVNATOR_DATA_DIR as the data root', () => { + const dataDir = join(tempRoot, 'volume'); + process.env[IPTVNATOR_DATA_DIR_ENV] = ` ${dataDir} `; + + expect(getIptvnatorDataRoot()).toBe(dataDir); + expect(existsSync(dataDir)).toBe(true); + expect(getIptvnatorDatabasePath()).toBe( + join(dataDir, 'databases', 'iptvnator.db') + ); + }); + + it('lets the E2E override win over IPTVNATOR_DATA_DIR', () => { + const e2eDir = join(tempRoot, 'e2e'); + process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDir; + process.env[IPTVNATOR_DATA_DIR_ENV] = join(tempRoot, 'volume'); + + expect(getIptvnatorDataRoot()).toBe(e2eDir); + }); + + it('ignores a blank IPTVNATOR_DATA_DIR', () => { + process.env[IPTVNATOR_DATA_DIR_ENV] = ' '; + + expect(getIptvnatorDataRoot()).toBe( + join(tempRoot, 'home', '.iptvnator') + ); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test database --testPathPatterns=path-utils` +Expected: FAIL, `IPTVNATOR_DATA_DIR_ENV` is not exported. + +- [ ] **Step 3: Implement** + +In `path-utils.ts`, add after `IPTVNATOR_E2E_DATA_DIR_ENV`: + +```ts +/** + * Data root for deployments that keep the database on a mounted volume, such + * as the self-hosted web backend in Docker (`/data`). The E2E override wins. + */ +export const IPTVNATOR_DATA_DIR_ENV = 'IPTVNATOR_DATA_DIR'; +``` + +Replace `getIptvnatorDataRoot` with: + +```ts +export function getIptvnatorDataRoot(): string { + const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim(); + + if (e2eDataDir) { + return ensureDirectory(e2eDataDir); + } + + const dataDir = process.env[IPTVNATOR_DATA_DIR_ENV]?.trim(); + + if (dataDir) { + return ensureDirectory(dataDir); + } + + return ensureDirectory(join(homedir(), '.iptvnator')); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm nx test database --testPathPatterns=path-utils` +Expected: PASS. + +- [ ] **Step 5: Document** + +In `libs/shared/database/README.md`, replace the "Database Location" section body with: + +```markdown +The SQLite database is stored at `/databases/iptvnator.db`. The data +root is resolved in this order: + +1. `IPTVNATOR_E2E_DATA_DIR` (E2E runs only) +2. `IPTVNATOR_DATA_DIR` (deployments with a mounted volume, such as the + self-hosted web backend in Docker, which sets `/data`) +3. `~/.iptvnator` +``` + +- [ ] **Step 6: Commit** + +```bash +git add libs/shared/database/src/lib/path-utils.ts libs/shared/database/src/lib/path-utils.spec.ts libs/shared/database/README.md +git commit -m "feat(database): read the data root from IPTVNATOR_DATA_DIR + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: `PlaylistStorageBackend` resolver + +**Files:** +- Create: `libs/services/src/lib/playlist-storage-backend.ts` +- Create: `libs/services/src/lib/playlist-storage-backend.spec.ts` +- Modify: `libs/services/src/index.ts` + +- [ ] **Step 1: Write the failing spec** + +Create `libs/services/src/lib/playlist-storage-backend.spec.ts`: + +```ts +import { TestBed } from '@angular/core/testing'; +import { PlaylistStorageBackend } from './playlist-storage-backend'; +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; + +describe('PlaylistStorageBackend', () => { + const testWindow = window as unknown as { electron?: unknown }; + const originalElectron = testWindow.electron; + + afterEach(() => { + testWindow.electron = originalElectron; + TestBed.resetTestingModule(); + }); + + function create(supportsSqlite: boolean) { + TestBed.configureTestingModule({ + providers: [ + { + provide: RuntimeCapabilitiesService, + useValue: { supportsSqlite }, + }, + ], + }); + return TestBed.inject(PlaylistStorageBackend); + } + + it('uses the Electron bridge when the SQLite bridge is complete', () => { + const bridge = { dbGetAppPlaylists: jest.fn() }; + testWindow.electron = bridge; + + const backend = create(true); + + expect(backend.kind()).toBe('electron'); + expect(backend.documentStore()).toBe(bridge); + }); + + it('uses IndexedDB without the SQLite bridge', () => { + testWindow.electron = { dbGetAppPlaylists: jest.fn() }; + + const backend = create(false); + + expect(backend.kind()).toBe('indexeddb'); + expect(backend.documentStore()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns=playlist-storage-backend` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement** + +Create `libs/services/src/lib/playlist-storage-backend.ts`: + +```ts +import { inject, Injectable } from '@angular/core'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; + +/** + * Whole-playlist document persistence. The Electron preload implements it + * over the database worker; the self-hosted server storage implements the + * same contract over HTTP (phase 2b). + */ +export type PlaylistDocumentStore = Pick< + ElectronBridgeApi, + | 'dbDeleteAllPlaylists' + | 'dbDeletePlaylist' + | 'dbGetAppPlaylist' + | 'dbGetAppPlaylists' + | 'dbGetAppState' + | 'dbSetAppState' + | 'dbUpsertAppPlaylist' + | 'dbUpsertAppPlaylists' +> & + Partial< + Pick< + ElectronBridgeApi, + | 'dbGetAppPlaylistFavoriteChannels' + | 'dbGetAppPlaylistMetas' + | 'dbMigrateAppPlaylists' + | 'dbRecoverLegacyPlaylists' + > + >; + +export type PlaylistStorageKind = 'electron' | 'indexeddb'; + +/** Single place that decides where `PlaylistsService` persists playlists. */ +@Injectable({ providedIn: 'root' }) +export class PlaylistStorageBackend { + private readonly runtime = inject(RuntimeCapabilitiesService); + + kind(): PlaylistStorageKind { + 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') { + return null; + } + return (window.electron as PlaylistDocumentStore | undefined) ?? null; + } +} +``` + +Add to `libs/services/src/index.ts`, after `export * from './lib/playlists.service';`: + +```ts +export * from './lib/playlist-storage-backend'; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm nx test services --testPathPatterns=playlist-storage-backend` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add libs/services/src/lib/playlist-storage-backend.ts libs/services/src/lib/playlist-storage-backend.spec.ts libs/services/src/index.ts +git commit -m "refactor(services): add the playlist storage backend resolver + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: `PlaylistsService` asks the resolver + +**Files:** +- Modify: `libs/services/src/lib/playlists.service.ts` +- Modify: `libs/services/src/lib/playlists.service.spec.ts` +- Modify: `libs/services/src/lib/playlists.service.inventory-read.spec.ts` +- Modify: `libs/services/src/lib/playlist-migration.spec.ts` + +This is a rename plus three guard changes. Behavior is identical because the resolver only returns `electron` or `indexeddb` in this phase. + +- [ ] **Step 1: Switch the three specs to a resolver stub (they fail first)** + +In `playlists.service.spec.ts`, inside `describe('PlaylistsService', ...)` before `createService`, add: + +```ts + function hasSqliteBridge(): boolean { + const electron = testWindow.electron as + Record | undefined; + return ( + !!electron && + typeof electron['dbGetAppPlaylists'] === 'function' && + typeof electron['dbUpsertAppPlaylist'] === 'function' && + typeof electron['dbGetAppState'] === 'function' && + typeof electron['dbSetAppState'] === 'function' + ); + } + + const testStorageBackend = { + kind: () => (hasSqliteBridge() ? 'electron' : 'indexeddb'), + documentStore: () => (hasSqliteBridge() ? testWindow.electron : null), + }; +``` + +In `createService`, replace the whole `runtime: { get supportsSqlite() { ... } },` property with: + +```ts + storageBackend: testStorageBackend, +``` + +In the test `owns one worker deletion and waits for cleanup before completion`, replace + +```ts + Object.defineProperties(service, { + isElectronStorageAvailable: { value: true }, + electronApi: { value: { dbDeletePlaylist: jest.fn() } }, + }); +``` + +with + +```ts + Object.assign(service, { + storageBackend: { + kind: () => 'electron', + documentStore: () => ({ dbDeletePlaylist: jest.fn() }), + }, + }); +``` + +Then search the file for any other use of the old names and convert it the same way: + +```bash +grep -n "isElectronStorageAvailable\|electronApi\|supportsSqlite" libs/services/src/lib/playlists.service.spec.ts +``` + +Expected after editing: no matches. + +In `playlists.service.inventory-read.spec.ts` and `playlist-migration.spec.ts`, replace `runtime: { supportsSqlite: true },` with: + +```ts + storageBackend: { + kind: () => 'electron', + documentStore: () => window.electron, + }, +``` + +- [ ] **Step 2: Run the specs to verify they fail** + +Run: `pnpm nx test services --testPathPatterns="playlists.service|playlist-migration"` +Expected: FAIL. The service still reads `this.runtime.supportsSqlite`, which the stubs no longer provide, so Electron-path tests take the IndexedDB branch. + +- [ ] **Step 3: Apply the mechanical rename** + +Run from the worktree root (Perl handles word boundaries the same on macOS and Linux): + +```bash +perl -pi -e ' + s/const electron = this\.electronApi;/const store = this.documentStore;/g; + s/this\.electronApi\b/this.documentStore/g; + s/\belectron(\??\.db)/store$1/g; + s/\(!electron\)/(!store)/g; + s/\(electron\)/(store)/g; + s/!electron \|\|/!store ||/g; + s/= electron$/= store/; + s/return electron$/return store/; + s/this\.isElectronStorageAvailable\b/this.usesDocumentStore/g; +' libs/services/src/lib/playlists.service.ts +perl -ne 'print "$.: $_" if /\belectron\b/' libs/services/src/lib/playlists.service.ts +``` + +Expected output of the last command: only the import line `} from './database-electron.service';`, the `PlaylistStorageWindow` type lines and the old getter body (removed in Step 4). + +- [ ] **Step 4: Replace the storage types and getters** + +1. Delete the `PlaylistStorageElectronApi` type and the `PlaylistStorageWindow` type near the top of the file. +2. Add to the imports: + +```ts +import { + PlaylistDocumentStore, + PlaylistStorageBackend, +} from './playlist-storage-backend'; +``` + +3. Remove `import { RuntimeCapabilitiesService } from './runtime-capabilities.service';` and the field `private readonly runtime = inject(RuntimeCapabilitiesService);`, then add the field: + +```ts + private readonly storageBackend = inject(PlaylistStorageBackend); +``` + +4. Replace the old `electronApi` and `isElectronStorageAvailable` getters with: + +```ts + /** Electron bridge or server store; `null` means IndexedDB. */ + private get documentStore(): PlaylistDocumentStore | null { + return this.storageBackend.documentStore(); + } + + private get usesDocumentStore(): boolean { + return this.storageBackend.kind() !== 'indexeddb'; + } + + /** Desktop-only paths: legacy IndexedDB import, worker deletion. */ + private get usesElectronStorage(): boolean { + return this.storageBackend.kind() === 'electron'; + } +``` + +- [ ] **Step 5: Narrow the three Electron-only guards** + +1. In `ensureElectronPlaylistMigrations`, change the first guard to: + +```ts + if (!this.usesElectronStorage) { + return; + } +``` + +2. In `migrateIndexedDbPlaylistsToSqlite`, replace the first two lines of the body (`const store = this.documentStore;` and `if (!store) return;`) with: + +```ts + const store = this.documentStore; + if (!store?.dbMigrateAppPlaylists) return; +``` + +3. In `deletePlaylist`, change `if (options) {` to: + +```ts + if (options && this.usesElectronStorage) { +``` + +4. In `getM3uFavoriteChannels`, change the condition line `!this.usesDocumentStore ||` to: + +```ts + !this.usesElectronStorage || +``` + +- [ ] **Step 6: Typecheck, lint and test** + +```bash +pnpm run typecheck:web +pnpm nx lint services +pnpm nx test services +``` + +Expected: no type or lint errors; all `services` tests pass with the same count as the Task 1 baseline plus 2. + +- [ ] **Step 7: Commit** + +```bash +git add libs/services/src/lib/playlists.service.ts libs/services/src/lib/playlists.service.spec.ts libs/services/src/lib/playlists.service.inventory-read.spec.ts libs/services/src/lib/playlist-migration.spec.ts +git commit -m "refactor(services): resolve playlist storage through one backend + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: `USER_DATA_STORAGE` for Xtream PWA user data + +**Files:** +- Create: `libs/services/src/lib/user-data-storage.ts` +- Modify: `libs/services/src/index.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts` + +- [ ] **Step 1: Write the failing test** + +In `pwa-xtream-data-source.spec.ts`, add `USER_DATA_STORAGE` to the `@iptvnator/services` import, then add this block at the end of the top-level `describe`: + +```ts + describe('with an injected user data storage', () => { + it('reads and writes favorites through it, not localStorage', async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }; + 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); + + values.set('xtream-favorites', JSON.stringify({ p1: ['live:1'] })); + await source.deletePlaylist('p1'); + + expect(JSON.parse(values.get('xtream-favorites') ?? '{}')).toEqual( + {} + ); + expect(localStorage.getItem('xtream-favorites')).toBeNull(); + }); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test portal-xtream-data-access --testPathPatterns=pwa-xtream-data-source` +Expected: FAIL, `USER_DATA_STORAGE` is not exported from `@iptvnator/services`. + +- [ ] **Step 3: Create the token** + +Create `libs/services/src/lib/user-data-storage.ts`: + +```ts +import { InjectionToken } from '@angular/core'; + +/** The slice of the Web Storage API that PWA user-data stores rely on. */ +export type UserDataStorage = Pick; + +/** + * Browser persistence for PWA user data such as Xtream favorites, recently + * viewed items and playback positions. Defaults to `localStorage`; the + * self-hosted server mode provides a server-synced implementation. + */ +export const USER_DATA_STORAGE = new InjectionToken( + 'USER_DATA_STORAGE', + { providedIn: 'root', factory: () => globalThis.localStorage } +); +``` + +Add to `libs/services/src/index.ts`: + +```ts +export * from './lib/user-data-storage'; +``` + +- [ ] **Step 4: Route `PwaCollectionStorage` through it** + +In `pwa-collection-storage.ts`: + +1. Add `import type { UserDataStorage } from '@iptvnator/services';` +2. Change the constructor to: + +```ts + constructor( + private readonly resolveItems: ( + playlistId: string, + ids: readonly CollectionKey[] + ) => Map, + private readonly storage: UserDataStorage + ) {} +``` + +3. Replace every `localStorage.getItem(` with `this.storage.getItem(` and every `localStorage.setItem(` with `this.storage.setItem(` in this file: + +```bash +perl -pi -e 's/\blocalStorage\.(getItem|setItem|removeItem)\(/this.storage.$1(/g' libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts +grep -n "localStorage" libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts +``` + +Expected: the grep prints nothing. + +- [ ] **Step 5: Route `PwaXtreamDataSource` through it** + +In `pwa-xtream-data-source.ts`: + +1. Change the services import to `import { ParentalLockService, PlaylistsService, USER_DATA_STORAGE } from '@iptvnator/services';` +2. Add the field before `collectionStorage`: + +```ts + private readonly userDataStorage = inject(USER_DATA_STORAGE); +``` + +3. Change the `collectionStorage` initializer to: + +```ts + private readonly collectionStorage = new PwaCollectionStorage( + (playlistId, ids) => this.getCollectionItemsById(playlistId, ids), + this.userDataStorage + ); +``` + +4. Replace the four direct calls and update the class comment: + +```bash +perl -pi -e 's/\blocalStorage\.(getItem|setItem|removeItem)\(/this.userDataStorage.$1(/g; s/Favorites and recently viewed are stored in localStorage\./Favorites, recently viewed and positions are stored in USER_DATA_STORAGE./' libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +grep -n "localStorage" libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +``` + +Expected: the grep prints only section comments such as `// Playlist Operations (localStorage)`. Change those comments to `(user data storage)`. + +- [ ] **Step 6: Run the data-access suite** + +```bash +pnpm nx test portal-xtream-data-access +pnpm nx lint portal-xtream-data-access +pnpm nx test services --testPathPatterns=user-data +``` + +Expected: PASS, including the new test; existing tests still pass because the default factory returns `localStorage`. + +- [ ] **Step 7: Commit** + +```bash +git add libs/services/src/lib/user-data-storage.ts libs/services/src/index.ts libs/portal/xtream/data-access/src/lib/data-sources +git commit -m "refactor(xtream): store PWA user data through USER_DATA_STORAGE + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Phase gate and pull request + +- [ ] **Step 1: Full checks for the touched projects** + +```bash +pnpm run typecheck:ci +pnpm run typecheck:spec +pnpm nx run-many -t test -p database services electron-backend portal-xtream-data-access +pnpm nx run-many -t lint -p database services electron-backend portal-xtream-data-access +pnpm nx build web --configuration=pwa +pnpm nx run web-e2e:e2e -- --project=chromium --grep "@self-hosted|@pwa" +``` + +Expected: all pass. The E2E run proves IndexedDB-backed PWA flows still work. + +- [ ] **Step 2: Docs** + +Update `docs/architecture/pwa-self-hosted.md`, section "PWA Portal User Data": after the bullet list of keys add: + +```markdown +`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. +``` + +and in "Runtime Limitations", change the playlist bullet's second sentence to say that `PlaylistsService` selects IndexedDB through `PlaylistStorageBackend`. + +- [ ] **Step 3: Local review gate, then push** + +Follow `docs/development/agent-workflow.md#local-review-before-a-pull-request`. Then: + +```bash +git push -u origin claude/server-storage +gh pr create --title "refactor(storage): seams for server-backed playlist storage" --label no-release-note --body "$(cat <<'EOF' +Prepares server-backed storage for the self-hosted PWA (#305) without changing behavior. + +- Playlist row codec moves to `@iptvnator/shared/database/app-playlist`; Electron re-exports it. +- `IPTVNATOR_DATA_DIR` sets the database root after the E2E override. +- `PlaylistStorageBackend` is the single place that picks the Electron bridge or IndexedDB. +- Xtream PWA user data goes through the `USER_DATA_STORAGE` token (default `localStorage`). + +Spec: docs/superpowers/specs/2026-10-08-server-storage-design.md + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Report: checks run and results, the reviewed commit, docs changed, and that no release note is needed because behavior is unchanged. diff --git a/.plans/2026-10-11-server-storage-phase-2a-backend.md b/.plans/2026-10-11-server-storage-phase-2a-backend.md new file mode 100644 index 000000000..cf9be912b --- /dev/null +++ b/.plans/2026-10-11-server-storage-phase-2a-backend.md @@ -0,0 +1,2033 @@ +# Server Storage Phase 2a: Backend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `web-backend` an optional server storage (SQLite on a volume) and the `/v1` HTTP API the PWA will use in Phase 2b, plus the Docker changes that persist it. + +**Architecture:** With `IPTVNATOR_STORAGE=server`, `main.ts` opens the shared database through `initDatabase()` (path from `IPTVNATOR_DATA_DIR`), adds one server-only table, and passes a `ServerStorage` object to `createWebBackendApp()`. New files under `apps/web-backend/src/app/storage/` hold the repositories (on the shared playlist codec from Phase 1), an in-memory ETag revision counter, an optional bearer-token guard and the Express routers. `web-backend-app.ts` gains one mounting call. The Docker image builds better-sqlite3 for the target platform, stores data under `/data` and raises nginx's body limit. + +**Tech Stack:** Express 5, Drizzle ORM 0.45 with better-sqlite3 13 (Node-API), Jest 30, esbuild via `@nx/esbuild`, Docker (node:24-alpine + nginx). + +Prerequisite: Phase 1 Tasks 2 and 3 are in the branch (`@iptvnator/shared/database/app-playlist`, `IPTVNATOR_DATA_DIR`). +Spec: `docs/superpowers/specs/2026-10-08-server-storage-design.md`, section 6.1. + +--- + +## File map + +| File | Change | Responsibility | +| --- | --- | --- | +| `libs/shared/interfaces/src/lib/server-storage.contract.ts` | create | Shared HTTP contract types and the user-state namespace list | +| `libs/shared/interfaces/src/index.ts` | modify | Export the contract | +| `apps/web-backend/src/app/storage/storage-config.ts` | create | Read `IPTVNATOR_STORAGE`, `IPTVNATOR_ACCESS_TOKEN`, `IPTVNATOR_MAX_DOCUMENT_MB` | +| `apps/web-backend/src/app/storage/server-user-state.table.ts` | create | Drizzle table for `server_user_state` | +| `apps/web-backend/src/app/storage/server-database.ts` | create | Open the database, create the server table, instance id | +| `apps/web-backend/src/app/storage/playlist-revisions.ts` | create | ETag revisions | +| `apps/web-backend/src/app/storage/server-playlist-repository.ts` | create | Playlist documents on the shared codec | +| `apps/web-backend/src/app/storage/server-user-state-repository.ts` | create | Xtream user-state entries | +| `apps/web-backend/src/app/storage/server-storage.ts` | create | `openServerStorage()` aggregate | +| `apps/web-backend/src/app/storage/storage-route-errors.ts` | create | Error type, JSON error mapping, redacted logging | +| `apps/web-backend/src/app/storage/access-guard.ts` | create | Optional bearer-token guard | +| `apps/web-backend/src/app/storage/instance-router.ts` | create | `/v1/instance`, `/v1/instance/status` | +| `apps/web-backend/src/app/storage/playlists-router.ts` | create | `/v1/playlists` | +| `apps/web-backend/src/app/storage/user-state-router.ts` | create | `/v1/user-state`, `/v1/app-state` | +| `apps/web-backend/src/app/storage/mount-server-storage.ts` | create | Mount CORS, guard, routers, error handler | +| `apps/web-backend/src/app/storage/server-storage.spec-helpers.ts` | create | Temp-directory storage for specs | +| `apps/web-backend/src/app/storage/*.spec.ts` | create | Unit and route tests | +| `apps/web-backend/src/app/web-backend-app.ts` | modify | Options + one mount call | +| `apps/web-backend/src/main.ts` | modify | Open storage, graceful shutdown | +| `apps/web-backend/project.json` | modify | `external: ["better-sqlite3"]` | +| `docker/Dockerfile`, `docker/docker-compose.yml`, `docker/nginx.conf` | modify | Native stage, volume, env, body limit, gzip | +| `docker/README.md`, `docs/architecture/pwa-self-hosted.md`, `README.md` | modify | Document storage, token and volume | + +--- + +### Task 1: Shared HTTP contract + +**Files:** +- Create: `libs/shared/interfaces/src/lib/server-storage.contract.ts` +- Modify: `libs/shared/interfaces/src/index.ts` + +- [ ] **Step 1: Create the contract** + +```ts +/** + * HTTP contract between the PWA and the self-hosted server storage. Routes + * live under `/v1` of the web backend (`/api/v1` behind the Docker nginx). + */ +export const SERVER_STORAGE_API_VERSION = 1; + +/** Xtream PWA user-data maps that the server stores per playlist id. */ +export const SERVER_USER_STATE_NAMESPACES = [ + 'xtream-favorites', + 'xtream-recent-items', + 'xtream-collection-items', + 'xtream-playback-positions', +] as const; + +export type ServerUserStateNamespace = + (typeof SERVER_USER_STATE_NAMESPACES)[number]; + +export function isServerUserStateNamespace( + value: string +): value is ServerUserStateNamespace { + return (SERVER_USER_STATE_NAMESPACES as readonly string[]).includes(value); +} + +/** + * Document fields that carry an M3U payload. An upsert with + * `keepPayload=1` omits them and the server keeps the stored ones. + */ +export const SERVER_PAYLOAD_FIELDS = ['playlist', 'items', 'header'] as const; + +export interface ServerInstanceInfo { + readonly service: 'iptvnator'; + readonly apiVersion: number; + /** Stable per database; `null` without server storage. */ + readonly instanceId: string | null; + readonly storage: 'server' | 'none'; + readonly auth: 'none' | 'token'; +} + +export interface ServerStorageStatusResponse { + readonly playlists: number; + readonly databaseBytes: number; + readonly databasePath: string; + readonly startedAt: string; +} + +export interface ServerUserStateResponse { + readonly entries: Record< + ServerUserStateNamespace, + Record + >; +} + +export interface ServerStorageErrorBody { + readonly message: string; + readonly status: number; + readonly code?: string; +} +``` + +- [ ] **Step 2: Export it** + +Append to `libs/shared/interfaces/src/index.ts`: + +```ts +export * from './lib/server-storage.contract'; +``` + +- [ ] **Step 3: Verify and commit** + +```bash +pnpm nx lint shared-interfaces +pnpm run typecheck:web +git add libs/shared/interfaces/src/lib/server-storage.contract.ts libs/shared/interfaces/src/index.ts +git commit -m "feat(interfaces): add the server storage HTTP contract + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: no errors. + +--- + +### Task 2: Storage configuration + +**Files:** +- Create: `apps/web-backend/src/app/storage/storage-config.ts` +- Test: `apps/web-backend/src/app/storage/storage-config.spec.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +import { readServerStorageConfig } from './storage-config'; + +describe('readServerStorageConfig', () => { + it('keeps storage off and the API open by default', () => { + expect(readServerStorageConfig({})).toEqual({ + enabled: false, + accessToken: null, + maxDocumentBytes: 64 * 1024 * 1024, + }); + }); + + it('enables storage only for IPTVNATOR_STORAGE=server', () => { + expect( + readServerStorageConfig({ IPTVNATOR_STORAGE: ' Server ' }).enabled + ).toBe(true); + expect( + readServerStorageConfig({ IPTVNATOR_STORAGE: 'none' }).enabled + ).toBe(false); + expect( + readServerStorageConfig({ IPTVNATOR_STORAGE: '1' }).enabled + ).toBe(false); + }); + + it('reads a trimmed access token and ignores a blank one', () => { + expect( + readServerStorageConfig({ IPTVNATOR_ACCESS_TOKEN: ' s3cret ' }) + .accessToken + ).toBe('s3cret'); + expect( + readServerStorageConfig({ IPTVNATOR_ACCESS_TOKEN: ' ' }) + .accessToken + ).toBeNull(); + }); + + it('reads a positive document limit in megabytes', () => { + expect( + readServerStorageConfig({ IPTVNATOR_MAX_DOCUMENT_MB: '8' }) + .maxDocumentBytes + ).toBe(8 * 1024 * 1024); + expect( + readServerStorageConfig({ IPTVNATOR_MAX_DOCUMENT_MB: '-1' }) + .maxDocumentBytes + ).toBe(64 * 1024 * 1024); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web-backend --testPathPatterns=storage-config` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement** + +```ts +export interface ServerStorageConfig { + /** `IPTVNATOR_STORAGE=server`; the Docker image sets it. */ + readonly enabled: boolean; + /** `IPTVNATOR_ACCESS_TOKEN`; guards every non-public route when set. */ + readonly accessToken: string | null; + /** Largest accepted playlist document (`IPTVNATOR_MAX_DOCUMENT_MB`). */ + readonly maxDocumentBytes: number; +} + +const DEFAULT_MAX_DOCUMENT_MB = 64; +const BYTES_PER_MB = 1024 * 1024; + +export function readServerStorageConfig( + env: Record = process.env +): ServerStorageConfig { + const mode = env['IPTVNATOR_STORAGE']?.trim().toLowerCase(); + const token = env['IPTVNATOR_ACCESS_TOKEN']?.trim(); + const megabytes = Number(env['IPTVNATOR_MAX_DOCUMENT_MB']); + + return { + enabled: mode === 'server', + accessToken: token ? token : null, + maxDocumentBytes: + (Number.isFinite(megabytes) && megabytes > 0 + ? megabytes + : DEFAULT_MAX_DOCUMENT_MB) * BYTES_PER_MB, + }; +} +``` + +- [ ] **Step 4: Run to verify it passes, then commit** + +```bash +pnpm nx test web-backend --testPathPatterns=storage-config +git add apps/web-backend/src/app/storage/storage-config.ts apps/web-backend/src/app/storage/storage-config.spec.ts +git commit -m "feat(web-backend): read server storage configuration + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 4 tests. + +--- + +### Task 3: Database bootstrap, revisions and spec helper + +**Files:** +- Create: `apps/web-backend/src/app/storage/server-user-state.table.ts` +- Create: `apps/web-backend/src/app/storage/server-database.ts` +- Create: `apps/web-backend/src/app/storage/playlist-revisions.ts` +- Test: `apps/web-backend/src/app/storage/playlist-revisions.spec.ts` + +- [ ] **Step 1: Write the failing revisions spec** + +```ts +import { PlaylistRevisions } from './playlist-revisions'; + +describe('PlaylistRevisions', () => { + it('keeps an ETag until the playlist is written', () => { + const revisions = new PlaylistRevisions('boot'); + + expect(revisions.etag('a')).toBe('"boot.0.0"'); + expect(revisions.etag('a')).toBe('"boot.0.0"'); + expect(revisions.bump('a')).toBe('"boot.0.1"'); + expect(revisions.etag('a')).toBe('"boot.0.1"'); + expect(revisions.etag('b')).toBe('"boot.0.0"'); + }); + + it('invalidates every ETag on reset', () => { + const revisions = new PlaylistRevisions('boot'); + revisions.bump('a'); + + revisions.reset(); + + expect(revisions.etag('a')).toBe('"boot.1.0"'); + expect(revisions.etag('b')).toBe('"boot.1.0"'); + }); + + it('uses a random boot id by default', () => { + expect(new PlaylistRevisions().etag('a')).not.toBe( + new PlaylistRevisions().etag('a') + ); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web-backend --testPathPatterns=playlist-revisions` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement revisions** + +`apps/web-backend/src/app/storage/playlist-revisions.ts`: + +```ts +import { randomUUID } from 'node:crypto'; + +/** + * ETags for playlist documents. The server is the only writer of its + * database, so an in-memory counter per playlist is exact. The boot id + * invalidates every client cache after a restart; the generation does so + * after "delete all". + */ +export class PlaylistRevisions { + private readonly revisions = new Map(); + private generation = 0; + + constructor(private readonly bootId = randomUUID().slice(0, 8)) {} + + etag(playlistId: string): string { + const revision = this.revisions.get(playlistId) ?? 0; + return `"${this.bootId}.${this.generation}.${revision}"`; + } + + bump(playlistId: string): string { + this.revisions.set( + playlistId, + (this.revisions.get(playlistId) ?? 0) + 1 + ); + return this.etag(playlistId); + } + + reset(): void { + this.revisions.clear(); + this.generation += 1; + } +} +``` + +- [ ] **Step 4: Implement the table and database bootstrap** + +`apps/web-backend/src/app/storage/server-user-state.table.ts`: + +```ts +import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * Server-only table: one JSON value per Xtream user-data namespace and + * playlist. Created by `openServerDatabase`, never by the desktop app. + */ +export const SERVER_USER_STATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS server_user_state ( + namespace TEXT NOT NULL, + playlist_id TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (namespace, playlist_id) +)`; + +export const serverUserState = sqliteTable( + 'server_user_state', + { + namespace: text('namespace').notNull(), + playlistId: text('playlist_id').notNull(), + value: text('value').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => [primaryKey({ columns: [table.namespace, table.playlistId] })] +); +``` + +`apps/web-backend/src/app/storage/server-database.ts`: + +```ts +import { randomUUID } from 'node:crypto'; +import { statSync } from 'node:fs'; +import { eq, sql } from 'drizzle-orm'; +import { + appState, + closeDatabase, + getDatabasePath, + initDatabase, + type DatabaseInstance, +} from '@iptvnator/shared/database'; +import { SERVER_USER_STATE_TABLE_SQL } from './server-user-state.table'; + +const INSTANCE_ID_KEY = 'server:instance-id'; + +export interface ServerDatabase { + readonly db: DatabaseInstance; + readonly instanceId: string; + readonly path: string; + sizeBytes(): number; + close(): void; +} + +/** + * Opens the shared IPTVnator database (full schema and migrations, the + * same as the desktop app) and adds the server-only table. The path comes + * from `IPTVNATOR_DATA_DIR`. + */ +export async function openServerDatabase(): Promise { + const db = await initDatabase(); + db.run(sql.raw(SERVER_USER_STATE_TABLE_SQL)); + const path = getDatabasePath(); + + return { + db, + instanceId: ensureInstanceId(db), + path, + sizeBytes: () => fileSize(path) + fileSize(`${path}-wal`), + close: () => closeDatabase(), + }; +} + +function ensureInstanceId(db: DatabaseInstance): string { + const existing = db + .select({ value: appState.value }) + .from(appState) + .where(eq(appState.key, INSTANCE_ID_KEY)) + .get(); + if (existing?.value) { + return existing.value; + } + + const instanceId = randomUUID(); + db.insert(appState) + .values({ + key: INSTANCE_ID_KEY, + value: instanceId, + updatedAt: new Date().toISOString(), + }) + .run(); + return instanceId; +} + +function fileSize(path: string): number { + try { + return statSync(path).size; + } catch { + return 0; + } +} +``` + +- [ ] **Step 5: Run the revisions spec and typecheck** + +```bash +pnpm nx test web-backend --testPathPatterns=playlist-revisions +pnpm nx build web-backend +``` + +Expected: PASS (3 tests); the build succeeds (it type-checks the new files). + +- [ ] **Step 6: Commit** + +```bash +git add apps/web-backend/src/app/storage/server-user-state.table.ts apps/web-backend/src/app/storage/server-database.ts apps/web-backend/src/app/storage/playlist-revisions.ts apps/web-backend/src/app/storage/playlist-revisions.spec.ts +git commit -m "feat(web-backend): open the server database and track revisions + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Repositories and the storage aggregate + +**Files:** +- Create: `apps/web-backend/src/app/storage/server-playlist-repository.ts` +- Create: `apps/web-backend/src/app/storage/server-user-state-repository.ts` +- Create: `apps/web-backend/src/app/storage/server-storage.ts` +- Create: `apps/web-backend/src/app/storage/server-storage.spec-helpers.ts` +- Test: `apps/web-backend/src/app/storage/server-playlist-repository.spec.ts` +- Test: `apps/web-backend/src/app/storage/server-user-state-repository.spec.ts` + +- [ ] **Step 1: Create the spec helper** + +`server-storage.spec-helpers.ts`: + +```ts +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { IPTVNATOR_DATA_DIR_ENV } from '@iptvnator/shared/database'; +import { openServerStorage, type ServerStorage } from './server-storage'; + +/** Real SQLite in a throwaway data directory, closed and removed afterwards. */ +export async function withTempStorage( + run: (storage: ServerStorage) => Promise | T +): Promise { + const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-server-storage-')); + const previous = process.env[IPTVNATOR_DATA_DIR_ENV]; + process.env[IPTVNATOR_DATA_DIR_ENV] = dataDir; + const storage = await openServerStorage(); + + try { + return await run(storage); + } finally { + storage.database.close(); + if (previous === undefined) { + delete process.env[IPTVNATOR_DATA_DIR_ENV]; + } else { + process.env[IPTVNATOR_DATA_DIR_ENV] = previous; + } + rmSync(dataDir, { recursive: true, force: true }); + } +} + +export const m3uDocument = (id: string, title = 'Family') => ({ + _id: id, + title, + url: `https://example.test/${id}.m3u`, + count: 1, + importDate: '2026-10-01T00:00:00.000Z', + lastUsage: '2026-10-01T00:00:00.000Z', + favorites: [], + recentlyViewed: [], + autoRefresh: false, + playlist: { + header: { raw: '#EXTM3U' }, + items: [{ id: 'a', url: 'https://example.test/a', raw: '#EXTINF:-1,A' }], + }, +}); +``` + +- [ ] **Step 2: Write the failing repository specs** + +`server-playlist-repository.spec.ts`: + +```ts +import { PlaylistPayloadMissingError } from './server-playlist-repository'; +import { m3uDocument, withTempStorage } from './server-storage.spec-helpers'; + +describe('ServerPlaylistRepository', () => { + it('stores and reads a whole playlist document', () => + withTempStorage(({ playlists }) => { + playlists.upsert(m3uDocument('p1'), { keepPayload: false }); + + expect(playlists.get('p1')).toMatchObject({ + _id: 'p1', + title: 'Family', + playlist: m3uDocument('p1').playlist, + }); + expect(playlists.count()).toBe(1); + expect(playlists.get('missing')).toBeNull(); + })); + + it('lists metadata without the payload', () => + withTempStorage(({ playlists }) => { + playlists.upsert(m3uDocument('p1'), { keepPayload: false }); + + const [meta] = playlists.listMetas(); + + expect(meta).toMatchObject({ _id: 'p1', title: 'Family' }); + expect(meta['playlist']).toBeUndefined(); + expect(playlists.listAll()[0]['playlist']).toEqual( + m3uDocument('p1').playlist + ); + })); + + it('keeps the stored payload when asked to', () => + withTempStorage(({ playlists }) => { + playlists.upsert(m3uDocument('p1'), { keepPayload: false }); + const { playlist: _payload, ...meta } = m3uDocument('p1'); + + playlists.upsert( + { ...meta, title: 'Renamed', favorites: ['https://example.test/a'] }, + { keepPayload: true } + ); + + expect(playlists.get('p1')).toMatchObject({ + title: 'Renamed', + favorites: ['https://example.test/a'], + playlist: m3uDocument('p1').playlist, + }); + })); + + it('refuses keepPayload for an unknown playlist', () => + withTempStorage(({ playlists }) => { + expect(() => + playlists.upsert({ _id: 'nope', title: 'x' }, { keepPayload: true }) + ).toThrow(PlaylistPayloadMissingError); + })); + + it('deletes one or all playlists', () => + withTempStorage(({ playlists }) => { + playlists.upsert(m3uDocument('p1'), { keepPayload: false }); + playlists.upsert(m3uDocument('p2'), { keepPayload: false }); + + playlists.delete('p1'); + expect(playlists.listMetas().map((p) => p['_id'])).toEqual(['p2']); + + playlists.deleteAll(); + expect(playlists.count()).toBe(0); + })); + + it('reads and writes app state', () => + withTempStorage(({ playlists }) => { + expect(playlists.getAppState('flag')).toBeNull(); + playlists.setAppState('flag', '1'); + expect(playlists.getAppState('flag')).toBe('1'); + })); +}); +``` + +`server-user-state-repository.spec.ts`: + +```ts +import { withTempStorage } from './server-storage.spec-helpers'; + +describe('ServerUserStateRepository', () => { + it('returns every namespace, empty by default', () => + withTempStorage(({ userState }) => { + expect(userState.all()).toEqual({ + 'xtream-favorites': {}, + 'xtream-recent-items': {}, + 'xtream-collection-items': {}, + 'xtream-playback-positions': {}, + }); + })); + + it('stores, overwrites and deletes one entry', () => + withTempStorage(({ userState }) => { + userState.put('xtream-favorites', 'p1', ['live:1']); + userState.put('xtream-favorites', 'p1', ['live:2']); + expect(userState.all()['xtream-favorites']).toEqual({ + p1: ['live:2'], + }); + + userState.delete('xtream-favorites', 'p1'); + expect(userState.all()['xtream-favorites']).toEqual({}); + })); + + it('deletes a playlist across namespaces', () => + withTempStorage(({ userState }) => { + userState.put('xtream-favorites', 'p1', ['live:1']); + userState.put('xtream-recent-items', 'p1', []); + userState.put('xtream-favorites', 'p2', ['live:3']); + + userState.deleteForPlaylist('p1'); + + const all = userState.all(); + expect(all['xtream-favorites']).toEqual({ p2: ['live:3'] }); + expect(all['xtream-recent-items']).toEqual({}); + })); +}); +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `pnpm nx test web-backend --testPathPatterns="server-playlist-repository|server-user-state-repository"` +Expected: FAIL, modules not found. + +- [ ] **Step 4: Implement the playlist repository** + +`server-playlist-repository.ts`: + +```ts +import { eq, getTableColumns } from 'drizzle-orm'; +import type { DatabaseInstance } from '@iptvnator/shared/database'; +import { + buildPlaylistRow, + parseAppPlaylist, + playlistConflictUpdate, +} from '@iptvnator/shared/database/app-playlist'; +import { appState, playlists } from '@iptvnator/shared/database/schema'; +import type { Playlist as PlaylistRow } from '@iptvnator/shared/database/schema'; +import { SERVER_PAYLOAD_FIELDS } from '@iptvnator/shared/interfaces'; + +export type PlaylistDocument = Record; + +export class PlaylistPayloadMissingError extends Error { + constructor() { + super('No stored payload to keep'); + this.name = 'PlaylistPayloadMissingError'; + } +} + +export class InvalidPlaylistDocumentError extends Error { + constructor() { + super('A playlist document needs an id'); + this.name = 'InvalidPlaylistDocumentError'; + } +} + +const { payload: _payload, ...metaColumns } = getTableColumns(playlists); + +/** Playlist documents in the shared `playlists` table, same rows as desktop. */ +export class ServerPlaylistRepository { + constructor(private readonly db: DatabaseInstance) {} + + count(): number { + return this.db.select({ id: playlists.id }).from(playlists).all() + .length; + } + + listMetas(): PlaylistDocument[] { + return this.db + .select(metaColumns) + .from(playlists) + .all() + .map((row) => + parseAppPlaylist({ ...row, payload: null } as PlaylistRow) + ); + } + + listAll(): PlaylistDocument[] { + return this.db + .select() + .from(playlists) + .all() + .map((row) => parseAppPlaylist(row)); + } + + get(playlistId: string): PlaylistDocument | null { + const row = this.db + .select() + .from(playlists) + .where(eq(playlists.id, playlistId)) + .get(); + return row ? parseAppPlaylist(row) : null; + } + + upsert( + document: PlaylistDocument, + options: { readonly keepPayload: boolean } + ): void { + const next = options.keepPayload + ? this.withStoredPayload(document) + : document; + const row = buildPlaylistRow(next); + if (!row) { + throw new InvalidPlaylistDocumentError(); + } + + this.db + .insert(playlists) + .values(row) + .onConflictDoUpdate({ + target: playlists.id, + set: playlistConflictUpdate(row, next), + }) + .run(); + } + + delete(playlistId: string): void { + this.db.delete(playlists).where(eq(playlists.id, playlistId)).run(); + } + + deleteAll(): void { + this.db.delete(playlists).run(); + } + + getAppState(key: string): string | null { + return ( + this.db + .select({ value: appState.value }) + .from(appState) + .where(eq(appState.key, key)) + .get()?.value ?? null + ); + } + + setAppState(key: string, value: string): void { + const updatedAt = new Date().toISOString(); + this.db + .insert(appState) + .values({ key, value, updatedAt }) + .onConflictDoUpdate({ + target: appState.key, + set: { value, updatedAt }, + }) + .run(); + } + + /** Fills the payload fields the client left out from the stored row. */ + private withStoredPayload(document: PlaylistDocument): PlaylistDocument { + const id = String(document['_id'] ?? ''); + const stored = this.db + .select({ payload: playlists.payload }) + .from(playlists) + .where(eq(playlists.id, id)) + .get(); + const parsed = parseStoredPayload(stored?.payload); + if (!parsed) { + throw new PlaylistPayloadMissingError(); + } + + const merged: PlaylistDocument = { ...document }; + for (const field of SERVER_PAYLOAD_FIELDS) { + if (!(field in merged) && field in parsed) { + merged[field] = parsed[field]; + } + } + return merged; + } +} + +function parseStoredPayload( + payload: string | null | undefined +): PlaylistDocument | null { + if (!payload) { + return null; + } + try { + const parsed: unknown = JSON.parse(payload); + return parsed && typeof parsed === 'object' + ? (parsed as PlaylistDocument) + : null; + } catch { + return null; + } +} +``` + +- [ ] **Step 5: Implement the user-state repository** + +`server-user-state-repository.ts`: + +```ts +import { and, eq } from 'drizzle-orm'; +import type { DatabaseInstance } from '@iptvnator/shared/database'; +import { + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, + type ServerUserStateResponse, +} from '@iptvnator/shared/interfaces'; +import { serverUserState } from './server-user-state.table'; + +/** Xtream PWA user data, one JSON value per namespace and playlist. */ +export class ServerUserStateRepository { + constructor(private readonly db: DatabaseInstance) {} + + all(): ServerUserStateResponse['entries'] { + const entries = Object.fromEntries( + SERVER_USER_STATE_NAMESPACES.map((namespace) => [namespace, {}]) + ) as ServerUserStateResponse['entries']; + + for (const row of this.db.select().from(serverUserState).all()) { + const bucket = + entries[row.namespace as ServerUserStateNamespace]; + if (bucket) { + bucket[row.playlistId] = JSON.parse(row.value); + } + } + return entries; + } + + put( + namespace: ServerUserStateNamespace, + playlistId: string, + value: unknown + ): void { + const serialized = JSON.stringify(value ?? null); + const updatedAt = new Date().toISOString(); + this.db + .insert(serverUserState) + .values({ namespace, playlistId, value: serialized, updatedAt }) + .onConflictDoUpdate({ + target: [serverUserState.namespace, serverUserState.playlistId], + set: { value: serialized, updatedAt }, + }) + .run(); + } + + delete(namespace: ServerUserStateNamespace, playlistId: string): void { + this.db + .delete(serverUserState) + .where( + and( + eq(serverUserState.namespace, namespace), + eq(serverUserState.playlistId, playlistId) + ) + ) + .run(); + } + + deleteForPlaylist(playlistId: string): void { + this.db + .delete(serverUserState) + .where(eq(serverUserState.playlistId, playlistId)) + .run(); + } + + deleteAll(): void { + this.db.delete(serverUserState).run(); + } +} +``` + +- [ ] **Step 6: Implement the aggregate** + +`server-storage.ts`: + +```ts +import { PlaylistRevisions } from './playlist-revisions'; +import { openServerDatabase, type ServerDatabase } from './server-database'; +import { ServerPlaylistRepository } from './server-playlist-repository'; +import { ServerUserStateRepository } from './server-user-state-repository'; + +export interface ServerStorage { + readonly database: ServerDatabase; + readonly playlists: ServerPlaylistRepository; + readonly userState: ServerUserStateRepository; + readonly revisions: PlaylistRevisions; + readonly startedAt: Date; +} + +export async function openServerStorage(): Promise { + const database = await openServerDatabase(); + return { + database, + playlists: new ServerPlaylistRepository(database.db), + userState: new ServerUserStateRepository(database.db), + revisions: new PlaylistRevisions(), + startedAt: new Date(), + }; +} +``` + +- [ ] **Step 7: Run to verify it passes** + +```bash +pnpm nx test web-backend --testPathPatterns="server-playlist-repository|server-user-state-repository" +``` + +Expected: PASS, 9 tests. If `initDatabase` fails to load better-sqlite3 under Node, run `node -e "new (require('better-sqlite3'))(':memory:')"`; it must succeed (Node-API binary). Do not switch the spec to the Electron runtime. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web-backend/src/app/storage +git commit -m "feat(web-backend): playlist and user-state repositories + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Errors and the access guard + +**Files:** +- Create: `apps/web-backend/src/app/storage/storage-route-errors.ts` +- Create: `apps/web-backend/src/app/storage/access-guard.ts` +- Test: `apps/web-backend/src/app/storage/storage-route-errors.spec.ts` +- Test: `apps/web-backend/src/app/storage/access-guard.spec.ts` + +- [ ] **Step 1: Write the failing specs** + +`storage-route-errors.spec.ts`: + +```ts +import { + InvalidPlaylistDocumentError, + PlaylistPayloadMissingError, +} from './server-playlist-repository'; +import { + describeStorageError, + StorageRequestError, + toStorageErrorBody, +} from './storage-route-errors'; + +describe('toStorageErrorBody', () => { + it.each([ + [new StorageRequestError(404, 'not_found', 'Playlist not found.'), 404, 'not_found'], + [new PlaylistPayloadMissingError(), 409, 'payload_missing'], + [new InvalidPlaylistDocumentError(), 400, 'invalid_body'], + [{ type: 'entity.too.large' }, 413, 'too_large'], + [{ type: 'entity.parse.failed' }, 400, 'invalid_json'], + [Object.assign(new Error('busy'), { code: 'SQLITE_BUSY' }), 503, 'storage_busy'], + [new Error('SELECT secret FROM x'), 500, 'storage_error'], + ])('maps %p to %i', (error, status, code) => { + expect(toStorageErrorBody(error)).toMatchObject({ status, code }); + }); + + it('never puts the raw error message into a 500 body or log line', () => { + const error = new Error('password=hunter2'); + + expect(JSON.stringify(toStorageErrorBody(error))).not.toContain( + 'hunter2' + ); + expect(describeStorageError(error)).toBe('Error'); + }); +}); +``` + +`access-guard.spec.ts`: + +```ts +import express from 'express'; +import { withServer } from '../web-backend-app.spec-helpers'; +import { createAccessGuard } from './access-guard'; + +function createApp() { + const app = express(); + app.use(createAccessGuard('s3cret')); + app.get('/health', (_req, res) => res.json({ ok: true })); + app.get('/v1/instance', (_req, res) => res.json({ ok: true })); + app.get('/v1/playlists', (_req, res) => res.json([])); + return app; +} + +describe('createAccessGuard', () => { + it('lets public routes through', () => + withServer(createApp(), async (baseUrl) => { + expect((await fetch(`${baseUrl}/health`)).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/instance`)).status).toBe(200); + })); + + it('rejects a missing or wrong token with a JSON 401', () => + withServer(createApp(), async (baseUrl) => { + const missing = await fetch(`${baseUrl}/v1/playlists`); + const wrong = await fetch(`${baseUrl}/v1/playlists`, { + headers: { authorization: 'Bearer nope' }, + }); + + expect(missing.status).toBe(401); + await expect(missing.json()).resolves.toMatchObject({ + code: 'unauthorized', + }); + expect(wrong.status).toBe(401); + })); + + it('accepts the right token and lets preflight through', () => + withServer(createApp(), async (baseUrl) => { + const ok = await fetch(`${baseUrl}/v1/playlists`, { + headers: { authorization: 'Bearer s3cret' }, + }); + const preflight = await fetch(`${baseUrl}/v1/playlists`, { + method: 'OPTIONS', + }); + + expect(ok.status).toBe(200); + expect(preflight.status).not.toBe(401); + })); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web-backend --testPathPatterns="storage-route-errors|access-guard"` +Expected: FAIL, modules not found. + +- [ ] **Step 3: Implement the errors module** + +`storage-route-errors.ts`: + +```ts +import type { ErrorRequestHandler } from 'express'; +import type { ServerStorageErrorBody } from '@iptvnator/shared/interfaces'; +import { + InvalidPlaylistDocumentError, + PlaylistPayloadMissingError, +} from './server-playlist-repository'; + +export class StorageRequestError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string + ) { + super(message); + this.name = 'StorageRequestError'; + } +} + +export function toStorageErrorBody(error: unknown): ServerStorageErrorBody { + if (error instanceof StorageRequestError) { + return { message: error.message, status: error.status, code: error.code }; + } + if (error instanceof PlaylistPayloadMissingError) { + return { + message: 'The server has no stored payload for this playlist.', + status: 409, + code: 'payload_missing', + }; + } + if (error instanceof InvalidPlaylistDocumentError) { + return { + message: 'A playlist needs an id.', + status: 400, + code: 'invalid_body', + }; + } + const type = (error as { type?: unknown } | null)?.type; + if (type === 'entity.too.large') { + return { + message: 'The request is larger than the server allows.', + status: 413, + code: 'too_large', + }; + } + if (type === 'entity.parse.failed') { + return { + message: 'The request body is not valid JSON.', + status: 400, + code: 'invalid_json', + }; + } + if ((error as { code?: unknown } | null)?.code === 'SQLITE_BUSY') { + return { + message: 'Storage is busy, try again.', + status: 503, + code: 'storage_busy', + }; + } + return { message: 'Storage request failed.', status: 500, code: 'storage_error' }; +} + +/** + * Error name and code only: Drizzle and SQLite messages can carry bound + * values such as portal credentials, which must never reach a log. + */ +export function describeStorageError(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name} (${code})` : name; +} + +export function storageErrorHandler( + log: (message: string) => void = console.error +): ErrorRequestHandler { + return (error, req, res, _next) => { + const body = toStorageErrorBody(error); + if (body.status >= 500) { + const route = `${req.baseUrl}${req.route?.path ?? ''}`; + log( + `[web-backend] storage ${req.method} ${route} failed: ${describeStorageError(error)}` + ); + } + res.status(body.status).json(body); + }; +} +``` + +- [ ] **Step 4: Implement the guard** + +`access-guard.ts`: + +```ts +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { RequestHandler } from 'express'; + +/** Routes a browser must reach before it has a token. */ +const PUBLIC_PATHS = new Set(['/', '/health', '/config.js', '/v1/instance']); + +/** + * Optional shared-token protection for every other route, including the + * provider proxy routes. Tokens are compared as SHA-256 digests so the + * comparison is constant-time regardless of length. + */ +export function createAccessGuard(token: string): RequestHandler { + const expected = digest(token); + + return (req, res, next) => { + if (req.method === 'OPTIONS' || PUBLIC_PATHS.has(req.path)) { + next(); + return; + } + + const match = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? ''); + if (match && timingSafeEqual(digest(match[1].trim()), expected)) { + next(); + return; + } + + res.status(401).json({ + message: 'This IPTVnator server requires an access token.', + status: 401, + code: 'unauthorized', + }); + }; +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} +``` + +- [ ] **Step 5: Run to verify, then commit** + +```bash +pnpm nx test web-backend --testPathPatterns="storage-route-errors|access-guard" +git add apps/web-backend/src/app/storage +git commit -m "feat(web-backend): storage error mapping and access token guard + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS. + +--- + +### Task 6: Routers and mounting + +**Files:** +- Create: `apps/web-backend/src/app/storage/instance-router.ts` +- Create: `apps/web-backend/src/app/storage/playlists-router.ts` +- Create: `apps/web-backend/src/app/storage/user-state-router.ts` +- Create: `apps/web-backend/src/app/storage/mount-server-storage.ts` +- Modify: `apps/web-backend/src/app/web-backend-app.ts` +- Test: `apps/web-backend/src/app/storage/storage-routes.spec.ts` + +- [ ] **Step 1: Write the failing route spec** + +`storage-routes.spec.ts`: + +```ts +import { createWebBackendApp } from '../web-backend-app'; +import { + resolvePublicHost, + StubHttpClient, + withServer, +} from '../web-backend-app.spec-helpers'; +import { m3uDocument, withTempStorage } from './server-storage.spec-helpers'; +import type { ServerStorage } from './server-storage'; + +function app( + storage: ServerStorage | null, + extra: { accessToken?: string; maxDocumentBytes?: number } = {} +) { + return createWebBackendApp({ + clientOrigins: ['http://localhost:4200'], + httpClient: new StubHttpClient(), + resolveHostname: resolvePublicHost, + storage, + ...extra, + }); +} + +const json = (body: unknown, init: RequestInit = {}): RequestInit => ({ + ...init, + body: JSON.stringify(body), + headers: { 'content-type': 'application/json', ...(init.headers ?? {}) }, +}); + +describe('server storage routes', () => { + it('reports no storage when it is disabled', () => + withServer(app(null), async (baseUrl) => { + const response = await fetch(`${baseUrl}/v1/instance`); + await expect(response.json()).resolves.toEqual({ + service: 'iptvnator', + apiVersion: 1, + instanceId: null, + storage: 'none', + auth: 'none', + }); + expect((await fetch(`${baseUrl}/v1/playlists`)).status).toBe(404); + })); + + it('reports server storage and its status', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + const instance = await (await fetch(`${baseUrl}/v1/instance`)).json(); + expect(instance).toMatchObject({ + storage: 'server', + instanceId: storage.database.instanceId, + }); + const status = await ( + await fetch(`${baseUrl}/v1/instance/status`) + ).json(); + expect(status).toMatchObject({ playlists: 0 }); + expect(status.databaseBytes).toBeGreaterThan(0); + }) + )); + + it('stores a document, serves it with an ETag and answers 304', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + const put = await fetch( + `${baseUrl}/v1/playlists/p1`, + json(m3uDocument('p1'), { method: 'PUT' }) + ); + expect(put.status).toBe(200); + const etag = put.headers.get('etag'); + expect(etag).toBeTruthy(); + + const get = await fetch(`${baseUrl}/v1/playlists/p1`); + expect(get.headers.get('etag')).toBe(etag); + await expect(get.json()).resolves.toMatchObject({ _id: 'p1' }); + + const cached = await fetch(`${baseUrl}/v1/playlists/p1`, { + headers: { 'if-none-match': etag! }, + }); + expect(cached.status).toBe(304); + }) + )); + + it('keeps the payload on keepPayload and refuses it for unknown ids', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + await fetch( + `${baseUrl}/v1/playlists/p1`, + json(m3uDocument('p1'), { method: 'PUT' }) + ); + const { playlist: _payload, ...meta } = m3uDocument('p1'); + + const keep = await fetch( + `${baseUrl}/v1/playlists/p1?keepPayload=1`, + json({ ...meta, title: 'Renamed' }, { method: 'PUT' }) + ); + const missing = await fetch( + `${baseUrl}/v1/playlists/zz?keepPayload=1`, + json({ title: 'x' }, { method: 'PUT' }) + ); + + expect(keep.status).toBe(200); + await expect( + (await fetch(`${baseUrl}/v1/playlists/p1`)).json() + ).resolves.toMatchObject({ + title: 'Renamed', + playlist: m3uDocument('p1').playlist, + }); + expect(missing.status).toBe(409); + await expect(missing.json()).resolves.toMatchObject({ + code: 'payload_missing', + }); + }) + )); + + it('lists metadata and deletes documents with their user state', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + await fetch( + `${baseUrl}/v1/playlists/p1`, + json(m3uDocument('p1'), { method: 'PUT' }) + ); + await fetch( + `${baseUrl}/v1/user-state/xtream-favorites/p1`, + json({ value: ['live:1'] }, { method: 'PUT' }) + ); + + const metas = await ( + await fetch(`${baseUrl}/v1/playlists?view=meta`) + ).json(); + expect(metas[0].playlist).toBeUndefined(); + + await fetch(`${baseUrl}/v1/playlists/p1`, { method: 'DELETE' }); + + expect((await fetch(`${baseUrl}/v1/playlists/p1`)).status).toBe(404); + const state = await (await fetch(`${baseUrl}/v1/user-state`)).json(); + expect(state.entries['xtream-favorites']).toEqual({}); + }) + )); + + it('validates user-state namespaces and stores app state', () => + withTempStorage((storage) => + withServer(app(storage), async (baseUrl) => { + const bad = await fetch( + `${baseUrl}/v1/user-state/settings/p1`, + json({ value: 1 }, { method: 'PUT' }) + ); + expect(bad.status).toBe(400); + + await fetch( + `${baseUrl}/v1/app-state/flag`, + json({ value: '1' }, { method: 'PUT' }) + ); + await expect( + (await fetch(`${baseUrl}/v1/app-state/flag`)).json() + ).resolves.toEqual({ value: '1' }); + }) + )); + + it('answers 413 above the document limit', () => + withTempStorage((storage) => + withServer(app(storage, { maxDocumentBytes: 512 }), async (baseUrl) => { + const response = await fetch( + `${baseUrl}/v1/playlists/p1`, + json({ ...m3uDocument('p1'), padding: 'x'.repeat(2048) }, { method: 'PUT' }) + ); + expect(response.status).toBe(413); + }) + )); + + it('requires the token for storage and proxy routes when one is set', () => + withTempStorage((storage) => + withServer(app(storage, { accessToken: 's3cret' }), async (baseUrl) => { + const instance = await (await fetch(`${baseUrl}/v1/instance`)).json(); + expect(instance.auth).toBe('token'); + + expect((await fetch(`${baseUrl}/v1/playlists`)).status).toBe(401); + expect( + ( + await fetch( + `${baseUrl}/provider-targets`, + json({ url: 'http://example.test' }, { method: 'POST' }) + ) + ).status + ).toBe(401); + expect( + ( + await fetch(`${baseUrl}/v1/playlists`, { + headers: { authorization: 'Bearer s3cret' }, + }) + ).status + ).toBe(200); + }) + )); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web-backend --testPathPatterns=storage-routes` +Expected: FAIL, the `storage` option does not exist on `WebBackendAppOptions`. + +- [ ] **Step 3: Implement the routers** + +`instance-router.ts`: + +```ts +import { Router } from 'express'; +import { + SERVER_STORAGE_API_VERSION, + type ServerInstanceInfo, + type ServerStorageStatusResponse, +} from '@iptvnator/shared/interfaces'; +import type { ServerStorage } from './server-storage'; +import { StorageRequestError } from './storage-route-errors'; + +export function createInstanceRouter(deps: { + readonly storage: ServerStorage | null; + readonly accessTokenRequired: boolean; +}): Router { + const router = Router(); + + router.get('/instance', (_req, res) => { + const body: ServerInstanceInfo = { + service: 'iptvnator', + apiVersion: SERVER_STORAGE_API_VERSION, + instanceId: deps.storage?.database.instanceId ?? null, + storage: deps.storage ? 'server' : 'none', + auth: deps.accessTokenRequired ? 'token' : 'none', + }; + res.set('Cache-Control', 'no-store').json(body); + }); + + router.get('/instance/status', (_req, res) => { + const storage = deps.storage; + if (!storage) { + throw new StorageRequestError( + 404, + 'storage_disabled', + 'Server storage is not enabled.' + ); + } + const body: ServerStorageStatusResponse = { + playlists: storage.playlists.count(), + databaseBytes: storage.database.sizeBytes(), + databasePath: storage.database.path, + startedAt: storage.startedAt.toISOString(), + }; + res.set('Cache-Control', 'no-store').json(body); + }); + + return router; +} +``` + +`playlists-router.ts`: + +```ts +import express, { Router, type Response } from 'express'; +import type { ServerStorage } from './server-storage'; +import { StorageRequestError } from './storage-route-errors'; + +export function createPlaylistsRouter( + storage: ServerStorage, + maxDocumentBytes: number +): Router { + const router = Router(); + const json = express.json({ limit: maxDocumentBytes }); + const { playlists, userState, revisions } = storage; + + router.get('/playlists', (req, res) => { + res.set('Cache-Control', 'no-store').json( + req.query['view'] === 'meta' + ? playlists.listMetas() + : playlists.listAll() + ); + }); + + router.get('/playlists/:id', (req, res) => { + const id = req.params.id; + const etag = revisions.etag(id); + withEtag(res, etag); + if (req.get('if-none-match') === etag) { + res.status(304).end(); + return; + } + const document = playlists.get(id); + if (!document) { + throw new StorageRequestError(404, 'not_found', 'Playlist not found.'); + } + res.json(document); + }); + + router.put('/playlists/:id', json, (req, res) => { + const id = req.params.id; + playlists.upsert( + { ...requireDocument(req.body), _id: id }, + { keepPayload: req.query['keepPayload'] === '1' } + ); + withEtag(res, revisions.bump(id)).json({ success: true }); + }); + + router.delete('/playlists/:id', (req, res) => { + const id = req.params.id; + playlists.delete(id); + userState.deleteForPlaylist(id); + revisions.bump(id); + res.json({ success: true }); + }); + + router.delete('/playlists', (_req, res) => { + playlists.deleteAll(); + userState.deleteAll(); + revisions.reset(); + res.json({ success: true }); + }); + + return router; +} + +function withEtag(res: Response, etag: string): Response { + return res + .set('ETag', etag) + .set('Access-Control-Expose-Headers', 'ETag') + .set('Cache-Control', 'no-cache'); +} + +function requireDocument(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new StorageRequestError( + 400, + 'invalid_body', + 'Expected a playlist object.' + ); + } + return value as Record; +} +``` + +`user-state-router.ts`: + +```ts +import express, { Router } from 'express'; +import { + isServerUserStateNamespace, + type ServerUserStateNamespace, +} from '@iptvnator/shared/interfaces'; +import type { ServerStorage } from './server-storage'; +import { StorageRequestError } from './storage-route-errors'; + +const USER_STATE_BODY_LIMIT = '8mb'; + +export function createUserStateRouter(storage: ServerStorage): Router { + const router = 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(), + }); + }); + + 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)) { + throw new StorageRequestError(400, 'invalid_body', 'Expected { value }.'); + } + storage.userState.put( + requireNamespace(req.params.namespace), + req.params.playlistId, + body.value + ); + res.json({ success: true }); + }); + + router.delete('/user-state/:namespace/:playlistId', (req, res) => { + storage.userState.delete( + requireNamespace(req.params.namespace), + req.params.playlistId + ); + res.json({ success: true }); + }); + + router.get('/app-state/:key', (req, res) => { + res.json({ value: storage.playlists.getAppState(req.params.key) }); + }); + + router.put('/app-state/:key', json, (req, res) => { + const value = (req.body as { value?: unknown } | undefined)?.value; + if (typeof value !== 'string') { + throw new StorageRequestError(400, 'invalid_body', 'Expected { value: string }.'); + } + storage.playlists.setAppState(req.params.key, value); + res.json({ success: true }); + }); + + return router; +} + +function requireNamespace(value: string): ServerUserStateNamespace { + if (!isServerUserStateNamespace(value)) { + throw new StorageRequestError(400, 'invalid_namespace', 'Unknown user-state namespace.'); + } + return value; +} +``` + +`mount-server-storage.ts`: + +```ts +import type { Express, RequestHandler } from 'express'; +import { createAccessGuard } from './access-guard'; +import { createInstanceRouter } from './instance-router'; +import { createPlaylistsRouter } from './playlists-router'; +import type { ServerStorage } from './server-storage'; +import { storageErrorHandler } from './storage-route-errors'; +import { createUserStateRouter } from './user-state-router'; + +export const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; + +export interface ServerStorageMountOptions { + readonly corsMiddleware: RequestHandler; + readonly storage: ServerStorage | null; + readonly accessToken: string | null; + readonly maxDocumentBytes: number; +} + +/** + * Mounts `/v1` and the optional token guard. Call it before the provider + * proxy routes are registered so the guard also covers them. With a token + * the CORS headers apply to every route, so a cross-origin 401 stays + * readable for the PWA in development. + */ +export function mountServerStorage( + app: Express, + options: ServerStorageMountOptions +): void { + app.use('/v1', options.corsMiddleware); + if (options.accessToken) { + app.use(options.corsMiddleware); + app.use(createAccessGuard(options.accessToken)); + } + + app.use( + '/v1', + createInstanceRouter({ + storage: options.storage, + accessTokenRequired: !!options.accessToken, + }) + ); + if (options.storage) { + app.use( + '/v1', + createPlaylistsRouter(options.storage, options.maxDocumentBytes) + ); + app.use('/v1', createUserStateRouter(options.storage)); + } + app.use('/v1', storageErrorHandler()); +} +``` + +- [ ] **Step 4: Mount it in `web-backend-app.ts`** + +1. Add the import: + +```ts +import { + DEFAULT_MAX_DOCUMENT_BYTES, + mountServerStorage, +} from './storage/mount-server-storage'; +import type { ServerStorage } from './storage/server-storage'; +``` + +2. Add to `WebBackendAppOptions`: + +```ts + /** Optional bearer token for every non-public route. */ + readonly accessToken?: string | null; + /** Largest accepted playlist document in bytes. */ + readonly maxDocumentBytes?: number; + /** Server storage; `null` or absent keeps the backend stateless. */ + readonly storage?: ServerStorage | null; +``` + +3. Directly after the `app.get('/config.js', ...)` route block and before `app.options('/provider-targets', corsMiddleware);`, add: + +```ts + mountServerStorage(app, { + corsMiddleware, + storage: options.storage ?? null, + accessToken: options.accessToken ?? null, + maxDocumentBytes: + options.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES, + }); +``` + +- [ ] **Step 5: Run the route spec and the existing suite** + +```bash +pnpm nx test web-backend +pnpm nx lint web-backend +``` + +Expected: all pass, including the unchanged `/health` and proxy specs. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web-backend/src/app +git commit -m "feat(web-backend): serve the /v1 server storage API + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Startup, shutdown and the bundle + +**Files:** +- Modify: `apps/web-backend/src/main.ts` +- Modify: `apps/web-backend/project.json` + +- [ ] **Step 1: Replace `main.ts`** + +```ts +import { + applyDefaultAutoSelectFamilyAttemptTimeout, + AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG, +} from '@iptvnator/shared/host-health'; +import { createWebBackendApp } from './app/web-backend-app'; +import { readServerStorageConfig } from './app/storage/storage-config'; +import { openServerStorage, type ServerStorage } from './app/storage/server-storage'; +import { describeStorageError } from './app/storage/storage-route-errors'; + +async function start(): Promise { + const attemptTimeout = applyDefaultAutoSelectFamilyAttemptTimeout(); + if (attemptTimeout !== null) { + console.log( + `[web-backend] connection attempt timeout set to ${attemptTimeout} ms for IPv6->IPv4 fallback; pass ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} via NODE_OPTIONS to override` + ); + } + + const config = readServerStorageConfig(); + let storage: ServerStorage | null = null; + if (config.enabled) { + try { + storage = await openServerStorage(); + console.log( + `[web-backend] server storage at ${storage.database.path}` + ); + } catch (error) { + console.error( + `[web-backend] server storage could not open: ${describeStorageError(error)}. Check that IPTVNATOR_DATA_DIR is a writable volume.` + ); + process.exit(1); + } + } + + const port = Number(process.env['PORT'] ?? 3000); + const app = createWebBackendApp({ + storage, + accessToken: config.accessToken, + maxDocumentBytes: config.maxDocumentBytes, + }); + const server = app.listen(port, () => { + console.log( + `IPTVnator web backend listening on http://localhost:${port}` + ); + }); + + const shutdown = () => { + server.close(() => { + storage?.database.close(); + process.exit(0); + }); + }; + process.once('SIGTERM', shutdown); + process.once('SIGINT', shutdown); +} + +void start(); +``` + +- [ ] **Step 2: Keep the native module out of the bundle** + +In `apps/web-backend/project.json`, `targets.build.options`, add after `"generatePackageJson": true`: + +```json + "external": ["better-sqlite3"] +``` + +(add the comma after `true`). + +- [ ] **Step 3: Build and smoke-test the bundle** + +```bash +pnpm nx build web-backend --skip-nx-cache +grep -c "require(\"better-sqlite3\")" dist/apps/web-backend/main.cjs +export TMPDATA="$(mktemp -d)" +IPTVNATOR_STORAGE=server IPTVNATOR_DATA_DIR="$TMPDATA" PORT=3399 NODE_PATH="$PWD/node_modules" node dist/apps/web-backend/main.cjs & +sleep 2 +curl -s http://localhost:3399/v1/instance +curl -s -X PUT -H 'content-type: application/json' -d '{"title":"Smoke"}' http://localhost:3399/v1/playlists/smoke +curl -s "http://localhost:3399/v1/playlists?view=meta" +kill %1 +ls "$TMPDATA/databases" +``` + +Expected: the grep prints at least `1`; `/v1/instance` reports `"storage":"server"`; the PUT answers `{"success":true}`; the list contains `"_id":"smoke"`; `iptvnator.db` exists in the temp directory. + +- [ ] **Step 4: Dev server** + +```bash +IPTVNATOR_STORAGE=server IPTVNATOR_DATA_DIR="$TMPDATA" pnpm nx serve web-backend & +sleep 4 +curl -s http://localhost:3333/v1/instance +kill %1 +``` + +Expected: `"storage":"server"`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web-backend/src/main.ts apps/web-backend/project.json +git commit -m "feat(web-backend): open server storage on start and close on shutdown + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Docker image, compose and nginx + +**Files:** +- Modify: `docker/Dockerfile` +- Modify: `docker/docker-compose.yml` +- Modify: `docker/nginx.conf` + +- [ ] **Step 1: Native build stage and runtime settings** + +In `docker/Dockerfile`, insert this stage between the build stage and `FROM node:24-alpine` (the runtime stage): + +```Dockerfile +# better-sqlite3 is a native addon. This stage is NOT pinned to +# $BUILDPLATFORM, so multi-arch builds produce it for each target platform. +# prebuild-install downloads a musl binary when one exists and otherwise +# compiles with the toolchain below. +FROM node:24-alpine AS native + +RUN apk add --no-cache python3 make g++ + +WORKDIR /opt/native + +COPY package.json ./root-package.json + +RUN BETTER_SQLITE3_VERSION="$(node -p 'require("./root-package.json").dependencies["better-sqlite3"]')" \ + && npm init -y >/dev/null \ + && npm install --omit=dev --no-audit --no-fund "better-sqlite3@${BETTER_SQLITE3_VERSION}" +``` + +In the runtime stage: + +1. After `ENV CLIENT_URL=http://localhost:4333` add: + +```Dockerfile +ENV IPTVNATOR_STORAGE=server +ENV IPTVNATOR_DATA_DIR=/data +``` + +2. After `COPY --from=build /usr/src/app/dist/apps/web-backend ./web-backend` add: + +```Dockerfile +COPY --from=native /opt/native/node_modules ./web-backend/node_modules +``` + +3. Replace `RUN chmod +x /usr/local/bin/iptvnator-entrypoint` with: + +```Dockerfile +RUN chmod +x /usr/local/bin/iptvnator-entrypoint && mkdir -p /data + +VOLUME ["/data"] +``` + +- [ ] **Step 2: Compose** + +Replace `docker/docker-compose.yml` with: + +```yaml +--- +services: + iptvnator: + image: 4gray/iptvnator:latest + build: + context: .. + dockerfile: docker/Dockerfile + restart: unless-stopped + ports: + - "4333:80" + environment: + BACKEND_URL: /api + CLIENT_URL: http://localhost:4333 + PORT: "3000" + IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: "0" + # Playlists and watch data live on the server for every device. + # Set to "none" to keep data only in each browser. + IPTVNATOR_STORAGE: server + # Uncomment to require a shared token before anyone can use this server. + # IPTVNATOR_ACCESS_TOKEN: change-me + volumes: + - iptvnator-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + iptvnator-data: {} +``` + +- [ ] **Step 3: nginx body limit and compression** + +In `docker/nginx.conf`, inside `location /api/ { ... }` after `proxy_set_header X-Forwarded-Proto $scheme;`, add: + +```nginx + # Playlist documents carry whole M3U payloads (default backend + # limit IPTVNATOR_MAX_DOCUMENT_MB=64). + client_max_body_size 64m; + proxy_read_timeout 120s; + gzip on; + gzip_proxied any; + gzip_types application/json; + gzip_min_length 1024; +``` + +- [ ] **Step 4: Validate** + +```bash +docker compose -f docker/docker-compose.yml config >/dev/null && echo compose-ok +docker build -t iptvnator:server-storage-test -f docker/Dockerfile . +docker run -d --name iptvnator-storage-test -p 4334:80 -v iptvnator-storage-test:/data iptvnator:server-storage-test +sleep 8 +curl -s http://localhost:4334/api/v1/instance +docker restart iptvnator-storage-test && sleep 8 +curl -s http://localhost:4334/api/v1/instance +docker rm -f iptvnator-storage-test && docker volume rm iptvnator-storage-test +``` + +Expected: `compose-ok`; the image builds; both `/api/v1/instance` calls report `"storage":"server"` with the same `instanceId` (the volume persisted the database). If no Docker daemon is available, report the skipped steps and rely on `docker.yml` in CI (`gh workflow run docker.yml --ref -f push=false`). + +- [ ] **Step 5: Commit** + +```bash +git add docker +git commit -m "feat(docker): persist server storage on a volume + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: Documentation and the pull request + +**Files:** +- Modify: `docker/README.md` +- Modify: `docs/architecture/pwa-self-hosted.md` +- Modify: `README.md` + +- [ ] **Step 1: `docker/README.md`** + +1. In the runtime variables table, add rows: + +```markdown +| `IPTVNATOR_STORAGE` | `server` in the image | `server` keeps playlists and watch data in SQLite under `IPTVNATOR_DATA_DIR`; `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. | +| `IPTVNATOR_MAX_DOCUMENT_MB` | `64` | Largest playlist document the backend accepts. Keep nginx `client_max_body_size` in step. | +``` + +2. Add a section "Server storage" after "Runtime Configuration": + +```markdown +## Server storage + +The image stores playlists, M3U and Stalker favorites and history, and Xtream +favorites, history and playback positions in SQLite on the `/data` volume, so +every device that opens the instance sees the same library. Provider +credentials are stored unencrypted in that database, like the desktop app +does. Do not expose an instance with server storage to the internet without +`IPTVNATOR_ACCESS_TOKEN` or authentication in your reverse proxy. + +Back up the volume like any database: stop the container or copy +`databases/iptvnator.db` together with its `-wal` file. +``` + +3. In "PWA Limitations", change the bullet about IndexedDB to: "Without server storage (`IPTVNATOR_STORAGE=none`), playlist metadata stays in each browser's IndexedDB and Xtream user data in its localStorage." + +- [ ] **Step 2: `docs/architecture/pwa-self-hosted.md`** + +1. In "Web Backend", add the `/v1` routes from spec section 6.1 to the route list and a paragraph: "Server storage lives in `apps/web-backend/src/app/storage/`. It reuses `initDatabase()` and the shared playlist codec; `server_user_state` is the only server-only table. Storage errors are logged by error name and route only." +2. In "Docker Runtime", list the new defaults `IPTVNATOR_STORAGE=server` and `IPTVNATOR_DATA_DIR=/data` and the native build stage. +3. In "Validation", add `pnpm nx test web-backend --testPathPatterns=storage`. + +- [ ] **Step 3: `README.md`** + +In "Self-hosted PWA", add one sentence after the compose command: "The container keeps playlists and watch progress on its `/data` volume, so every device that opens it shares one library; see `docker/README.md` for the access token." + +- [ ] **Step 4: Gate and push** + +```bash +pnpm nx run-many -t test,lint -p web-backend shared-interfaces database +pnpm nx build web-backend +pnpm run typecheck:ci +pnpm run typecheck:spec +pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted +``` + +Expected: all pass. Then run the local review gate, push and open the PR: + +```bash +git push -u origin claude/server-storage-backend +gh pr create --title "feat(self-hosted): server storage API in web-backend" --label no-release-note --body "$(cat <<'EOF' +Adds optional server storage to web-backend (#305), not yet used by the PWA (phase 2b). + +- `IPTVNATOR_STORAGE=server` opens the shared SQLite database under `IPTVNATOR_DATA_DIR`. +- `/v1` API: instance, playlists (ETag, keepPayload), user-state, app-state. +- Optional `IPTVNATOR_ACCESS_TOKEN` guards every non-public route, proxy included. +- Docker: native better-sqlite3 stage per target platform, `/data` volume, nginx body limit and gzip. + +No release note yet: nothing user-visible until the PWA uses it in the next PR. + +Spec: docs/superpowers/specs/2026-10-08-server-storage-design.md + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` diff --git a/.plans/2026-10-11-server-storage-phase-2b-pwa.md b/.plans/2026-10-11-server-storage-phase-2b-pwa.md new file mode 100644 index 000000000..4d31d52ed --- /dev/null +++ b/.plans/2026-10-11-server-storage-phase-2b-pwa.md @@ -0,0 +1,3790 @@ +# Server Storage Phase 2b: PWA Server Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the self-hosted backend reports server storage, the PWA keeps playlists and Xtream user data on the server, offers to move browser data once, and shows the connection in Settings, the rail and an offline banner. + +**Architecture:** `main.ts` probes `GET /v1/instance` only when the deployment wrote a runtime `BACKEND_URL`. On `storage: 'server'` it dynamically imports `server-storage.providers.ts`, which registers the server session, the server-synced `USER_DATA_STORAGE`, an access-token interceptor and two app initializers. The session plugs `ServerPlaylistDocumentStore` (HTTP, ETag cache, `keepPayload`) into `PlaylistStorageBackend`, so `PlaylistsService` is unchanged. A tiny `ServerStorageStatusService` in the main `@iptvnator/services` barrel carries the connection state for the rail, banner, settings and guards. Everything else lives behind the sub-entry `@iptvnator/services/server-storage`. + +**Tech Stack:** Angular 22 (signals, standalone components, `provideAppInitializer`), Angular Material, ngx-translate, ngx-indexed-db, Jest 30, Playwright. + +Prerequisites: Phase 1 and Phase 2a merged (or rebased into this branch). +Spec: `docs/superpowers/specs/2026-10-08-server-storage-design.md`, sections 5–8. +Before any UI task (Tasks 9–12) read `.codex/skills/iptvnator-ui-design/SKILL.md`, `.codex/skills/iptvnator-theme-style/SKILL.md` and `docs/architecture/iptvnator-ui-guidelines.md` ("Settings Surfaces", "Shared Tokens"). + +--- + +## File map + +| File | Change | Responsibility | +| --- | --- | --- | +| `libs/services/src/lib/server-storage-status.service.ts` | create | Connection state signals (main barrel) | +| `libs/services/src/lib/server-access-token.ts` | create | Token persistence and `Authorization` header (main barrel) | +| `libs/services/src/lib/playlist-storage-backend.ts` | modify | `server` kind, temporary browser override | +| `libs/services/src/lib/server-storage/server-storage.config.ts` | create | `SERVER_STORAGE_CONFIG` token | +| `libs/services/src/lib/server-storage/server-api-client.ts` | create | `fetch` for `/v1`, errors, state reporting | +| `libs/services/src/lib/server-storage/server-playlist-document-store.ts` | create | `PlaylistDocumentStore` over HTTP | +| `libs/services/src/lib/server-storage/server-user-data-storage.ts` | create | Write-through `USER_DATA_STORAGE` | +| `libs/services/src/lib/server-storage/server-storage-session.ts` | create | Wires the above, refresh, flush, snackbar | +| `libs/services/src/lib/server-storage/server-access-token.interceptor.ts` | create | Adds the token to backend `HttpClient` calls | +| `libs/services/src/lib/server-storage/server-migration.service.ts` | create | Detect and move browser data | +| `libs/services/src/lib/server-storage/index.ts` | create | Sub-entry barrel | +| `tsconfig.base.json` | modify | Alias `@iptvnator/services/server-storage` | +| `apps/web/src/app/server-storage/server-instance-probe.ts` | create | Startup probe | +| `apps/web/src/app/server-storage/server-storage.providers.ts` | create | Providers (dynamic import) | +| `apps/web/src/app/server-storage/server-access.guard.ts` | create | Route guards | +| `apps/web/src/app/server-storage/server-sign-in.component.*` | create | Token page | +| `apps/web/src/app/server-storage/server-migration-prompt.service.ts` | create | Opens the migration dialog once | +| `apps/web/src/app/server-storage/server-migration-dialog.component.*` | create | Migration dialog | +| `apps/web/src/main.ts`, `apps/web/src/app/app.routes.ts` | modify | Probe and routes | +| `apps/web/src/app/services/pwa.service.ts` | modify | Token on the raw Stalker `fetch` | +| `apps/web/src/app/settings/settings-server-section.component.*` | create | Settings → Server | +| `apps/web/src/app/settings/settings-options.ts`, `settings.component.ts/.html`, `settings-delete-all-playlists-dialog.component.*` | modify | Section wiring, delete-all warning | +| `libs/workspace/shell/util/src/lib/settings-search/*` | modify | `server` section and requirement | +| `libs/workspace/shell/feature/.../workspace-shell-rail/*` | modify | Server indicator | +| `libs/workspace/shell/feature/.../workspace-shell-server-banner/*` | create | Offline banner | +| `libs/workspace/shell/feature/.../workspace-shell.component.ts/.html` | modify | Host the banner | +| `apps/web/src/assets/i18n/*.json` | modify | 19 locales | +| `apps/web-e2e/playwright.config.ts`, `apps/web-backend/src/app/project-config.spec.ts`, `apps/web-e2e/src/server-storage.e2e.ts` | modify/create | Storage backend for E2E | +| `.changes/self-hosted-server-storage.md` | create | Release note | +| `docs/architecture/pwa-self-hosted.md`, `docs/architecture/workspace-shell.md`, `docker/README.md`, `README.md` | modify | Docs | + +--- + +### Task 1: Connection state and access token (main barrel) + +**Files:** +- Create: `libs/services/src/lib/server-storage-status.service.ts` +- Create: `libs/services/src/lib/server-access-token.ts` +- Test: `libs/services/src/lib/server-storage-status.service.spec.ts` +- Test: `libs/services/src/lib/server-access-token.spec.ts` +- Modify: `libs/services/src/index.ts` + +- [ ] **Step 1: Write the failing specs** + +`server-storage-status.service.spec.ts`: + +```ts +import { TestBed } from '@angular/core/testing'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +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'); + }); +}); +``` + +`server-access-token.spec.ts`: + +```ts +import { + clearServerAccessToken, + readServerAccessToken, + SERVER_ACCESS_TOKEN_STORAGE_KEY, + serverAuthorizationHeaders, + writeServerAccessToken, +} from './server-access-token'; + +describe('server access token', () => { + afterEach(() => localStorage.clear()); + + it('has no header without a token', () => { + expect(readServerAccessToken()).toBeNull(); + expect(serverAuthorizationHeaders()).toEqual({}); + }); + + it('stores a trimmed token and builds the bearer header', () => { + writeServerAccessToken(' s3cret '); + + expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe('s3cret'); + expect(serverAuthorizationHeaders()).toEqual({ + Authorization: 'Bearer s3cret', + }); + + clearServerAccessToken(); + expect(readServerAccessToken()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns="server-storage-status|server-access-token"` +Expected: FAIL, modules not found. + +- [ ] **Step 3: Implement** + +`server-storage-status.service.ts`: + +```ts +import { computed, Injectable, signal } from '@angular/core'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; + +export type ServerConnectionState = + | 'inactive' + | 'online' + | 'offline' + | 'unauthorized'; + +/** + * 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; + + readonly state = this.stateSignal.asReadonly(); + readonly instance = this.instanceSignal.asReadonly(); + readonly lastSuccessAt = this.lastSuccessSignal.asReadonly(); + readonly isActive = computed(() => this.stateSignal() !== 'inactive'); + + activate(instance: ServerInstanceInfo, retry: () => void): void { + this.instanceSignal.set(instance); + this.retryHandler = retry; + this.stateSignal.set('online'); + } + + reportSuccess(): void { + if (!this.isActive()) { + return; + } + this.stateSignal.set('online'); + this.lastSuccessSignal.set(new Date()); + } + + reportOffline(): void { + if (this.isActive()) { + this.stateSignal.set('offline'); + } + } + + reportUnauthorized(): void { + if (this.isActive()) { + this.stateSignal.set('unauthorized'); + } + } + + retry(): void { + this.retryHandler?.(); + } +} +``` + +`server-access-token.ts`: + +```ts +export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token'; + +export function readServerAccessToken(): string | null { + try { + const token = localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)?.trim(); + return token ? token : null; + } catch { + return null; + } +} + +export function writeServerAccessToken(token: string): void { + try { + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, token.trim()); + } catch { + // Storage unavailable (private mode): the token cannot persist. + } +} + +export function clearServerAccessToken(): void { + try { + localStorage.removeItem(SERVER_ACCESS_TOKEN_STORAGE_KEY); + } catch { + // Nothing stored. + } +} + +/** `Authorization` header for calls to the self-hosted backend. */ +export function serverAuthorizationHeaders(): Record { + const token = readServerAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} +``` + +Append to `libs/services/src/index.ts`: + +```ts +export * from './lib/server-storage-status.service'; +export * from './lib/server-access-token'; +``` + +- [ ] **Step 4: Run to verify it passes, then commit** + +```bash +pnpm nx test services --testPathPatterns="server-storage-status|server-access-token" +git add libs/services/src/lib/server-storage-status.service.ts libs/services/src/lib/server-storage-status.service.spec.ts libs/services/src/lib/server-access-token.ts libs/services/src/lib/server-access-token.spec.ts libs/services/src/index.ts +git commit -m "feat(services): server storage connection state and access token + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 4 tests. + +--- + +### Task 2: `PlaylistStorageBackend` learns the server kind + +**Files:** +- Modify: `libs/services/src/lib/playlist-storage-backend.ts` +- Modify: `libs/services/src/lib/playlist-storage-backend.spec.ts` + +- [ ] **Step 1: Add failing tests** + +Append inside the `describe` of `playlist-storage-backend.spec.ts`: + +```ts + 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); + }); + + it('falls back to browser storage while an override task runs', async () => { + testWindow.electron = undefined; + const backend = create(false); + backend.useServerStore({ dbGetAppPlaylists: jest.fn() } as never); + + const seen = await backend.runWithBrowserStorage(async () => [ + backend.kind(), + backend.usesBrowserStorageOverride(), + ]); + + expect(seen).toEqual(['indexeddb', true]); + expect(backend.kind()).toBe('server'); + expect(backend.usesBrowserStorageOverride()).toBe(false); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns=playlist-storage-backend` +Expected: FAIL, `useServerStore` is not a function. + +- [ ] **Step 3: Implement** + +Replace the class and the kind type in `playlist-storage-backend.ts` (keep `PlaylistDocumentStore` as is): + +```ts +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; + private browserOverrideDepth = 0; + + /** Server mode: set once by the server storage session at startup. */ + useServerStore(store: PlaylistDocumentStore): void { + this.serverStore = store; + } + + usesBrowserStorageOverride(): boolean { + return this.browserOverrideDepth > 0; + } + + /** + * Runs `task` against browser storage although server mode is active. + * Only the migration export uses it, inside a modal flow. + */ + async runWithBrowserStorage(task: () => Promise): Promise { + this.browserOverrideDepth += 1; + try { + return await task(); + } finally { + this.browserOverrideDepth -= 1; + } + } + + kind(): PlaylistStorageKind { + if (this.serverStore && this.browserOverrideDepth === 0) { + return 'server'; + } + return this.runtime.supportsSqlite ? 'electron' : 'indexeddb'; + } + + /** The document store for `kind()`, or `null` for IndexedDB. */ + documentStore(): PlaylistDocumentStore | null { + 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; + } +} +``` + +- [ ] **Step 4: Run, then commit** + +```bash +pnpm nx test services --testPathPatterns="playlist-storage-backend|playlists.service|playlist-migration" +git add libs/services/src/lib/playlist-storage-backend.ts libs/services/src/lib/playlist-storage-backend.spec.ts +git commit -m "feat(services): server kind for the playlist storage backend + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS; the existing `PlaylistsService` specs are unaffected. + +--- + +### Task 3: API client and the sub-entry + +**Files:** +- Create: `libs/services/src/lib/server-storage/server-storage.config.ts` +- Create: `libs/services/src/lib/server-storage/server-api-client.ts` +- Create: `libs/services/src/lib/server-storage/index.ts` +- Test: `libs/services/src/lib/server-storage/server-api-client.spec.ts` +- Modify: `tsconfig.base.json` + +- [ ] **Step 1: Write the failing spec** + +```ts +import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token'; +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(), + reportUnauthorized: jest.fn(), + }; + const onWriteFailure = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + }); + + function client(fetchFn: jest.Mock) { + return new ServerApiClient({ + baseUrl: '/api/', + status, + onWriteFailure, + fetchFn: fetchFn as unknown as typeof fetch, + }); + } + + it('calls /v1 with the token and reports success', async () => { + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, '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('clears the token and reports unauthorized on 401', async () => { + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old'); + const fetchFn = jest.fn().mockResolvedValue(response(401, { code: 'unauthorized' })); + + await expect(client(fetchFn).getJson('/playlists')).rejects.toBeInstanceOf(ServerApiError); + expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBeNull(); + expect(status.reportUnauthorized).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(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns=server-api-client` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement** + +`server-storage.config.ts`: + +```ts +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; +} + +export const SERVER_STORAGE_CONFIG = new InjectionToken( + 'SERVER_STORAGE_CONFIG' +); +``` + +`server-api-client.ts`: + +```ts +import type { ServerStorageErrorBody } from '@iptvnator/shared/interfaces'; +import { + clearServerAccessToken, + serverAuthorizationHeaders, +} from '../server-access-token'; +import type { ServerStorageStatusService } from '../server-storage-status.service'; + +const REQUEST_TIMEOUT_MS = 20_000; + +export class ServerApiError extends Error { + constructor( + readonly status: number, + readonly code: string | undefined, + message: string + ) { + super(message); + this.name = 'ServerApiError'; + } +} + +export interface ServerApiClientOptions { + readonly baseUrl: string; + readonly status: Pick< + ServerStorageStatusService, + 'reportSuccess' | 'reportOffline' | 'reportUnauthorized' + >; + /** Failed writes the user should hear about: network, 413, 5xx. */ + readonly onWriteFailure?: (error: ServerApiError) => void; + readonly fetchFn?: typeof fetch; + readonly timeoutMs?: number; +} + +/** `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`; + } + + url(path: string): string { + return `${this.root}${path}`; + } + + async request(path: string, init: RequestInit = {}): Promise { + const isWrite = (init.method ?? 'GET') !== 'GET'; + let response: Response; + try { + response = await this.fetchFn()(this.url(path), { + ...init, + cache: 'no-store', + headers: { + ...serverAuthorizationHeaders(), + ...(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) { + clearServerAccessToken(); + this.options.status.reportUnauthorized(); + throw new ServerApiError(401, 'unauthorized', 'The server needs an access token.'); + } + + if (!response.ok && response.status !== 304) { + const body = await readErrorBody(response); + const error = new ServerApiError(response.status, body.code, body.message); + 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 (await this.request(path)).json() as Promise; + } + + sendJson(method: 'PUT' | 'POST', path: string, body: unknown): Promise { + return this.request(path, { + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + } + + /** Best effort while the page is hidden or closed; errors are ignored. */ + sendKeepalive(method: 'PUT' | 'DELETE', path: string, body?: unknown): void { + void this.fetchFn()(this.url(path), { + method, + keepalive: true, + headers: { + ...serverAuthorizationHeaders(), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }).catch(() => undefined); + } + + 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<{ message: string; code?: string }> { + try { + const body = (await response.json()) as Partial; + return { message: body.message ?? response.statusText, code: body.code }; + } catch { + return { message: response.statusText }; + } +} +``` + +`index.ts`: + +```ts +export * from './server-storage.config'; +export * from './server-api-client'; +``` + +In `tsconfig.base.json`, after the `"@iptvnator/services/playlist-backup"` entry add: + +```json +"@iptvnator/services/server-storage": [ + "libs/services/src/lib/server-storage/index.ts" +], +``` + +- [ ] **Step 4: Run, then commit** + +```bash +pnpm nx test services --testPathPatterns=server-api-client +git add libs/services/src/lib/server-storage tsconfig.base.json +git commit -m "feat(services): server storage API client + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 4 tests. + +--- + +### Task 4: `ServerPlaylistDocumentStore` + +**Files:** +- Create: `libs/services/src/lib/server-storage/server-playlist-document-store.ts` +- Test: `libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts` +- Modify: `libs/services/src/lib/server-storage/index.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { ServerApiError } from './server-api-client'; +import { ServerPlaylistDocumentStore } from './server-playlist-document-store'; + +function fakeResponse(status: number, body?: unknown, etag?: string) { + return { + status, + ok: status >= 200 && status < 300, + headers: new Headers(etag ? { etag } : {}), + json: async () => body, + } as unknown as Response; +} + +describe('ServerPlaylistDocumentStore', () => { + const doc = { + _id: 'p1', + title: 'Family', + favorites: [], + playlist: { header: { raw: '#EXTM3U' }, items: [{ id: 'a' }] }, + } as unknown as Playlist; + + function setup() { + const api = { + request: jest.fn(), + getJson: jest.fn(), + sendJson: jest.fn(), + }; + return { api, store: new ServerPlaylistDocumentStore(api as never) }; + } + + it('caches by ETag and serves a private copy on 304', async () => { + const { api, store } = setup(); + api.request + .mockResolvedValueOnce(fakeResponse(200, doc, '"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': '"b.0.1"' }, + }); + expect(second).toEqual(doc); + expect(second).not.toBe(first); + }); + + it('returns null for an unknown playlist', async () => { + const { api, store } = setup(); + api.request.mockRejectedValue(new ServerApiError(404, 'not_found', 'x')); + + await expect(store.dbGetAppPlaylist('zz')).resolves.toBeNull(); + }); + + it('omits an unchanged payload and keeps it on the server', async () => { + const { api, store } = setup(); + api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"')); + api.sendJson.mockResolvedValue(fakeResponse(200, { success: true }, '"b.0.2"')); + const served = (await store.dbGetAppPlaylist('p1'))!; + + await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] }); + + const [method, path, body] = api.sendJson.mock.calls[0]; + expect([method, path]).toEqual(['PUT', '/playlists/p1?keepPayload=1']); + expect(body).toEqual({ _id: 'p1', title: 'Family', favorites: ['a'] }); + }); + + it('uploads a new payload in full', async () => { + const { api, store } = setup(); + api.sendJson.mockResolvedValue(fakeResponse(200, { success: true }, '"b.0.1"')); + + await store.dbUpsertAppPlaylist(doc); + + expect(api.sendJson.mock.calls[0][1]).toBe('/playlists/p1'); + expect(api.sendJson.mock.calls[0][2]).toBe(doc); + }); + + it('retries in full when the server has no payload to keep', async () => { + const { api, store } = setup(); + api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"')); + const served = (await store.dbGetAppPlaylist('p1'))!; + 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); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns=server-playlist-document-store` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement** + +```ts +import { + SERVER_PAYLOAD_FIELDS, + type ElectronBridgeCountResult, + type ElectronBridgeResult, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import type { PlaylistDocumentStore } from '../playlist-storage-backend'; +import { ServerApiError, type ServerApiClient } from './server-api-client'; + +interface CachedDocument { + readonly etag: string; + readonly document: Playlist; +} + +type ServerApi = Pick; + +/** + * `PlaylistDocumentStore` over the server storage API. Full documents are + * cached by ETag; every read hands out a private copy and remembers its + * payload objects, so an upsert whose payload is one of them omits it + * (`keepPayload=1`). `PlaylistsService` builds a new object per mutation, + * so cached documents are never mutated in place. + */ +export class ServerPlaylistDocumentStore implements PlaylistDocumentStore { + private readonly cache = new Map(); + private readonly servedPayloads = new WeakSet(); + + constructor(private readonly api: ServerApi) {} + + dbGetAppPlaylists(): Promise { + return this.api.getJson('/playlists'); + } + + dbGetAppPlaylistMetas(): Promise { + return this.api.getJson('/playlists?view=meta'); + } + + async dbGetAppPlaylist(playlistId: string): Promise { + const cached = this.cache.get(playlistId); + let response: Response; + try { + response = await this.api.request( + `/playlists/${encodeURIComponent(playlistId)}`, + cached ? { headers: { 'If-None-Match': cached.etag } } : {} + ); + } catch (error) { + if (error instanceof ServerApiError && error.status === 404) { + this.cache.delete(playlistId); + return null; + } + throw error; + } + + if (response.status === 304 && cached) { + return this.serve(cached.document); + } + const document = (await response.json()) as Playlist; + this.remember(playlistId, response, document); + return this.serve(document); + } + + async dbUpsertAppPlaylist(playlist: Playlist): Promise { + const path = `/playlists/${encodeURIComponent(playlist._id)}`; + const keepPayload = this.carriesServedPayload(playlist); + let response: Response; + try { + response = keepPayload + ? await this.api.sendJson('PUT', `${path}?keepPayload=1`, withoutPayload(playlist)) + : await this.api.sendJson('PUT', path, playlist); + } catch (error) { + if (!(keepPayload && error instanceof ServerApiError && error.code === 'payload_missing')) { + throw error; + } + response = await this.api.sendJson('PUT', path, playlist); + } + this.remember(playlist._id, response, playlist); + return { success: true }; + } + + async dbUpsertAppPlaylists(playlists: Playlist[]): Promise { + for (const playlist of playlists) { + await this.dbUpsertAppPlaylist(playlist); + } + return { success: true, count: playlists.length }; + } + + async dbDeletePlaylist(playlistId: string): Promise { + await this.api.request(`/playlists/${encodeURIComponent(playlistId)}`, { + method: 'DELETE', + }); + this.cache.delete(playlistId); + return { success: true }; + } + + async dbDeleteAllPlaylists(): Promise { + await this.api.request('/playlists', { method: 'DELETE' }); + this.cache.clear(); + return { success: true }; + } + + async dbGetAppState(key: string): Promise { + const body = await this.api.getJson<{ value: string | null }>( + `/app-state/${encodeURIComponent(key)}` + ); + return body.value; + } + + async dbSetAppState(key: string, value: string): Promise { + await this.api.sendJson('PUT', `/app-state/${encodeURIComponent(key)}`, { value }); + return { success: true }; + } + + private remember(id: string, response: Response, document: Playlist): void { + const etag = response.headers.get('etag'); + if (etag) { + this.cache.set(id, { etag, document }); + } else { + this.cache.delete(id); + } + } + + private serve(document: Playlist): Playlist { + const copy = structuredClone(document); + const record = copy as unknown as Record; + for (const field of SERVER_PAYLOAD_FIELDS) { + const value = record[field]; + if (value && typeof value === 'object') { + this.servedPayloads.add(value); + } + } + return copy; + } + + private carriesServedPayload(playlist: Playlist): boolean { + const record = playlist as unknown as Record; + const present = SERVER_PAYLOAD_FIELDS.filter( + (field) => record[field] !== undefined && record[field] !== null + ); + return ( + present.length > 0 && + present.every( + (field) => + typeof record[field] === 'object' && + this.servedPayloads.has(record[field] as object) + ) + ); + } +} + +function withoutPayload(playlist: Playlist): Record { + const copy = { ...playlist } as Record; + for (const field of SERVER_PAYLOAD_FIELDS) { + delete copy[field]; + } + return copy; +} +``` + +Add to `server-storage/index.ts`: + +```ts +export * from './server-playlist-document-store'; +``` + +- [ ] **Step 4: Run, then commit** + +```bash +pnpm nx test services --testPathPatterns=server-playlist-document-store +git add libs/services/src/lib/server-storage +git commit -m "feat(services): playlist document store over the server API + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 5 tests. + +--- + +### Task 5: `ServerUserDataStorage` + +**Files:** +- Create: `libs/services/src/lib/server-storage/server-user-data-storage.ts` +- Test: `libs/services/src/lib/server-storage/server-user-data-storage.spec.ts` +- Modify: `libs/services/src/lib/server-storage/index.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +import { ServerUserDataStorage } from './server-user-data-storage'; + +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), + }; +} + +describe('ServerUserDataStorage', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function setup(useLocal = false) { + 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(), + }; + const onWriteFailure = jest.fn(); + const storage = new ServerUserDataStorage( + api as never, + local, + () => useLocal, + 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('iptvnator-server:xtream-favorites')).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', + }); + expect(api.sendJson).toHaveBeenCalledTimes(1); + }); + + it('passes other keys and the browser override to plain storage', () => { + const { api, local, storage } = setup(true); + + storage.setItem('xtream-favorites', '{"legacy":[]}'); + storage.setItem('xtream-playlists', '[]'); + + expect(local.values.get('xtream-favorites')).toBe('{"legacy":[]}'); + 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(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns=server-user-data-storage` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement** + +```ts +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'; + +/** Server-mode cache keys; the legacy unprefixed keys are never touched. */ +export const SERVER_CACHE_PREFIX = 'iptvnator-server:'; +const FLUSH_DELAY_MS = 500; + +interface PendingEntry { + readonly namespace: ServerUserStateNamespace; + readonly playlistId: string; + /** `undefined` deletes the entry. */ + readonly value: unknown; +} + +type ServerApi = Pick; + +/** + * Write-through `USER_DATA_STORAGE` for server mode. Synced namespaces are + * maps keyed by playlist id; a write compares the new map with the last one + * the server saw and sends only the playlist entries that changed. Other + * keys, and every key while the browser override runs, use plain storage. + */ +export class ServerUserDataStorage implements UserDataStorage { + private readonly synced = new Map>(); + private readonly pending = new Map(); + private flushTimer: ReturnType | null = null; + + constructor( + private readonly api: ServerApi, + private readonly local: UserDataStorage, + private readonly useLocal: () => boolean, + private readonly onWriteFailure: (error: unknown) => void + ) {} + + getItem(key: string): string | null { + return this.isSynced(key) + ? this.local.getItem(SERVER_CACHE_PREFIX + key) + : this.local.getItem(key); + } + + setItem(key: string, value: string): void { + if (!this.isSynced(key)) { + this.local.setItem(key, value); + return; + } + this.local.setItem(SERVER_CACHE_PREFIX + key, value); + this.queueChanges(key, parseMap(value)); + } + + removeItem(key: string): void { + if (!this.isSynced(key)) { + this.local.removeItem(key); + return; + } + this.local.removeItem(SERVER_CACHE_PREFIX + key); + this.queueChanges(key, {}); + } + + /** Replaces the cache with the server's data, after sending pending writes. */ + async hydrate(): Promise { + await this.flush(); + const { entries } = await this.api.getJson('/user-state'); + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const value = entries[namespace] ?? {}; + this.synced.set(namespace, value); + this.local.setItem(SERVER_CACHE_PREFIX + namespace, JSON.stringify(value)); + } + } + + async flush(): Promise { + const batch = this.takePending(); + await Promise.all(batch.map((entry) => this.send(entry))); + } + + flushWithKeepalive(): void { + for (const entry of this.takePending()) { + const path = entryPath(entry); + if (entry.value === undefined) { + this.api.sendKeepalive('DELETE', path); + } else { + this.api.sendKeepalive('PUT', path, { value: entry.value }); + } + } + } + + private isSynced(key: string): key is ServerUserStateNamespace { + return !this.useLocal() && isServerUserStateNamespace(key); + } + + private queueChanges( + namespace: ServerUserStateNamespace, + next: Record + ): void { + const previous = this.synced.get(namespace) ?? {}; + for (const [playlistId, value] of Object.entries(next)) { + if (JSON.stringify(previous[playlistId]) !== JSON.stringify(value)) { + this.pending.set(`${namespace}|${playlistId}`, { namespace, playlistId, value }); + } + } + for (const playlistId of Object.keys(previous)) { + if (!(playlistId in next)) { + this.pending.set(`${namespace}|${playlistId}`, { + namespace, + playlistId, + value: undefined, + }); + } + } + this.synced.set(namespace, next); + this.scheduleFlush(); + } + + private scheduleFlush(): void { + if (this.flushTimer !== null || this.pending.size === 0) { + return; + } + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flush(); + }, FLUSH_DELAY_MS); + } + + private takePending(): PendingEntry[] { + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + const batch = [...this.pending.values()]; + this.pending.clear(); + return batch; + } + + private async send(entry: PendingEntry): Promise { + try { + if (entry.value === undefined) { + await this.api.request(entryPath(entry), { method: 'DELETE' }); + } else { + await this.api.sendJson('PUT', entryPath(entry), { value: entry.value }); + } + } catch (error) { + this.onWriteFailure(error); + } + } +} + +function entryPath(entry: PendingEntry): string { + return `/user-state/${entry.namespace}/${encodeURIComponent(entry.playlistId)}`; +} + +function parseMap(value: string): Record { + try { + const parsed: unknown = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} +``` + +Add to `server-storage/index.ts`: + +```ts +export * from './server-user-data-storage'; +``` + +- [ ] **Step 4: Run, then commit** + +```bash +pnpm nx test services --testPathPatterns=server-user-data-storage +git add libs/services/src/lib/server-storage +git commit -m "feat(services): server-synced user data storage + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 5 tests. + +--- + +### Task 6: Session and token interceptor + +**Files:** +- Create: `libs/services/src/lib/server-storage/server-storage-session.ts` +- Create: `libs/services/src/lib/server-storage/server-access-token.interceptor.ts` +- Test: `libs/services/src/lib/server-storage/server-storage-session.spec.ts` +- Test: `libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts` +- Modify: `libs/services/src/lib/server-storage/index.ts` + +- [ ] **Step 1: Write the failing specs** + +`server-storage-session.spec.ts`: + +```ts +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideRouter } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; +import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token'; +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(); + TestBed.resetTestingModule(); + }); + + function setup(auth: 'none' | 'token') { + 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', + 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('starts with a stored token', async () => { + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't'); + const { session, status } = setup('token'); + + await session.start(); + + expect(status.state()).toBe('online'); + }); +}); +``` + +`server-access-token.interceptor.spec.ts`: + +```ts +import { HttpClient, provideHttpClient, withInterceptorsFromDi, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token'; +import { ServerAccessTokenInterceptor } from './server-access-token.interceptor'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; + +describe('ServerAccessTokenInterceptor', () => { + beforeEach(() => { + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't'); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: HTTP_INTERCEPTORS, useClass: ServerAccessTokenInterceptor, multi: true }, + { provide: SERVER_STORAGE_CONFIG, useValue: { baseUrl: '/api', instance: {} } }, + ], + }); + }); + + afterEach(() => localStorage.clear()); + + it('adds the token to backend calls only', () => { + 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); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns="server-storage-session|server-access-token.interceptor"` +Expected: FAIL, modules not found. + +- [ ] **Step 3: Implement** + +`server-access-token.interceptor.ts`: + +```ts +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { readServerAccessToken } from '../server-access-token'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; + +/** Adds the server access token to `HttpClient` calls to the backend. */ +@Injectable() +export class ServerAccessTokenInterceptor implements HttpInterceptor { + private readonly baseUrl = inject(SERVER_STORAGE_CONFIG).baseUrl.replace(/\/+$/, ''); + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const token = readServerAccessToken(); + if (!token || !request.url.startsWith(this.baseUrl)) { + return next.handle(request); + } + return next.handle( + request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) + ); + } +} +``` + +`server-storage-session.ts`: + +```ts +import { effect, inject, Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { clearServerAccessToken, readServerAccessToken } from '../server-access-token'; +import { ServerStorageStatusService } from '../server-storage-status.service'; +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; +export const SERVER_SIGN_IN_PATH = '/server-sign-in'; + +/** 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); + + readonly api = new ServerApiClient({ + baseUrl: this.config.baseUrl, + status: this.status, + onWriteFailure: (error) => this.announceWriteFailure(error), + }); + readonly documentStore = new ServerPlaylistDocumentStore(this.api); + readonly userData = new ServerUserDataStorage( + this.api, + globalThis.localStorage, + () => this.backend.usesBrowserStorageOverride(), + () => this.announceWriteFailure() + ); + + constructor() { + effect(() => { + if ( + this.status.state() === 'unauthorized' && + !this.router.url.startsWith(SERVER_SIGN_IN_PATH) + ) { + void this.router.navigateByUrl(SERVER_SIGN_IN_PATH); + } + }); + } + + /** 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()); + if (this.config.instance.auth === 'token' && !readServerAccessToken()) { + this.status.reportUnauthorized(); + return; + } + await Promise.race([ + this.refresh(), + new Promise((resolve) => setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS)), + ]); + this.listen(); + } + + /** Re-reads synced user data; also the offline retry. */ + async refresh(): Promise { + try { + await this.userData.hydrate(); + } catch { + // The API client already reported offline or unauthorized. + } + } + + fetchStatus(): Promise { + return this.api.getJson('/instance/status'); + } + + signOut(): void { + clearServerAccessToken(); + this.status.reportUnauthorized(); + } + + private listen(): void { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.userData.flushWithKeepalive(); + } else { + void this.refresh(); + } + }); + window.addEventListener('pagehide', () => this.userData.flushWithKeepalive()); + setInterval(() => { + if (this.status.state() === 'offline') { + void this.refresh(); + } + }, RETRY_INTERVAL_MS); + } + + private announceWriteFailure(error?: ServerApiError): void { + const key = + error?.status === 413 ? 'SERVER_STORAGE.TOO_LARGE' : 'SERVER_STORAGE.WRITE_FAILED'; + this.snackBar.open(this.translate.instant(key), undefined, { duration: 5000 }); + } +} +``` + +Add to `server-storage/index.ts`: + +```ts +export * from './server-access-token.interceptor'; +export * from './server-storage-session'; +``` + +- [ ] **Step 4: Run, then commit** + +```bash +pnpm nx test services --testPathPatterns="server-storage-session|server-access-token.interceptor" +git add libs/services/src/lib/server-storage +git commit -m "feat(services): server storage session and token interceptor + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 4 tests. + +--- + +### Task 7: Migration service + +**Files:** +- Create: `libs/services/src/lib/server-storage/server-migration.service.ts` +- Test: `libs/services/src/lib/server-storage/server-migration.service.spec.ts` +- Modify: `libs/services/src/lib/server-storage/index.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +import { TestBed } from '@angular/core/testing'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { of } from 'rxjs'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { PlaylistBackupService } from '../playlist-backup.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' }, + { _id: 'x', title: 'Provider', serverUrl: 'http://panel.test' }, + ]; + let session: { + fetchStatus: jest.Mock; + documentStore: { dbUpsertAppPlaylist: jest.Mock }; + api: { sendJson: jest.Mock }; + userData: { hydrate: jest.Mock }; + }; + let backup: { exportBackup: jest.Mock; importBackup: jest.Mock }; + + beforeEach(() => { + session = { + fetchStatus: jest.fn().mockResolvedValue({ playlists: 0 }), + documentStore: { dbUpsertAppPlaylist: jest.fn().mockResolvedValue({ success: true }) }, + api: { sendJson: jest.fn().mockResolvedValue(undefined) }, + userData: { hydrate: jest.fn().mockResolvedValue(undefined) }, + }; + backup = { + exportBackup: jest.fn().mockResolvedValue({ json: '{}' }), + importBackup: jest.fn().mockResolvedValue({ imported: 1, merged: 1, skipped: 0, failed: 0, errors: [] }), + }; + TestBed.configureTestingModule({ + providers: [ + { provide: NgxIndexedDBService, useValue: { getAll: () => of(browserPlaylists) } }, + { provide: ServerStorageSession, useValue: session }, + { provide: PlaylistBackupService, useValue: backup }, + { + provide: PlaylistStorageBackend, + useValue: { runWithBrowserStorage: (task: () => Promise) => task() }, + }, + { 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.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); + expect(session.api.sendJson).toHaveBeenCalledWith('PUT', '/user-state/xtream-favorites/x', { + value: ['live:1'], + }); + expect(session.api.sendJson).toHaveBeenCalledTimes(1); + expect(migration.browserDataMoved()).toBe(true); + }); + + it('merges through the backup format when the server already has playlists', async () => { + session.fetchStatus.mockResolvedValue({ playlists: 4 }); + + const result = await TestBed.inject(ServerMigrationService).moveBrowserData(); + + expect(backup.exportBackup).toHaveBeenCalled(); + expect(backup.importBackup).toHaveBeenCalledWith('{}'); + expect(result).toEqual({ imported: 1, merged: 1, failed: 0 }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test services --testPathPatterns=server-migration` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement** + +```ts +import { inject, Injectable } from '@angular/core'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { firstValueFrom } from 'rxjs'; +import { + DbStores, + SERVER_USER_STATE_NAMESPACES, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import { PlaylistBackupService } from '../playlist-backup.service'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +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. An empty server receives + * them with their ids plus the legacy Xtream user-data maps; a server that + * already has playlists goes through the backup export/import, whose + * fingerprints merge duplicates. Browser data is never deleted. + */ +@Injectable({ providedIn: 'root' }) +export class ServerMigrationService { + private readonly dbService = inject(NgxIndexedDBService); + private readonly session = inject(ServerStorageSession); + private readonly backend = inject(PlaylistStorageBackend); + private readonly backup = inject(PlaylistBackupService); + 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 { + const { playlists: serverCount } = await this.session.fetchStatus(); + const result = + serverCount === 0 ? await this.copyWithIds() : await this.mergeThroughBackup(); + this.writeMarker('done'); + return result; + } + + private async copyWithIds(): Promise { + const playlists = await this.readBrowserPlaylists(); + let imported = 0; + let failed = 0; + for (const playlist of playlists) { + try { + await this.session.documentStore.dbUpsertAppPlaylist(playlist); + imported += 1; + } catch { + failed += 1; + } + } + await this.copyXtreamUserData(new Set(playlists.map((p) => p._id))); + return { imported, merged: 0, failed }; + } + + private async copyXtreamUserData(playlistIds: ReadonlySet): Promise { + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const legacy = parseMap(localStorage.getItem(namespace)); + for (const [playlistId, value] of Object.entries(legacy)) { + if (playlistIds.has(playlistId)) { + await this.session.api.sendJson( + 'PUT', + `/user-state/${namespace}/${encodeURIComponent(playlistId)}`, + { value } + ); + } + } + } + await this.session.userData.hydrate(); + } + + private async mergeThroughBackup(): Promise { + const { json } = await this.backend.runWithBrowserStorage(() => + this.backup.exportBackup() + ); + const summary = await this.backup.importBackup(json); + return { imported: summary.imported, merged: summary.merged, failed: summary.failed }; + } + + private readBrowserPlaylists(): Promise { + return firstValueFrom(this.dbService.getAll(DbStores.Playlists)); + } + + private readMarker(): string | null { + try { + return localStorage.getItem(MARKER_PREFIX + this.instanceId); + } catch { + return null; + } + } + + private writeMarker(value: 'done' | 'skipped'): void { + try { + localStorage.setItem(MARKER_PREFIX + this.instanceId, value); + } catch { + // Without storage the offer simply returns next time. + } + } +} + +function parseMap(value: string | null): Record { + try { + const parsed: unknown = value ? JSON.parse(value) : {}; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} +``` + +Add to `server-storage/index.ts`: + +```ts +export * from './server-migration.service'; +``` + +- [ ] **Step 4: Run, lint the library, then commit** + +```bash +pnpm nx test services --testPathPatterns=server-migration +pnpm nx lint services +git add libs/services/src/lib/server-storage +git commit -m "feat(services): move browser playlists to the server + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, 4 tests; no lint errors. + +--- + +### Task 8: Startup probe, providers, routes and guards + +**Files:** +- Create: `apps/web/src/app/server-storage/server-instance-probe.ts` (+ `.spec.ts`) +- Create: `apps/web/src/app/server-storage/server-storage.providers.ts` +- Create: `apps/web/src/app/server-storage/server-access.guard.ts` (+ `.spec.ts`) +- Create: `apps/web/src/app/server-storage/server-migration-prompt.service.ts` +- Modify: `apps/web/src/main.ts`, `apps/web/src/app/app.routes.ts`, `apps/web/src/app/services/pwa.service.ts` + +- [ ] **Step 1: Write the failing specs** + +`server-instance-probe.spec.ts`: + +```ts +import { probeServerInstance } from './server-instance-probe'; + +const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'none', +}; + +describe('probeServerInstance', () => { + const ok = (body: unknown) => + jest.fn().mockResolvedValue({ ok: true, json: async () => body }); + + it('skips the probe without a runtime backend URL', async () => { + const fetchFn = ok(instance); + await expect( + probeServerInstance({ runtimeConfig: {}, fetchFn: fetchFn as never }) + ).resolves.toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('skips the probe in Electron', async () => { + await expect( + probeServerInstance({ + runtimeConfig: { BACKEND_URL: '/api' }, + isElectron: true, + fetchFn: ok(instance) as never, + }) + ).resolves.toBeNull(); + }); + + it('returns the instance when it reports server storage', async () => { + const fetchFn = ok(instance); + await expect( + probeServerInstance({ runtimeConfig: { BACKEND_URL: '/api/' }, fetchFn: fetchFn as never }) + ).resolves.toEqual(instance); + expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance'); + }); + + it('returns null for storage none, bad bodies and failures', async () => { + const config = { runtimeConfig: { BACKEND_URL: '/api' } }; + await expect( + probeServerInstance({ ...config, fetchFn: ok({ ...instance, storage: 'none' }) as never }) + ).resolves.toBeNull(); + await expect( + probeServerInstance({ ...config, fetchFn: ok({ hello: 1 }) as never }) + ).resolves.toBeNull(); + await expect( + probeServerInstance({ ...config, fetchFn: jest.fn().mockRejectedValue(new Error('x')) as never }) + ).resolves.toBeNull(); + }); +}); +``` + +`server-access.guard.spec.ts`: + +```ts +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.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: ServerStorageStatusService, useValue: { state: () => state } }, + ], + }); + return TestBed.runInInjectionContext(() => guard({} as never, {} as never)); + } + + afterEach(() => TestBed.resetTestingModule()); + + it('sends an unauthorized workspace visit to the sign-in page', () => { + const result = run(serverAccessGuard, 'unauthorized') as UrlTree; + expect(TestBed.inject(Router).serializeUrl(result)).toBe('/server-sign-in'); + }); + + it('lets everyone else into the workspace', () => { + expect(run(serverAccessGuard, 'online')).toBe(true); + TestBed.resetTestingModule(); + expect(run(serverAccessGuard, 'inactive')).toBe(true); + }); + + it('opens the sign-in page only while unauthorized', () => { + expect(run(serverSignInGuard, 'unauthorized')).toBe(true); + TestBed.resetTestingModule(); + const result = run(serverSignInGuard, 'online') as UrlTree; + expect(TestBed.inject(Router).serializeUrl(result)).toBe('/workspace'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web --testPathPatterns="server-instance-probe|server-access.guard"` +Expected: FAIL, modules not found. + +- [ ] **Step 3: Implement the probe and the guards** + +`server-instance-probe.ts`: + +```ts +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import type { IptvnatorRuntimeConfig } from '../services/runtime-config'; + +const PROBE_TIMEOUT_MS = 2_500; + +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__; + const isElectron = options.isElectron ?? !!globalThis.window?.electron; + const baseUrl = runtimeConfig?.BACKEND_URL?.trim(); + if (isElectron || !baseUrl) { + return null; + } + + try { + const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + const response = await fetchFn(`${baseUrl.replace(/\/+$/, '')}/v1/instance`, { + cache: 'no-store', + signal: + typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout(PROBE_TIMEOUT_MS) + : undefined, + }); + if (!response.ok) { + return null; + } + const body: unknown = await response.json(); + return isServerInstance(body) && body.storage === 'server' ? body : null; + } catch { + return null; + } +} + +function isServerInstance(value: unknown): value is ServerInstanceInfo { + const record = value as Partial | null; + return ( + !!record && + record.service === 'iptvnator' && + typeof record.apiVersion === 'number' && + (record.storage === 'server' || record.storage === 'none') && + (record.auth === 'none' || record.auth === 'token') + ); +} +``` + +If `window.__IPTVNATOR_CONFIG__` is not typed on `Window`, check `apps/web/src/typings.d.ts`; it already declares it for `runtime-config.ts`. Do not add a second declaration. + +`server-access.guard.ts`: + +```ts +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { ServerStorageStatusService } from '@iptvnator/services'; + +export const serverAccessGuard: CanActivateFn = () => + inject(ServerStorageStatusService).state() === 'unauthorized' + ? inject(Router).parseUrl('/server-sign-in') + : true; + +export const serverSignInGuard: CanActivateFn = () => + inject(ServerStorageStatusService).state() === 'unauthorized' + ? true + : inject(Router).parseUrl('/workspace'); +``` + +- [ ] **Step 4: Implement the prompt service and providers** + +`server-migration-prompt.service.ts`: + +```ts +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 } 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; + 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, + }); + } +} +``` + +`server-storage.providers.ts`: + +```ts +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 type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import { ServerMigrationPrompt } from './server-migration-prompt.service'; + +/** Loaded with a dynamic import from `main.ts`, only in server mode. */ +export function provideServerStorage( + instance: ServerInstanceInfo, + baseUrl: string +): (Provider | EnvironmentProviders)[] { + return [ + { provide: SERVER_STORAGE_CONFIG, useValue: { instance, baseUrl } }, + { + provide: USER_DATA_STORAGE, + useFactory: () => inject(ServerStorageSession).userData, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: ServerAccessTokenInterceptor, + multi: true, + }, + provideAppInitializer(() => inject(ServerStorageSession).start()), + provideAppInitializer(() => inject(ServerMigrationPrompt).schedule()), + ]; +} +``` + +- [ ] **Step 5: Wire `main.ts`** + +Replace the bootstrap chain in `apps/web/src/main.ts` (keep the restored-route block above it): + +```ts +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { probeServerInstance } from './app/server-storage/server-instance-probe'; +import { getRuntimeBackendUrl } from './app/services/runtime-config'; + +async function resolveAppConfig(): Promise { + const instance = await probeServerInstance(); + if (!instance) { + return appConfig; + } + const { provideServerStorage } = await import( + './app/server-storage/server-storage.providers' + ); + return mergeApplicationConfig(appConfig, { + providers: provideServerStorage(instance, getRuntimeBackendUrl()), + }); +} + +// 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. 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(() => { + requestAnimationFrame(() => { + document.getElementById('initial-splash')?.remove(); + }); + }) + .catch((err) => console.error(err)); +``` + +Merge the new imports with the existing import block at the top of the file instead of adding a second block. + +- [ ] **Step 6: Routes** + +In `apps/web/src/app/app.routes.ts`: + +1. Add `import { serverAccessGuard, serverSignInGuard } from './server-storage/server-access.guard';` +2. In the `path: 'workspace'` route, add after the `resolve: { ... }` block: + +```ts + canActivate: [serverAccessGuard], +``` + +3. Before the `path: 'settings'` redirect, add: + +```ts + { + path: 'server-sign-in', + canActivate: [serverSignInGuard], + loadComponent: () => + import('./server-storage/server-sign-in.component').then( + (c) => c.ServerSignInComponent + ), + }, +``` + +(The component is created in Task 9; until then the route file compiles but the lazy chunk is missing. Run Task 9 before the build in Step 8.) + +- [ ] **Step 7: Token on the raw Stalker `fetch`** + +In `apps/web/src/app/services/pwa.service.ts`, add `serverAuthorizationHeaders` to the existing `@iptvnator/services` import (or add `import { serverAuthorizationHeaders } from '@iptvnator/services';`) and change + +```ts + const response = await fetch(requestUrl); +``` + +to + +```ts + const response = await fetch(requestUrl, { + headers: serverAuthorizationHeaders(), + }); +``` + +All other backend calls go through `HttpClient` and get the header from the interceptor. + +- [ ] **Step 8: Run and commit** + +```bash +pnpm nx test web --testPathPatterns="server-instance-probe|server-access.guard|pwa.service|runtime-config" +git add apps/web/src/main.ts apps/web/src/app/app.routes.ts apps/web/src/app/services/pwa.service.ts apps/web/src/app/server-storage +git commit -m "feat(pwa): probe for server storage and wire server mode + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS. (`pnpm run typecheck:web` runs after Task 9 creates the components.) + +--- + +### Task 9: Sign-in page and migration dialog + +**Files:** +- Create: `apps/web/src/app/server-storage/server-sign-in.component.ts`, `.html`, `.scss`, `.spec.ts` +- Create: `apps/web/src/app/server-storage/server-migration-dialog.component.ts`, `.html`, `.scss`, `.spec.ts` + +- [ ] **Step 1: Write the failing specs** + +`server-sign-in.component.spec.ts`: + +```ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageSession } from '@iptvnator/services/server-storage'; +import { ServerSignInComponent } from './server-sign-in.component'; + +describe('ServerSignInComponent', () => { + let fixture: ComponentFixture; + const session = { fetchStatus: jest.fn() }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ServerSignInComponent, TranslateModule.forRoot()], + providers: [{ provide: ServerStorageSession, useValue: session }], + }).compileComponents(); + fixture = TestBed.createComponent(ServerSignInComponent); + fixture.detectChanges(); + }); + + afterEach(() => localStorage.clear()); + + it('shows a rejection and clears the token when the server refuses it', async () => { + session.fetchStatus.mockRejectedValue(new Error('401')); + const component = fixture.componentInstance; + + component.token.set('wrong'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + + expect(component.rejected()).toBe(true); + expect(localStorage.getItem('iptvnator-server:access-token')).toBeNull(); + expect(fixture.nativeElement.querySelector('[data-test-id="server-sign-in-error"]')).not.toBeNull(); + }); + + it('keeps an accepted token and reloads into the workspace', async () => { + session.fetchStatus.mockResolvedValue({ playlists: 0 }); + const component = fixture.componentInstance; + const navigate = jest.spyOn(component, 'openWorkspace').mockImplementation(() => undefined); + + component.token.set('right'); + await component.submit(new Event('submit')); + + expect(localStorage.getItem('iptvnator-server:access-token')).toBe('right'); + expect(navigate).toHaveBeenCalled(); + }); +}); +``` + +`server-migration-dialog.component.spec.ts`: + +```ts +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(); + }); + + it('lists the browser playlists', () => { + expect(fixture.nativeElement.textContent).toContain('Family'); + }); + + it('records "not now" and closes', () => { + fixture.componentInstance.notNow(); + + expect(migration.skip).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('moves the data and shows the result', async () => { + migration.moveBrowserData.mockResolvedValue({ imported: 1, merged: 0, failed: 0 }); + + await fixture.componentInstance.move(); + fixture.detectChanges(); + + expect(fixture.componentInstance.result()).toEqual({ imported: 1, merged: 0, failed: 0 }); + expect(fixture.nativeElement.querySelector('[data-test-id="server-migration-reload"]')).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web --testPathPatterns="server-sign-in|server-migration-dialog"` +Expected: FAIL, modules not found. + +- [ ] **Step 3: Implement the sign-in page** + +`server-sign-in.component.ts`: + +```ts +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { TranslatePipe } from '@ngx-translate/core'; +import { clearServerAccessToken, writeServerAccessToken } from '@iptvnator/services'; +import { ServerStorageSession } from '@iptvnator/services/server-storage'; + +@Component({ + selector: 'app-server-sign-in', + imports: [MatButtonModule, MatFormFieldModule, MatInputModule, TranslatePipe], + templateUrl: './server-sign-in.component.html', + styleUrl: './server-sign-in.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ServerSignInComponent { + private readonly session = inject(ServerStorageSession); + + readonly token = signal(''); + readonly busy = signal(false); + readonly rejected = signal(false); + + async submit(event: Event): Promise { + event.preventDefault(); + const token = this.token().trim(); + if (!token || this.busy()) { + return; + } + this.busy.set(true); + this.rejected.set(false); + writeServerAccessToken(token); + try { + await this.session.fetchStatus(); + this.openWorkspace(); + } catch { + clearServerAccessToken(); + this.rejected.set(true); + } 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); + } +} +``` + +`server-sign-in.component.html`: + +```html +
+ +
+``` + +`server-sign-in.component.scss`: + +```scss +: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; + 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: 650; + color: var(--app-heading-color); + } + + p { + margin: 0; + color: var(--app-body-color); + font-size: 0.9rem; + line-height: 1.45; + } + + button { + align-self: flex-end; + } +} + +.server-sign-in__logo { + border-radius: 12px; +} + +.server-sign-in__error { + color: var(--app-expiry-expired-text) !important; +} +``` + +- [ ] **Step 4: Implement the migration dialog** + +`server-migration-dialog.component.ts`: + +```ts +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); + } + } + + reload(): void { + window.location.reload(); + } +} +``` + +`server-migration-dialog.component.html`: + +```html +

+ {{ '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 }} +
  • + } +
+ @if (failed()) { + + } + } +
+ + + @if (result()) { + + } @else { + {{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }} + + + } + +``` + +`server-migration-dialog.component.scss`: + +```scss +.server-migration { + p { + margin: 0 0 12px; + color: var(--app-body-color); + } +} + +.server-migration__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + background: var(--app-card-hover-bg); + color: var(--app-heading-color); + } +} + +.server-migration__hint { + margin-right: auto; + color: var(--app-muted-color); + font-size: 0.8rem; +} + +.server-migration__error { + color: var(--app-expiry-expired-text) !important; +} +``` + +- [ ] **Step 5: Run, typecheck, commit** + +```bash +pnpm nx test web --testPathPatterns="server-sign-in|server-migration-dialog" +pnpm run typecheck:web +git add apps/web/src/app/server-storage +git commit -m "feat(pwa): server sign-in page and migration dialog + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS (5 tests); no type errors. The translation keys are added in Task 13. + +--- + +### Task 10: Settings → Server + +**Files:** +- Modify: `libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts` +- Modify: `libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts` +- Modify: `libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts` +- Modify: `apps/web/src/app/settings/settings-options.ts`, `settings-options.spec.ts` +- Create: `apps/web/src/app/settings/settings-server-section.component.ts`, `.html`, `.spec.ts` +- Modify: `apps/web/src/app/settings/settings.component.ts`, `settings.component.html` + +- [ ] **Step 1: Write the failing tests** + +In `settings-options.spec.ts`, add `supportsServerStorage: true` to the two "supported" calls and `supportsServerStorage: false` to the "unsupported" call, then add: + +```ts + it('shows the server section only with server storage', () => { + const withServer = buildSettingsSectionNavItems({ + supportsEpg: false, + supportsRemoteControl: false, + supportsServerStorage: true, + }); + const without = buildSettingsSectionNavItems({ + supportsEpg: false, + supportsRemoteControl: false, + supportsServerStorage: false, + }); + + expect(withServer.find((item) => item.id === 'server')).toMatchObject({ + visible: true, + group: 'data', + icon: 'dns', + }); + expect(without.find((item) => item.id === 'server')?.visible).toBe(false); + }); +``` + +`settings-server-section.component.spec.ts`: + +```ts +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { 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(() => false), + moveBrowserData: jest.fn(), + }; + + async function render(auth: 'none' | 'token') { + session.fetchStatus.mockResolvedValue({ + playlists: 4, + databaseBytes: 38 * 1024 * 1024, + databasePath: '/data/databases/iptvnator.db', + startedAt: '2026-10-11T08:00:00.000Z', + }); + migration.browserPlaylists.mockResolvedValue([{ id: 'a', title: 'A', kind: 'm3u' }]); + await TestBed.configureTestingModule({ + imports: [SettingsServerSectionComponent, TranslateModule.forRoot()], + providers: [ + { provide: ServerStorageSession, useValue: session }, + { provide: ServerMigrationService, useValue: migration }, + { + provide: ServerStorageStatusService, + useValue: { + state: signal('online'), + instance: signal({ auth, instanceId: 'i-1' }), + lastSuccessAt: signal(null), + }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(SettingsServerSectionComponent); + await fixture.componentInstance.load(); + fixture.detectChanges(); + } + + afterEach(() => TestBed.resetTestingModule()); + + it('shows storage figures and offers the browser import', async () => { + await render('none'); + const text = fixture.nativeElement.textContent; + + expect(text).toContain('/data/databases/iptvnator.db'); + expect(text).toContain('38.0 MB'); + expect(fixture.nativeElement.querySelector('[data-test-id="server-import-browser"]')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('[data-test-id="server-sign-out"]')).toBeNull(); + }); + + it('offers sign out when a token is in use', async () => { + await render('token'); + + fixture.nativeElement.querySelector('[data-test-id="server-sign-out"]').click(); + + expect(session.signOut).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test web --testPathPatterns="settings-options|settings-server-section"` +Expected: FAIL (unknown property `supportsServerStorage`, missing component). + +- [ ] **Step 3: Settings search contract** + +1. In `settings-search.types.ts`, add `| 'server-storage'` to `SettingsSearchRequirement` (after `'embedded-mpv-frame-copy'`, with the comment `/** The PWA runs against self-hosted server storage. */`). +2. In `settings-search-sections.ts`, insert before the `backup` entry: + +```ts + { + id: 'server', + navLabelKey: 'SETTINGS.NAV_SERVER', + icon: 'dns', + requires: ['server-storage'], + group: 'data', + }, +``` + +3. In `settings-search.service.ts`, add `ServerStorageStatusService` to the `@iptvnator/services` import, the field `private readonly serverStorageStatus = inject(ServerStorageStatusService);`, and in `capabilities()` the entry: + +```ts + 'server-storage': this.serverStorageStatus.isActive(), +``` + +- [ ] **Step 4: Settings page wiring** + +In `settings-options.ts`, extend the visibility type and map: + +```ts +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, + }; +``` + +(the `return` block is unchanged). + +In `settings.component.ts`: + +1. Add `ServerStorageStatusService` to the `@iptvnator/services` import and import `SettingsServerSectionComponent` from `./settings-server-section.component`; add it to the component `imports` array. +2. Add next to `supportsRemoteControl`: + +```ts + readonly supportsServerStorage = inject(ServerStorageStatusService).isActive(); +``` + +3. Pass it in `buildSettingsSectionNavItems({ ... supportsServerStorage: this.supportsServerStorage })`. + +In `settings.component.html`, directly before `@case ('backup') {` add: + +```html + @case ('server') { + + } +``` + +- [ ] **Step 5: The section component** + +`settings-server-section.component.ts`: + +```ts +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 { + ServerMigrationService, + ServerStorageSession, + type ServerMigrationResult, +} from '@iptvnator/services/server-storage'; +import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces'; + +@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'); + readonly address = globalThis.location?.origin ?? ''; + readonly details = signal(null); + readonly browserPlaylistCount = signal(0); + readonly importing = signal(false); + readonly importResult = signal(null); + readonly sizeLabel = computed(() => + formatMegabytes(this.details()?.databaseBytes ?? 0) + ); + readonly canImportBrowser = computed( + () => this.browserPlaylistCount() > 0 && !this.migration.browserDataMoved() + ); + + constructor() { + void this.load(); + } + + async load(): Promise { + try { + this.details.set(await this.session.fetchStatus()); + } catch { + this.details.set(null); + } + try { + this.browserPlaylistCount.set((await this.migration.browserPlaylists()).length); + } catch { + this.browserPlaylistCount.set(0); + } + } + + async importBrowserPlaylists(): Promise { + this.importing.set(true); + try { + this.importResult.set(await this.migration.moveBrowserData()); + await this.load(); + } finally { + this.importing.set(false); + } + } + + signOut(): void { + this.session.signOut(); + } +} + +function formatMegabytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} +``` + +`settings-server-section.component.html`: + +```html +
+
+

{{ 'SETTINGS.NAV_SERVER' | translate }}

+

{{ 'SETTINGS.SERVER_DESCRIPTION' | translate }}

+
+ +
+
+
+

{{ 'SETTINGS.SERVER_CONNECTION' | translate }}

+

{{ address }}

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

{{ 'SETTINGS.SERVER_STORAGE' | translate }}

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

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

+

{{ info.databasePath }}

+ } +
+
+ +
+
+ + @if (canImportBrowser()) { +
+
+

{{ 'SETTINGS.SERVER_IMPORT_BROWSER' | translate }}

+

+ {{ + 'SETTINGS.SERVER_IMPORT_BROWSER_DESCRIPTION' + | translate: { count: browserPlaylistCount() } + }} +

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

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

+ } +
+
+ +
+
+ } + +
+
+

{{ 'SETTINGS.SERVER_ACCESS' | translate }}

+

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

+
+ @if (usesToken()) { +
+ +
+ } +
+
+
+``` + +Add the state pill style to `apps/web/src/app/settings/settings.component.scss` (the settings section styles live there): + +```scss +.server-state { + display: inline-flex; + align-items: center; + gap: 6px; + 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); + } +} +``` + +- [ ] **Step 6: Run, then commit** + +```bash +pnpm nx test web --testPathPatterns="settings" +pnpm nx test workspace-shell-util +pnpm run typecheck:web +git add apps/web/src/app/settings libs/workspace/shell/util/src/lib/settings-search +git commit -m "feat(settings): server section for self-hosted storage + +Co-Authored-By: Claude Opus 5.5 " +``` + +Expected: PASS, including "every nav id renders a section page in the settings template". + +--- + +### Task 11: Rail indicator and offline banner + +**Files:** +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts`, `.html`, `.scss`, `.spec.ts` +- Create: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts`, `.scss`, `.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts`, `.html` + +- [ ] **Step 1: Write the failing tests** + +Append to `workspace-shell-rail.component.spec.ts` (inside the `describe`; add `import { ServerStorageStatusService } from '@iptvnator/services';`): + +```ts + it('hides the server indicator without server storage', () => { + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('[data-test-id="rail-server-status"]') + ).toBeNull(); + }); + + it('shows the server state once server storage is active', () => { + const status = TestBed.inject(ServerStorageStatusService); + status.activate( + { service: 'iptvnator', apiVersion: 1, instanceId: 'i', storage: 'server', auth: 'none' }, + () => undefined + ); + status.reportOffline(); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('[data-test-id="rail-server-status"]'); + expect(link.getAttribute('data-state')).toBe('offline'); + expect(link.textContent).toContain('cloud_off'); + }); +``` + +`workspace-shell-server-banner.component.spec.ts`: + +```ts +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'; + +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); + }); + + it('renders nothing while online', () => { + status.activate({ service: 'iptvnator', apiVersion: 1, instanceId: 'i', storage: 'server', auth: 'none' }, retry); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-test-id="server-offline-banner"]')).toBeNull(); + }); + + it('explains the offline state and retries', () => { + status.activate({ service: 'iptvnator', apiVersion: 1, instanceId: 'i', storage: 'server', auth: 'none' }, retry); + status.reportOffline(); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-test-id="server-offline-retry"]').click(); + + expect(retry).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm nx test workspace-shell-feature --testPathPatterns="workspace-shell-rail|server-banner"` +Expected: FAIL. + +- [ ] **Step 3: Rail** + +In `workspace-shell-rail.component.ts`, change the Angular import to include `computed` and `inject`, add `import { ServerStorageStatusService } from '@iptvnator/services';`, and add to the class: + +```ts + private readonly serverStatus = inject(ServerStorageStatusService); + readonly serverState = this.serverStatus.state; + readonly serverIcon = computed( + () => + ({ offline: 'cloud_off', unauthorized: 'lock' })[ + this.serverState() as 'offline' | 'unauthorized' + ] ?? 'dns' + ); + readonly serverTooltipKey = computed(() => { + switch (this.serverState()) { + case 'offline': + return 'SERVER_STORAGE.STATUS_OFFLINE'; + case 'unauthorized': + return 'SERVER_STORAGE.STATUS_UNAUTHORIZED'; + default: + return 'SERVER_STORAGE.STATUS_ONLINE'; + } + }); +``` + +In `workspace-shell-rail.component.html`, inside `