diff --git a/.changes/self-hosted-server-storage-backend.md b/.changes/self-hosted-server-storage-backend.md deleted file mode 100644 index 282b743c9..000000000 --- a/.changes/self-hosted-server-storage-backend.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -type: feature -area: self-hosted -issues: [305] ---- - -The Docker image now keeps a server database on a `/data` volume, the groundwork for sharing playlists and watch progress across devices. Mount a volume to keep it; nothing changes in the browser yet. diff --git a/.changes/self-hosted-server-storage.md b/.changes/self-hosted-server-storage.md new file mode 100644 index 000000000..68053952a --- /dev/null +++ b/.changes/self-hosted-server-storage.md @@ -0,0 +1,8 @@ +--- +type: feature +area: self-hosted +issues: [305] +highlight: Playlists on your server +--- + +The Docker version now keeps playlists, favorites and watch progress on the server, so every phone, TV and computer that opens it shares one library. Playlists already in a browser move over with one click, and an optional access token keeps the library private. diff --git a/README.md b/README.md index 95f767353..0bf2eac6c 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ The application is available at . See compose file and [`docker/README.md`](./docker/README.md) for environment variables, reverse proxy notes, PWA limitations, and build details. +The image keeps playlists, favorites and watch progress on the server, so +every browser that opens it shares one library. The first browser that opens +an updated instance offers to move the playlists it already has. + The self-hosted image runs the browser PWA rather than the Electron desktop app: EPG/XMLTV panels, Embedded MPV, managed MPV/VLC launching, the download manager, and Electron remote-control features are not available there. If browser diff --git a/docker/README.md b/docker/README.md index 897cd7634..5b1816630 100644 --- a/docker/README.md +++ b/docker/README.md @@ -18,9 +18,12 @@ The Docker image runs the browser PWA, not the Electron desktop application. That means: - EPG/XMLTV panels and multi-EPG views are not available in the self-hosted PWA. -- Playlist metadata is stored in the browser through IndexedDB, and Xtream user - data is stored by the PWA data source in localStorage. The PWA does not use - the Electron SQLite database or DB worker. +- With server storage (the image default) playlists, M3U favorites and + history, and Xtream favorites, recently viewed items and playback positions + live on the server, and every browser that opens the instance shares them. + Settings, parental locks, the global favorites order and UI preferences stay + per browser. With `IPTVNATOR_STORAGE=none` each browser keeps its own data + in IndexedDB and localStorage. - Docker cannot launch local desktop players or Embedded MPV. If a stream does not play inline in the browser, use the in-app copy URL action and open the stream manually in an external player such as MPV, VLC, or IINA. @@ -100,7 +103,7 @@ safe local defaults shown below. | `NODE_EXTRA_CA_CERTS` | unset | Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path. | | `IPTVNATOR_STORAGE` | `server` in the image | `server` opens the server storage database under `IPTVNATOR_DATA_DIR` and its `/api/v1` routes; `none` keeps the backend stateless. | | `IPTVNATOR_DATA_DIR` | `/data` in the image | Data root; the database is `/databases/iptvnator.db`. Mount a volume here. | -| `IPTVNATOR_ACCESS_TOKEN` | unset | When set, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer `, proxy routes included. The browser app cannot send a token yet: leave it unset until it can. | +| `IPTVNATOR_ACCESS_TOKEN` | unset | When set, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer `, proxy routes included. Each browser asks for the token once and keeps it. | | `IPTVNATOR_MAX_DOCUMENT_MB` | `64` | Largest playlist document the backend accepts. Keep the nginx `client_max_body_size` in step. | | `IPTVNATOR_BACKEND_START_TIMEOUT` | `300` | Seconds the entrypoint waits for the backend's `/health` before giving up. Database migrations run before the backend listens. | @@ -136,16 +139,21 @@ the `restart: unless-stopped` policy. The image opens a server storage database on the `/data` volume (`IPTVNATOR_STORAGE=server`, `IPTVNATOR_DATA_DIR=/data`) and serves its API -under `/api/v1`. It is the backend half of keeping playlists, favorites, -history and playback positions on the server for every device; the PWA -starts using it in a follow-up change. Until then the volume holds an empty -database and each browser keeps its own data. +under `/api/v1`. The PWA finds it on start and keeps playlists, favorites, +history and playback positions there, so every phone, TV and computer that +opens the instance sees the same library. Settings → Server shows the +connection, the database size and path, and exports a backup. -Provider credentials will be stored unencrypted in that database, as the +The first browser that opens an instance with an empty database offers to +move the playlists it already kept, with their Xtream favorites and +progress; nothing is deleted from the browser. Later browsers can merge +theirs from Settings → Server. + +Provider credentials are stored unencrypted in that database, as the desktop app does. Do not expose an instance with server storage to the -internet without authentication in your reverse proxy. The backend already -supports `IPTVNATOR_ACCESS_TOKEN`, but the browser app cannot send the -token yet, so setting it now makes the app unusable. Back up the volume like any database: stop the container, or copy +internet without authentication. Set `IPTVNATOR_ACCESS_TOKEN` to require a +token: each browser then asks for it once on a sign-in page. Back up the +volume like any database: stop the container, or copy `databases/iptvnator.db` together with its `-wal` file. ## IPv6, VPNs, And Connection Fallback diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ccfca27b0..fff635513 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,6 +16,8 @@ services: # Server storage database on the /data volume. Set to "none" to keep # the backend stateless. IPTVNATOR_STORAGE: server + # Ask every browser for this token once before it shows the library. + # IPTVNATOR_ACCESS_TOKEN: change-me volumes: - iptvnator-data:/data healthcheck: diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index ebf92c5eb..9bf7f4e51 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -73,8 +73,8 @@ The current self-hosted PWA uses these `apps/web-backend` routes: - `GET /xtream?targetId=&username=&password=

