* feat(services): server storage connection state and access token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server kind for the playlist storage backend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server storage API client The @iptvnator/services/server-storage sub-entry keeps server mode out of the eager services barrel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): playlist document store over the server API Full documents are cached by ETag (weak tags included) and served as private copies; an upsert whose payload is the one the store handed out sends keepPayload=1 without it and retries in full on payload_missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server-synced user data storage Synced Xtream maps live in a write-through cache under the iptvnator-server: prefix; writes send only the playlist entries that changed, debounced, and flush with keepalive when the page hides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server storage session and token interceptor The session plugs the HTTP document store into PlaylistStorageBackend, hydrates synced user data before the first read (bounded to 3 s), retries while offline, flushes on hide, and routes to sign-in when the server answers 401. The interceptor adds the access token to HttpClient calls to the backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): move browser playlists to the server An empty server gets the browser playlists with their ids plus the legacy Xtream user-data maps; a server that already has playlists goes through the backup export and import, so duplicates merge. Browser data is never deleted; the decision is remembered per server instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(pwa): probe for server storage and wire server mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(pwa): server sign-in page and migration dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): server section for self-hosted storage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(workspace): server status in the rail and an offline banner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): warn that delete-all clears the server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(i18n): server storage strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): server storage across browsers and the migration dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(self-hosted): server storage mode, sign-in and the release note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pwa): stay in server mode when a known server does not answer A failed start-up probe used to fall back to browser mode, which showed an empty library while the backend restarted and let new playlists land in the browser. The browser now remembers the server; without an answer it starts in server mode, offline, and reloads once the server is back. Settings → Server shows the backend address instead of the page's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep Xtream data, ordered sync, sign-in and stale-cache fixes - Moving browser playlists matches them to the server's by id or source and carries the legacy Xtream maps under the server id; it no longer goes through the backup format, whose PWA export has no Xtream user data. A partial move stays on offer. - User-data batches go out in order, and a re-read keeps entries edited while it was in flight. - A keepPayload write keeps its cached copy only when the server replaced the version its payload came from (new X-Replaced-ETag header). - 502/503/504 from the proxy count as offline. - The start-up screen lets the sign-in page through while a token is required. - Settings → Server offers a reload after a move. - The probe detects Electron through RuntimeCapabilitiesService. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): merge user data on move, keep failed writes, sign-in route gate - A merged playlist gains the browser document's favorites and history; Xtream values for one server entry are combined (lists unioned, maps merged, the server winning conflicts) and written once. - A failed user-data write stays queued and survives a re-read; the page-hide flush waits for a batch still travelling. - The start-up screen lets the sign-in route through whatever its check reports. - The backend refuses IPTVNATOR_ACCESS_TOKEN without server storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes - Moved playback positions are matched by content type and Xtream id and name the server playlist. - Page-hide writes report whether the server took them; refused ones go back into the queue. Re-reads run one at a time. - The access guard's 401 carries WWW-Authenticate: Bearer realm="iptvnator"; the interceptor and PwaService's raw fetches turn it into sign-in, so a token changed on the server is noticed on provider calls too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Stalker keys by category, keepalive ordering, cut-off probe - Moving Stalker favorites and history keys items by id and category, so a channel and a movie with one id both survive. - Writes after a page-hide flush wait for its keepalive requests. - A probe whose body is cut off counts as unreachable and keeps the remembered server. - Settings → Server renders without @defer so settings search can reveal its rows on the first visit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): cache baseline before first read, versioned retries, M3U favorite ids - Before the first user-data read finishes, an edit is compared with the cache, so it sends only its own playlist entry. - Queued entries carry their edit number; a failed write is retried only when nothing newer replaced it. - Moving M3U favorites and history translates channel ids through the stream URL (ids are random per import). - The server-storage E2E runs in Chromium only: one shared database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies - Moving M3U favorites keeps URL favorites and anything the server cannot resolve; only older channel ids are translated. - Queued user-data edits are sent before the move writes combined values. - A re-read that does not fit the cache keeps the old cache and baseline together. - A response body cut off after its headers counts as offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): unsent writes survive reloads, guarded move, lazy storage - User-data writes the server has not confirmed are kept under iptvnator-server:unsent-writes and sent first on the next visit. - Moving browser data does not start while user-data writes are unsent, and a duplicate of a failed upload counts as failed, not merged. - The session reaches localStorage through the lazy adapter, so a browser that denies storage still boots. - Raise launch/renderer.domMutationsToFirstCard by 2 (569 → 571): the rail server shortcut and the offline banner each leave an @if anchor in every build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): locks follow merged playlists, typed legacy Xtream ids, instance-scoped cache - Moving a playlist under another server id takes its parental locks along (they stay in the browser). - Bare legacy Xtream ids in each copy are typed from that copy's own snapshots before favorites, history and snapshots are combined, so a live channel and a movie sharing an id both survive. - Writes still travelling are saved on page hide too; saved writes and the cache belong to one server database (cache owner = instanceId). - A late successful answer no longer turns unauthorized back to online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): apply early edits to the server value, newest unsent write, sticky sign-out - Edits made before the first successful user-data read are kept as before → after and applied to the server's value once it arrives (applyEdit), so a new device or a stale cache adds a favorite instead of replacing the server's list. - Saved unsent writes keep only the newest per entry; an answered older write no longer retires a newer one still travelling. - A late failure after sign-out no longer turns unauthorized into offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep early edits across reloads and tabs, numeric-string legacy ids - Edits made before the first read are saved (before → after) and restored after a reload; a second early edit keeps the first one's baseline, even an empty one; a cache-full first read still applies them to the server's value. - Queued writes are saved as soon as they are queued, and a re-read keeps entries another tab has not confirmed yet. - Legacy Xtream ids stored as numeric strings are typed too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases - Unconfirmed writes and early edits are kept in the tab's own sessionStorage, so tabs never clear each other's; a shared registry of unconfirmed entries (one record per tab, ignored after ten minutes) keeps a re-read in one tab from replacing another tab's values. - A cache value too big for localStorage stays readable from memory, so later edits are diffed against what the app reads. - Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so a reference not yet rewritten still resolves and a retry can type it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional user-data writes, scoped token, deletes - User-state entries carry a content tag; PUT/DELETE accept If-Match or If-None-Match: * and answer 412 with the current value and tag. The PWA sends each edit on the tag it read and replays it (before → after) on the server's value after a conflict, so another device's change to the same playlist's favorites or positions is kept instead of overwritten. - The access token goes only to the backend URL (same origin and path), never to another host or a sibling path. - Deleting a playlist (or all) first drops its queued user-data edits and waits for writes in flight, so none recreates deleted data. - The migration copy writes on the tags it read. - The write queue moves out of ServerUserDataStorage into UserDataWriteQueue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(server-storage): conditional user-state writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep tags with their values, rebase page-hide writes, hold deletes - A re-read that keeps an entry from the cache (another tab's or this tab's unconfirmed value) no longer takes the server's tag for it, so the next edit conflicts and is replayed instead of overwriting another device's change. - Page-hide writes behind a travelling one are taken from the queue when it is answered, so its new version becomes their baseline. - A playlist delete holds that playlist's user-data edits instead of discarding them up front; only a successful delete drops them, a failed one sends them as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): replay edits behind travelling writes in order, hold writes after an offline start - An edit queued behind a travelling write keeps its own before → after and carries the travelling write's edit as `prior`, also in the saved writes. A conflict replays both in order, so adding and then removing a favorite removes it whether or not the first write landed, and a lost write is not dropped. - Page-hide writes now report their new tag or a 412 like normal writes: a confirmed one rebases the next edit, a conflict is replayed. - After an offline start from a remembered instance, writes are held and the retry only checks /instance before reloading, so saved writes never reach another database behind the same address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability - Unconfirmed writes and early edits are saved in localStorage per page load; the tab remembers its page load in sessionStorage, so a reload takes them over, and another tab replays a closed tab's writes once its record is stale. Live tabs renew their record every 30 s. - An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has, so the write conflicts and is replayed instead of carrying another tab's change past a later removal. - Moving browser data counts the parental-lock step as failed while the lock store cannot be read, so the move stays on offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory - A queued write keeps its edits as steps: an edit continuing the last one joins it, one made behind a travelling write or on another tab's change stays apart, and a conflict replays them in order. Saved writes and early edits store the steps too. - The instance probe treats any refusal but 404 (a proxy's 429 or 403) as unreachable, so a browser keeps its remembered server. - Cache reads that browser storage refuses fall back to memory, so a re-read and later edits still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep steps of possibly-landed writes apart, queue closed tabs' edits even when the cache shows them - Steps of a write whose answer was lost, of saved writes taken over after a reload, and of a closed tab's saved edits are marked uncertain, so a later edit never joins them: adding and then removing an item after a lost answer still removes it. - A closed tab's saved edits are queued ahead of this tab's own even when the cache already shows them, so a conflict replays them too. - The tag map moves into UserStateTags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(workspace): style the server banner from the shell, baseline 570 The offline banner's own stylesheet added a <style> element to every launch, one of the two mutations the launch journey counted on top of master. Its rules move into the workspace shell's stylesheet. The other one is a parser-inserted modulepreload link: the new lazy server-storage chunks split one shared initial chunk in two. A/B on macOS: master 555, this branch 556 (was 557); the baseline note now says so instead of blaming @if anchors, which cost nothing here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): apply a caller's edits to the cache as it is now ServerUserDataStorage remembers the map getItem handed out. When the cache changed since (another tab saved, a re-read), a write applies only the entries the caller changed to the current cache, so a map read before another tab's save no longer reads as deleting that save. The cache moves into UserDataCache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): retry unsynced user data while online, keep newer typed snapshots, explain a refused token store - The 30 s retry also re-reads and flushes while online when the first user-data read failed or writes the server refused (a 5xx) are queued; the same failure notice shows at most every five minutes. - Moving browser data adds a legacy snapshot alias only where no typed snapshot exists, so a newer typed copy is not overwritten. - writeServerAccessToken reports whether storage kept the token; the sign-in page says so (SERVER_STORAGE.SIGN_IN_STORAGE, 19 locales) instead of calling a right token rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional playlist metadata writes, retried on a fresh read A favorite or rename (keepPayload PUT) is sent with If-Match on the version it was read at. The server answers 412 when another device wrote since; the store throws PlaylistWriteConflict and PlaylistsService's per-playlist write queue runs the mutation again, which reads the playlist afresh, so two devices' metadata edits both survive. A retry on the same unread copy writes unconditionally, as before; refresh and import writes stay last write wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional writes for every playlist mutation, ordered adoption, keep journals until copied - Every playlist mutation is sent on the version it was read at: the payload's ETag, else the playlist's last read (portal documents have no payload). Refused versions are tracked per playlist, so a conflict on one never disables the check on another at the same revision. Imports and start-up migrations (dbUpsertAppPlaylists) stay unconditional. - A closed tab's adopted edits replay before this tab's queued ones, from the base of its first step, so newer progress is not replaced by older. - A reload or adoption removes the old page load's saved writes only when this page saved its copy; each closed tab is adopted once per page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes - A served copy carries the version it came from (a symbol property that spreads keep and JSON drops), so a mutation of a portal document names the version it was built on, not the latest read. - A 401 for a token replaced since (a new sign-in) neither clears the new token nor reports unauthorized, in the API client, the HttpClient interceptor and PwaService's proxy calls. - Moving browser data counts a browser user-data map that cannot be read as a failure, so the move stays on offer. - An edit made before the first read on top of this tab's recovered unsent write is applied to that write, not to the server's value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): favorite toggles as deltas, early edits as steps on recovered writes, release kept journal copies - PlaylistEffects.updateFavorites$ applies the toggled channel through transformPlaylistFavorites, on the favorites read at write time, instead of writing the reducer's whole array: a conflict retry keeps another device's favorites (#1919). - Edits made before the first read on top of this tab's recovered unsent write are appended to it as their own steps, so a removal of an item the recovered write adds survives a conflict replay. - Journal copies of earlier page loads kept because this page could not save its own are removed once it can. - adopt/extend move into server-user-data-adopt.ts over a small queue API (unsent, isQueued, put); the queue exposes its tag map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): refuse user state for deleted playlists PUT /v1/user-state/:namespace/:playlistId answers 404 playlist_missing when the server has no such playlist, so an edit queued on another device (including its conflict replay with If-None-Match: *) cannot recreate the state of a playlist deleted meanwhile. The client drops such an edit and its cache entry instead of retrying; page-hide writes report it as gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep duplicated tabs off live journals, drain writes around remove-all, normalize legacy Stalker rows on the move - A page marks its unconfirmed-writes record closed on pagehide (not into the back-forward cache); a new page takes over the previous page load's saved writes only when that record is closed, stale or missing, so a duplicated tab, which copies sessionStorage, leaves the running tab its writes. - PlaylistsService.removeAll lets queued playlist writes land first and makes writes issued meanwhile wait until it finished; the server store refuses a document without an id. - Moving browser data sets the explicit Stalker portal-mode flag on rows from before it (withExplicitStalkerPortalFlag, now shared with PlaylistsService). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): take over a reload's writes once the previous page load stops looking live When the close flag could not be saved (storage full), a reload took the previous page load for a running duplicated tab and left its saved writes unsent. The page now remembers that page load and its 30 s touch takes it over as soon as its record is closed, stale or gone, so the session's retry sends the writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopt a late previous page load instead of restoring it When touch() finds that the previous page load no longer looks live, this page may already have read and queued newer edits. Its saved writes and early edits are now adopted like a closed tab's (their steps ahead of this page's newer ones, queued at once) instead of restored as if this page had just started, which replaced newer progress with older and parked early edits that were never sent after the first read. Cache forgetting moves into UserDataCache.forget(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopted writes without a known version conflict instead of overwriting A page that never read the server has no version tags, so an adopted write went out unconditionally and replaced newer server data with its cached value. Adopted edits now use the never-matching version when this page knows none (and for a queued write without a tag), so the server answers 412 and the edits are replayed on its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a re-read sent before a playlist delete skips that playlist ServerUserDataStorage records each successful delete; a re-read sent before it drops the deleted playlist's entries and tags from the server's answer (withoutDeleted), so a late answer cannot write the deleted favorites and positions back into the cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep a playlist id like __proto__ when filtering a read withoutDeleted() builds its maps without a prototype, so a surviving playlist whose id is __proto__ stays a key instead of changing the object's prototype and losing its favorites and tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): reorders on the stored favorites, URL-insensitive remembered server, prototype-free id maps - PlaylistEffects.setFavorites$ applies the drag's order through transformPlaylistFavorites (reorderFavorites): favorites added on another device stay, ones removed there stay out. - The remembered server matches the backend URL by origin and path without trailing slashes, so /api and /api/ start offline in server mode alike. - Maps keyed by playlist id are built without a prototype (idMap), so an id such as __proto__ is an ordinary key in the cache, conflict replays, re-reads and tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): one registry key per page load, server token only in server mode - The unconfirmed-writes registry stores each page load's record under its own key (iptvnator-server:unconfirmed:<instance>:<page>), listed through a new keys() on the user-data storage adapter, so tabs never overwrite each other's records. Saved writes without a fresh record, also when the record was lost, are adopted as a closed tab's (#1920). - PwaService sends the stored server token on its raw fetch calls only while server mode runs, and a backend that answers without server storage makes the browser forget the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records - A server that answers as another backend URL or instance id than the remembered one makes the browser forget the stored token, so server A's token never reaches server B. - The session installs its page-hide and heartbeat listeners before it returns for sign-in, so a page holding another page load's writes still marks itself closed on unload. - Closed tabs are found by stale records only again: saved writes without a record may belong to a running tab whose record storage refused, and are left to that tab's own reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): no token for an unverified instance, sign-in remembers its server - ServerStorageStatusService.accessToken() returns the stored token only while server mode runs on an instance this start verified. The API client, the HttpClient interceptor and PwaService's raw fetch calls use it, so after an offline start from a remembered instance no request (including the /instance reachability check) carries the token to whatever answers at that address, and a 401 then does not clear it. - Sign-in remembers the server along with the token; when storage refuses either, it reports SIGN_IN_STORAGE instead of reloading into a start that would drop the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): check a remembered instance before sign-in sends the token A page started from memory (startedOffline) withholds the token until the instance is verified, so sign-in on that page sent no token and the server's 401 cleared the right one. Sign-in now first calls ServerStorageSession.ensureVerified(): it reads /instance without a token, marks the status verified when the instance id matches, and reloads the page when another server answers at the address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): read migration maps by own playlist ids only The browser's legacy maps and the server's user-state entries are read into null-prototype maps, so a playlist id such as `toString` is not taken for an entry and a missing server value no longer resolves to an inherited function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): bind the token to its instance; adopted edits yield to newer changes The access token is stored with the instance id it was given for, and a page reads it only for its own instance: a tab still open on the old backend after BACKEND_URL changed no longer sends the new server's token there. The API client hands every 401 to reportAccessDenied(), which now also sends a page without a token for its instance to sign-in, while one that holds its token back until the instance is checked keeps it. A closed tab's adopted edits carry `yields`: replayed, they change only what is still as that tab saw it, so they never replace progress this tab or another device saved since. The flag survives the journal. The sync spec's fake server and memory storage move to a test-helpers file to keep the spec under its line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read The user-data cache is keyed by server instance (`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on the old backend after BACKEND_URL changed no longer writes over the new server's cache, which the new tab's next edit would have sent there. claimCache also removes the old owner's keys and the unscoped keys of earlier builds. touch(), which runs every 30 s, adopts closed tabs' saved writes once this tab has read: a tab with nothing to sync never re-reads, so they waited until a tab switch or reload. UserDataStorage gains an optional ready(); the server storage resolves it after its first read and rejects while that fails. A PWA backup export awaits it, so a fresh browser whose /user-state read is slow or failed cannot export empty favorites, history and positions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): token only to backend URLs in raw fetches; Stalker series keys in the move PwaService joins paths to the backend URL without its trailing slashes, so BACKEND_URL=/ no longer yields a protocol-relative //… URL that names another host, and it attaches the server token only when the request URL resolves to the backend (isBackendUrl), like the interceptor. The move's item key adds the Stalker series flag to id and category: a provider category can hold a movie and a series of one id, and the series favorite was dropped as a duplicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Xtream reads and backups wait for the first read; keepalive deadline Startup waits for the first user-data read only a few seconds. ready() now waits for a first read in flight without starting one (an offline page is not held up) and tells whether one succeeded: - PwaXtreamDataSource awaits it before reading favorites, recent items and playback positions, so a new device whose /user-state read is slow does not show empty lists or start playback without its resume point. - A PWA backup export and restore fail while no read succeeded; the restore stays parked, since its replacement lists would otherwise merge into the server's (an empty favorites list changed nothing). A page-hide keepalive write gets the request timeout: a server that takes it and never answers no longer holds every later write, re-read and delete behind it until reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a failed sign-in keeps another tab's token; moved history newest first A sign-in that fails clears the token only while it is still the one this attempt stored: another tab that signed in meanwhile keeps its valid token. The move orders the combined Xtream recent items by viewedAt, newest first, as the app keeps them: browser views appended after the server's history were cut by the next view once the server held 50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): recheck the instance after offline, back-forward cache pages, Stalker server rows Going offline now marks the instance unchecked: the token and writes wait until /instance answers with this page's instance (another one reloads the page). A replaced volume at the same address no longer gets the old token and queued user-state writes on reconnect. The session checks as soon as it goes offline, so a blip costs no retry interval. A page restored from the back-forward cache whose record another tab adopted meanwhile (its timers were frozen, so the record went stale) reloads instead of sending those writes again after newer edits. The Stalker playlist metadata migration also runs on server rows, once per server: a desktop database copied to the server can predate it, and its Stalker rows lacked isFullStalkerPortal. The take-over and change-diff loops move out of the user-data facade, and the adoption specs get their own file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopted edits keep yielding through conflicts; newest view per item; same-token sign-ins A conflict replay collapsed every step into one edit, so a closed tab's adopted step lost `yields` after the first conflict and a second one replayed its old position over a newer one. Its steps now stay as they are, and only this page's own collapse into one edit on top of them. The move combines recently viewed items one per item, keeping the newest view and its artwork, then newest first; before, the server's older copy of an item won over a later browser view. A failed sign-in clears the stored token only on a 401: a network failure says nothing about the token, and another tab may have stored the same one meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): suspended tabs give up adopted writes; history stays newest first; favorite checks wait A background tab the browser suspended past the record lifetime can have its writes adopted elsewhere, like a back-forward cache page: shown again, it now checks resumed() and reloads instead of sending them. Conflict replays appended a newly viewed item at the end of the history, where the next 50-item cut dropped it; a replayed recently viewed list is now kept newest first. isFavorite() waits for the first user-data read like the list reads, so a detail page opened during a slow first read does not toggle an existing favorite the wrong way. A parked backup restore replayed when the playlist opens waits too, and stays parked while no read succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): offline recheck checks only the instance; sign-out always navigates The recheck on going offline ran a full refresh: when /instance answered but /user-state kept failing, each failed read set the page offline again and started the next one at once, bypassing the retry interval. It now checks the instance only; reads wait for the retry. Sign-out navigates to sign-in itself, also when the page is signed out already: a guard (unsaved Settings) may have cancelled the redirect the first time, and the state change that drives it does not repeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a superseded page sends and saves nothing; re-reads keep other tabs' newer saves A suspended tab's retry timer could run before it was shown again: touch() recreated the record another tab had removed when it adopted the page's writes, so the page sent them again over newer edits. The facade now treats a page whose record is gone as superseded: the queue sends nothing (maySend hook), its journal and record are not written again, and the retry timer reloads it like a page shown again. A re-read answered after another tab saved an entry no longer writes the older answer over the shared cache: entries changed while the read travelled are kept, with an unknown tag, so the next write here conflicts and replays instead of removing what the other tab saved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(web-e2e): never reuse another backend as the storage E2E backend server-storage.e2e.ts deletes every playlist before each test. Reusing a backend already listening on STORAGE_BACKEND_PORT outside CI could have wiped a real library; an occupied port now fails the run instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): an outage on the sign-in page also unverifies the instance reportOffline() left `verified` set while the page was unauthorized, so a sign-in retried after an outage skipped the instance check and could send the old instance's token to a replacement database. Verification is now cleared on any outage; only the state change stays guarded, so the workspace remains locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): interceptor and raw fetches report backend outages too Only the storage API client reported an unreachable backend as offline. When the first sign of an outage was an HttpClient call through the interceptor (provider targets, playlist parsing) or one of PwaService's raw fetches (Stalker, connectivity reset), the instance stayed verified, and the next request after a replacement instance came up at the same address would carry the old token. Both now report no answer (status 0, a failed fetch) and a gateway's 502/503/504 as offline, so the session checks /instance first. The backend's own JSON 502 for a failed provider is an answer and changes nothing; neither does a fetch this page aborted on its own timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): the immediate offline check runs once per retry interval A response whose headers arrived but whose body was cut off reports success and then offline, which started the next immediate instance check at once: repeated cut-off answers kept requests going and the connection indicator flickering. The check on going offline now runs at most once per retry interval; later failures wait for the timed retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a saved write without a readable tag counts as saved; unverified online pages keep retrying A write whose headers said saved but whose body (with the new tag) was cut off was retired without confirmation: a newer queued edit kept its steps and old tag, and a later conflict replayed the saved step over a newer value from another device. It now counts as saved on an unknown version: the landed steps drop out and the next write conflicts, so only edits made after it replay. Writes that were unconditional stay so. The keepalive path does the same. A late answer to a request sent before an outage can set an unverified page online; with nothing to sync the retry then never ran and the token stayed withheld. The retry now also runs for online pages that are not verified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep server storage off the startup bundle; form-field defaults on sign-in PwaService imported isBackendUrl from the @iptvnator/services/server-storage barrel, which pulled the whole storage subsystem (sync, migration, API client) into main, though server mode loads it by dynamic import. The helper now lives with the token helpers in @iptvnator/services (re-exported from the interceptor): renderer.initialBytes 1,392,902 -> 1,357,795 bytes locally. The remaining 11,988 bytes over #1931's baseline are what must run before the first route (instance probe, connection status, token bound to its instance, storage backend and write serialization); the baseline is raised with a note, under the PR's perf-baseline-increase label. The sign-in component provides the form-field defaults per component, as master's check (#1928) now requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move The Stalker metadata migration wrote server rows back through the unconditional bulk upsert, from the list read: a device that edited a Stalker playlist in between (or a second browser migrating the same copied database) lost its change. Server rows are now read again one by one and written as conditional mutations on that version; a conflict leaves the flag unset, so the next start retries. Moving a document's history kept the server's copy of an item both held, even when the browser had viewed it later, and skipped the write when the length did not change. The combined history is now newest first by added_at, one entry per item, and any content change is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): M3U favorites by URL and history channel ids in the move; restores wait for the server The move's M3U remap swapped references for server channel ids, but the app looks favorites up by stream URL and history entries carry the URL as their id: legacy favorites became invisible and history duplicated. Legacy id favorites now become their channel's URL on both sides, and history keeps its URL id with the server's channel id as channel_id. A restore in server mode reported success once its lists were queued: a list the server refused (413) left the old data while the parked restore was consumed. Both restore paths now wait for that playlist's writes (UserDataStorage.flushed, a few flush rounds for conflict replays) and stay parked when the server did not take them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a closed page record stays closed when a late write rewrites it On pagehide the page flushes with keepalive and then marks its record closed. When writes were held (offline), the keepalive write fails at once and its requeue saved the journal again a microtask later, which rewrote the record without `closed`: the reload took the previous page load for live and left its favorites and progress unsent until the record aged out (ten minutes). markUnconfirmed now keeps an existing record's closed flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): the move uploads playlists create-only and merges into a copy moved first Two tabs or devices moving browser copies with the same playlist id both planned an upload; the browser documents carry no served version, so both PUTs were unconditional and the later one overwrote the first copy (and any change made on it since), with both moves reporting success. The backend's playlist PUT now honours `If-None-Match: *` (412 when the playlist exists; checked and written in one synchronous handler). The move uploads with it through ServerPlaylistDocumentStore .dbCreateAppPlaylist, and an upload that finds its id taken merges its favorites and history into that copy instead, counted as merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Delete All waits for repairs holding a reservation; backups read the server again Delete All raised its write barrier before it held the exclusive authority reset. A Stalker repair already holding its row reservation then waited on that barrier for its conditional write, while the reset waited for the reservation: neither finished, and later playlist operations stalled behind them. The barrier now goes up inside the reset, once repairs in flight have finished; writes queued so far land first and new ones wait for the delete, as before. ready() said yes after any earlier read, so an export could miss what another device added since, and restoring an empty list did nothing when the cache was already empty. Backup export, restore and the restore replay now ask for a fresh read (ready(true)) and fail, the restore staying parked, when it does not succeed. The facade's adoption loop moves to the adopt module to keep the facade under its line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): Delete All drains queued writes before its reset; reserved writes skip its barrier The previous commit raised Delete All's barrier inside the exclusive authority reset and drained queued writes there, but a queued deletion or import takes the shared side of that lock: the reset waited for it while it waited for the reset. Delete All drains the writes queued before it outside the lock again, then takes the reset, with its barrier up from the start. The deadlock with a Stalker repair (or Edit) that holds a row reservation and writes under it is broken on the other side: such a write does not wait for the barrier (holdsPlaylistAuthority, counted per reservation in this context), and the reset waits for the reservation as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a re-read keeps writes another tab had unconfirmed when it went out A re-read checked other tabs' unconfirmed entries only after its answer arrived. A tab whose write was unconfirmed when the read went out (its edit already in the snapshot) and confirmed before the old answer came back matched neither check, so the stale answer replaced its saved value in the shared cache; that tab's next edit, holding the newer tag, then wrote the stale list and deleted the item on the server. The entries unconfirmed when the read is sent are kept too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): writes wait for Delete All before they queue; restores replace only settled lists Delete All, third variant: a write that arrived after Delete All waited for it inside its playlist's queue. A Stalker repair that reserved that playlist meanwhile skipped the barrier, but its write still queued behind that waiting one, while the reset waited for the repair's reservation. While a Delete All runs, writes now wait for it before they join their queue, so no queue holds work blocked on the reset; a write under a reservation held here joins at once. A restore replaced lists while this page's own write for the playlist was still unsent (refused, or lost): that write's edits joined the replacement and a conflict replay kept items the backup did not have. Both restore paths now require the playlist's writes to be on the server first (flushed), and otherwise stay parked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a reload finds page loads a reload before it kept; restore master's tick baselines When storage refused a reload's copy of the last page load's saved writes, the old copy stayed but only this page's memory named it: the tab's pointer already named the new page, so a second reload before any copy was saved recovered from the empty new page, and without a registry record no other tab could find the old one. The pointer now also lists the page loads a page keeps (keepPages), and a reload takes them over, oldest first, before its last page load. The pointer helpers move to server-page-pointer.ts. Both merges of master resolved journey-baselines.json by taking this branch's whole file, which dropped #1929's cdTicksToFirstCard, cdTicksIdle30s and cdTicksToFirstPage. The file is master's again with only this branch's initialBytes and domMutationsToFirstCard raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a duplicate leaves a running tab's kept page loads to it A duplicated tab copies the tab's session storage, so after a reload that kept older page loads its pointer lists them too, and the duplicate took them over at once, although the tab still running owns those edits and may have newer ones queued; its replay could then write older watch progress over newer. When the previous page load still runs, the kept ones now wait with it and `touch` adopts them, oldest first, once it no longer looks live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a restore merged by a conflict stays parked; first-read tests wait for the read A restore whose write conflicted with an edit another device made during it had the conflict replay merge that edit in, and flushed() still said the lists were taken, so the restore was consumed with lists that were not the backup's. Both restore paths now compare the stored lists with the ones they wrote once the server took them, and a difference keeps the restore parked to run again on a fresh read. The two first-read tests released the mocked read after a fixed number of microtask turns, which could come before the read started; they now wait until it has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): page-hide keepalive writes report an outage too sendKeepalive() turned a failed fetch, a timeout or a gateway's 502/503/504 into a failed outcome without telling the status, so the instance stayed verified: when the hidden tab came back, its refresh skipped the /instance check and resent the queued writes, which could reach a replacement database behind the same address. Like requests, a keepalive write now reports these as offline, so the instance is checked before anything goes out again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a keepalive answer cut off after its headers counts as offline A page-hide write whose headers said saved but whose body broke off was returned as saved with the read error dropped, so the instance stayed verified. Like readJson() for requests, it now reports offline (the outcome stays saved on an unknown version), and the instance is checked before the next token or write goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): kept page loads replay oldest first; restore checks compare normalized reads A page load and an older one it kept could both hold steps for one entry. Adopting them, each adopted page went ahead of what was queued, so adopting the oldest first put the newer page's steps before the older's; restoring them after a reload collapsed the newer page's steps into one, so its removal of an item the older page had added was lost on a conflict replay. Adoption now goes newest first, and a reload appends a later page's steps (and early edits) after an older copy's. restoreUserData() compared the lists it wrote with reads that drop duplicate favorites, so a saved restore listing one twice never finished, also in browser-only mode. It now compares reads right after writing with reads after the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): flushes wait for a page-hide batch sent at once; keepalive 401s; parked restores move A flush scheduled in the same turn as a page-hide flush was chained on the queue before the keepalive batch, which went out at once beside it: a later edit (removing a favorite the keepalive batch added) could land first and be undone when the keepalive write arrived. A scheduled flush now waits for a page-hide batch sent at once before it takes entries. A keepalive write refused with 401 returned failed without telling the status; like requests it now reports the token it sent as refused. The move copies a parked Xtream restore (a browser backup import that could not finish) to the server playlist id a playlist merged into, so it is applied and exported there; one parked there already wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a slow server does not reload a page started from memory forever The start probe gives /v1/instance 2.5 s. A remembered server slower than that started the page from memory; the recovery check (with the request timeout) then got an answer and reloaded, and the reloaded page timed out again: an endless reload loop for a server answering in 2.5 to 20 s. The reload now leaves a one-time mark in sessionStorage, and the next probe waits as long as requests do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
37 KiB
PWA Self-hosted Architecture
This document describes the browser PWA and self-hosted Docker path.
Ownership
apps/webowns the Angular browser UI and PWA service worker configuration.apps/web-backendowns the browser-only backend proxy for remote playlist, Xtream, and Stalker requests.docker/owns the production self-hosted image that bundles the PWA andweb-backendinto one container.
The old external 4gray/iptvnator-backend repository is not required for the
default self-hosted deployment. Sync behavior from that repository only when a
change intentionally restores or imports missing backend capabilities.
Runtime Backend Configuration
The PWA reads window.__IPTVNATOR_CONFIG__.BACKEND_URL through
apps/web/src/app/services/runtime-config.ts. The static placeholder lives at
apps/web/src/assets/app-config.js and keeps hosted builds working without
Docker-specific values.
The Docker entrypoint rewrites assets/app-config.js at container startup.
ngsw-config.json explicitly excludes this file from Angular service worker
asset hashing so a runtime rewrite does not break cache validation.
Service Worker Build
Use the PWA build configuration for browser deployments:
pnpm nx build web --configuration=pwa
The build must emit these files in dist/apps/web:
ngsw-worker.jsngsw.jsonsafety-worker.jsworker-basic.min.js
Angular also emits hashed font and media assets under dist/apps/web/media/.
Keep /media/** in ngsw-config.json so the PWA service worker can cache
bundled fonts, including Material Icons.
The Angular service worker is a browser/PWA feature only. Packaged Electron
loads the same Angular production bundle from file://.../app.asar/web, but it
must not register ngsw-worker.js; otherwise a desktop update can leave the
first Electron window controlled by a stale file-origin service worker and serve
old chunks from Electron userData. Electron clears legacy serviceworkers and
cachestorage storage from its default session before loading the packaged
renderer so existing desktop installs recover on the next startup without
clearing unrelated app storage.
web:serve-static serves dist/apps/web and builds with web:build:pwa, so it
exercises the same output layout as Docker. If Nx daemon state returns stale
service worker outputs while changing build options, run:
pnpm nx reset
pnpm nx build web --configuration=pwa --skip-nx-cache
Web Backend
The current self-hosted PWA uses these apps/web-backend routes:
GET /healthGET /config.jsPOST /provider-targetswith{ "url": "<provider-url>" }GET /parse?targetId=<id>GET /xtream?targetId=<id>&username=<u>&password=<p>&action=<action>GET /stalker?targetId=<id>&macAddress=<mac>&action=<action>
Server storage adds versioned routes under /v1, which the PWA uses in
server storage mode:
GET /v1/instance(always public):{ service, apiVersion, instanceId, storage: 'server' | 'none', auth: 'none' | 'token' }GET /v1/instance/status: playlist count, database size and pathGET /v1/playlists[?view=meta],GET|PUT|DELETE /v1/playlists/:id,DELETE /v1/playlists: whole playlist documents;GETsends anETagand answers304to a matchingIf-None-Match, andPUT ...?keepPayload=1keeps the storedplaylist,itemsandheaderfields (409 payload_missingwithout a stored row); everyPUTanswers with the newETagandX-Replaced-ETag, the version it replaced, and one withIf-Matchanswers412 conflictwhen another write replaced that version, one withIf-None-Match: *when the playlist exists alreadyGET /v1/user-state,PUT|DELETE /v1/user-state/:namespace/:playlistId: Xtream favorites, recently viewed, collection snapshots and playback positions, one JSON value per namespace and playlist;GETadds a content tag per entry (tags), and a write withIf-Match: "<tag>"orIf-None-Match: *answers412with the current value and tag when the entry changed since, and aPUTfor a playlist the server does not have answers404 playlist_missing(the client drops that edit), so an edit queued on another device cannot bring a deleted playlist's state backGET|PUT /v1/app-state/:key
Server storage lives in apps/web-backend/src/app/storage/. With
IPTVNATOR_STORAGE=server, main.ts opens the shared database through
initDatabase() (path from IPTVNATOR_DATA_DIR), so the server has the same
schema and migrations as the desktop app and writes playlists with the
shared row codec. It adds two server-only tables: server_user_state for
Xtream user data, and server_playlist_meta, a copy of each document
without its M3U payload fields that metadata reads use instead of walking
the payload blobs (filled once on open for rows written without it). ETags
come from an in-memory revision counter; the server is the only writer of
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 <token>, the proxy
routes included; its 401 carries WWW-Authenticate: Bearer realm="iptvnator", which a provider error passed on by the proxy never
does; the PWA sends it once its sign-in page has stored it.
Browser app state written through
/v1/app-state is stored under a client: key prefix, apart from
server-owned keys such as the instance id.
pnpm nx run web-backend:serve starts the backend by hand on port 3333 with
TSX_TSCONFIG_PATH=tsconfig.base.json, which tsx needs for the
@iptvnator/* path aliases. apps/web-e2e/playwright.config.ts does not use
that target: it launches node --import tsx apps/web-backend/src/main.ts with
an env that mirrors the target's. Nx run-commands starts its command in a
detached process group, and Playwright stops a webServer with a
process-group SIGKILL (taskkill /T /F on Windows). The kill therefore
reached only pnpm/nx, and the orphaned server kept port 3333 after the run.
apps/web-backend/src/app/project-config.spec.ts fails if a Playwright config
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. 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
non-parallel task with a continuous dependency. The mock servers follow the
same rule; see Xtream mock Playwright integration.
The PWA continues to use PwaService; only the backend base URL is resolved at
runtime. Electron routes remain owned by the Electron backend and preload
bridge.
Renderer code that needs to branch by runtime should use
RuntimeCapabilitiesService from @iptvnator/services instead of adding new
direct window.electron or DataService.getAppEnvironment() checks. Keep
feature decisions expressed as capabilities such as supportsEpg,
supportsSqlite, supportsXtreamSqliteDataSource, supportsDownloads, or
supportsManagedExternalPlayers so PWA and Electron behavior stays auditable
from one shared boundary. supportsSqlite requires the complete playlist
storage preload API surface used by PlaylistsService, supportsDownloads
requires the complete downloads preload API surface used by DownloadsService,
and EPG renderer code should use EpgRuntimeBridgeService from
@iptvnator/epg/data-access instead of calling window.electron directly.
Playback-position renderer code should use PlaybackPositionRuntimeBridgeService
from @iptvnator/services for Electron SQLite persistence and external-player
position update events; supportsPlaybackPositionStorage and
supportsPlaybackPositionUpdates describe those surfaces independently so PWA
and partial Electron bridges can degrade without direct preload checks.
supportsEpg remains the aggregate full EPG capability, while narrower runtime
capabilities cover individual EPG surfaces: import/progress, current-program
lookup, optional current-program batch reads, optional channel metadata,
freshness checks, clear/force-fetch management, channel browsing, and program
search. supportsPlaylistRefresh requires the native playlist
refresh/cancel/progress bridge, supportsXtreamSectionNavigation is available
in PWA and in Electron when either the SQLite Xtream data source or the Xtream
API transport is available, supportsDesktopFileSave requires both
saveFileDialog and writeFile, supportsManagedExternalPlayers requires the
MPV and VLC preload launch methods (openInMpv and openInVlc), and
supportsExternalPlayerPathSettings requires the path-setting methods
(setMpvPlayerPath and setVlcPlayerPath); a partial Electron bridge must not
expose desktop-only actions in the PWA/shared UI.
Server Storage Mode
main.ts probes GET /v1/instance before bootstrapping, and only when the
deployment wrote a runtime BACKEND_URL into window.__IPTVNATOR_CONFIG__
(the Docker entrypoint does; hosted builds and Electron never probe). On
storage: 'server' it imports apps/web/src/app/server-storage/ lazily and
merges provideServerStorage() into the app config, and remembers the
instance under iptvnator-server:instance. When the probe gets no answer
(network error, timeout, a 5xx such as nginx's 502 while the backend
restarts, or a refusal other than 404, such as a proxy's 429), a browser that remembers this backend still starts in server
mode, offline, and reloads once the server answers; it never falls back to
an empty browser library. Until that reload it sends no writes
(holdWrites) and no token anywhere (ServerStorageStatusService.accessToken()
is null for an unverified instance): the address may lead to another
server or database by then, which the reload's probe finds out. An answer without server storage, or a 404 from
an older backend, forgets it (server-storage-start.ts). Everything else of the
client lives behind the @iptvnator/services/server-storage sub-entry, so
other builds never load it; ServerStorageStatusService and the token
helpers stay in the main @iptvnator/services barrel because the rail,
settings and guards read them in every build.
- Playlists.
ServerStorageSession.start()(an app initializer) plugsServerPlaylistDocumentStoreintoPlaylistStorageBackend(kindselectron,server,indexeddb). The store caches full documents by ETag and uploads a document whose payload it served unchanged withkeepPayload=1, so a favorite or a rename does not resend a large M3U. A mutation carriesIf-Matchwith the version it was read at (of its payload, else of the served copy it was built from, as for portal documents): when another device wrote in between, the server answers412, the store throwsPlaylistWriteConflict, andPlaylistsService's per-playlist write queue runs the mutation again on a fresh read (a retry on the same, unread copy writes unconditionally, as before). Imports and start-up migrations (dbUpsertAppPlaylists) write unconditionally, and the store refuses a document without an id. "Remove all" first lets queued playlist writes land, and writes issued meanwhile wait until it is done. An M3U favorite toggle or reorder goes throughtransformPlaylistFavorites: a toggle adds or removes that one channel, a reorder applies its order, on the favorites read at write time. After a write the store keeps its copy only whenX-Replaced-ETagnames the version its payload came from. - Xtream user data.
USER_DATA_STORAGEbecomesServerUserDataStorage: the four synced namespaces are cached per server instance, underiptvnator-server:cache:<instanceId>:<namespace>(the legacy unprefixed keys are never written in server mode; a tab still open on another server never writes over this one's cache) and each change sends only the playlist entries that differ, debounced, with akeepaliveflush when the page hides. A change is compared with the map the caller read: when the cache changed since (another tab, a re-read), only the entries the caller changed are applied to it. Batches go out one after another (the page-hide flush waits for a batch still travelling, and a flush scheduled before it waits for the page-hide batch it sent at once), a failed write, including a refusedkeepaliveone or one the server took and never answered (it ends after the request timeout), stays queued for the next flush (a write saved whose answer was cut off counts as saved on an unknown version: only later edits replay on the next conflict), and every unconfirmed write (queued or travelling) is saved inlocalStorageper page load (iptvnator-server:unsent-writes:<instanceId>:<page>). The tab remembers its page load insessionStorage, so a reload takes its writes over and sends them first (the old copy goes only once the new one is saved, at the latest with this page's next saved copy; until then the tab's pointer names the kept page loads too, so another reload finds them even without their registry records; a duplicate of a tab that still runs leaves them, like that tab's page load, to it). A page marks its record closed onpagehide, and it stays closed when a page-hide write failing afterwards rewrites it (not into the back-forward cache, where its record goes stale, as in a background tab the browser suspends; restored or shown again after another tab adopted it, the page reloads rather than send those writes again, and until then it neither sends nor saves them, also when its retry timer runs first), and only a closed, stale or missing record is taken over: a duplicated tab, which copiessessionStorage, leaves the running tab its writes. A page load that still looked live (or whose close flag storage refused) is checked again every 30 s and, once it does not, adopted like a closed tab's: its edits go ahead of this page's newer ones, and without a server version this page read they are sent so that they conflict and replay; the writes of a tab closed before it could send them are queued, ahead of its own, by another tab once that tab's record has gone stale (at its next re-read, or its 30 s check when it has nothing to sync and never re-reads); the result replays the closed tab's edits first and this tab's newer ones last. An adopted edit changes only what is still as its page saw it (EditStep.yields), also at every later conflict: an item or key changed since, by this tab or another device, keeps its newer value. Tabs never clear each other's. Cache and saved writes belong to one server database (iptvnator-server:cache-owner); another one behind the same address starts clean. Startup waits for the first read only a few seconds;UserDataStorage.ready()waits for a first read in flight (it starts none, so an offline page is not held up) and tells whether one succeeded. The Xtream data source's favorites, favorite checks, history and position reads wait for it, so a new device does not show empty lists or start playback without its resume position. A backup export or restore (also its replay when the playlist opens) reads the server again first (ready(true): another device may have changed the lists since) and fails when that read does not succeed: unread maps would export as empty lists, and a restore's lists would merge into the server's instead of replacing them (the restore stays parked). A restore also replaces nothing while this page's own writes for the playlist are unsent (they would join the replacement) and counts only once the server took its lists (UserDataStorage.flushed) as they are: a conflict that merged another device's edit in leaves it parked to run again on a fresh read. Replayed edits keep recently viewed lists newest first, as the app cuts them at their limit from the end. Also, re-reads run one at a time and keep entries edited while they were in flight or not yet saved, and entries another tab saved meanwhile or had unconfirmed when the read went out (the answer predates them; their next write here conflicts and replays). A response body cut off after its headers counts as offline. Each write is sent on the tag of the entry it was made against (UserDataWriteQueue); after a412the edit (before → after) is replayed on the server's current value, so another device's change to the same playlist's entry is kept. A queued write keeps its edits as steps (before → after, also in the saved writes): an edit continuing the last one joins it; one made behind a travelling write, after a write whose answer was lost, or on another tab's change is kept apart; and a conflict replays them in order. So a lost write is not dropped, and adding and then removing an item still removes it when the addition landed. When two tabs edit one entry before either write lands, a removal of the other tab's unsent item holds if that tab's write lands first, as it does when a tab is left (its writes go out on hide); otherwise the item can come back. An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has (UNKNOWN_TAG), so they conflict and are replayed too. Page-hide writes report their new tag or conflict like any other, and one behind a travelling write is taken from the queue only when that one is answered. While a playlist is being deleted its edits wait; the delete drops them only when it succeeds, and a re-read sent before it skips the deleted playlist. Edits made before the first successful read are held as before → after and applied to the server's value once it arrives (applyEdit): a new device or a stale cache adds its favorite instead of replacing the server's list. On this tab's recovered unsent write they are kept as their own steps after it. They are saved per page load too (iptvnator-server:deferred-edits:<instanceId>:<page>). Records inlocalStorage(iptvnator-server:unconfirmed:<instanceId>:<page>, one key per page load so tabs never overwrite each other's, renewed every 30 s while it has unsent writes, stale after ten minutes) let a re-read in one tab keep the cache values another tab is still sending, and a stale record marks a closed tab whose writes another tab adopts (the storage adapter lists its keys for this). Saved writes without any record are left to the tab's own reload: that tab may still run. A value too big forlocalStorage, or any value when the browser refuses storage access, stays readable from memory, so callers and the diff baseline agree. A re-read updates the cache; views already open show another device's changes on their next load (no live push). Start-up waits up to 3 s for the first read. - Connection state.
ServerStorageStatusServiceisonline,offline(a request did not reach the server, or a proxy answered 502, 503 or 504 for it; retried every 30 s and when the tab returns, as are writes the server refused and a first read that failed while it isonline) orunauthorized(a 401 clears the stored token; only a reload after a new token leaves it, so a late answer cannot undo sign-out). The rail shows a status shortcut, an offline banner sits above the workspace content, and failed writes raise a snackbar (SERVER_STORAGE.WRITE_FAILED, orTOO_LARGEfor a 413), the same one at most every five minutes. - Access token. With
IPTVNATOR_ACCESS_TOKENthe instance reportsauth: 'token'(the backend refuses a token without server storage: the PWA has no sign-in flow for a stateless backend); without a stored token, or after a 401, the guards route to/server-sign-in(the start-up screen lets it through although the playlists could not load), which checks the token against/v1/instance/statusbefore it stores it underiptvnator-server:access-tokenand reloads (a browser that refuses to store it is told so instead). The token is stored with the instance id it was given for, and a page sends it only to that instance: a tab still open on the old backend afterBACKEND_URLchanged does not send the new server's token there, and goes to sign-in instead.ServerAccessTokenInterceptoradds the header toHttpClientcalls to the backend, andPwaServiceadds it to its two rawfetchcalls only while server mode runs on a verified instance and only when the URL resolves to the backend (isBackendUrl); it joins paths to the backend URL without its trailing slashes, soBACKEND_URL=/never yields a protocol-relative//…URL that names another host. The browser forgets the token when the backend answers without server storage, or as another server or database (backend URL or instance id) than the one it remembers; sign-in therefore remembers the server along with the token, or reports storage trouble. A page started from memory first checks that the instance at the address is the remembered one (ensureVerified), and reloads if not (a reload after a server answered late gives the next start probe as long as requests take, once, so a slow server does not reload forever), before sign-in sends the token. So does a page that went offline (any backend call can tell: the storage API with its page-hidekeepalivewrites,HttpClientcalls through the interceptor andPwaService's raw fetches report no answer, or a gateway's 502/503/504 that is not the backend's own JSON, as offline) before its next read: until then it sends neither the token nor writes (a replaced volume at the same address is another database), and it checks the instance as soon as it goes offline, so a blip costs no retry interval (only the instance, and once per retry interval: a read or a check that keeps failing waits for the retry; the retry also runs while a late answer set an unchecked page online). Sign-out always navigates to sign-in, also when a guard kept the page the first time. Both turn the guard's 401 (isServerAccessChallenge) intoreportAccessDenied(), as the storage API client does with any 401, so a token changed on the server sends an open page back to sign-in; a late 401 for a token replaced since (a new sign-in) changes nothing, nor does one for a page that holds its token back until the instance is checked. - Moving browser data.
ServerMigrationServiceoffers, once per server instance (markeriptvnator-server:migration:<instanceId>), to move the playlists this browser kept in IndexedDB (planServerMigration), Stalker rows from before the portal-mode flag normalized first (server rows get the same Stalker metadata migration once per server, as a desktop database copied there can predate it: each row read again and written as a conditional mutation, a conflict retried at the next start). A playlist the server already has, by id or by source (Xtream server and user, Stalker portal and MAC, M3U URL), merges into the server's copy; the others upload with their ids, create-only (If-None-Match: *): one that another tab or device moved first is merged into instead of overwritten. A merged playlist gains the browser document's favorites and history (M3U favorites name channels by stream URL, as the app looks them up; older ones by a channel id that is random per import, which becomes its URL on both sides; history entries keep their URL id and get the server's channel id aschannel_id; recently viewed newest first byadded_at, one per item with its newest view; Stalker items by id, category and series flag, since one provider category can hold a movie and a series of one id; nothing is dropped), and the legacy Xtream maps follow under the server id; each copy's bare legacy Xtream ids are typed from its own snapshots first (typeLegacyXtreamRefs; snapshots keep their bare key as an alias), thencombineUserDataunions lists (playback positions by content type and Xtream id, renamed to the server playlist; recently viewed items newest first and one per item, its newest view, since the app cuts history from the end) and merges maps, the server's value winning on a conflict. Parental locks stay in the browser but move to the server playlist id with it; while the lock store or a browser user-data map cannot be read the move counts as failed. A parked Xtream restore (a browser backup import that could not finish) is copied to the server playlist id it merged into, unless that one has its own. Browser data is never deleted, the move does not start while user-data writes are unsent, and the marker is written only when nothing failed, so Settings → Server keeps offering a partial move.
What stays per device: settings, parental lock PINs and locked groups, the global favorites order and other UI preferences in browser storage.
Runtime Limitations
The self-hosted build is the browser PWA, not the Electron desktop app. Keep these limitations explicit in UI, troubleshooting, and release notes:
- EPG/XMLTV is not supported in the PWA yet. Do not render live EPG panels, programme-guide entry points, or EPG-fetching flows in browser/PWA mode, and do not use EPG as the readiness signal for Docker.
- The PWA does not use the Electron SQLite database or DB worker. Playlist
metadata uses
PlaylistsServicewith IndexedDB, whichPlaylistStorageBackendselects when no SQLite bridge exists; Xtream favorites, recently viewed items, playback positions, and cached collection snapshots usePwaXtreamDataSourcebrowser storage. In server storage mode both live on the server instead. - Browser playlist deletion must go through
PlaylistsService, notDatabaseService.PlaylistsService.deletePlaylist()runs registered cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does not survive after the source is removed. - The Docker/PWA runtime cannot launch MPV, VLC, IINA, Embedded MPV, download manager flows, or Electron remote-control features. If inline browser playback fails, the supported browser fallback is copying the stream URL and opening it manually in an external player.
Provider URLs are registered before proxy calls so the proxy endpoints do not accept raw target URLs in query strings. Registration validates the target URL before any outbound request:
- only
http:andhttps:provider URLs are accepted - URL credentials are rejected
- loopback, private, link-local, and reserved network targets are blocked by default
IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1explicitly enables trusted local/LAN targets for development, mock servers, or private deployments
Provider redirects and connection policy
ValidatedHttpClient applies the registration URL policy again before every
outbound request on /xtream, /stalker, /parse, and /parse-xml, including
the initial request. Axios automatic redirects are disabled (maxRedirects: 0);
301, 302, 303, 307 and 308 are followed manually, with at most five redirects.
Relative Location values resolve against the URL actually sent, including its
query. Missing/malformed locations, repeated URLs (ignoring fragments), and an
exhausted redirect budget fail without dispatching another request. Intermediate
bodies are destroyed at headers; malformed or endless redirect bodies do not
block following an otherwise valid location. Final bodies retain a native socket
inactivity timer: axios stream mode stops its own timeout handling at headers,
so provider-response.ts keeps that timer active until consumption finishes.
A body failure preserves received HTTP status evidence; cancellation remains
inconclusive before any redirect.
Every DNS answer must be a valid permitted IP. Mixed public/private answers
fail closed. The strict policy rejects private/reserved IPv4, IPv4-mapped private
IPv6, and IPv6 outside global unicast 2000::/3, plus protocol-assignment,
documentation and 6to4 ranges within it. These deliberately conservative ranges
follow the IANA IPv6 address space
and special-purpose registry.
The LAN opt-in applies to the whole
chain; it still requires HTTP(S), no URL userinfo and concrete DNS addresses.
Fresh HTTP/HTTPS agents pin socket lookup to the exact validated IPv4/IPv6
answer set for that hop. No second DNS query or pooled connection may substitute
an unvalidated address. The default axios transport uses a fixed logical hostname (provider.invalid)
so its connection authority cannot come from user-controlled URL metadata;
the pinned lookup alone selects an IP. Its native request-options adapter sets
path separately, so even //host/path cannot replace the connection authority.
It also owns the deadline from dispatch through response headers, since axios's
built-in connection timer only covers its own native transport objects. The original provider host and port are
explicitly preserved in Host, TLS SNI and certificate identity checks (literal
IPs omit SNI but still verify the original IP). Axios proxies and Node environment proxies
are disabled for these requests so a proxy cannot independently resolve the
origin. Deployments that require an outbound HTTP proxy must use a different
network arrangement; setting HTTP_PROXY/HTTPS_PROXY does not route provider
requests through it. This guarantee concerns address selection in the Node
transport, not routing/NAT performed outside the process. Private-network
opt-in intentionally relaxes address restrictions.
Original axios params are applied only to the initial request. Subsequent
queries come from Location resolution, without appending the original
credentials again. Authorization, Cookie, Proxy-Authorization and Stalker SN
headers are retained only on the same origin; changes of host, port or scheme
(including HTTPS downgrades) strip them for the rest of the chain. User-Agent
and other non-secret protocol headers survive. Providers can explicitly put
query values in Location; the backend does not rewrite provider-issued URLs.
Policy errors contain fixed messages/statuses without URLs, DNS exceptions or
transport objects. Portal routes keep HTTP 200 with a { message, status }
error envelope; playlist/XMLTV routes use the actual error status. Registration
continues to use real HTTP error statuses. See
host connectivity guard for chain ownership and
failure attribution.
Do not disable TLS certificate validation in the backend proxy. For private
certificate authorities, configure Node with NODE_EXTRA_CA_CERTS.
PWA Portal User Data
Xtream favorites and recently viewed items use the browser-side
PwaXtreamDataSource when Electron DB preload APIs are unavailable. The
XTREAM_DATA_SOURCE provider chooses the Electron SQLite-backed source only
when RuntimeCapabilitiesService.supportsXtreamSqliteDataSource is true; a
browser PWA or partial preload bridge must fall back to the PWA source and run
the browser cleanup hook on playlist deletion. The PWA stores this user activity
and sidecar state in localStorage:
xtream-collection-itemsxtream-favoritesxtream-recent-itemsxtream-playlistsxtream-playback-positions
PwaXtreamDataSource and PwaCollectionStorage reach these keys through the
USER_DATA_STORAGE token from @iptvnator/services (default:
localStorage), never through localStorage directly, so server storage
mode can substitute ServerUserDataStorage. The key names are shared as
XTREAM_USER_DATA_KEYS with the playlist backup, which exports and restores
the favorites, recent items, positions and snapshots through the same token
(PwaXtreamBackupStateService; see
backup/restore).
Entries should include a content snapshot when the item is added. Global collection routes and the dashboard can then restore titles, posters, content type, and category IDs after navigation or a page reload without relying on the Electron SQLite content table.
Shared collection services that need Xtream favorites or recent data should use
XTREAM_DATA_SOURCE from @iptvnator/portal/xtream/data-access from a
type:data-access or type:feature boundary. UI libraries in the M3U domain
must not import Xtream data-access directly; use PlaylistsService for source
metadata changes and let app-level cleanup providers handle portal-specific
browser sidecar data.
Docker Runtime
The Docker image has two stages:
- Build stage installs dependencies and runs
web:pwaplusweb-backend. pnpm is installed globally at the exactpackageManagerversion rather than through Corepack, which Node 25 unbundled, so the base-image major stays free to move. - Runtime stage uses
node:24-alpinewith nginx installed. nginx servesdist/apps/weband proxies/api/*to the local Express backend. The entrypoint renders the nginx config from a${PORT}template, starts the backend, waits for/health, and then starts nginx. If either process exits after startup, the entrypoint exits the container so the compose restart policy can recover the service.
Default runtime values:
BACKEND_URL=/apiCLIENT_URL=http://localhost:4333PORT=3000IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=0NODE_EXTRA_CA_CERTSunsetIPTVNATOR_STORAGE=server,IPTVNATOR_DATA_DIR=/data(aVOLUME)IPTVNATOR_ACCESS_TOKENunset,IPTVNATOR_MAX_DOCUMENT_MB=64IPTVNATOR_BACKEND_START_TIMEOUT=300: seconds the entrypoint waits for/health; the backend migrates its database before it listens
better-sqlite3 is a native addon, so the Dockerfile builds it in a separate
native stage that is not pinned to $BUILDPLATFORM; multi-arch builds
produce it for each target platform. nginx raises client_max_body_size to
64 MB for /api/ and gzips JSON responses.
When hosting behind another domain, set CLIENT_URL to the browser origin and
keep BACKEND_URL=/api unless the reverse proxy exposes the backend elsewhere.
Only set IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1 when the self-hosted
instance is restricted to trusted users and intentionally needs private network
IPTV targets. For providers using private certificate authorities, mount the CA
bundle into the container and set NODE_EXTRA_CA_CERTS to that mounted path.
Validation
Use the narrow validation ladder for self-hosted changes:
pnpm nx test web-backend
pnpm nx test web-backend --testPathPatterns=storage
pnpm nx test web --runTestsByPath apps/web/src/app/services/runtime-config.spec.ts
pnpm nx build web --configuration=pwa --skip-nx-cache
pnpm nx build web-backend
pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted
docker compose -f docker/docker-compose.yml config
Run docker build -t iptvnator:self-hosted-test -f docker/Dockerfile . when a
Docker daemon is available.
For manual Docker smoke testing, run the Xtream and Stalker mock servers plus a small M3U fixture, then verify in the browser that M3U, Xtream, and Stalker can add sources, play an item, toggle favorites, populate global favorites, populate recently viewed, and appear on the dashboard rails.
Service factory and build bases
DataService in libs/services/src/lib/data.service.ts is the renderer service
contract. DataFactory() in apps/web/src/app/app.config.ts chooses
ElectronService for the desktop bridge and PwaService for browser HTTP and
IndexedDB work. This environment-level selection is not evidence for an
individual capability: Xtream data-source selection requires its complete
SQLite bridge, and feature visibility follows RuntimeCapabilitiesService.
The same workspace route tree is used in both runtimes.
Desktop relational data uses the canonical schema/connection in
libs/shared/database; the SQLite path is ~/.iptvnator/databases/iptvnator.db.
Desktop Chromium settings/storage still exist alongside SQLite. PWA data uses
browser IndexedDB (with browser quotas); its structure is not the SQL schema.
Browser-selected file uploads remain possible even though native filesystem
access is Electron-only.
Web development and PWA use baseHref="/"; packaged Electron frontend uses
baseHref="./" so file URLs resolve. In apps/web/project.json, production
is the Electron frontend build, pwa is the web build, and development uses
the index base. Do not ship the Electron frontend build as a PWA deployment.