&action=` - `GET /stalker?targetId=&macAddress=&action=` -Server storage adds versioned routes under `/v1`; the PWA does not call them -yet: +Server storage adds versioned routes under `/v1`, which the PWA uses in +[server storage mode](#server-storage-mode): - `GET /v1/instance` (always public): `{ service, apiVersion, instanceId, storage: 'server' | 'none', auth: 'none' | 'token' }` @@ -102,8 +102,8 @@ its database. Storage errors are logged by error name and code only, because Drizzle messages can carry bound values such as portal credentials. With `IPTVNATOR_ACCESS_TOKEN`, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer `, the proxy -routes included. The PWA cannot send that token yet, so the variable must -stay unset until the browser side lands. Browser app state written through +routes included; the PWA sends it once its sign-in page has stored it. +Browser app state written through `/v1/app-state` is stored under a `client:` key prefix, apart from server-owned keys such as the instance id. @@ -119,7 +119,11 @@ reached only `pnpm`/`nx`, and the orphaned server kept port 3333 after the run. launches the backend through Nx or its `env` drifts from the `serve` target. `WEB_BACKEND_PORT` (default 3333) relocates the E2E backend and the `self-hosted.e2e.ts` URLs, like `MOCK_PORT` does for the mocks. Use it when -another worktree holds 3333. The `web:serve` entry can stay on Nx: +another worktree holds 3333. `server-storage.e2e.ts` talks to a second +backend from the same config, launched with `IPTVNATOR_STORAGE=server` on +`STORAGE_BACKEND_PORT` (default 3334) and a data directory named after the +Playwright process, so every run starts with an empty store; the spec +accepts at most that one extra launch. The `web:serve` entry can stay on Nx: `@angular/build:dev-server` runs inside the Nx process, so the group kill stops it. Playwright launches that command itself even outside CI: the web-e2e targets carry no Nx `serve` dependency, because Nx refuses a @@ -160,6 +164,55 @@ MPV and VLC preload launch methods (`openInMpv` and `openInVlc`), and (`setMpvPlayerPath` and `setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only actions in the PWA/shared UI. +## Server Storage Mode + +`main.ts` probes `GET /v1/instance` before bootstrapping, and only when the +deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__` +(the Docker entrypoint does; hosted builds and Electron never probe). On +`storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and +merges `provideServerStorage()` into the app config. Everything else of the +client lives behind the `@iptvnator/services/server-storage` sub-entry, so +other builds never load it; `ServerStorageStatusService` and the token +helpers stay in the main `@iptvnator/services` barrel because the rail, +settings and guards read them in every build. + +- **Playlists.** `ServerStorageSession.start()` (an app initializer) plugs + `ServerPlaylistDocumentStore` into `PlaylistStorageBackend` (kinds + `electron`, `server`, `indexeddb`), so `PlaylistsService` is unchanged. The + store caches full documents by ETag and uploads a document whose payload it + served unchanged with `keepPayload=1`, so a favorite or a rename does not + resend a large M3U. +- **Xtream user data.** `USER_DATA_STORAGE` becomes `ServerUserDataStorage`: + the four synced namespaces are cached under the `iptvnator-server:` key + prefix (the legacy unprefixed keys are never written in server mode) and + each change sends only the playlist entries that differ, debounced, with a + `keepalive` flush when the page hides. Start-up waits up to 3 s for the + first read. +- **Connection state.** `ServerStorageStatusService` is `online`, `offline` + (a request did not reach the server; retried every 30 s and when the tab + returns) or + `unauthorized` (a 401 clears the stored token). The rail shows a status + shortcut, an offline banner sits above the workspace content, and failed + writes raise a snackbar (`SERVER_STORAGE.WRITE_FAILED`, or `TOO_LARGE` for + a 413). +- **Access token.** With `IPTVNATOR_ACCESS_TOKEN` the instance reports + `auth: 'token'`; without a stored token, or after a 401, the guards route to + `/server-sign-in`, which checks the token against `/v1/instance/status` + before it stores it under `iptvnator-server:access-token` and reloads. + `ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the + backend, and `PwaService` adds it to its two raw `fetch` calls. +- **Moving browser data.** `ServerMigrationService` offers, once per server + instance (marker `iptvnator-server:migration:`), to move the + playlists this browser kept in IndexedDB. An empty server receives them + with their ids plus the legacy Xtream maps for those ids; a server that + already has playlists goes through the backup export and import, whose + fingerprints merge duplicates. Browser data is never deleted. Settings → + Server repeats the offer while the browser has playlists that were not + moved. + +What stays per device: settings, parental lock PINs and locked groups, +the global favorites order and other UI preferences in browser storage. + ## Runtime Limitations The self-hosted build is the browser PWA, not the Electron desktop app. Keep @@ -172,7 +225,9 @@ these limitations explicit in UI, troubleshooting, and release notes: metadata uses `PlaylistsService` with IndexedDB, which `PlaylistStorageBackend` selects when no SQLite bridge exists; Xtream favorites, recently viewed items, playback positions, and cached collection - snapshots use `PwaXtreamDataSource` browser storage. + snapshots use `PwaXtreamDataSource` browser storage. In + [server storage mode](#server-storage-mode) both live on the server + instead. - Browser playlist deletion must go through `PlaylistsService`, not `DatabaseService`. `PlaylistsService.deletePlaylist()` runs registered cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does @@ -271,8 +326,8 @@ and sidecar state in localStorage: `PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the `USER_DATA_STORAGE` token from `@iptvnator/services` (default: -`localStorage`), never through `localStorage` directly, so a deployment can -substitute a server-synced storage. +`localStorage`), never through `localStorage` directly, so server storage +mode can substitute `ServerUserDataStorage`. Entries should include a content snapshot when the item is added. Global collection routes and the dashboard can then restore titles, posters, content diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index a161a3eee..94f985103 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -89,7 +89,10 @@ The shell is intentionally split into four persistent regions: recently viewed. The routed global-search rail link is Electron-only because its data source is the SQLite worker bridge. 2. Provider-aware context links derived from the active or current playlist. - 3. Settings remains a persistent footer shortcut in the rail. + 3. Settings remains a persistent footer shortcut in the rail. In + [server storage mode](pwa-self-hosted.md#server-storage-mode) a server + status shortcut sits above it: its dot shows online, offline or + sign-in needed, and it opens Settings → Server. 4. No brand mark: it only repeated the first workspace link (Dashboard, or Sources when the dashboard is off). 2. Top header: @@ -103,7 +106,9 @@ The shell is intentionally split into four persistent regions: 6. Downloads shortcut in Electron. 3. Main body: 1. Optional left context panel. - 2. Main router outlet content. + 2. Main router outlet content. In server storage mode an offline banner + with Retry takes the first row of `.workspace-content-frame`, above + the scroller, so full-height pages keep their height. 4. Optional footer: 1. External playback session bar when a docked session is visible.