mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(self-hosted): keep playlists on the server for every device (#1903)
* 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>
This commit is contained in:
1 parent
2139ddd5c0
commit
8098ea8bb8
147 files changed
+15337
-189
No files matched your search
@@ -33,3 +33,6 @@ export * from './lib/source-health-evidence.service';
|
||||
export * from './lib/source-activity.service';
|
||||
export * from './lib/translation-tick';
|
||||
export * from './lib/user-data-storage';
|
||||
export * from './lib/server-storage-status.service';
|
||||
export * from './lib/server-access-token';
|
||||
export * from './lib/server-slow-start';
|
||||
@@ -269,6 +269,46 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
|
||||
|
||||
await expect(service.exportBackup()).rejects.toThrow('denied');
|
||||
});
|
||||
|
||||
it('waits for server user data before it reads the lists', async () => {
|
||||
// Server mode: the maps hold the server's data once it was read.
|
||||
const ready = jest.fn(async () => {
|
||||
seed();
|
||||
return true;
|
||||
});
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => localStorage.setItem(key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
ready,
|
||||
}),
|
||||
});
|
||||
|
||||
const { manifest } = await service.exportBackup();
|
||||
|
||||
// A fresh read: another device may have changed them since.
|
||||
expect(ready).toHaveBeenCalledWith(true);
|
||||
expect(ready).toHaveBeenCalledTimes(1);
|
||||
expect(xtreamEntry(manifest).userState.favorites).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses to export server user data it could not read', async () => {
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: () => null,
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
ready: async () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.exportBackup()).rejects.toThrow(
|
||||
/user data could not be read/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import', () => {
|
||||
@@ -472,6 +512,130 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the restore parked until server user data was read', async () => {
|
||||
const manifest = await exportSeeded();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
KEYS.favorites,
|
||||
JSON.stringify({ 'xtream-1': ['live:1'] })
|
||||
);
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => localStorage.setItem(key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
ready: async () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(xtreamOnly(manifest));
|
||||
|
||||
expect(summary.failed).toBe(1);
|
||||
// Nothing merged into lists the server may still replace.
|
||||
expect(read(KEYS.favorites)).toEqual({ 'xtream-1': ['live:1'] });
|
||||
expect(
|
||||
pendingRestore(service).getOrThrow('xtream-1')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the restore parked when a conflict merged other lists in', async () => {
|
||||
const manifest = await exportSeeded();
|
||||
localStorage.clear();
|
||||
// Taken, but merged with an edit another device made meanwhile.
|
||||
const flushed = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockImplementation(async () => {
|
||||
const map = JSON.parse(
|
||||
localStorage.getItem(KEYS.favorites) ?? '{}'
|
||||
);
|
||||
map['xtream-1'] = [...map['xtream-1'], 'live:9'];
|
||||
localStorage.setItem(KEYS.favorites, JSON.stringify(map));
|
||||
return true;
|
||||
});
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => localStorage.setItem(key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
ready: async () => true,
|
||||
flushed,
|
||||
}),
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(xtreamOnly(manifest));
|
||||
|
||||
expect(summary.failed).toBe(1);
|
||||
expect(summary.errors[0]).toContain('changed elsewhere');
|
||||
expect(
|
||||
pendingRestore(service).getOrThrow('xtream-1')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('replaces nothing while earlier changes to the playlist are unsent', async () => {
|
||||
const manifest = await exportSeeded();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
KEYS.favorites,
|
||||
JSON.stringify({ 'xtream-1': ['live:1'] })
|
||||
);
|
||||
// A write of this page for the playlist that the server refused.
|
||||
const flushed = jest.fn().mockResolvedValueOnce(false);
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => localStorage.setItem(key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
ready: async () => true,
|
||||
flushed,
|
||||
}),
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(xtreamOnly(manifest));
|
||||
|
||||
expect(summary.failed).toBe(1);
|
||||
// Its edits would have joined the replacement lists.
|
||||
expect(read(KEYS.favorites)).toEqual({ 'xtream-1': ['live:1'] });
|
||||
expect(
|
||||
pendingRestore(service).getOrThrow('xtream-1')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the restore parked until the server took the lists', async () => {
|
||||
const manifest = await exportSeeded();
|
||||
localStorage.clear();
|
||||
// Nothing unsent before; the restored lists are refused.
|
||||
const flushed = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValue(false);
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: playlistsService([existingXtreamPlaylist]),
|
||||
pwaXtream: createPwaXtreamBackupState({
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => localStorage.setItem(key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
ready: async () => true,
|
||||
flushed,
|
||||
}),
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(xtreamOnly(manifest));
|
||||
|
||||
expect(flushed).toHaveBeenCalledWith('xtream-1');
|
||||
expect(summary.failed).toBe(1);
|
||||
expect(
|
||||
pendingRestore(service).getOrThrow('xtream-1')
|
||||
).not.toBeNull();
|
||||
// Titles are merged regardless: the replay carries none.
|
||||
expect(read(KEYS.collectionItems)).toEqual({
|
||||
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces a damaged title cache but refuses an unreadable storage', async () => {
|
||||
const manifest = await exportSeeded();
|
||||
localStorage.setItem(KEYS.collectionItems, '{"xtream-1": {');
|
||||
|
||||
@@ -91,6 +91,9 @@ export class PlaylistBackupService {
|
||||
'The parental lock store could not be read; the backup would omit its locks.'
|
||||
);
|
||||
}
|
||||
if (!this.runtime.supportsXtreamSqliteDataSource) {
|
||||
await this.pwaXtream.ready();
|
||||
}
|
||||
const playlists = await firstValueFrom(
|
||||
this.playlistsService.getAllData()
|
||||
);
|
||||
|
||||
@@ -69,6 +69,17 @@ export async function runWithPlaylistAuthorityReset<T>(
|
||||
);
|
||||
}
|
||||
|
||||
/** Row reservations this browsing context holds now, per playlist. */
|
||||
const heldHere = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Whether this context holds a reservation on the playlist: a store-wide
|
||||
* reset waits for it, so its own writes must not wait for the reset.
|
||||
*/
|
||||
export function holdsPlaylistAuthority(playlistId: string): boolean {
|
||||
return (heldHere.get(playlistId) ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Tries to hold one origin-wide row reservation until its owner releases it. */
|
||||
export async function reservePlaylistAuthority(
|
||||
playlistId: string
|
||||
@@ -120,11 +131,18 @@ export async function reservePlaylistAuthority(
|
||||
}
|
||||
|
||||
let released = false;
|
||||
heldHere.set(playlistId, (heldHere.get(playlistId) ?? 0) + 1);
|
||||
return {
|
||||
status: 'acquired',
|
||||
release: () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
const count = (heldHere.get(playlistId) ?? 1) - 1;
|
||||
if (count > 0) {
|
||||
heldHere.set(playlistId, count);
|
||||
} else {
|
||||
heldHere.delete(playlistId);
|
||||
}
|
||||
releaseHeldLock();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,4 +41,15 @@ describe('PlaylistStorageBackend', () => {
|
||||
expect(backend.kind()).toBe('indexeddb');
|
||||
expect(backend.documentStore()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the server store once it is set', () => {
|
||||
testWindow.electron = undefined;
|
||||
const backend = create(false);
|
||||
const store = { dbGetAppPlaylists: jest.fn() } as never;
|
||||
|
||||
backend.useServerStore(store);
|
||||
|
||||
expect(backend.kind()).toBe('server');
|
||||
expect(backend.documentStore()).toBe(store);
|
||||
});
|
||||
});
|
||||
@@ -28,20 +28,44 @@ export type PlaylistDocumentStore = Pick<
|
||||
>
|
||||
>;
|
||||
|
||||
export type PlaylistStorageKind = 'electron' | 'indexeddb';
|
||||
/**
|
||||
* The server refused a write built on a playlist version another device has
|
||||
* replaced since; the write queue runs the mutation again on a fresh read.
|
||||
*/
|
||||
export class PlaylistWriteConflict extends Error {
|
||||
constructor(readonly playlistId: string) {
|
||||
super(`Playlist ${playlistId} changed since it was read.`);
|
||||
this.name = 'PlaylistWriteConflict';
|
||||
}
|
||||
}
|
||||
|
||||
export type PlaylistStorageKind = 'electron' | 'server' | 'indexeddb';
|
||||
|
||||
/** Single place that decides where `PlaylistsService` persists playlists. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistStorageBackend {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private serverStore: PlaylistDocumentStore | null = null;
|
||||
|
||||
/** Server mode: set once by the server storage session at startup. */
|
||||
useServerStore(store: PlaylistDocumentStore): void {
|
||||
this.serverStore = store;
|
||||
}
|
||||
|
||||
kind(): PlaylistStorageKind {
|
||||
if (this.serverStore) {
|
||||
return 'server';
|
||||
}
|
||||
return this.runtime.supportsSqlite ? 'electron' : 'indexeddb';
|
||||
}
|
||||
|
||||
/** The document store for `kind()`, or `null` for IndexedDB. */
|
||||
documentStore(): PlaylistDocumentStore | null {
|
||||
if (this.kind() !== 'electron' || typeof window === 'undefined') {
|
||||
const kind = this.kind();
|
||||
if (kind === 'server') {
|
||||
return this.serverStore;
|
||||
}
|
||||
if (kind !== 'electron' || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return (window.electron as PlaylistDocumentStore | undefined) ?? null;
|
||||
|
||||
@@ -54,6 +54,8 @@ describe('PlaylistsService inventory reads', () => {
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
playlistWriteQueues: new Map(),
|
||||
deletingAll: Promise.resolve(),
|
||||
resets: 0,
|
||||
});
|
||||
const settle = async (index: number, playlists: Playlist[]) => {
|
||||
// The read starts after the memoized migration's awaits.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EMPTY, firstValueFrom, from, of } from 'rxjs';
|
||||
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { reservePlaylistAuthority } from './playlist-cross-context-lock';
|
||||
import { PlaylistWriteConflict } from './playlist-storage-backend';
|
||||
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
@@ -66,6 +68,8 @@ describe('PlaylistsService', () => {
|
||||
electronMigrationPromise: null,
|
||||
indexedDbMigrationPromise: null,
|
||||
playlistWriteQueues: new Map(),
|
||||
deletingAll: Promise.resolve(),
|
||||
resets: 0,
|
||||
playlistDeleteCleanups: [],
|
||||
});
|
||||
|
||||
@@ -73,7 +77,10 @@ describe('PlaylistsService', () => {
|
||||
}
|
||||
|
||||
it('announces a committed SQLite connection edit', async () => {
|
||||
const playlist = { _id: 'source', url: 'https://old.test/list' } as Playlist;
|
||||
const playlist = {
|
||||
_id: 'source',
|
||||
url: 'https://old.test/list',
|
||||
} as Playlist;
|
||||
testWindow.electron = {
|
||||
dbGetAppState: jest.fn().mockResolvedValue('1'),
|
||||
dbSetAppState: jest.fn(),
|
||||
@@ -84,8 +91,22 @@ describe('PlaylistsService', () => {
|
||||
const service = createService();
|
||||
const next = jest.fn();
|
||||
Object.assign(service, { healthEvidence: { connections: { next } } });
|
||||
await firstValueFrom(service.updatePlaylistMeta({ ...playlist, title: 'Source', count: 0, importDate: '', autoRefresh: false, serverUrl: 'https://new.test' }));
|
||||
expect(next).toHaveBeenCalledWith({ id: 'source', playlist: expect.objectContaining({ serverUrl: 'https://new.test' }) });
|
||||
await firstValueFrom(
|
||||
service.updatePlaylistMeta({
|
||||
...playlist,
|
||||
title: 'Source',
|
||||
count: 0,
|
||||
importDate: '',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://new.test',
|
||||
})
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith({
|
||||
id: 'source',
|
||||
playlist: expect.objectContaining({
|
||||
serverUrl: 'https://new.test',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('announces committed deletion but does not retire health after a failed write', async () => {
|
||||
@@ -95,9 +116,15 @@ describe('PlaylistsService', () => {
|
||||
await firstValueFrom(service.deletePlaylist('retired'));
|
||||
expect(next).toHaveBeenCalledWith({ id: 'retired' });
|
||||
next.mockClear();
|
||||
const failed = createService({ delete: jest.fn(() => { throw new Error('write failed'); }) });
|
||||
const failed = createService({
|
||||
delete: jest.fn(() => {
|
||||
throw new Error('write failed');
|
||||
}),
|
||||
});
|
||||
Object.assign(failed, { healthEvidence: { connections: { next } } });
|
||||
await expect(firstValueFrom(failed.deletePlaylist('kept'))).rejects.toThrow();
|
||||
await expect(
|
||||
firstValueFrom(failed.deletePlaylist('kept'))
|
||||
).rejects.toThrow();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -554,6 +581,85 @@ describe('PlaylistsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('legacy Stalker portal flags on the server', () => {
|
||||
// A desktop database copied to the server, from before the flag.
|
||||
const copied = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Copied Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'http://example.com/stalker_portal/c/',
|
||||
} as Playlist;
|
||||
|
||||
function serverService(
|
||||
fresh: Playlist,
|
||||
upsert: jest.Mock = jest.fn(async () => ({ success: true }))
|
||||
) {
|
||||
const appState = new Map<string, string>();
|
||||
const server = {
|
||||
dbGetAppPlaylists: jest.fn(async () => [copied]),
|
||||
// Another device may have changed it since the list read.
|
||||
dbGetAppPlaylist: jest.fn(async () => fresh),
|
||||
dbGetAppState: jest.fn(
|
||||
async (key: string) => appState.get(key) ?? null
|
||||
),
|
||||
dbSetAppState: jest.fn(async (key: string, value: string) => {
|
||||
appState.set(key, value);
|
||||
}),
|
||||
dbUpsertAppPlaylist: upsert,
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
dbRecoverLegacyPlaylists: jest.fn(),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
const service = createService();
|
||||
Object.assign(service as object, {
|
||||
storageBackend: {
|
||||
kind: () => 'server',
|
||||
documentStore: () => server,
|
||||
},
|
||||
});
|
||||
return { service, server, appState };
|
||||
}
|
||||
|
||||
it('are written per row on a fresh read, as conditional mutations', async () => {
|
||||
const edited = { ...copied, title: 'Renamed elsewhere' };
|
||||
const { service, server, appState } = serverService(edited);
|
||||
|
||||
await firstValueFrom(service.getAllPlaylists());
|
||||
|
||||
// Never the stale list copy, never an unconditional bulk write.
|
||||
expect(server.dbUpsertAppPlaylists).not.toHaveBeenCalled();
|
||||
expect(server.dbUpsertAppPlaylist).toHaveBeenCalledWith({
|
||||
...edited,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe(
|
||||
'1'
|
||||
);
|
||||
// The desktop-only steps stay with the desktop.
|
||||
expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stay unflagged for the next start after a conflict', async () => {
|
||||
const { service, appState } = serverService(
|
||||
copied,
|
||||
jest.fn(async () => {
|
||||
throw new PlaylistWriteConflict('stalker-1');
|
||||
})
|
||||
);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await firstValueFrom(service.getAllPlaylists());
|
||||
|
||||
expect(
|
||||
appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => {
|
||||
const electron = {
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
@@ -1109,7 +1215,9 @@ describe('PlaylistsService', () => {
|
||||
] as Playlist[];
|
||||
const dbService = {
|
||||
getAll: jest.fn(() => of([])),
|
||||
getByID: jest.fn((_store: string, id: string) => of(playlists.find((p) => p._id === id))),
|
||||
getByID: jest.fn((_store: string, id: string) =>
|
||||
of(playlists.find((p) => p._id === id))
|
||||
),
|
||||
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
||||
of(playlist)
|
||||
),
|
||||
@@ -1142,19 +1250,32 @@ describe('PlaylistsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])('does not resurrect a deleted source when startup refresh finishes (SQLite=%s)', async (sqlite) => {
|
||||
const upsert = jest.fn();
|
||||
testWindow.electron = sqlite ? {
|
||||
dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(),
|
||||
dbGetAppPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbUpsertAppPlaylist: upsert,
|
||||
} : undefined;
|
||||
const update = jest.fn(() => of(undefined));
|
||||
const service = createService({ update });
|
||||
expect(await firstValueFrom(service.updateManyPlaylists([{ _id: 'deleted', autoRefresh: true } as Playlist]))).toEqual([]);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([false, true])(
|
||||
'does not resurrect a deleted source when startup refresh finishes (SQLite=%s)',
|
||||
async (sqlite) => {
|
||||
const upsert = jest.fn();
|
||||
testWindow.electron = sqlite
|
||||
? {
|
||||
dbGetAppState: jest.fn().mockResolvedValue('1'),
|
||||
dbSetAppState: jest.fn(),
|
||||
dbGetAppPlaylists: jest.fn().mockResolvedValue([]),
|
||||
dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbUpsertAppPlaylist: upsert,
|
||||
}
|
||||
: undefined;
|
||||
const update = jest.fn(() => of(undefined));
|
||||
const service = createService({ update });
|
||||
expect(
|
||||
await firstValueFrom(
|
||||
service.updateManyPlaylists([
|
||||
{ _id: 'deleted', autoRefresh: true } as Playlist,
|
||||
])
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('short-circuits updateManyPlaylists when no playlists are provided', async () => {
|
||||
const dbService = {
|
||||
@@ -1635,6 +1756,273 @@ describe('PlaylistsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes everything only after queued writes, before later ones', async () => {
|
||||
const order: string[] = [];
|
||||
let finishWrite: () => void = () => undefined;
|
||||
const electron = {
|
||||
dbGetAppPlaylist: jest.fn(async () => {
|
||||
order.push('read');
|
||||
return { _id: 'p9', title: 'P', favorites: [] };
|
||||
}),
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
dbGetAppState: jest.fn(async (key: string) =>
|
||||
key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null
|
||||
),
|
||||
dbSetAppState: jest.fn(),
|
||||
dbUpsertAppPlaylist: jest.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
order.push('write');
|
||||
finishWrite = () => resolve();
|
||||
})
|
||||
),
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
dbDeleteAllPlaylists: jest.fn(async () => {
|
||||
order.push('delete all');
|
||||
}),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
// Microtasks only: a real timer would let other tests' late errors in.
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const first = firstValueFrom(
|
||||
service.transformPlaylistFavorites('p9', (f) => [...f, 'a'])
|
||||
);
|
||||
await settle();
|
||||
const removed = firstValueFrom(service.removeAll());
|
||||
const later = firstValueFrom(
|
||||
service.transformPlaylistFavorites('p9', (f) => [...f, 'b'])
|
||||
);
|
||||
await settle();
|
||||
expect(order).toEqual(['read', 'write']);
|
||||
|
||||
finishWrite();
|
||||
await first;
|
||||
await removed;
|
||||
await settle();
|
||||
finishWrite();
|
||||
await later;
|
||||
|
||||
expect(order).toEqual(['read', 'write', 'delete all', 'read', 'write']);
|
||||
});
|
||||
|
||||
describe('Delete All with cross-context locks', () => {
|
||||
/** Web Locks as far as these tests need: the barrier and free rows. */
|
||||
function fakeLocks() {
|
||||
let shared = 0;
|
||||
let exclusive = false;
|
||||
const waiting: (() => void)[] = [];
|
||||
const changed = () => waiting.splice(0).forEach((wake) => wake());
|
||||
const until = async (free: () => boolean) => {
|
||||
while (!free()) {
|
||||
await new Promise<void>((wake) => waiting.push(wake));
|
||||
}
|
||||
};
|
||||
const request = jest.fn(
|
||||
async <T>(
|
||||
name: string,
|
||||
options: LockOptions,
|
||||
callback: (lock: Lock | null) => Promise<T>
|
||||
) => {
|
||||
const lock = {
|
||||
name,
|
||||
mode: options.mode ?? 'exclusive',
|
||||
} as Lock;
|
||||
if (name !== 'iptvnator:playlist-authority') {
|
||||
return callback(lock);
|
||||
}
|
||||
if (options.mode === 'shared') {
|
||||
await until(() => !exclusive);
|
||||
shared += 1;
|
||||
try {
|
||||
return await callback(lock);
|
||||
} finally {
|
||||
shared -= 1;
|
||||
changed();
|
||||
}
|
||||
}
|
||||
await until(() => !exclusive && shared === 0);
|
||||
exclusive = true;
|
||||
try {
|
||||
return await callback(lock);
|
||||
} finally {
|
||||
exclusive = false;
|
||||
changed();
|
||||
}
|
||||
}
|
||||
);
|
||||
Object.defineProperty(globalThis.navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request },
|
||||
});
|
||||
}
|
||||
|
||||
let finishWrite: () => void = () => undefined;
|
||||
function store(slowWrites = false) {
|
||||
const electron = {
|
||||
dbGetAppPlaylist: jest.fn(async () => ({
|
||||
_id: 'p9',
|
||||
title: 'P',
|
||||
favorites: [],
|
||||
})),
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
dbGetAppState: jest.fn(async (key: string) =>
|
||||
key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null
|
||||
),
|
||||
dbSetAppState: jest.fn(),
|
||||
// Only the first write is slow.
|
||||
dbUpsertAppPlaylist: jest.fn(() => {
|
||||
if (!slowWrites) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
slowWrites = false;
|
||||
return new Promise<void>(
|
||||
(resolve) => (finishWrite = resolve)
|
||||
);
|
||||
}),
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
dbDeletePlaylist: jest.fn(async () => undefined),
|
||||
dbDeleteAllPlaylists: jest.fn(async () => undefined),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
return electron;
|
||||
}
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
it('lets a repair holding its reservation write first', async () => {
|
||||
fakeLocks();
|
||||
const electron = store();
|
||||
const service = createService();
|
||||
const reservation = await reservePlaylistAuthority('p9');
|
||||
if (reservation.status !== 'acquired') {
|
||||
throw new Error('expected a reservation');
|
||||
}
|
||||
|
||||
const removed = firstValueFrom(service.removeAll());
|
||||
// The repair's conditional row write, under its reservation.
|
||||
let written = false;
|
||||
const repair = firstValueFrom(
|
||||
service.transformPlaylistFavorites('p9', (f) => [...f, 'a'])
|
||||
).then(() => (written = true));
|
||||
await settle();
|
||||
|
||||
expect(written).toBe(true);
|
||||
expect(electron.dbDeleteAllPlaylists).not.toHaveBeenCalled();
|
||||
reservation.release();
|
||||
await repair;
|
||||
await removed;
|
||||
expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a repair write past later writes waiting for the reset', async () => {
|
||||
fakeLocks();
|
||||
const electron = store(true);
|
||||
const service = createService();
|
||||
// An earlier slow write that Delete All waits for.
|
||||
const slow = firstValueFrom(
|
||||
service.transformPlaylistFavorites('q1', (f) => [...f, 'a'])
|
||||
);
|
||||
await settle();
|
||||
const removed = firstValueFrom(service.removeAll());
|
||||
// A later write for P, which waits for the reset.
|
||||
const later = firstValueFrom(
|
||||
service.transformPlaylistFavorites('p9', (f) => [...f, 'b'])
|
||||
);
|
||||
// A repair reserves P before the reset takes its lock.
|
||||
const reservation = await reservePlaylistAuthority('p9');
|
||||
if (reservation.status !== 'acquired') {
|
||||
throw new Error('expected a reservation');
|
||||
}
|
||||
let repaired = false;
|
||||
const repair = firstValueFrom(
|
||||
service.transformPlaylistFavorites('p9', (f) => [...f, 'r'])
|
||||
).then(() => {
|
||||
repaired = true;
|
||||
reservation.release();
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(repaired).toBe(true);
|
||||
finishWrite();
|
||||
await Promise.all([slow, repair, removed, later]);
|
||||
expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a deletion queued behind a slow write finish first', async () => {
|
||||
fakeLocks();
|
||||
const electron = store(true);
|
||||
const service = createService();
|
||||
|
||||
const slow = firstValueFrom(
|
||||
service.transformPlaylistFavorites('p9', (f) => [...f, 'a'])
|
||||
);
|
||||
await settle();
|
||||
const deleted = firstValueFrom(service.deletePlaylist('p9'));
|
||||
let done = false;
|
||||
const removed = firstValueFrom(service.removeAll()).then(
|
||||
() => (done = true)
|
||||
);
|
||||
await settle();
|
||||
finishWrite();
|
||||
await settle();
|
||||
|
||||
// The deletion needs the shared lock: it cannot wait for a
|
||||
// reset that waits for it.
|
||||
expect(done).toBe(true);
|
||||
await slow;
|
||||
await deleted;
|
||||
await removed;
|
||||
expect(electron.dbDeletePlaylist).toHaveBeenCalledWith('p9');
|
||||
expect(electron.dbDeleteAllPlaylists).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('runs a mutation again on a fresh read when the server refused it', async () => {
|
||||
const versions = [
|
||||
{ _id: 'p9', title: 'P', favorites: ['a'] },
|
||||
{ _id: 'p9', title: 'P', favorites: ['a', 'theirs'] },
|
||||
] as unknown as Playlist[];
|
||||
let reads = 0;
|
||||
const electron = {
|
||||
dbGetAppPlaylist: jest.fn(
|
||||
async () => versions[Math.min(reads++, 1)]
|
||||
),
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
dbGetAppState: jest.fn(async (key: string) =>
|
||||
key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null
|
||||
),
|
||||
dbSetAppState: jest.fn(),
|
||||
dbUpsertAppPlaylist: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new PlaylistWriteConflict('p9'))
|
||||
.mockResolvedValue(undefined),
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
|
||||
await firstValueFrom(
|
||||
createService().transformPlaylistFavorites('p9', (favorites) => [
|
||||
...favorites,
|
||||
'mine',
|
||||
])
|
||||
);
|
||||
|
||||
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2);
|
||||
expect(electron.dbUpsertAppPlaylist).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ favorites: ['a', 'theirs', 'mine'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('short-circuits removeFromPlaylistRecentlyViewedBatch when identities is empty', async () => {
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'playlist-5',
|
||||
@@ -1813,19 +2201,30 @@ describe('PlaylistsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])('does not recreate a removed source on singular refresh (SQLite: %s)', async (sqlite) => {
|
||||
const upsert = jest.fn();
|
||||
testWindow.electron = sqlite ? {
|
||||
dbGetAllPlaylists: jest.fn().mockResolvedValue([]),
|
||||
dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbUpsertAppPlaylist: upsert,
|
||||
} as unknown as typeof testWindow.electron : undefined;
|
||||
const update = jest.fn(() => of(undefined));
|
||||
const service = createService({ update });
|
||||
await expect(firstValueFrom(service.updatePlaylist('deleted', { _id: 'deleted' } as Playlist))).rejects.toThrow('Playlist no longer exists');
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([false, true])(
|
||||
'does not recreate a removed source on singular refresh (SQLite: %s)',
|
||||
async (sqlite) => {
|
||||
const upsert = jest.fn();
|
||||
testWindow.electron = sqlite
|
||||
? ({
|
||||
dbGetAllPlaylists: jest.fn().mockResolvedValue([]),
|
||||
dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbUpsertAppPlaylist: upsert,
|
||||
} as unknown as typeof testWindow.electron)
|
||||
: undefined;
|
||||
const update = jest.fn(() => of(undefined));
|
||||
const service = createService({ update });
|
||||
await expect(
|
||||
firstValueFrom(
|
||||
service.updatePlaylist('deleted', {
|
||||
_id: 'deleted',
|
||||
} as Playlist)
|
||||
)
|
||||
).rejects.toThrow('Playlist no longer exists');
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => {
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'playlist-2',
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
Channel,
|
||||
DbStores,
|
||||
extractStalkerItemId,
|
||||
isFullStalkerPortalUrl,
|
||||
isM3uRecentlyViewedItem,
|
||||
M3uFavoriteChannel,
|
||||
M3uRecentlyViewedItem,
|
||||
@@ -38,15 +37,18 @@ import {
|
||||
PlaylistUpdateState,
|
||||
StalkerPortalItem,
|
||||
normalizeStalkerDate,
|
||||
withExplicitStalkerPortalFlag,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token';
|
||||
import {
|
||||
runWithPlaylistAuthorityMutation,
|
||||
holdsPlaylistAuthority,
|
||||
runWithPlaylistAuthorityReset,
|
||||
} from './playlist-cross-context-lock';
|
||||
import {
|
||||
PlaylistDocumentStore,
|
||||
PlaylistStorageBackend,
|
||||
PlaylistWriteConflict,
|
||||
} from './playlist-storage-backend';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
@@ -83,7 +85,9 @@ export function resolvePlaylistParser(parserModule: PlaylistParserModule) {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlaylistsService {
|
||||
private readonly healthEvidence = inject(SourceHealthEvidenceService, { optional: true });
|
||||
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly dbService = inject(NgxIndexedDBService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -100,6 +104,10 @@ export class PlaylistsService {
|
||||
private startupMetas?: Promise<Playlist[]> | null;
|
||||
private indexedDbMigrationPromise: Promise<void> | null = null;
|
||||
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
|
||||
/** "Remove all" in progress: every playlist write waits for it. */
|
||||
private deletingAll: Promise<void> = Promise.resolve();
|
||||
/** Delete All runs in progress (see serializePlaylistWrite). */
|
||||
private resets = 0;
|
||||
|
||||
/** Electron bridge or server store; `null` means IndexedDB. */
|
||||
private get documentStore(): PlaylistDocumentStore | null {
|
||||
@@ -128,14 +136,18 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async ensureElectronPlaylistMigrations(): Promise<void> {
|
||||
if (!this.usesElectronStorage) {
|
||||
if (!this.usesDocumentStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.electronMigrationPromise) {
|
||||
this.electronMigrationPromise = (async () => {
|
||||
await this.migrateIndexedDbPlaylistsToSqlite();
|
||||
await this.documentStore?.dbRecoverLegacyPlaylists?.();
|
||||
if (this.usesElectronStorage) {
|
||||
await this.migrateIndexedDbPlaylistsToSqlite();
|
||||
await this.documentStore?.dbRecoverLegacyPlaylists?.();
|
||||
}
|
||||
// Server rows too, once per server: a desktop database
|
||||
// copied to the server can predate this migration.
|
||||
await this.migrateStalkerPlaylistMetadataInSqlite();
|
||||
})().catch((error) => {
|
||||
this.electronMigrationPromise = null;
|
||||
@@ -241,19 +253,7 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private withExplicitLegacyStalkerPortalFlag(playlist: Playlist): Playlist {
|
||||
if (
|
||||
!playlist?.macAddress ||
|
||||
playlist.isFullStalkerPortal !== undefined
|
||||
) {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
|
||||
|
||||
return {
|
||||
...playlist,
|
||||
isFullStalkerPortal: isFullStalkerPortalUrl(portalUrl),
|
||||
};
|
||||
return withExplicitStalkerPortalFlag(playlist);
|
||||
}
|
||||
|
||||
private collectStalkerMetadataMigrationUpdates(
|
||||
@@ -307,7 +307,20 @@ export class PlaylistsService {
|
||||
const updates =
|
||||
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
|
||||
|
||||
if (updates.length > 0) {
|
||||
if (this.storageBackend.kind() === 'server') {
|
||||
// Shared with other devices: each row is read again and
|
||||
// written on that version, never over a newer copy. A
|
||||
// conflict leaves the flag unset, so the next start retries.
|
||||
for (const { _id } of updates) {
|
||||
const fresh = await store.dbGetAppPlaylist(_id);
|
||||
const [update] = fresh
|
||||
? this.collectStalkerMetadataMigrationUpdates([fresh])
|
||||
: [];
|
||||
if (update) {
|
||||
await store.dbUpsertAppPlaylist(update);
|
||||
}
|
||||
}
|
||||
} else if (updates.length > 0) {
|
||||
await store.dbUpsertAppPlaylists(updates);
|
||||
}
|
||||
|
||||
@@ -384,7 +397,10 @@ export class PlaylistsService {
|
||||
} else {
|
||||
await store.dbUpsertAppPlaylist(playlist, operationId);
|
||||
}
|
||||
this.healthEvidence?.connections.next({ id: playlist._id, playlist });
|
||||
this.healthEvidence?.connections.next({
|
||||
id: playlist._id,
|
||||
playlist,
|
||||
});
|
||||
return playlist;
|
||||
});
|
||||
}
|
||||
@@ -398,7 +414,12 @@ export class PlaylistsService {
|
||||
|
||||
this.startupMetas = null;
|
||||
await store.dbUpsertAppPlaylists(playlists);
|
||||
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
|
||||
playlists.forEach((playlist) =>
|
||||
this.healthEvidence?.connections.next({
|
||||
id: playlist._id,
|
||||
playlist,
|
||||
})
|
||||
);
|
||||
return playlists;
|
||||
});
|
||||
}
|
||||
@@ -411,9 +432,21 @@ export class PlaylistsService {
|
||||
operation: () => Promise<T>
|
||||
): Observable<T> {
|
||||
return defer(() => {
|
||||
const previous =
|
||||
this.playlistWriteQueues.get(playlistId) ?? Promise.resolve();
|
||||
const next = previous.then(() => operation());
|
||||
// While Delete All runs, a write waits for it before it joins
|
||||
// its playlist's queue, so no queue holds work blocked on the
|
||||
// reset; a write under a reservation held here (a Stalker repair
|
||||
// or Edit) joins at once, as the reset waits for that reservation.
|
||||
if (this.resets > 0 && !holdsPlaylistAuthority(playlistId)) {
|
||||
return this.deletingAll.then(() =>
|
||||
firstValueFrom(
|
||||
this.serializePlaylistWrite(playlistId, operation)
|
||||
)
|
||||
);
|
||||
}
|
||||
const previous = Promise.resolve(
|
||||
this.playlistWriteQueues.get(playlistId)
|
||||
);
|
||||
const next = previous.then(() => retryOnConflict(operation));
|
||||
const tail = next.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
@@ -572,7 +605,9 @@ export class PlaylistsService {
|
||||
);
|
||||
|
||||
return delete$.pipe(
|
||||
tap(() => this.healthEvidence?.connections.next({ id: playlistId })),
|
||||
tap(() =>
|
||||
this.healthEvidence?.connections.next({ id: playlistId })
|
||||
),
|
||||
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
|
||||
map((cleanupWarnings) =>
|
||||
cleanupWarnings
|
||||
@@ -1039,7 +1074,9 @@ export class PlaylistsService {
|
||||
return nextPlaylist;
|
||||
})
|
||||
)
|
||||
).pipe(map((rows) => rows.filter((row): row is Playlist => row !== null)));
|
||||
).pipe(
|
||||
map((rows) => rows.filter((row): row is Playlist => row !== null))
|
||||
);
|
||||
}
|
||||
|
||||
getFavoriteChannels(playlistId: string) {
|
||||
@@ -1318,23 +1355,37 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
removeAll(): Observable<void> {
|
||||
return defer(() =>
|
||||
runWithPlaylistAuthorityReset(async () => {
|
||||
if (this.usesDocumentStore) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const store = this.documentStore;
|
||||
if (store) {
|
||||
this.startupMetas = null;
|
||||
await store.dbDeleteAllPlaylists();
|
||||
this.healthEvidence?.connections.next({});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// Writes queued so far land first, outside the reset (some take the
|
||||
// shared side of its lock); new ones wait for the delete before they
|
||||
// queue, except those under a reservation held here.
|
||||
return defer(() => {
|
||||
const pending = [...this.playlistWriteQueues.values()];
|
||||
this.resets += 1;
|
||||
const run = Promise.all(pending)
|
||||
.then(() =>
|
||||
runWithPlaylistAuthorityReset(() => this.deleteAllRows())
|
||||
)
|
||||
.finally(() => (this.resets -= 1));
|
||||
this.deletingAll = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
});
|
||||
}
|
||||
|
||||
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
private async deleteAllRows(): Promise<void> {
|
||||
if (this.usesDocumentStore) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const store = this.documentStore;
|
||||
if (store) {
|
||||
this.startupMetas = null;
|
||||
await store.dbDeleteAllPlaylists();
|
||||
this.healthEvidence?.connections.next({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
|
||||
}
|
||||
|
||||
private normalizePortalRecentIdentity(value: unknown): string {
|
||||
@@ -1553,3 +1604,19 @@ export class PlaylistsService {
|
||||
return this.clearPlaylistRecentlyViewed(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutation the server refused as built on an older version (another device
|
||||
* wrote since) runs again: it reads the playlist afresh first.
|
||||
*/
|
||||
async function retryOnConflict<T>(operation: () => Promise<T>): Promise<T> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!(error instanceof PlaylistWriteConflict) || attempt >= 3) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,22 @@ const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series'];
|
||||
export class PwaXtreamBackupStateService {
|
||||
private readonly storage = inject(USER_DATA_STORAGE);
|
||||
|
||||
/**
|
||||
* Before an export or a restore: in server mode the maps show the
|
||||
* server's data only after the first read, which may still run or have
|
||||
* failed at start. An export would write unread lists as empty, and a
|
||||
* restore's replacement lists would merge into the server's instead.
|
||||
*/
|
||||
async ready(): Promise<void> {
|
||||
// Read again: another device may have changed them since this
|
||||
// page's first read, and a restore must replace the current lists.
|
||||
if ((await this.storage.ready?.(true)) === false) {
|
||||
throw new Error(
|
||||
"The server's user data could not be read yet; try again once the server answers."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry with the playlist's stored lists and snapshots filled in;
|
||||
* hidden categories and pins, which the browser build does not track,
|
||||
@@ -103,13 +119,28 @@ export class PwaXtreamBackupStateService {
|
||||
state: XtreamPendingRestoreState,
|
||||
entry: XtreamPlaylistBackupEntry
|
||||
): Promise<void> {
|
||||
await this.ready();
|
||||
// A user-supplied file: the field may be anything.
|
||||
const archived: unknown = entry.userState.collectionItems;
|
||||
const snapshots = snapshotsByKey(
|
||||
Array.isArray(archived) ? archived : []
|
||||
);
|
||||
// The lists replace what the server has now: this page's own unsent
|
||||
// writes for the playlist would join them, so they go first or the
|
||||
// restore waits (titles merged regardless, as below).
|
||||
if ((await this.storage.flushed?.(playlistId)) === false) {
|
||||
try {
|
||||
this.mergeSnapshots(playlistId, snapshots);
|
||||
} catch {
|
||||
// The unsent writes are the failure to report.
|
||||
}
|
||||
throw new Error(
|
||||
'Earlier changes to this playlist are not on the server yet; the restore waits.'
|
||||
);
|
||||
}
|
||||
let written: Map<UserDataMap, unknown[]>;
|
||||
try {
|
||||
this.replaceLists(playlistId, state);
|
||||
written = this.replaceLists(playlistId, state);
|
||||
} catch (error) {
|
||||
if (!isQuotaExceeded(error)) {
|
||||
try {
|
||||
@@ -120,17 +151,46 @@ export class PwaXtreamBackupStateService {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Server mode: the lists count once the server took them; a refused
|
||||
// one keeps the restore parked (titles merged regardless, as above).
|
||||
const saved = (await this.storage.flushed?.(playlistId)) !== false;
|
||||
// A conflict merges an edit another device made meanwhile into the
|
||||
// lists: the backup replaces them, so it waits and runs again on a
|
||||
// fresh read.
|
||||
const replaced =
|
||||
saved &&
|
||||
[...written].every(
|
||||
([name, list]) =>
|
||||
JSON.stringify(this.listFor(name, playlistId)) ===
|
||||
JSON.stringify(list)
|
||||
);
|
||||
this.mergeSnapshots(playlistId, snapshots);
|
||||
if (!saved) {
|
||||
throw new Error(
|
||||
'The server did not take the restored lists; the restore waits.'
|
||||
);
|
||||
}
|
||||
if (!replaced) {
|
||||
throw new Error(
|
||||
'The lists changed elsewhere during the restore; it runs again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The lists it wrote, by map. */
|
||||
private replaceLists(
|
||||
playlistId: string,
|
||||
state: XtreamPendingRestoreState
|
||||
): void {
|
||||
): Map<UserDataMap, unknown[]> {
|
||||
const written = new Map<UserDataMap, unknown[]>();
|
||||
const write = (name: UserDataMap, list: unknown[]) => {
|
||||
this.writeFor(name, playlistId, list);
|
||||
written.set(name, list);
|
||||
};
|
||||
const favorites = state.favorites
|
||||
.map((item) => typedKey(item.contentType, item.xtreamId))
|
||||
.filter((key): key is string => key !== null);
|
||||
this.writeFor('favorites', playlistId, [...new Set(favorites)]);
|
||||
write('favorites', [...new Set(favorites)]);
|
||||
|
||||
const recent: StoredMap[] = [];
|
||||
for (const item of state.recentlyViewed) {
|
||||
@@ -143,18 +203,18 @@ export class PwaXtreamBackupStateService {
|
||||
});
|
||||
}
|
||||
}
|
||||
this.writeFor('recentItems', playlistId, recent);
|
||||
write('recentItems', recent);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.writeFor(
|
||||
write(
|
||||
'playbackPositions',
|
||||
playlistId,
|
||||
state.playbackPositions.filter(isPlaybackPosition).map((item) => ({
|
||||
...item,
|
||||
playlistId,
|
||||
updatedAt: item.updatedAt ?? now,
|
||||
}))
|
||||
);
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
SERVER_ACCESS_TOKEN_OWNER_KEY,
|
||||
SERVER_ACCESS_TOKEN_STORAGE_KEY,
|
||||
writeServerAccessToken,
|
||||
} from './server-access-token';
|
||||
|
||||
describe('server access token', () => {
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('has no token until one is stored', () => {
|
||||
expect(readServerAccessToken()).toBeNull();
|
||||
expect(readServerAccessToken('i-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores a trimmed token for the instance it was given for', () => {
|
||||
writeServerAccessToken(' s3cret ', 'i-1');
|
||||
|
||||
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
|
||||
's3cret'
|
||||
);
|
||||
expect(readServerAccessToken('i-1')).toBe('s3cret');
|
||||
expect(readServerAccessToken()).toBe('s3cret');
|
||||
|
||||
clearServerAccessToken();
|
||||
expect(readServerAccessToken()).toBeNull();
|
||||
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_OWNER_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("never hands another instance's token out", () => {
|
||||
writeServerAccessToken('new-server', 'i-2');
|
||||
expect(readServerAccessToken('i-1')).toBeNull();
|
||||
|
||||
// A token stored without its instance belongs to none.
|
||||
localStorage.removeItem(SERVER_ACCESS_TOKEN_OWNER_KEY);
|
||||
expect(readServerAccessToken('i-2')).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a token browser storage refuses', () => {
|
||||
const refuse = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
});
|
||||
try {
|
||||
expect(writeServerAccessToken('s3cret', 'i-1')).toBe(false);
|
||||
expect(readServerAccessToken()).toBeNull();
|
||||
} finally {
|
||||
refuse.mockRestore();
|
||||
}
|
||||
expect(writeServerAccessToken('s3cret', 'i-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token';
|
||||
|
||||
/**
|
||||
* A gateway's answer for a backend that is down (nginx's 502/503/504 page),
|
||||
* not the backend's own JSON error for a provider that failed.
|
||||
*/
|
||||
export function isGatewayOutage(
|
||||
status: number,
|
||||
contentType: string | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
[502, 503, 504].includes(status) &&
|
||||
!contentType?.includes('application/json')
|
||||
);
|
||||
}
|
||||
|
||||
/** A 401 from the backend's access guard, not one a provider sent. */
|
||||
export function isServerAccessChallenge(
|
||||
status: number,
|
||||
challenge: string | null
|
||||
): boolean {
|
||||
return status === 401 && challenge === SERVER_ACCESS_CHALLENGE;
|
||||
}
|
||||
|
||||
/** The instance the stored token was given for. */
|
||||
export const SERVER_ACCESS_TOKEN_OWNER_KEY =
|
||||
'iptvnator-server:access-token-instance';
|
||||
|
||||
/**
|
||||
* The stored token; with `instanceId`, only when it was given for that
|
||||
* instance: a tab still open on another server (a changed backend URL)
|
||||
* never sends it there.
|
||||
*/
|
||||
export function readServerAccessToken(
|
||||
instanceId?: string | null
|
||||
): string | null {
|
||||
try {
|
||||
const token = localStorage
|
||||
.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
||||
?.trim();
|
||||
return token &&
|
||||
(instanceId === undefined ||
|
||||
localStorage.getItem(SERVER_ACCESS_TOKEN_OWNER_KEY) ===
|
||||
(instanceId ?? ''))
|
||||
? token
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** False when browser storage refused it (private mode, full). */
|
||||
export function writeServerAccessToken(
|
||||
token: string,
|
||||
instanceId: string | null
|
||||
): boolean {
|
||||
try {
|
||||
// The owner goes last: a token stored without one is sent nowhere.
|
||||
localStorage.removeItem(SERVER_ACCESS_TOKEN_OWNER_KEY);
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, token.trim());
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_OWNER_KEY, instanceId ?? '');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearServerAccessToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(SERVER_ACCESS_TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(SERVER_ACCESS_TOKEN_OWNER_KEY);
|
||||
} catch {
|
||||
// Nothing stored.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `url` goes to the backend: the same origin as the backend URL and
|
||||
* a path inside it. A plain prefix check fails for `BACKEND_URL=/`, where
|
||||
* every URL, other origins included, would carry the token.
|
||||
*/
|
||||
export function isBackendUrl(
|
||||
url: string,
|
||||
baseUrl: string,
|
||||
pageUrl = globalThis.location?.href ?? 'http://localhost/'
|
||||
): boolean {
|
||||
try {
|
||||
const base = new URL(baseUrl, pageUrl);
|
||||
const target = new URL(url, pageUrl);
|
||||
const root = base.pathname.endsWith('/')
|
||||
? base.pathname
|
||||
: `${base.pathname}/`;
|
||||
return (
|
||||
target.origin === base.origin &&
|
||||
(target.pathname === base.pathname ||
|
||||
target.pathname.startsWith(root))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A page started from memory reloads once the server answers
|
||||
* (`ServerStorageSession`), but a server slower than the start probe would
|
||||
* time it out again and reload forever: the reloaded page's probe waits as
|
||||
* long as requests do, once.
|
||||
*/
|
||||
const SLOW_START_KEY = 'iptvnator-server:slow-start';
|
||||
|
||||
export function markSlowStart(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SLOW_START_KEY, '1');
|
||||
} catch {
|
||||
// Without session storage the next probe keeps its short wait.
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this page load follows such a reload; asks only once. */
|
||||
export function takeSlowStart(): boolean {
|
||||
try {
|
||||
const slow = sessionStorage.getItem(SLOW_START_KEY) !== null;
|
||||
sessionStorage.removeItem(SLOW_START_KEY);
|
||||
return slow;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
readServerAccessToken,
|
||||
SERVER_ACCESS_TOKEN_STORAGE_KEY,
|
||||
writeServerAccessToken,
|
||||
} from './server-access-token';
|
||||
import { ServerStorageStatusService } from './server-storage-status.service';
|
||||
|
||||
const instance: ServerInstanceInfo = {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
};
|
||||
|
||||
describe('ServerStorageStatusService', () => {
|
||||
let status: ServerStorageStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
status = TestBed.inject(ServerStorageStatusService);
|
||||
});
|
||||
|
||||
it('stays inactive and ignores reports until activated', () => {
|
||||
status.reportOffline();
|
||||
status.reportUnauthorized();
|
||||
|
||||
expect(status.state()).toBe('inactive');
|
||||
expect(status.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks online, offline and unauthorized after activation', () => {
|
||||
const retry = jest.fn();
|
||||
status.activate(instance, retry);
|
||||
expect(status.state()).toBe('online');
|
||||
expect(status.instance()).toEqual(instance);
|
||||
|
||||
status.reportOffline();
|
||||
expect(status.state()).toBe('offline');
|
||||
status.retry();
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
|
||||
status.reportSuccess();
|
||||
expect(status.state()).toBe('online');
|
||||
expect(status.lastSuccessAt()).toBeInstanceOf(Date);
|
||||
|
||||
status.reportUnauthorized();
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
|
||||
// Late answers to requests sent with the old token.
|
||||
status.reportSuccess();
|
||||
status.reportOffline();
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('ignores a refusal of a token replaced since', () => {
|
||||
status.activate(instance, jest.fn());
|
||||
writeServerAccessToken('new', 'i-1');
|
||||
|
||||
status.reportAccessDenied('old');
|
||||
expect(status.state()).toBe('online');
|
||||
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
|
||||
'new'
|
||||
);
|
||||
|
||||
status.reportAccessDenied('new');
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(
|
||||
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sends no other instance's token and asks for this one's", () => {
|
||||
status.activate(instance, jest.fn());
|
||||
// Another tab signed in to the server a new backend URL leads to.
|
||||
writeServerAccessToken('new-server', 'i-2');
|
||||
|
||||
expect(status.accessToken()).toBeNull();
|
||||
status.reportAccessDenied(null);
|
||||
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(readServerAccessToken('i-2')).toBe('new-server');
|
||||
});
|
||||
|
||||
it('keeps the token of a page that holds it back on a refusal', () => {
|
||||
status.activate(instance, jest.fn(), false);
|
||||
writeServerAccessToken('kept', 'i-1');
|
||||
|
||||
expect(status.accessToken()).toBeNull();
|
||||
status.reportAccessDenied(null);
|
||||
|
||||
expect(status.state()).toBe('online');
|
||||
expect(readServerAccessToken('i-1')).toBe('kept');
|
||||
});
|
||||
|
||||
it('checks the instance again after an outage on the sign-in page', () => {
|
||||
status.activate(instance, jest.fn());
|
||||
status.reportUnauthorized();
|
||||
|
||||
status.reportOffline();
|
||||
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(status.isVerified()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
} from './server-access-token';
|
||||
|
||||
export type ServerConnectionState =
|
||||
'inactive' | 'online' | 'offline' | 'unauthorized';
|
||||
|
||||
/** Where server mode asks for the access token. */
|
||||
export const SERVER_SIGN_IN_PATH = '/server-sign-in';
|
||||
|
||||
/**
|
||||
* Connection state of the self-hosted server storage. `inactive` in Electron
|
||||
* and in a PWA without server storage; the server session activates it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerStorageStatusService {
|
||||
private readonly stateSignal = signal<ServerConnectionState>('inactive');
|
||||
private readonly instanceSignal = signal<ServerInstanceInfo | null>(null);
|
||||
private readonly lastSuccessSignal = signal<Date | null>(null);
|
||||
private retryHandler: (() => void) | null = null;
|
||||
private verified = true;
|
||||
|
||||
readonly state = this.stateSignal.asReadonly();
|
||||
readonly instance = this.instanceSignal.asReadonly();
|
||||
readonly lastSuccessAt = this.lastSuccessSignal.asReadonly();
|
||||
readonly isActive = computed(() => this.stateSignal() !== 'inactive');
|
||||
|
||||
/**
|
||||
* `verified` is false for a start from a remembered instance that did
|
||||
* not answer: the address may lead to another server by now. So it is
|
||||
* after going offline, until the instance is checked again.
|
||||
*/
|
||||
activate(
|
||||
instance: ServerInstanceInfo,
|
||||
retry: () => void,
|
||||
verified = true
|
||||
): void {
|
||||
this.instanceSignal.set(instance);
|
||||
this.retryHandler = retry;
|
||||
this.verified = verified;
|
||||
this.stateSignal.set('online');
|
||||
}
|
||||
|
||||
/** The instance at the address turned out to be the remembered one. */
|
||||
markVerified(): void {
|
||||
this.verified = true;
|
||||
}
|
||||
|
||||
/** Whether the token and writes may go out (see `activate`). */
|
||||
isVerified(): boolean {
|
||||
return this.verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored token, only while server mode runs on an instance this
|
||||
* start verified: a token must never reach a server it was not given
|
||||
* for.
|
||||
*/
|
||||
accessToken(): string | null {
|
||||
const instance = this.instanceSignal();
|
||||
return instance && this.isActive() && this.verified
|
||||
? readServerAccessToken(instance.instanceId)
|
||||
: null;
|
||||
}
|
||||
|
||||
reportSuccess(): void {
|
||||
// A late answer to a request sent with the old token must not undo
|
||||
// sign-out; only a reload after a new token leaves this state.
|
||||
if (!this.isActive() || this.stateSignal() === 'unauthorized') {
|
||||
return;
|
||||
}
|
||||
this.stateSignal.set('online');
|
||||
this.lastSuccessSignal.set(new Date());
|
||||
}
|
||||
|
||||
reportOffline(): void {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
// It may come back as another database (a replaced volume at the
|
||||
// same address): no token, no writes until it is checked, also
|
||||
// from the sign-in page.
|
||||
this.verified = false;
|
||||
// A late failure after sign-out must not unlock the workspace.
|
||||
if (this.stateSignal() !== 'unauthorized') {
|
||||
this.stateSignal.set('offline');
|
||||
}
|
||||
}
|
||||
|
||||
reportUnauthorized(): void {
|
||||
if (this.isActive()) {
|
||||
this.stateSignal.set('unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The access guard refused a request sent with `sentToken`: the token
|
||||
* stored for this instance is stale or missing, ask for a new one.
|
||||
*/
|
||||
reportAccessDenied(sentToken: string | null): void {
|
||||
const instance = this.instanceSignal();
|
||||
// A late answer to a token replaced since (a new sign-in) is moot,
|
||||
// and so is one to a page that held its token back until the
|
||||
// instance is checked.
|
||||
if (
|
||||
!instance ||
|
||||
!this.isActive() ||
|
||||
readServerAccessToken(instance.instanceId) !== sentToken
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (sentToken) {
|
||||
clearServerAccessToken();
|
||||
}
|
||||
this.stateSignal.set('unauthorized');
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.retryHandler?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './server-storage.config';
|
||||
export * from './server-api-client';
|
||||
export * from './server-playlist-document-store';
|
||||
export * from './server-user-data-storage';
|
||||
export * from './server-access-token.interceptor';
|
||||
export * from './server-storage-session';
|
||||
export * from './server-migration.service';
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
HTTP_INTERCEPTORS,
|
||||
HttpClient,
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
SERVER_ACCESS_TOKEN_STORAGE_KEY,
|
||||
writeServerAccessToken,
|
||||
} from '../server-access-token';
|
||||
import { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
import {
|
||||
isBackendUrl,
|
||||
ServerAccessTokenInterceptor,
|
||||
} from './server-access-token.interceptor';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
|
||||
describe('ServerAccessTokenInterceptor', () => {
|
||||
beforeEach(() => {
|
||||
writeServerAccessToken('t', 'i');
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: ServerAccessTokenInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: { baseUrl: '/api', instance: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
const activate = (verified = true) =>
|
||||
TestBed.inject(ServerStorageStatusService).activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined,
|
||||
verified
|
||||
);
|
||||
|
||||
it('adds the token to backend calls only', () => {
|
||||
activate();
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
http.get('/api/parse').subscribe();
|
||||
http.get('https://provider.example/list.m3u').subscribe();
|
||||
|
||||
expect(
|
||||
controller
|
||||
.expectOne('/api/parse')
|
||||
.request.headers.get('Authorization')
|
||||
).toBe('Bearer t');
|
||||
expect(
|
||||
controller
|
||||
.expectOne('https://provider.example/list.m3u')
|
||||
.request.headers.has('Authorization')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('withholds the token from an instance not checked yet', () => {
|
||||
activate(false);
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
http.get('/api/v1/instance').subscribe();
|
||||
|
||||
expect(
|
||||
controller
|
||||
.expectOne('/api/v1/instance')
|
||||
.request.headers.has('Authorization')
|
||||
).toBe(false);
|
||||
controller.verify();
|
||||
});
|
||||
|
||||
it('asks for a new token when the backend refuses the stored one', () => {
|
||||
const status = TestBed.inject(ServerStorageStatusService);
|
||||
status.activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
// A provider's own 401 passed on by the proxy changes nothing.
|
||||
http.get('/api/xtream').subscribe({ error: () => undefined });
|
||||
controller
|
||||
.expectOne('/api/xtream')
|
||||
.flush({ message: 'bad login' }, { status: 401, statusText: 'x' });
|
||||
expect(status.state()).toBe('online');
|
||||
|
||||
http.get('/api/xtream').subscribe({ error: () => undefined });
|
||||
controller.expectOne('/api/xtream').flush(
|
||||
{ code: 'unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: { 'WWW-Authenticate': 'Bearer realm="iptvnator"' },
|
||||
}
|
||||
);
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(
|
||||
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a backend that does not answer as offline, not a provider failing', () => {
|
||||
const status = TestBed.inject(ServerStorageStatusService);
|
||||
activate();
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
// The backend's own JSON error for a provider that failed.
|
||||
http.get('/api/xtream').subscribe({ error: () => undefined });
|
||||
controller.expectOne('/api/xtream').flush(
|
||||
{ message: 'provider down' },
|
||||
{
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
expect(status.state()).toBe('online');
|
||||
expect(status.isVerified()).toBe(true);
|
||||
|
||||
// No answer at all: it may come back as another instance.
|
||||
http.get('/api/parse').subscribe({ error: () => undefined });
|
||||
controller
|
||||
.expectOne('/api/parse')
|
||||
.error(new ProgressEvent('error'), { status: 0 });
|
||||
expect(status.state()).toBe('offline');
|
||||
expect(status.accessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBackendUrl', () => {
|
||||
const page = 'http://nas.local:4333/workspace';
|
||||
|
||||
it('matches the backend path and nothing else', () => {
|
||||
expect(isBackendUrl('/api/xtream', '/api', page)).toBe(true);
|
||||
expect(isBackendUrl('/api', '/api', page)).toBe(true);
|
||||
expect(isBackendUrl('/apiary', '/api', page)).toBe(false);
|
||||
expect(isBackendUrl('https://other.test/api/x', '/api', page)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps other origins out for a root deployment', () => {
|
||||
expect(isBackendUrl('/v1/playlists', '/', page)).toBe(true);
|
||||
expect(
|
||||
isBackendUrl('https://api.github.com/repos/x/releases', '/', page)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('handles an absolute backend URL', () => {
|
||||
expect(
|
||||
isBackendUrl(
|
||||
'http://nas.local:3000/parse',
|
||||
'http://nas.local:3000',
|
||||
page
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isBackendUrl('/parse', 'http://nas.local:3000', page)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import {
|
||||
isBackendUrl,
|
||||
isGatewayOutage,
|
||||
isServerAccessChallenge,
|
||||
} from '../server-access-token';
|
||||
|
||||
// Lives with the token helpers so the startup bundle can check URLs
|
||||
// without loading the server storage code.
|
||||
export { isBackendUrl } from '../server-access-token';
|
||||
import { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
|
||||
/**
|
||||
* Adds the server access token to `HttpClient` calls to the backend, sends
|
||||
* the user to sign-in when the backend refuses it (a token changed on the
|
||||
* server while this page was open), and reports a backend that does not
|
||||
* answer as offline, like the storage API client does.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServerAccessTokenInterceptor implements HttpInterceptor {
|
||||
private readonly status = inject(ServerStorageStatusService);
|
||||
private readonly baseUrl = inject(SERVER_STORAGE_CONFIG).baseUrl;
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!isBackendUrl(request.url, this.baseUrl)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
const token = this.status.accessToken();
|
||||
const sent = token
|
||||
? request.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
: request;
|
||||
return next.handle(sent).pipe(
|
||||
tap({
|
||||
error: (error: unknown) => {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error.status === 0 ||
|
||||
isGatewayOutage(
|
||||
error.status,
|
||||
error.headers.get('Content-Type')
|
||||
)
|
||||
) {
|
||||
// No answer: it may come back as another instance,
|
||||
// checked before the token goes out again.
|
||||
this.status.reportOffline();
|
||||
} else if (
|
||||
isServerAccessChallenge(
|
||||
error.status,
|
||||
error.headers.get('WWW-Authenticate')
|
||||
)
|
||||
) {
|
||||
this.status.reportAccessDenied(token);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { ServerApiClient, ServerApiError } from './server-api-client';
|
||||
|
||||
// jsdom has Headers but no Response or fetch.
|
||||
function response(status: number, body?: unknown) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('ServerApiClient', () => {
|
||||
const status = {
|
||||
reportSuccess: jest.fn(),
|
||||
reportOffline: jest.fn(),
|
||||
reportAccessDenied: jest.fn(),
|
||||
};
|
||||
let token: string | null = null;
|
||||
const onWriteFailure = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
token = null;
|
||||
});
|
||||
|
||||
function client(fetchFn: jest.Mock) {
|
||||
return new ServerApiClient({
|
||||
baseUrl: '/api/',
|
||||
status,
|
||||
onWriteFailure,
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
accessToken: () => token,
|
||||
});
|
||||
}
|
||||
|
||||
it('calls /v1 with the token and reports success', async () => {
|
||||
token = 't';
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValue(response(200, { ok: true }));
|
||||
|
||||
await expect(client(fetchFn).getJson('/instance')).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/instance');
|
||||
expect(init.headers).toMatchObject({ Authorization: 'Bearer t' });
|
||||
expect(status.reportSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports offline on a network error and announces failed writes', async () => {
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(
|
||||
client(fetchFn).sendJson('PUT', '/playlists/a', {})
|
||||
).rejects.toMatchObject({ status: 0, code: 'network' });
|
||||
expect(status.reportOffline).toHaveBeenCalled();
|
||||
expect(onWriteFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports offline when a proxy answers for a down backend', async () => {
|
||||
for (const code of [502, 503, 504]) {
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValue(response(code, 'Bad Gateway'));
|
||||
|
||||
await expect(
|
||||
client(fetchFn).getJson('/user-state')
|
||||
).rejects.toMatchObject({ status: code });
|
||||
}
|
||||
expect(status.reportOffline).toHaveBeenCalledTimes(3);
|
||||
|
||||
// A backend error is not an outage.
|
||||
status.reportOffline.mockClear();
|
||||
await expect(
|
||||
client(
|
||||
jest.fn().mockResolvedValue(response(500, { code: 'x' }))
|
||||
).getJson('/user-state')
|
||||
).rejects.toMatchObject({ status: 500 });
|
||||
expect(status.reportOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives up on a keepalive write the server never answers', async () => {
|
||||
const hanging = jest.fn(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Timed out', 'TimeoutError'))
|
||||
);
|
||||
})
|
||||
);
|
||||
const slow = new ServerApiClient({
|
||||
baseUrl: '/api',
|
||||
status,
|
||||
fetchFn: hanging as unknown as typeof fetch,
|
||||
accessToken: () => null,
|
||||
timeoutMs: 10,
|
||||
});
|
||||
|
||||
// Later writes queue behind it: it must end, as a failed one.
|
||||
await expect(
|
||||
slow.sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
|
||||
).resolves.toEqual({ status: 'failed' });
|
||||
// An outage: the instance is checked before the next write.
|
||||
expect(status.reportOffline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a keepalive answer cut off after its headers as offline', async () => {
|
||||
const cutOff = jest.fn().mockResolvedValue({
|
||||
...response(200),
|
||||
json: async () => {
|
||||
throw new TypeError('body cut off');
|
||||
},
|
||||
});
|
||||
|
||||
// Saved, on a version it cannot tell (see the write queue).
|
||||
await expect(
|
||||
client(cutOff).sendKeepalive('PUT', '/user-state/a/b', {
|
||||
value: 1,
|
||||
})
|
||||
).resolves.toEqual({ status: 'saved', tag: undefined });
|
||||
expect(status.reportOffline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a keepalive write refused by the access guard', async () => {
|
||||
token = 'old';
|
||||
const refused = jest.fn().mockResolvedValue(response(401));
|
||||
|
||||
await expect(
|
||||
client(refused).sendKeepalive('PUT', '/user-state/a/b', {
|
||||
value: 1,
|
||||
})
|
||||
).resolves.toEqual({ status: 'failed' });
|
||||
// With the token it sent: one replaced since is not refused.
|
||||
expect(status.reportAccessDenied).toHaveBeenCalledWith('old');
|
||||
});
|
||||
|
||||
it('reports a gateway answering for a down backend on a keepalive write', async () => {
|
||||
const down = jest.fn().mockResolvedValue(response(502));
|
||||
|
||||
await expect(
|
||||
client(down).sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
|
||||
).resolves.toEqual({ status: 'failed' });
|
||||
expect(status.reportOffline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports how a keepalive write ended', async () => {
|
||||
const ok = jest.fn().mockResolvedValue(response(200, { tag: 't2' }));
|
||||
await expect(
|
||||
client(ok).sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
|
||||
).resolves.toEqual({ status: 'saved', tag: 't2' });
|
||||
expect(ok.mock.calls[0][1]).toMatchObject({ keepalive: true });
|
||||
|
||||
const conflict = jest.fn().mockResolvedValue(
|
||||
response(412, {
|
||||
code: 'conflict',
|
||||
message: 'changed',
|
||||
current: { value: [2], tag: 't3' },
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
client(conflict).sendKeepalive('PUT', '/user-state/a/b', {
|
||||
value: 1,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: 'conflict',
|
||||
current: { value: [2], tag: 't3' },
|
||||
});
|
||||
|
||||
const gone = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
response(404, { code: 'playlist_missing', message: 'gone' })
|
||||
);
|
||||
await expect(
|
||||
client(gone).sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
|
||||
).resolves.toEqual({ status: 'gone' });
|
||||
|
||||
const refused = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new TypeError('body too large'));
|
||||
await expect(
|
||||
client(refused).sendKeepalive('DELETE', '/user-state/a/b')
|
||||
).resolves.toEqual({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('holds writes, not reads, until the instance is checked', async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue(response(200, {}));
|
||||
const held = new ServerApiClient({
|
||||
baseUrl: '/api',
|
||||
status,
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
accessToken: () => null,
|
||||
holdWrites: () => true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
held.sendJson('PUT', '/user-state/a/b', { value: 1 })
|
||||
).rejects.toMatchObject({ status: 0, code: 'network' });
|
||||
await expect(
|
||||
held.sendKeepalive('DELETE', '/user-state/a/b')
|
||||
).resolves.toEqual({ status: 'failed' });
|
||||
await held.getJson('/instance');
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance');
|
||||
});
|
||||
|
||||
it('reports offline when a body is cut off after the headers', async () => {
|
||||
const cut = {
|
||||
...response(200),
|
||||
json: async () => {
|
||||
throw new TypeError('network error');
|
||||
},
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(
|
||||
client(jest.fn().mockResolvedValue(cut)).getJson('/user-state')
|
||||
).rejects.toMatchObject({ status: 0, code: 'network' });
|
||||
expect(status.reportOffline).toHaveBeenCalled();
|
||||
|
||||
// Malformed JSON is an answer, not an outage.
|
||||
status.reportOffline.mockClear();
|
||||
const malformed = {
|
||||
...response(200),
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token');
|
||||
},
|
||||
} as unknown as Response;
|
||||
await expect(
|
||||
client(jest.fn().mockResolvedValue(malformed)).getJson('/x')
|
||||
).rejects.toBeInstanceOf(SyntaxError);
|
||||
expect(status.reportOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the refused token on 401', async () => {
|
||||
token = 'old';
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValue(response(401, { code: 'unauthorized' }));
|
||||
|
||||
await expect(
|
||||
client(fetchFn).getJson('/playlists')
|
||||
).rejects.toBeInstanceOf(ServerApiError);
|
||||
// The status decides: it ignores a token replaced since.
|
||||
expect(status.reportAccessDenied).toHaveBeenCalledWith('old');
|
||||
expect(status.reportOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes 304 through and maps error bodies', async () => {
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(304))
|
||||
.mockResolvedValueOnce(
|
||||
response(409, { message: 'x', code: 'payload_missing' })
|
||||
);
|
||||
const api = client(fetchFn);
|
||||
|
||||
expect((await api.request('/playlists/a')).status).toBe(304);
|
||||
await expect(
|
||||
api.sendJson('PUT', '/playlists/a', {})
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: 'payload_missing',
|
||||
});
|
||||
expect(onWriteFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import type {
|
||||
ServerStorageErrorBody,
|
||||
ServerUserStateCurrent,
|
||||
ServerUserStateWriteResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** A user-state write for a playlist the server no longer has. */
|
||||
export const PLAYLIST_MISSING = 'playlist_missing';
|
||||
|
||||
/** nginx answers these while the backend behind it is down. */
|
||||
const GATEWAY_UNAVAILABLE = new Set([502, 503, 504]);
|
||||
|
||||
export class ServerApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | undefined,
|
||||
message: string,
|
||||
/** A `412` conflict's user-state entry as the server has it. */
|
||||
readonly current?: ServerUserStateCurrent
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ServerApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** How a page-hide write ended, as far as the page could see. */
|
||||
export type KeepaliveOutcome =
|
||||
| { readonly status: 'saved'; readonly tag?: string | null }
|
||||
| { readonly status: 'conflict'; readonly current: ServerUserStateCurrent }
|
||||
| { readonly status: 'gone' }
|
||||
| { readonly status: 'failed' };
|
||||
|
||||
export interface ServerApiClientOptions {
|
||||
readonly baseUrl: string;
|
||||
readonly status: Pick<
|
||||
ServerStorageStatusService,
|
||||
'reportSuccess' | 'reportOffline' | 'reportAccessDenied'
|
||||
>;
|
||||
/** Failed writes the user should hear about: network, 413, 5xx. */
|
||||
readonly onWriteFailure?: (error: ServerApiError) => void;
|
||||
readonly fetchFn?: typeof fetch;
|
||||
readonly timeoutMs?: number;
|
||||
/**
|
||||
* Refuse writes as if offline while true: an instance not checked yet
|
||||
* (a start from memory, or one that went offline), whose address may
|
||||
* lead to another database by the time it answers.
|
||||
*/
|
||||
readonly holdWrites?: () => boolean;
|
||||
/** The token to send (`ServerStorageStatusService.accessToken`). */
|
||||
readonly accessToken: () => string | null;
|
||||
}
|
||||
|
||||
/** `fetch` for `/v1`: token, timeout, error mapping, connection state. */
|
||||
export class ServerApiClient {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(private readonly options: ServerApiClientOptions) {
|
||||
this.root = `${options.baseUrl.replace(/\/+$/, '')}/v1`;
|
||||
}
|
||||
|
||||
private token(): string | null {
|
||||
return this.options.accessToken();
|
||||
}
|
||||
|
||||
url(path: string): string {
|
||||
return `${this.root}${path}`;
|
||||
}
|
||||
|
||||
async request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const isWrite = (init.method ?? 'GET') !== 'GET';
|
||||
const token = this.token();
|
||||
let response: Response;
|
||||
try {
|
||||
if (isWrite && this.options.holdWrites?.()) {
|
||||
throw new Error('Writes wait for the instance to answer.');
|
||||
}
|
||||
response = await this.fetchFn()(this.url(path), {
|
||||
...init,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
...bearer(token),
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
},
|
||||
signal: init.signal ?? this.timeoutSignal(),
|
||||
});
|
||||
} catch {
|
||||
this.options.status.reportOffline();
|
||||
const error = new ServerApiError(
|
||||
0,
|
||||
'network',
|
||||
'The server is not reachable.'
|
||||
);
|
||||
if (isWrite) {
|
||||
this.options.onWriteFailure?.(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
this.options.status.reportAccessDenied(token);
|
||||
throw new ServerApiError(
|
||||
401,
|
||||
'unauthorized',
|
||||
'The server needs an access token.'
|
||||
);
|
||||
}
|
||||
|
||||
if (GATEWAY_UNAVAILABLE.has(response.status)) {
|
||||
// A proxy in front of a backend that is down or restarting.
|
||||
this.options.status.reportOffline();
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 304) {
|
||||
const body = await readErrorBody(response);
|
||||
const error = new ServerApiError(
|
||||
response.status,
|
||||
body.code,
|
||||
body.message,
|
||||
body.current
|
||||
);
|
||||
if (
|
||||
isWrite &&
|
||||
(response.status === 413 || response.status >= 500)
|
||||
) {
|
||||
this.options.onWriteFailure?.(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.options.status.reportSuccess();
|
||||
return response;
|
||||
}
|
||||
|
||||
async getJson<T>(path: string): Promise<T> {
|
||||
return this.readJson<T>(await this.request(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A body cut off after the headers arrived is an outage too; malformed
|
||||
* JSON is not.
|
||||
*/
|
||||
async readJson<T>(response: Response): Promise<T> {
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw error;
|
||||
}
|
||||
this.options.status.reportOffline();
|
||||
throw new ServerApiError(
|
||||
0,
|
||||
'network',
|
||||
'The server is not reachable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(
|
||||
method: 'PUT' | 'POST',
|
||||
path: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<Response> {
|
||||
return this.request(path, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A write that survives the page. Browsers reject keepalive bodies
|
||||
* above 64 KiB, which ends as `failed`.
|
||||
*/
|
||||
async sendKeepalive(
|
||||
method: 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<KeepaliveOutcome> {
|
||||
if (this.options.holdWrites?.()) {
|
||||
return { status: 'failed' };
|
||||
}
|
||||
const token = this.token();
|
||||
try {
|
||||
const response = await this.fetchFn()(this.url(path), {
|
||||
method,
|
||||
keepalive: true,
|
||||
headers: {
|
||||
...bearer(token),
|
||||
...headers,
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
// A server that takes the request and never answers would
|
||||
// hold every later write behind it.
|
||||
signal: this.timeoutSignal(),
|
||||
});
|
||||
if (response.ok) {
|
||||
const body = (await response.json().catch(() => {
|
||||
// Saved, on a version it cannot tell: cut off after its
|
||||
// headers, which counts as offline, as for requests.
|
||||
this.options.status.reportOffline();
|
||||
return {};
|
||||
})) as Partial<ServerUserStateWriteResponse>;
|
||||
return { status: 'saved', tag: body?.tag };
|
||||
}
|
||||
if (GATEWAY_UNAVAILABLE.has(response.status)) {
|
||||
// As for requests: the backend may come back as another
|
||||
// instance, checked before the next write goes out.
|
||||
this.options.status.reportOffline();
|
||||
}
|
||||
if (response.status === 401) {
|
||||
// As for requests: a token replaced since is not refused.
|
||||
this.options.status.reportAccessDenied(token);
|
||||
}
|
||||
const { code, current } =
|
||||
response.status === 412 || response.status === 404
|
||||
? await readErrorBody(response)
|
||||
: {};
|
||||
if (code === PLAYLIST_MISSING) {
|
||||
return { status: 'gone' };
|
||||
}
|
||||
return current
|
||||
? { status: 'conflict', current }
|
||||
: { status: 'failed' };
|
||||
} catch {
|
||||
this.options.status.reportOffline();
|
||||
return { status: 'failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private fetchFn(): typeof fetch {
|
||||
return this.options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
private timeoutSignal(): AbortSignal | undefined {
|
||||
return typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(this.options.timeoutMs ?? REQUEST_TIMEOUT_MS)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorBody(
|
||||
response: Response
|
||||
): Promise<Pick<ServerStorageErrorBody, 'message' | 'code' | 'current'>> {
|
||||
try {
|
||||
const body = (await response.json()) as Partial<ServerStorageErrorBody>;
|
||||
return {
|
||||
message: body.message ?? response.statusText,
|
||||
code: body.code,
|
||||
current: body.current,
|
||||
};
|
||||
} catch {
|
||||
return { message: response.statusText };
|
||||
}
|
||||
}
|
||||
|
||||
function bearer(token: string | null): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
combineUserData,
|
||||
remapChannelReferences,
|
||||
retargetUserData,
|
||||
unionItems,
|
||||
} from './server-migration-merge';
|
||||
|
||||
describe('combineUserData', () => {
|
||||
it('unions lists, server entries first', () => {
|
||||
expect(
|
||||
combineUserData([
|
||||
['live:1', 'live:2'],
|
||||
['live:2', 'live:3'],
|
||||
])
|
||||
).toEqual(['live:1', 'live:2', 'live:3']);
|
||||
});
|
||||
|
||||
it('merges maps and keeps the first value of a key', () => {
|
||||
expect(
|
||||
combineUserData([
|
||||
{ a: { position: 10 } },
|
||||
{ a: { position: 99 }, b: { position: 5 } },
|
||||
])
|
||||
).toEqual({ a: { position: 10 }, b: { position: 5 } });
|
||||
});
|
||||
|
||||
it('ignores missing values and keeps the first of mixed kinds', () => {
|
||||
expect(combineUserData([undefined, ['x']])).toEqual(['x']);
|
||||
expect(combineUserData([undefined])).toBeUndefined();
|
||||
expect(combineUserData([['x'], { a: 1 }])).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unionItems', () => {
|
||||
it('identifies items by content id or URL', () => {
|
||||
expect(
|
||||
unionItems(
|
||||
[{ id: 1, title: 'Server' }, { url: 'http://a' }],
|
||||
[{ id: 1, title: 'Browser' }, { url: 'http://b' }]
|
||||
)
|
||||
).toEqual([
|
||||
{ id: 1, title: 'Server' },
|
||||
{ url: 'http://a' },
|
||||
{ url: 'http://b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stalker items', () => {
|
||||
it('keep the same id in different categories apart', () => {
|
||||
expect(
|
||||
unionItems(
|
||||
[{ id: 7, category_id: 'itv', name: 'Channel' }],
|
||||
[
|
||||
{ id: 7, category_id: 'vod', name: 'Movie' },
|
||||
{ id: 7, category_id: 'itv', name: 'Channel again' },
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
{ id: 7, category_id: 'itv', name: 'Channel' },
|
||||
{ id: 7, category_id: 'vod', name: 'Movie' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keep a movie and a series of one provider category apart', () => {
|
||||
expect(
|
||||
unionItems<Record<string, unknown>>(
|
||||
[{ id: 7, category_id: '12', name: 'Movie' }],
|
||||
[
|
||||
{ id: 7, category_id: '12', is_series: 1, name: 'Show' },
|
||||
{
|
||||
id: 7,
|
||||
category_id: '12',
|
||||
is_series: true,
|
||||
name: 'Again',
|
||||
},
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
{ id: 7, category_id: '12', name: 'Movie' },
|
||||
{ id: 7, category_id: '12', is_series: 1, name: 'Show' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback positions', () => {
|
||||
it('keep the server position of the same item', () => {
|
||||
expect(
|
||||
combineUserData([
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 7,
|
||||
positionSeconds: 60,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 7,
|
||||
positionSeconds: 900,
|
||||
},
|
||||
{
|
||||
contentType: 'episode',
|
||||
contentXtreamId: 7,
|
||||
positionSeconds: 5,
|
||||
},
|
||||
],
|
||||
])
|
||||
).toEqual([
|
||||
{ contentType: 'vod', contentXtreamId: 7, positionSeconds: 60 },
|
||||
{ contentType: 'episode', contentXtreamId: 7, positionSeconds: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('name the server playlist after a move', () => {
|
||||
expect(
|
||||
retargetUserData(
|
||||
[{ contentXtreamId: 1, playlistId: 'b' }, 'live:1'],
|
||||
'b',
|
||||
's'
|
||||
)
|
||||
).toEqual([{ contentXtreamId: 1, playlistId: 's' }, 'live:1']);
|
||||
const same = [{ playlistId: 'b' }];
|
||||
expect(retargetUserData(same, 'b', 'b')).toBe(same);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remapChannelReferences', () => {
|
||||
const browser = [
|
||||
{ id: 'b1', url: 'http://s/1' },
|
||||
{ id: 'b2', url: 'http://s/2' },
|
||||
{ id: 'b3', url: 'http://s/gone' },
|
||||
];
|
||||
const server = [
|
||||
{ id: 's1', url: 'http://s/1' },
|
||||
{ id: 's2', url: 'http://s/2' },
|
||||
];
|
||||
|
||||
it('turns legacy channel-id favorites into their stream URL', () => {
|
||||
expect(
|
||||
remapChannelReferences(['b2', 'b1', 'http://s/1'], browser, server)
|
||||
).toEqual(['http://s/2', 'http://s/1', 'http://s/1']);
|
||||
// The server's own legacy ids, against its own channels.
|
||||
expect(remapChannelReferences(['s1'], server, server)).toEqual([
|
||||
'http://s/1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps URL favorites and anything it cannot resolve', () => {
|
||||
expect(
|
||||
remapChannelReferences(
|
||||
['http://elsewhere/9', 'b9', 'b3'],
|
||||
browser,
|
||||
server
|
||||
)
|
||||
).toEqual(['http://elsewhere/9', 'b9', 'http://s/gone']);
|
||||
});
|
||||
|
||||
it("keeps a history entry's URL id and names the server's channel", () => {
|
||||
const portal = { id: 7, category_id: 'itv' };
|
||||
const entry = {
|
||||
source: 'm3u',
|
||||
id: 'http://s/1',
|
||||
url: 'http://s/1',
|
||||
channel_id: 'b1',
|
||||
};
|
||||
expect(
|
||||
remapChannelReferences([entry, portal], browser, server)
|
||||
).toEqual([{ ...entry, channel_id: 's1' }, portal]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { isStalkerSeriesFlag } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* How the move to the server combines user data that two places hold for
|
||||
* one playlist: the server's value comes first and wins, the browser only
|
||||
* adds. Lists are unioned, maps keep the first value of each key.
|
||||
*/
|
||||
export function combineUserData(values: readonly unknown[]): unknown {
|
||||
const present = values.filter((value) => value !== undefined);
|
||||
if (present.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (present.every(Array.isArray)) {
|
||||
return unionItems(...(present as unknown[][]));
|
||||
}
|
||||
if (present.every(isPlainObject)) {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const value of present as Record<string, unknown>[]) {
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (!(key in merged)) {
|
||||
merged[key] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
return present[0];
|
||||
}
|
||||
|
||||
/** Items in order of first appearance, without duplicates. */
|
||||
export function unionItems<T>(...lists: readonly (readonly T[])[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
for (const list of lists) {
|
||||
for (const item of list) {
|
||||
const key = itemKey(item);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content ids or URLs identify favorites and history entries; a playback
|
||||
* position is identified by its content type and Xtream id.
|
||||
*/
|
||||
export function itemKey(item: unknown): string {
|
||||
if (!isPlainObject(item)) {
|
||||
return `value:${JSON.stringify(item)}`;
|
||||
}
|
||||
if (
|
||||
typeof item['contentType'] === 'string' &&
|
||||
(typeof item['contentXtreamId'] === 'number' ||
|
||||
typeof item['contentXtreamId'] === 'string')
|
||||
) {
|
||||
return `position:${item['contentType']}:${item['contentXtreamId']}`;
|
||||
}
|
||||
// Stalker ids repeat across content types; the category tells them
|
||||
// apart, and the series flag where a provider category holds both.
|
||||
const category = item['category_id'];
|
||||
const scope =
|
||||
(typeof category === 'string' || typeof category === 'number'
|
||||
? `|category:${category}`
|
||||
: '') + (isStalkerSeriesFlag(item['is_series']) ? '|series' : '');
|
||||
for (const field of ['id', 'stream_id', 'series_id', 'movie_id', 'url']) {
|
||||
const value = item[field];
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return `${field}:${value}${scope}`;
|
||||
}
|
||||
}
|
||||
return `json:${JSON.stringify(item)}`;
|
||||
}
|
||||
|
||||
interface ChannelRef {
|
||||
readonly id?: unknown;
|
||||
readonly url?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* M3U favorites and history as the app reads them, moved to the server's
|
||||
* copy of a playlist. Favorites name channels by stream URL; older ones by
|
||||
* a channel id that every import makes up anew, which becomes that
|
||||
* channel's URL here (`ownChannels`: the playlist the favorite came from).
|
||||
* History entries keep their URL id and get the server's channel id as
|
||||
* `channel_id`. Anything else passes through, never dropped.
|
||||
*/
|
||||
export function remapChannelReferences<T>(
|
||||
references: readonly T[],
|
||||
ownChannels: readonly ChannelRef[],
|
||||
serverChannels: readonly ChannelRef[]
|
||||
): T[] {
|
||||
const urlById = new Map<unknown, unknown>(
|
||||
ownChannels.map((channel) => [channel.id, channel.url])
|
||||
);
|
||||
const idByUrl = new Map<unknown, unknown>(
|
||||
serverChannels.map((channel) => [channel.url, channel.id])
|
||||
);
|
||||
return references.map((reference) => {
|
||||
if (typeof reference === 'string') {
|
||||
const url = urlById.get(reference);
|
||||
return (typeof url === 'string' ? url : reference) as T;
|
||||
}
|
||||
if (isPlainObject(reference) && reference['source'] === 'm3u') {
|
||||
const channelId = idByUrl.get(reference['url']);
|
||||
return (
|
||||
typeof channelId === 'string'
|
||||
? { ...reference, channel_id: channelId }
|
||||
: reference
|
||||
) as T;
|
||||
}
|
||||
return reference;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser value moved under another playlist id: list items that name
|
||||
* their playlist (playback positions do) name the server's instead.
|
||||
*/
|
||||
export function retargetUserData(
|
||||
value: unknown,
|
||||
browserId: string,
|
||||
serverId: string
|
||||
): unknown {
|
||||
if (browserId === serverId || !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return value.map((item) =>
|
||||
isPlainObject(item) && item['playlistId'] === browserId
|
||||
? { ...item, playlistId: serverId }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recently viewed items as the app keeps them: newest first, since it cuts
|
||||
* the list at its limit from the end, and one per item, its newest view
|
||||
* with that view's artwork. Undated items follow the dated ones in order.
|
||||
*/
|
||||
export function combineRecentViews(values: readonly unknown[]): unknown {
|
||||
const present = values.filter((value) => value !== undefined);
|
||||
if (present.length === 0 || !present.every(Array.isArray)) {
|
||||
return combineUserData(values);
|
||||
}
|
||||
return unionItems(
|
||||
newestViewsFirst(([] as unknown[]).concat(...(present as unknown[][])))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recently viewed items newest first, by `viewedAt` (Xtream maps) or
|
||||
* `added_at` (playlist documents: an ISO date or epoch milliseconds);
|
||||
* stable, undated items after dated ones.
|
||||
*/
|
||||
export function newestViewsFirst<T>(
|
||||
items: readonly T[],
|
||||
field: 'viewedAt' | 'added_at' = 'viewedAt'
|
||||
): T[] {
|
||||
const viewedAt = (item: unknown) => {
|
||||
const value = isPlainObject(item) ? item[field] : undefined;
|
||||
const at =
|
||||
typeof value === 'number' ? value : Date.parse(String(value));
|
||||
return Number.isNaN(at) ? 0 : at;
|
||||
};
|
||||
return [...items].sort((a, b) => viewedAt(b) - viewedAt(a));
|
||||
}
|
||||
|
||||
/** Whether a list holds recently viewed items, which stay newest first. */
|
||||
export function isViewHistory(items: readonly unknown[]): boolean {
|
||||
return items.some((item) => isPlainObject(item) && 'viewedAt' in item);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { planServerMigration, sourceIdentity } from './server-migration-plan';
|
||||
|
||||
const playlist = (fields: Partial<Playlist> & { _id: string }) =>
|
||||
({ title: fields._id, ...fields }) as Playlist;
|
||||
|
||||
describe('planServerMigration', () => {
|
||||
it('uploads everything to an empty server', () => {
|
||||
const browser = [
|
||||
playlist({ _id: 'm', url: 'http://a.test/list.m3u' }),
|
||||
playlist({ _id: 'file' }),
|
||||
];
|
||||
|
||||
expect(planServerMigration(browser, [])).toEqual([
|
||||
{ kind: 'upload', playlist: browser[0] },
|
||||
{ kind: 'upload', playlist: browser[1] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges by id first, then by source', () => {
|
||||
const server = [
|
||||
playlist({ _id: 'moved' }),
|
||||
playlist({
|
||||
_id: 's-x',
|
||||
serverUrl: 'HTTP://Panel.test/',
|
||||
username: 'u',
|
||||
}),
|
||||
playlist({
|
||||
_id: 's-st',
|
||||
portalUrl: 'http://portal.test/c',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
}),
|
||||
playlist({ _id: 's-m', url: 'http://a.test/list.m3u' }),
|
||||
];
|
||||
const browser = [
|
||||
playlist({ _id: 'moved' }),
|
||||
playlist({
|
||||
_id: 'b-x',
|
||||
serverUrl: 'http://panel.test',
|
||||
username: 'u',
|
||||
}),
|
||||
playlist({
|
||||
_id: 'b-st',
|
||||
url: 'http://portal.test/c/',
|
||||
macAddress: '00:1a:79:aa:bb:cc',
|
||||
}),
|
||||
playlist({ _id: 'b-m', url: 'http://a.test/list.m3u' }),
|
||||
playlist({
|
||||
_id: 'b-other',
|
||||
serverUrl: 'http://panel.test',
|
||||
username: 'other',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planServerMigration(browser, server)).toEqual([
|
||||
{ kind: 'merge', browserId: 'moved', serverId: 'moved' },
|
||||
{ kind: 'merge', browserId: 'b-x', serverId: 's-x' },
|
||||
{ kind: 'merge', browserId: 'b-st', serverId: 's-st' },
|
||||
{ kind: 'merge', browserId: 'b-m', serverId: 's-m' },
|
||||
{ kind: 'upload', playlist: browser[4] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses browser duplicates of one source onto the first', () => {
|
||||
const browser = [
|
||||
playlist({ _id: 'one', url: 'http://a.test/list.m3u' }),
|
||||
playlist({ _id: 'two', url: 'http://A.test/list.m3u' }),
|
||||
];
|
||||
|
||||
expect(planServerMigration(browser, [])).toEqual([
|
||||
{ kind: 'upload', playlist: browser[0] },
|
||||
{ kind: 'merge', browserId: 'two', serverId: 'one' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no source for a file or pasted M3U', () => {
|
||||
expect(sourceIdentity(playlist({ _id: 'file' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Where one browser playlist goes on the server. */
|
||||
export type ServerMigrationStep =
|
||||
| {
|
||||
readonly kind: 'upload';
|
||||
readonly playlist: Playlist;
|
||||
}
|
||||
| {
|
||||
/** The server already has this source; it keeps its document. */
|
||||
readonly kind: 'merge';
|
||||
readonly browserId: string;
|
||||
readonly serverId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches browser playlists to the server's: the same id first (a playlist
|
||||
* moved before), then the same source (Xtream server and user, Stalker
|
||||
* portal and MAC, M3U URL). A file or pasted M3U has no source to compare
|
||||
* and is uploaded unless its id is taken. Browser duplicates of one source
|
||||
* collapse onto the first.
|
||||
*/
|
||||
export function planServerMigration(
|
||||
browser: readonly Playlist[],
|
||||
server: readonly Playlist[]
|
||||
): ServerMigrationStep[] {
|
||||
const serverIds = new Set(server.map((playlist) => playlist._id));
|
||||
const bySource = new Map<string, string>();
|
||||
for (const playlist of server) {
|
||||
const source = sourceIdentity(playlist);
|
||||
if (source && !bySource.has(source)) {
|
||||
bySource.set(source, playlist._id);
|
||||
}
|
||||
}
|
||||
|
||||
const steps: ServerMigrationStep[] = [];
|
||||
for (const playlist of browser) {
|
||||
const source = sourceIdentity(playlist);
|
||||
const match = serverIds.has(playlist._id)
|
||||
? playlist._id
|
||||
: source
|
||||
? bySource.get(source)
|
||||
: undefined;
|
||||
if (match) {
|
||||
steps.push({
|
||||
kind: 'merge',
|
||||
browserId: playlist._id,
|
||||
serverId: match,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
steps.push({ kind: 'upload', playlist });
|
||||
serverIds.add(playlist._id);
|
||||
if (source) {
|
||||
bySource.set(source, playlist._id);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** The provider account or URL a playlist reads from, if it has one. */
|
||||
export function sourceIdentity(playlist: Playlist): string | null {
|
||||
if (playlist.serverUrl && playlist.username) {
|
||||
return [
|
||||
'xtream',
|
||||
normalizeUrl(playlist.serverUrl),
|
||||
playlist.username.trim(),
|
||||
].join('|');
|
||||
}
|
||||
const portalUrl = playlist.portalUrl ?? playlist.url;
|
||||
if (playlist.macAddress && portalUrl) {
|
||||
return [
|
||||
'stalker',
|
||||
normalizeUrl(portalUrl),
|
||||
playlist.macAddress.trim().toLowerCase(),
|
||||
].join('|');
|
||||
}
|
||||
return playlist.url ? `m3u|${normalizeUrl(playlist.url)}` : null;
|
||||
}
|
||||
|
||||
/** Same rules as the backup import's fingerprints. */
|
||||
function normalizeUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const pathname =
|
||||
url.pathname === '/'
|
||||
? '/'
|
||||
: url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { combineUserData } from './server-migration-merge';
|
||||
import { typeLegacyXtreamRefs } from './server-migration-xtream';
|
||||
|
||||
describe('typeLegacyXtreamRefs', () => {
|
||||
it('types bare ids from the same copy snapshots', () => {
|
||||
expect(
|
||||
typeLegacyXtreamRefs({
|
||||
'xtream-favorites': [42, 'movie:7', 99],
|
||||
'xtream-recent-items': [{ id: 42, viewedAt: 't' }],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'live', title: 'News' },
|
||||
'movie:7': { xtream_id: 7, type: 'movie' },
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
'xtream-favorites': ['live:42', 'movie:7', 99],
|
||||
'xtream-recent-items': [{ id: 'live:42', viewedAt: 't' }],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'live', title: 'News' },
|
||||
'live:42': { xtream_id: 42, type: 'live', title: 'News' },
|
||||
'movie:7': { xtream_id: 7, type: 'movie' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a live and a movie favorite that share an id apart', () => {
|
||||
const browser = typeLegacyXtreamRefs({
|
||||
'xtream-favorites': [42],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'live' },
|
||||
},
|
||||
});
|
||||
const server = typeLegacyXtreamRefs({
|
||||
'xtream-favorites': [42],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'movie' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
combineUserData([
|
||||
server['xtream-favorites'],
|
||||
browser['xtream-favorites'],
|
||||
])
|
||||
).toEqual(['movie:42', 'live:42']);
|
||||
expect(
|
||||
combineUserData([
|
||||
server['xtream-collection-items'],
|
||||
browser['xtream-collection-items'],
|
||||
])
|
||||
).toEqual({
|
||||
// The server's own bare alias wins its key; both typed keys stay.
|
||||
'42': { xtream_id: 42, type: 'movie' },
|
||||
'movie:42': { xtream_id: 42, type: 'movie' },
|
||||
'live:42': { xtream_id: 42, type: 'live' },
|
||||
});
|
||||
});
|
||||
|
||||
it('types numeric-string ids too', () => {
|
||||
expect(
|
||||
typeLegacyXtreamRefs({
|
||||
'xtream-favorites': ['42'],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'movie' },
|
||||
},
|
||||
})['xtream-favorites']
|
||||
).toEqual(['movie:42']);
|
||||
});
|
||||
|
||||
it('keeps a typed snapshot newer than its legacy alias', () => {
|
||||
const typed = typeLegacyXtreamRefs({
|
||||
'xtream-favorites': ['movie:42'],
|
||||
'xtream-collection-items': {
|
||||
'42': { xtream_id: 42, type: 'movie', backdrop: 'old' },
|
||||
'movie:42': { xtream_id: 42, type: 'movie', backdrop: 'new' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(typed['xtream-collection-items']).toEqual({
|
||||
'42': { xtream_id: 42, type: 'movie', backdrop: 'old' },
|
||||
'movie:42': { xtream_id: 42, type: 'movie', backdrop: 'new' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** One playlist's Xtream user data from one place (browser or server). */
|
||||
export type XtreamUserData = Partial<Record<ServerUserStateNamespace, unknown>>;
|
||||
|
||||
const SNAPSHOTS = 'xtream-collection-items';
|
||||
|
||||
/**
|
||||
* Older favorites and history name content by its bare Xtream id, which
|
||||
* repeats across live, movie and series. Before two copies are combined,
|
||||
* each one's bare ids become typed keys (`live:42`) using that copy's own
|
||||
* collection snapshots, the same evidence the PWA reads them with; ids
|
||||
* without a snapshot stay as they are. Snapshots gain the typed key and
|
||||
* keep the bare one.
|
||||
*/
|
||||
export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
|
||||
const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {};
|
||||
const typed = (key: unknown): unknown => {
|
||||
// Bare ids were stored as numbers and, older still, numeric strings.
|
||||
const id =
|
||||
typeof key === 'number'
|
||||
? key
|
||||
: typeof key === 'string' && /^\d+$/.test(key)
|
||||
? Number(key)
|
||||
: null;
|
||||
if (id === null) {
|
||||
return key;
|
||||
}
|
||||
const snapshot = snapshots[String(id)];
|
||||
return isRecord(snapshot) &&
|
||||
snapshot['xtream_id'] === id &&
|
||||
typeof snapshot['type'] === 'string'
|
||||
? `${snapshot['type']}:${id}`
|
||||
: key;
|
||||
};
|
||||
|
||||
const result: XtreamUserData = { ...data };
|
||||
const favorites = data['xtream-favorites'];
|
||||
if (Array.isArray(favorites)) {
|
||||
result['xtream-favorites'] = favorites.map(typed);
|
||||
}
|
||||
const recent = data['xtream-recent-items'];
|
||||
if (Array.isArray(recent)) {
|
||||
result['xtream-recent-items'] = recent.map((item) =>
|
||||
isRecord(item) ? { ...item, id: typed(item['id']) } : item
|
||||
);
|
||||
}
|
||||
if (isRecord(data[SNAPSHOTS])) {
|
||||
// The bare key stays as an alias: a reference not yet rewritten (a
|
||||
// failed write, an older client) still finds its snapshot, and a
|
||||
// retry can type it again. A typed snapshot that exists is newer.
|
||||
const aliased: Record<string, unknown> = { ...snapshots };
|
||||
for (const [key, snapshot] of Object.entries(snapshots)) {
|
||||
const typedKey = String(typed(Number(key)));
|
||||
if (/^\d+$/.test(key) && !(typedKey in snapshots)) {
|
||||
aliased[typedKey] = snapshot;
|
||||
}
|
||||
}
|
||||
result[SNAPSHOTS] = aliased;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NgxIndexedDBService } from 'ngx-indexed-db';
|
||||
import { of } from 'rxjs';
|
||||
import { getXtreamPendingRestoreStorageKey } from '@iptvnator/shared/interfaces';
|
||||
import { ParentalLockService } from '../parental-lock/parental-lock.service';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
import { ServerStorageSession } from './server-storage-session';
|
||||
import { ServerMigrationService } from './server-migration.service';
|
||||
|
||||
describe('ServerMigrationService', () => {
|
||||
const browserPlaylists = [
|
||||
{ _id: 'm', title: 'Family', url: 'http://lists.test/family.m3u' },
|
||||
{
|
||||
_id: 'x',
|
||||
title: 'Provider',
|
||||
serverUrl: 'http://panel.test',
|
||||
username: 'user',
|
||||
},
|
||||
];
|
||||
let session: {
|
||||
fetchStatus: jest.Mock;
|
||||
documentStore: {
|
||||
dbGetAppPlaylist: jest.Mock;
|
||||
dbGetAppPlaylistMetas: jest.Mock;
|
||||
dbUpsertAppPlaylist: jest.Mock;
|
||||
dbCreateAppPlaylist: jest.Mock;
|
||||
};
|
||||
api: { getJson: jest.Mock; sendJson: jest.Mock };
|
||||
userData: {
|
||||
flush: jest.Mock;
|
||||
hasUnsentWrites: jest.Mock;
|
||||
hydrate: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
const emptyLocks = () => ({ xtream: [], stalker: [], m3u: [] });
|
||||
let parentalLock: {
|
||||
ensureLocksReadable: jest.Mock;
|
||||
locksFor: jest.Mock;
|
||||
replacePlaylistLocks: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
parentalLock = {
|
||||
ensureLocksReadable: jest.fn().mockResolvedValue(true),
|
||||
locksFor: jest.fn(() => emptyLocks()),
|
||||
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
session = {
|
||||
fetchStatus: jest.fn().mockResolvedValue({ playlists: 0 }),
|
||||
documentStore: {
|
||||
dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbGetAppPlaylistMetas: jest.fn().mockResolvedValue([]),
|
||||
dbUpsertAppPlaylist: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
dbCreateAppPlaylist: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
api: {
|
||||
getJson: jest.fn().mockResolvedValue({ entries: {} }),
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
userData: {
|
||||
flush: jest.fn().mockResolvedValue(undefined),
|
||||
hasUnsentWrites: jest.fn(() => false),
|
||||
hydrate: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: NgxIndexedDBService,
|
||||
useValue: { getAll: () => of(browserPlaylists) },
|
||||
},
|
||||
{ provide: ServerStorageSession, useValue: session },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: {
|
||||
baseUrl: '/api',
|
||||
instance: { instanceId: 'i-1' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('offers the move on an empty server and summarizes browser playlists', async () => {
|
||||
const offer = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).offerOnStartup();
|
||||
|
||||
expect(offer).toEqual([
|
||||
{ id: 'm', title: 'Family', kind: 'm3u' },
|
||||
{ id: 'x', title: 'Provider', kind: 'xtream' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not offer after a decision or when the server has playlists', async () => {
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
session.fetchStatus.mockResolvedValueOnce({ playlists: 3 });
|
||||
expect(await migration.offerOnStartup()).toBeNull();
|
||||
|
||||
migration.skip();
|
||||
expect(await migration.offerOnStartup()).toBeNull();
|
||||
});
|
||||
|
||||
it('copies playlists with their ids and their Xtream user data to an empty server', async () => {
|
||||
localStorage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ x: ['live:1'], other: ['live:2'] })
|
||||
);
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
|
||||
const result = await migration.moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 2, merged: 0, failed: 0 });
|
||||
expect(session.documentStore.dbCreateAppPlaylist).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
expect(session.api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/x',
|
||||
{ value: ['live:1'] },
|
||||
{}
|
||||
);
|
||||
expect(session.api.sendJson).toHaveBeenCalledTimes(1);
|
||||
// Queued edits were sent before the combined values were read.
|
||||
expect(session.userData.flush.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
session.api.getJson.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(session.userData.hydrate).toHaveBeenCalled();
|
||||
expect(migration.browserDataMoved()).toBe(true);
|
||||
});
|
||||
|
||||
it('merges into the server copy and combines Xtream data under its id', async () => {
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{
|
||||
_id: 'server-x',
|
||||
title: 'Provider',
|
||||
serverUrl: 'http://panel.test/',
|
||||
username: 'user',
|
||||
},
|
||||
{
|
||||
_id: 'server-m',
|
||||
title: 'Family',
|
||||
url: 'http://lists.test/family.m3u',
|
||||
},
|
||||
]);
|
||||
session.api.getJson.mockResolvedValue({
|
||||
entries: {
|
||||
'xtream-recent-items': { 'server-x': ['vod:9'] },
|
||||
},
|
||||
});
|
||||
localStorage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ x: ['live:1'] })
|
||||
);
|
||||
localStorage.setItem(
|
||||
'xtream-recent-items',
|
||||
JSON.stringify({ x: ['vod:1', 'vod:9'] })
|
||||
);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 0, merged: 2, failed: 0 });
|
||||
expect(
|
||||
session.documentStore.dbUpsertAppPlaylist
|
||||
).not.toHaveBeenCalled();
|
||||
expect(session.api.sendJson.mock.calls).toEqual([
|
||||
[
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/server-x',
|
||||
{ value: ['live:1'] },
|
||||
{},
|
||||
],
|
||||
[
|
||||
'PUT',
|
||||
'/user-state/xtream-recent-items/server-x',
|
||||
{ value: ['vod:9', 'vod:1'] },
|
||||
{},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts the browser's newer views ahead of the server's history", async () => {
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{ ...browserPlaylists[1], _id: 'server-x' },
|
||||
]);
|
||||
const view = (id: string, day: number) => ({
|
||||
id,
|
||||
viewedAt: `2026-10-0${day}T20:00:00.000Z`,
|
||||
});
|
||||
session.api.getJson.mockResolvedValue({
|
||||
entries: {
|
||||
'xtream-recent-items': {
|
||||
'server-x': [view('vod:9', 2), view('vod:8', 1)],
|
||||
},
|
||||
},
|
||||
});
|
||||
// vod:8 was watched again in this browser, with other artwork.
|
||||
const again = { ...view('vod:8', 4), backdropUrl: 'b.jpg' };
|
||||
localStorage.setItem(
|
||||
'xtream-recent-items',
|
||||
JSON.stringify({ x: [again, view('vod:1', 3)] })
|
||||
);
|
||||
|
||||
await TestBed.inject(ServerMigrationService).moveBrowserData();
|
||||
|
||||
// The app cuts history from the end: the newest view leads, and an
|
||||
// item keeps its newest view.
|
||||
expect(session.api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-recent-items/server-x',
|
||||
{ value: [again, view('vod:1', 3), view('vod:9', 2)] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('combines browser duplicates of one source in one write', async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
browserPlaylists[1],
|
||||
{ ...browserPlaylists[1], _id: 'x2' },
|
||||
]),
|
||||
},
|
||||
});
|
||||
localStorage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ x: ['live:1'], x2: ['live:2'] })
|
||||
);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 1, merged: 1, failed: 0 });
|
||||
expect(session.api.sendJson.mock.calls).toEqual([
|
||||
[
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/x',
|
||||
{ value: ['live:1', 'live:2'] },
|
||||
{},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds a browser document's favorites to the matching server playlist", async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
{
|
||||
...browserPlaylists[0],
|
||||
favorites: ['b1', 'b2'],
|
||||
playlist: {
|
||||
items: [
|
||||
{ id: 'b1', url: 'http://s/1' },
|
||||
{ id: 'b2', url: 'http://s/2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{ _id: 'server-m', title: 'Family', url: browserPlaylists[0].url },
|
||||
]);
|
||||
// The server imported the same list: same URLs, other channel ids.
|
||||
const stored = {
|
||||
_id: 'server-m',
|
||||
title: 'Family',
|
||||
url: browserPlaylists[0].url,
|
||||
favorites: ['s1'],
|
||||
recentlyViewed: [],
|
||||
playlist: {
|
||||
items: [
|
||||
{ id: 's1', url: 'http://s/1' },
|
||||
{ id: 's2', url: 'http://s/2' },
|
||||
],
|
||||
},
|
||||
};
|
||||
session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 0, merged: 1, failed: 0 });
|
||||
// By stream URL, as the app looks favorites up.
|
||||
expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({
|
||||
...stored,
|
||||
favorites: ['http://s/1', 'http://s/2'],
|
||||
recentlyViewed: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a document's newest view of an item both copies hold", async () => {
|
||||
const view = (day: number, extra = {}) => ({
|
||||
source: 'm3u',
|
||||
id: 'http://s/1',
|
||||
url: 'http://s/1',
|
||||
title: 'One',
|
||||
channel_id: 'b1',
|
||||
category_id: 'live',
|
||||
added_at: `2026-10-0${day}T20:00:00.000Z`,
|
||||
...extra,
|
||||
});
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
{
|
||||
...browserPlaylists[0],
|
||||
recentlyViewed: [
|
||||
view(3, { poster_url: 'new.png' }),
|
||||
],
|
||||
playlist: {
|
||||
items: [{ id: 'b1', url: 'http://s/1' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{ _id: 'server-m', title: 'Family', url: browserPlaylists[0].url },
|
||||
]);
|
||||
const stored = {
|
||||
_id: 'server-m',
|
||||
title: 'Family',
|
||||
url: browserPlaylists[0].url,
|
||||
favorites: [],
|
||||
// The same channel, watched there before this browser's view.
|
||||
recentlyViewed: [view(1, { channel_id: 's1' })],
|
||||
playlist: { items: [{ id: 's1', url: 'http://s/1' }] },
|
||||
};
|
||||
session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored);
|
||||
|
||||
await TestBed.inject(ServerMigrationService).moveBrowserData();
|
||||
|
||||
// Not skipped for an unchanged length: the newer view replaces it.
|
||||
expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({
|
||||
...stored,
|
||||
recentlyViewed: [
|
||||
view(3, { channel_id: 's1', poster_url: 'new.png' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('merges into a copy another tab or device uploaded first', async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([{ ...browserPlaylists[0], favorites: ['http://s/2'] }]),
|
||||
},
|
||||
});
|
||||
// The id was free when planned, taken when uploaded.
|
||||
session.documentStore.dbCreateAppPlaylist.mockResolvedValue(false);
|
||||
const first = {
|
||||
...browserPlaylists[0],
|
||||
favorites: ['http://s/1'],
|
||||
recentlyViewed: [],
|
||||
};
|
||||
session.documentStore.dbGetAppPlaylist.mockResolvedValue(first);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 0, merged: 1, failed: 0 });
|
||||
// Never overwritten: this browser's favorites join the first copy's.
|
||||
expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({
|
||||
...first,
|
||||
favorites: ['http://s/1', 'http://s/2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a parked Xtream restore along to the server playlist id', async () => {
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{ ...browserPlaylists[1], _id: 'server-x' },
|
||||
]);
|
||||
// A browser backup import that could not finish.
|
||||
const parked = JSON.stringify({
|
||||
hiddenCategories: [],
|
||||
favorites: [{ xtreamId: 5, contentType: 'movie' }],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
});
|
||||
localStorage.setItem(getXtreamPendingRestoreStorageKey('x'), parked);
|
||||
|
||||
await TestBed.inject(ServerMigrationService).moveBrowserData();
|
||||
|
||||
expect(
|
||||
localStorage.getItem(getXtreamPendingRestoreStorageKey('server-x'))
|
||||
).toBe(parked);
|
||||
});
|
||||
|
||||
it('keeps the move on offer when a playlist could not be uploaded', async () => {
|
||||
session.documentStore.dbCreateAppPlaylist
|
||||
.mockRejectedValueOnce(new Error('413'))
|
||||
.mockResolvedValue(true);
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
|
||||
const result = await migration.moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 1, merged: 0, failed: 1 });
|
||||
expect(migration.browserDataMoved()).toBe(false);
|
||||
|
||||
// A second run merges the moved one and retries the other.
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
browserPlaylists[1],
|
||||
]);
|
||||
await expect(migration.moveBrowserData()).resolves.toEqual({
|
||||
imported: 1,
|
||||
merged: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(migration.browserDataMoved()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start while user-data writes are still unsent', async () => {
|
||||
session.userData.hasUnsentWrites.mockReturnValue(true);
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
|
||||
await expect(migration.moveBrowserData()).rejects.toThrow('Unsent');
|
||||
expect(
|
||||
session.documentStore.dbCreateAppPlaylist
|
||||
).not.toHaveBeenCalled();
|
||||
expect(session.api.sendJson).not.toHaveBeenCalled();
|
||||
expect(migration.browserDataMoved()).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a duplicate of a failed upload as failed, not merged', async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
browserPlaylists[1],
|
||||
{ ...browserPlaylists[1], _id: 'x2' },
|
||||
]),
|
||||
},
|
||||
});
|
||||
session.documentStore.dbCreateAppPlaylist.mockRejectedValue(
|
||||
new Error('500')
|
||||
);
|
||||
localStorage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ x: ['live:1'], x2: ['live:2'] })
|
||||
);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 0, merged: 0, failed: 2 });
|
||||
expect(session.api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes parental locks along to the server playlist id', async () => {
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{
|
||||
_id: 'server-x',
|
||||
title: 'Provider',
|
||||
serverUrl: 'http://panel.test',
|
||||
username: 'user',
|
||||
},
|
||||
]);
|
||||
parentalLock.locksFor.mockImplementation((id: string) =>
|
||||
id === 'x'
|
||||
? {
|
||||
xtream: [{ categoryType: 'live', xtreamId: 5 }],
|
||||
stalker: [],
|
||||
m3u: [],
|
||||
}
|
||||
: {
|
||||
xtream: [{ categoryType: 'vod', xtreamId: 9 }],
|
||||
stalker: [],
|
||||
m3u: [],
|
||||
}
|
||||
);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
expect(parentalLock.replacePlaylistLocks).toHaveBeenCalledWith(
|
||||
'server-x',
|
||||
{
|
||||
xtream: [
|
||||
{ categoryType: 'vod', xtreamId: 9 },
|
||||
{ categoryType: 'live', xtreamId: 5 },
|
||||
],
|
||||
stalker: [],
|
||||
m3u: [],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the move on offer while parental locks cannot be read', async () => {
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{
|
||||
_id: 'server-x',
|
||||
title: 'Provider',
|
||||
serverUrl: 'http://panel.test',
|
||||
username: 'user',
|
||||
},
|
||||
]);
|
||||
parentalLock.ensureLocksReadable.mockResolvedValue(false);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result.failed).toBeGreaterThan(0);
|
||||
expect(parentalLock.replacePlaylistLocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the move on offer when browser favorites cannot be read', async () => {
|
||||
localStorage.setItem('xtream-favorites', '{not json');
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
|
||||
const result = await migration.moveBrowserData();
|
||||
|
||||
expect(result.failed).toBeGreaterThan(0);
|
||||
expect(migration.browserDataMoved()).toBe(false);
|
||||
});
|
||||
|
||||
it('uploads a legacy Stalker row with its portal flag set', async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
{
|
||||
_id: 's1',
|
||||
title: 'Portal',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.test/stalker_portal/c',
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await TestBed.inject(ServerMigrationService).moveBrowserData();
|
||||
|
||||
expect(session.documentStore.dbCreateAppPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: 's1', isFullStalkerPortal: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not take an inherited name for a playlist id's data", async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([{ _id: 'toString', title: 'Odd id', url: 'http://x' }]),
|
||||
},
|
||||
});
|
||||
localStorage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ toString: ['live:1'] })
|
||||
);
|
||||
// The server has the namespace, but no entry for that id.
|
||||
session.api.getJson.mockResolvedValue({
|
||||
entries: { 'xtream-favorites': {} },
|
||||
tags: { 'xtream-favorites': {} },
|
||||
});
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
expect(session.api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/toString',
|
||||
{ value: ['live:1'] },
|
||||
{ 'If-None-Match': '*' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { NgxIndexedDBService } from 'ngx-indexed-db';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DbStores,
|
||||
getXtreamPendingRestoreStorageKey,
|
||||
SERVER_USER_STATE_NAMESPACES,
|
||||
type Playlist,
|
||||
type ServerUserStateNamespace,
|
||||
type ServerUserStateResponse,
|
||||
withExplicitStalkerPortalFlag,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
combineRecentViews,
|
||||
combineUserData,
|
||||
newestViewsFirst,
|
||||
remapChannelReferences,
|
||||
retargetUserData,
|
||||
unionItems,
|
||||
} from './server-migration-merge';
|
||||
import { planServerMigration } from './server-migration-plan';
|
||||
import { withoutDeleted } from './server-user-data-edit';
|
||||
import { idMap, preconditionHeaders } from './server-user-data-entries';
|
||||
import {
|
||||
typeLegacyXtreamRefs,
|
||||
type XtreamUserData,
|
||||
} from './server-migration-xtream';
|
||||
import { ParentalLockService } from '../parental-lock/parental-lock.service';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
import { ServerStorageSession } from './server-storage-session';
|
||||
|
||||
const MARKER_PREFIX = 'iptvnator-server:migration:';
|
||||
|
||||
export interface ServerMigrationPlaylist {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly kind: 'm3u' | 'xtream' | 'stalker';
|
||||
}
|
||||
|
||||
export interface ServerMigrationResult {
|
||||
readonly imported: number;
|
||||
readonly merged: number;
|
||||
readonly failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves this browser's playlists to the server. New playlists keep their
|
||||
* ids; one the server already has (same id, or same provider source) is
|
||||
* merged into the server's copy, adding the browser's favorites and
|
||||
* history to it. The legacy Xtream favorites, history, collections and
|
||||
* playback positions follow under the server id, combined with what is
|
||||
* there (`combineUserData`). Browser data is never deleted, and the move
|
||||
* can run again after a failure.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerMigrationService {
|
||||
private readonly dbService = inject(NgxIndexedDBService);
|
||||
private readonly session = inject(ServerStorageSession);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly instanceId = inject(SERVER_STORAGE_CONFIG).instance
|
||||
.instanceId;
|
||||
|
||||
async browserPlaylists(): Promise<ServerMigrationPlaylist[]> {
|
||||
const playlists = await this.readBrowserPlaylists();
|
||||
return playlists.map((playlist) => ({
|
||||
id: playlist._id,
|
||||
title: playlist.title,
|
||||
kind: playlist.serverUrl
|
||||
? 'xtream'
|
||||
: playlist.macAddress
|
||||
? 'stalker'
|
||||
: 'm3u',
|
||||
}));
|
||||
}
|
||||
|
||||
/** The startup offer: only for an empty server and an undecided browser. */
|
||||
async offerOnStartup(): Promise<ServerMigrationPlaylist[] | null> {
|
||||
if (this.readMarker() !== null) {
|
||||
return null;
|
||||
}
|
||||
const playlists = await this.browserPlaylists();
|
||||
if (playlists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const { playlists: serverCount } = await this.session.fetchStatus();
|
||||
return serverCount === 0 ? playlists : null;
|
||||
}
|
||||
|
||||
browserDataMoved(): boolean {
|
||||
return this.readMarker() === 'done';
|
||||
}
|
||||
|
||||
skip(): void {
|
||||
this.writeMarker('skipped');
|
||||
}
|
||||
|
||||
async moveBrowserData(): Promise<ServerMigrationResult> {
|
||||
// An edit still unsent could land after the combined values below.
|
||||
await this.session.userData.flush();
|
||||
if (this.session.userData.hasUnsentWrites()) {
|
||||
throw new Error('Unsent user data; try again when online');
|
||||
}
|
||||
const [browser, server] = await Promise.all([
|
||||
this.readBrowserPlaylists(),
|
||||
this.session.documentStore.dbGetAppPlaylistMetas(),
|
||||
]);
|
||||
const byId = new Map(
|
||||
browser.map((playlist) => [playlist._id, playlist])
|
||||
);
|
||||
/** Browser playlist id → its id on the server. */
|
||||
const targets = new Map<string, string>();
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
let failed = 0;
|
||||
|
||||
const onServer = new Set(server.map((playlist) => playlist._id));
|
||||
for (const step of planServerMigration(browser, server)) {
|
||||
try {
|
||||
if (step.kind === 'merge') {
|
||||
// A duplicate of a browser playlist whose upload failed.
|
||||
if (!onServer.has(step.serverId)) {
|
||||
throw new Error('Merge target was not uploaded');
|
||||
}
|
||||
const playlist = byId.get(step.browserId);
|
||||
if (playlist) {
|
||||
await this.mergeDocumentUserData(
|
||||
playlist,
|
||||
step.serverId
|
||||
);
|
||||
}
|
||||
merged += 1;
|
||||
targets.set(step.browserId, step.serverId);
|
||||
} else {
|
||||
// Create-only: another tab or device that moved the
|
||||
// same playlist first keeps its copy, merged into.
|
||||
const created =
|
||||
await this.session.documentStore.dbCreateAppPlaylist(
|
||||
step.playlist
|
||||
);
|
||||
if (created) {
|
||||
imported += 1;
|
||||
} else {
|
||||
await this.mergeDocumentUserData(
|
||||
step.playlist,
|
||||
step.playlist._id
|
||||
);
|
||||
merged += 1;
|
||||
}
|
||||
onServer.add(step.playlist._id);
|
||||
targets.set(step.playlist._id, step.playlist._id);
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
failed += await this.copyXtreamUserData(targets);
|
||||
failed += await this.moveParentalLocks(targets);
|
||||
failed += movePendingRestores(targets);
|
||||
await this.session.userData.hydrate();
|
||||
if (failed === 0) {
|
||||
// A partial move stays on offer, in Settings → Server.
|
||||
this.writeMarker('done');
|
||||
}
|
||||
return { imported, merged, failed };
|
||||
}
|
||||
|
||||
/** Adds a browser document's favorites and history to the server's. */
|
||||
private async mergeDocumentUserData(
|
||||
playlist: Playlist,
|
||||
serverId: string
|
||||
): Promise<void> {
|
||||
const favorites = playlist.favorites ?? [];
|
||||
const recentlyViewed = playlist.recentlyViewed ?? [];
|
||||
if (favorites.length === 0 && recentlyViewed.length === 0) {
|
||||
return;
|
||||
}
|
||||
const stored =
|
||||
await this.session.documentStore.dbGetAppPlaylist(serverId);
|
||||
if (!stored) {
|
||||
throw new Error('The server playlist disappeared');
|
||||
}
|
||||
const browserChannels = channelsOf(playlist);
|
||||
const serverChannels = channelsOf(stored);
|
||||
const next = {
|
||||
...stored,
|
||||
// By stream URL on both sides, so one channel is one favorite.
|
||||
favorites: unionItems(
|
||||
remapChannelReferences(
|
||||
stored.favorites ?? [],
|
||||
serverChannels,
|
||||
serverChannels
|
||||
),
|
||||
remapChannelReferences(
|
||||
favorites,
|
||||
browserChannels,
|
||||
serverChannels
|
||||
)
|
||||
),
|
||||
// Newest first, one per item: its newest view (the server's on
|
||||
// a tie), as the app keeps the list.
|
||||
recentlyViewed: unionItems(
|
||||
newestViewsFirst(
|
||||
[
|
||||
...(stored.recentlyViewed ?? []),
|
||||
...remapChannelReferences(
|
||||
recentlyViewed,
|
||||
browserChannels,
|
||||
serverChannels
|
||||
),
|
||||
],
|
||||
'added_at'
|
||||
)
|
||||
),
|
||||
};
|
||||
// Also when only a newer view of an item it has changed.
|
||||
if (
|
||||
JSON.stringify(next.favorites) !==
|
||||
JSON.stringify(stored.favorites ?? []) ||
|
||||
JSON.stringify(next.recentlyViewed) !==
|
||||
JSON.stringify(stored.recentlyViewed ?? [])
|
||||
) {
|
||||
await this.session.documentStore.dbUpsertAppPlaylist(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the legacy maps, one write per server entry: each copy's
|
||||
* legacy ids are typed first, then the browser copies of one source
|
||||
* are combined with each other and with the server's value. Returns
|
||||
* how many writes failed.
|
||||
*/
|
||||
private async copyXtreamUserData(
|
||||
targets: ReadonlyMap<string, string>
|
||||
): Promise<number> {
|
||||
if (targets.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
// Maps keyed by playlist id without a prototype: an id such as
|
||||
// `toString` is not mistaken for an entry.
|
||||
const { entries, tags } = withoutDeleted(
|
||||
await this.session.api.getJson<ServerUserStateResponse>(
|
||||
'/user-state'
|
||||
),
|
||||
[]
|
||||
);
|
||||
// A map this browser has but cannot read fails the move, so it
|
||||
// stays on offer instead of being marked done without it.
|
||||
let unreadable = 0;
|
||||
const legacy = new Map<
|
||||
ServerUserStateNamespace,
|
||||
Record<string, unknown>
|
||||
>();
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const map = readLegacyMap(namespace);
|
||||
if (map) {
|
||||
legacy.set(namespace, map);
|
||||
} else {
|
||||
unreadable += 1;
|
||||
}
|
||||
}
|
||||
const copies = new Map<string, XtreamUserData[]>();
|
||||
for (const [browserId, serverId] of targets) {
|
||||
const copy: XtreamUserData = {};
|
||||
for (const [namespace, map] of legacy) {
|
||||
if (browserId in map) {
|
||||
copy[namespace] = retargetUserData(
|
||||
map[browserId],
|
||||
browserId,
|
||||
serverId
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Object.keys(copy).length > 0) {
|
||||
copies.set(serverId, [...(copies.get(serverId) ?? []), copy]);
|
||||
}
|
||||
}
|
||||
|
||||
let failed = unreadable;
|
||||
for (const [serverId, browserCopies] of copies) {
|
||||
const stored: XtreamUserData = {};
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const value = entries[namespace]?.[serverId];
|
||||
if (value !== undefined) {
|
||||
stored[namespace] = value;
|
||||
}
|
||||
}
|
||||
const typed = [stored, ...browserCopies].map(typeLegacyXtreamRefs);
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const value = (
|
||||
namespace === 'xtream-recent-items'
|
||||
? combineRecentViews
|
||||
: combineUserData
|
||||
)(typed.map((copy) => copy[namespace]));
|
||||
if (
|
||||
value === undefined ||
|
||||
JSON.stringify(value) === JSON.stringify(stored[namespace])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// On the version read above: a device that changed it
|
||||
// meanwhile wins, and the move stays on offer.
|
||||
await this.session.api.sendJson(
|
||||
'PUT',
|
||||
`/user-state/${namespace}/${encodeURIComponent(serverId)}`,
|
||||
{ value },
|
||||
tags
|
||||
? preconditionHeaders(
|
||||
tags[namespace]?.[serverId] ?? null
|
||||
)
|
||||
: {}
|
||||
);
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parental locks stay in this browser, keyed by playlist id: a playlist
|
||||
* merged under another id takes its locks along, or its locked
|
||||
* categories would show after the move. Returns how many failed.
|
||||
*/
|
||||
private async moveParentalLocks(
|
||||
targets: ReadonlyMap<string, string>
|
||||
): Promise<number> {
|
||||
const moving = [...targets].filter(([from, to]) => from !== to);
|
||||
// An unreadable lock store reads as empty: keep the move on offer.
|
||||
if (
|
||||
moving.length > 0 &&
|
||||
!(await this.parentalLock.ensureLocksReadable())
|
||||
) {
|
||||
return moving.length;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const [browserId, serverId] of targets) {
|
||||
const from = this.parentalLock.locksFor(browserId);
|
||||
if (
|
||||
browserId === serverId ||
|
||||
from.xtream.length + from.stalker.length + from.m3u.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const to = this.parentalLock.locksFor(serverId);
|
||||
const moved = await this.parentalLock.replacePlaylistLocks(
|
||||
serverId,
|
||||
{
|
||||
xtream: unionItems(to.xtream, from.xtream),
|
||||
stalker: unionItems(to.stalker, from.stalker),
|
||||
m3u: unionItems(to.m3u, from.m3u),
|
||||
}
|
||||
);
|
||||
if (!moved) {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
private async readBrowserPlaylists(): Promise<Playlist[]> {
|
||||
const playlists = await firstValueFrom(
|
||||
this.dbService.getAll<Playlist>(DbStores.Playlists)
|
||||
);
|
||||
// A browser that skipped the Stalker metadata migration still has
|
||||
// rows without the explicit portal flag.
|
||||
return playlists.map(withExplicitStalkerPortalFlag);
|
||||
}
|
||||
|
||||
private readMarker(): string | null {
|
||||
return readLocal(MARKER_PREFIX + this.instanceId);
|
||||
}
|
||||
|
||||
private writeMarker(value: 'done' | 'skipped'): void {
|
||||
try {
|
||||
localStorage.setItem(MARKER_PREFIX + this.instanceId, value);
|
||||
} catch {
|
||||
// Without storage the offer simply returns next time.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A parked Xtream restore (a browser backup import that could not finish)
|
||||
* follows a playlist merged under another id, where it is applied and
|
||||
* exported; one the server playlist has parked already wins. Returns how
|
||||
* many could not be copied.
|
||||
*/
|
||||
function movePendingRestores(targets: ReadonlyMap<string, string>): number {
|
||||
let failed = 0;
|
||||
for (const [browserId, serverId] of targets) {
|
||||
if (browserId === serverId) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parked = localStorage.getItem(
|
||||
getXtreamPendingRestoreStorageKey(browserId)
|
||||
);
|
||||
const target = getXtreamPendingRestoreStorageKey(serverId);
|
||||
if (parked !== null && localStorage.getItem(target) === null) {
|
||||
localStorage.setItem(target, parked);
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** The M3U channels of a full playlist document. */
|
||||
function channelsOf(playlist: Playlist): { id?: unknown; url?: unknown }[] {
|
||||
const items = (playlist as { playlist?: { items?: unknown } }).playlist
|
||||
?.items;
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
function readLocal(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A browser map, `{}` when absent, `null` when it cannot be read. */
|
||||
function readLegacyMap(key: string): Record<string, unknown> | null {
|
||||
let value: string | null;
|
||||
try {
|
||||
value = localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value === null) {
|
||||
return idMap();
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? idMap(parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
|
||||
/**
|
||||
* The tab's pointer to its page load, in `sessionStorage` (one per tab,
|
||||
* kept on reload): a reload finds the writes the page loads before it
|
||||
* saved.
|
||||
*/
|
||||
export const PAGE_KEY = 'iptvnator-server:page';
|
||||
|
||||
/**
|
||||
* Records `page` as the tab's current page load and returns the ones
|
||||
* before it: the last page load (a reload, whose saved writes this page
|
||||
* takes over), then, oldest first, older ones whose copies a page load
|
||||
* kept because storage refused its own (`keepPages`).
|
||||
*/
|
||||
export function swapPage(
|
||||
tabStore: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): string[] {
|
||||
try {
|
||||
const previous = tabStore.getItem(`${PAGE_KEY}:${instanceId}`);
|
||||
tabStore.setItem(`${PAGE_KEY}:${instanceId}`, page);
|
||||
return previous ? pageList(previous) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's pointer names `page` and the older page loads whose saved
|
||||
* copies it still keeps: a reload before this page saved its own copy must
|
||||
* find them too, as their registry records may be missing.
|
||||
*/
|
||||
export function keepPages(
|
||||
tabStore: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string,
|
||||
kept: readonly string[]
|
||||
): void {
|
||||
try {
|
||||
tabStore.setItem(
|
||||
`${PAGE_KEY}:${instanceId}`,
|
||||
kept.length > 0 ? JSON.stringify([page, ...kept]) : page
|
||||
);
|
||||
} catch {
|
||||
// Session storage refused: the registry records remain.
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain page id, or a page and the older ones it keeps (`keepPages`). */
|
||||
function pageList(value: string): string[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter(
|
||||
(page): page is string => typeof page === 'string'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A plain page id.
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistWriteConflict } from '../playlist-storage-backend';
|
||||
import { ServerApiError } from './server-api-client';
|
||||
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
|
||||
|
||||
function fakeResponse(status: number, body?: unknown, etag?: string) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: new Headers(etag ? { etag } : {}),
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function putResponse(etag: string, replaced: string) {
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers({ etag, 'x-replaced-etag': replaced }),
|
||||
json: async () => ({ success: true }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('ServerPlaylistDocumentStore', () => {
|
||||
const doc = {
|
||||
_id: 'p1',
|
||||
title: 'Family',
|
||||
favorites: [],
|
||||
playlist: { header: { raw: '#EXTM3U' }, items: [{ id: 'a' }] },
|
||||
} as unknown as Playlist;
|
||||
|
||||
function setup() {
|
||||
const api = {
|
||||
request: jest.fn(),
|
||||
getJson: jest.fn(),
|
||||
readJson: jest.fn((response: Response) => response.json()),
|
||||
sendJson: jest.fn(),
|
||||
};
|
||||
return { api, store: new ServerPlaylistDocumentStore(api as never) };
|
||||
}
|
||||
|
||||
it('caches by ETag and serves a private copy on 304', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request
|
||||
.mockResolvedValueOnce(fakeResponse(200, doc, 'W/"b.0.1"'))
|
||||
.mockResolvedValueOnce(fakeResponse(304));
|
||||
|
||||
const first = await store.dbGetAppPlaylist('p1');
|
||||
const second = await store.dbGetAppPlaylist('p1');
|
||||
|
||||
expect(api.request.mock.calls[1][1]).toEqual({
|
||||
headers: { 'If-None-Match': 'W/"b.0.1"' },
|
||||
});
|
||||
// structuredClone drops the served-version symbol, as JSON does.
|
||||
expect(structuredClone(second)).toEqual(doc);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('returns null for an unknown playlist', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockRejectedValue(
|
||||
new ServerApiError(404, 'not_found', 'x')
|
||||
);
|
||||
|
||||
await expect(store.dbGetAppPlaylist('zz')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('omits an unchanged payload and keeps it on the server', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
|
||||
api.sendJson.mockResolvedValue(
|
||||
fakeResponse(200, { success: true }, '"b.0.2"')
|
||||
);
|
||||
const served = await store.dbGetAppPlaylist('p1');
|
||||
if (!served) throw new Error('expected a document');
|
||||
|
||||
await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] });
|
||||
|
||||
const [method, path, body] = api.sendJson.mock.calls[0];
|
||||
expect([method, path]).toEqual(['PUT', '/playlists/p1?keepPayload=1']);
|
||||
expect(JSON.parse(JSON.stringify(body))).toEqual({
|
||||
_id: 'p1',
|
||||
title: 'Family',
|
||||
favorites: ['a'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a new payload in full', async () => {
|
||||
const { api, store } = setup();
|
||||
api.sendJson.mockResolvedValue(
|
||||
fakeResponse(200, { success: true }, '"b.0.1"')
|
||||
);
|
||||
|
||||
await store.dbUpsertAppPlaylist(doc);
|
||||
|
||||
expect(api.sendJson.mock.calls[0][1]).toBe('/playlists/p1');
|
||||
expect(api.sendJson.mock.calls[0][2]).toBe(doc);
|
||||
});
|
||||
|
||||
it('retries in full when the server has no payload to keep', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
|
||||
const served = await store.dbGetAppPlaylist('p1');
|
||||
if (!served) throw new Error('expected a document');
|
||||
api.sendJson
|
||||
.mockRejectedValueOnce(
|
||||
new ServerApiError(409, 'payload_missing', 'x')
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
fakeResponse(200, { success: true }, '"b.1.1"')
|
||||
);
|
||||
|
||||
await store.dbUpsertAppPlaylist(served);
|
||||
|
||||
expect(api.sendJson.mock.calls[1][1]).toBe('/playlists/p1');
|
||||
expect(api.sendJson.mock.calls[1][2]).toBe(served);
|
||||
});
|
||||
|
||||
describe('after a keepPayload write', () => {
|
||||
async function readThenEdit(replaced: string) {
|
||||
const { api, store } = setup();
|
||||
// nginx gzip weakens the read's ETag.
|
||||
api.request.mockResolvedValueOnce(
|
||||
fakeResponse(200, doc, 'W/"b.0.1"')
|
||||
);
|
||||
const served = await store.dbGetAppPlaylist('p1');
|
||||
if (!served) throw new Error('expected a document');
|
||||
api.sendJson.mockResolvedValueOnce(
|
||||
putResponse('"b.0.2"', replaced)
|
||||
);
|
||||
await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] });
|
||||
api.request.mockResolvedValueOnce(
|
||||
fakeResponse(200, doc, '"b.0.3"')
|
||||
);
|
||||
await store.dbGetAppPlaylist('p1');
|
||||
return api.request.mock.calls[1][1];
|
||||
}
|
||||
|
||||
it('keeps its copy when it replaced the version it read', async () => {
|
||||
await expect(readThenEdit('"b.0.1"')).resolves.toEqual({
|
||||
headers: { 'If-None-Match': '"b.0.2"' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops its copy when another device wrote in between', async () => {
|
||||
// The server kept a newer payload than the one this client has.
|
||||
await expect(readThenEdit('"b.0.4"')).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it('writes an edit on the version it read and reports a refusal', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
|
||||
const served = await store.dbGetAppPlaylist('p1');
|
||||
if (!served) throw new Error('expected a document');
|
||||
api.sendJson.mockRejectedValueOnce(
|
||||
new ServerApiError(412, 'conflict', 'changed')
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] })
|
||||
).rejects.toBeInstanceOf(PlaylistWriteConflict);
|
||||
expect(api.sendJson.mock.calls[0][3]).toEqual({
|
||||
'If-Match': '"b.0.1"',
|
||||
});
|
||||
|
||||
// A retry on the same copy, not read again, writes as before.
|
||||
api.sendJson.mockResolvedValueOnce(putResponse('"b.0.3"', '"b.0.2"'));
|
||||
await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] });
|
||||
expect(api.sendJson.mock.calls[1][3]).toEqual({});
|
||||
});
|
||||
|
||||
it('creates a playlist only while the server has none of that id', async () => {
|
||||
const { api, store } = setup();
|
||||
api.sendJson.mockResolvedValueOnce(putResponse('"b.0.1"', ''));
|
||||
|
||||
await expect(store.dbCreateAppPlaylist(doc)).resolves.toBe(true);
|
||||
expect(api.sendJson.mock.calls[0][3]).toEqual({ 'If-None-Match': '*' });
|
||||
|
||||
api.sendJson.mockRejectedValueOnce(
|
||||
new ServerApiError(412, 'conflict', 'exists')
|
||||
);
|
||||
await expect(store.dbCreateAppPlaylist(doc)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('writes a portal document on the version it read', async () => {
|
||||
const { api, store } = setup();
|
||||
const portal = { _id: 'p2', title: 'Portal', macAddress: 'm' };
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, portal, '"b.0.1"'));
|
||||
const served = await store.dbGetAppPlaylist('p2');
|
||||
if (!served) throw new Error('expected a document');
|
||||
api.sendJson.mockResolvedValueOnce(fakeResponse(200, {}, '"b.0.2"'));
|
||||
|
||||
await store.dbUpsertAppPlaylist({ ...served, title: 'Renamed' });
|
||||
|
||||
expect(api.sendJson.mock.calls[0][1]).toBe('/playlists/p2');
|
||||
expect(api.sendJson.mock.calls[0][3]).toEqual({
|
||||
'If-Match': '"b.0.1"',
|
||||
});
|
||||
});
|
||||
|
||||
it('names the version of the copy a mutation was built on', async () => {
|
||||
const { api, store } = setup();
|
||||
const portal = { _id: 'p2', title: 'Portal', macAddress: 'm' };
|
||||
api.request
|
||||
.mockResolvedValueOnce(fakeResponse(200, portal, '"b.0.1"'))
|
||||
// Another device wrote; a later read sees its version.
|
||||
.mockResolvedValueOnce(
|
||||
fakeResponse(200, { ...portal, title: 'Theirs' }, '"b.0.2"')
|
||||
);
|
||||
const older = await store.dbGetAppPlaylist('p2');
|
||||
await store.dbGetAppPlaylist('p2');
|
||||
if (!older) throw new Error('expected a document');
|
||||
api.sendJson.mockResolvedValueOnce(fakeResponse(200, {}, '"b.0.3"'));
|
||||
|
||||
await store.dbUpsertAppPlaylist({ ...older, title: 'Mine' });
|
||||
|
||||
expect(api.sendJson.mock.calls[0][3]).toEqual({
|
||||
'If-Match': '"b.0.1"',
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps checking a playlist after another one's refusal", async () => {
|
||||
const { api, store } = setup();
|
||||
// Both playlists are at revision 1 of the same boot: one ETag.
|
||||
for (const id of ['p1', 'p2']) {
|
||||
api.request.mockResolvedValueOnce(
|
||||
fakeResponse(200, { ...doc, _id: id }, '"b.0.1"')
|
||||
);
|
||||
}
|
||||
const first = await store.dbGetAppPlaylist('p1');
|
||||
const second = await store.dbGetAppPlaylist('p2');
|
||||
if (!first || !second) throw new Error('expected documents');
|
||||
api.sendJson.mockRejectedValueOnce(
|
||||
new ServerApiError(412, 'conflict', 'changed')
|
||||
);
|
||||
await expect(
|
||||
store.dbUpsertAppPlaylist({ ...first, title: 'A' })
|
||||
).rejects.toBeInstanceOf(PlaylistWriteConflict);
|
||||
|
||||
api.sendJson.mockResolvedValueOnce(putResponse('"b.0.2"', '"b.0.1"'));
|
||||
await store.dbUpsertAppPlaylist({ ...second, title: 'B' });
|
||||
|
||||
expect(api.sendJson.mock.calls[1][3]).toEqual({
|
||||
'If-Match': '"b.0.1"',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a document without an id', async () => {
|
||||
const { api, store } = setup();
|
||||
|
||||
await expect(
|
||||
store.dbUpsertAppPlaylist({ title: 'Orphan' } as Playlist)
|
||||
).rejects.toThrow('without an id');
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes imports as they are', async () => {
|
||||
const { api, store } = setup();
|
||||
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
|
||||
await store.dbGetAppPlaylist('p1');
|
||||
api.sendJson.mockResolvedValue(fakeResponse(200, {}, '"b.0.2"'));
|
||||
|
||||
await store.dbUpsertAppPlaylists([{ ...doc, title: 'Imported' }]);
|
||||
|
||||
expect(api.sendJson.mock.calls[0][3]).toEqual({});
|
||||
});
|
||||
|
||||
it('runs deletes through the user-data hold', async () => {
|
||||
const order: string[] = [];
|
||||
const api = {
|
||||
request: jest.fn(async (path: string) => {
|
||||
order.push(`DELETE ${path}`);
|
||||
return fakeResponse(200, { success: true });
|
||||
}),
|
||||
getJson: jest.fn(),
|
||||
readJson: jest.fn(),
|
||||
sendJson: jest.fn(),
|
||||
};
|
||||
const store = new ServerPlaylistDocumentStore(
|
||||
api as never,
|
||||
async (playlistId, remove) => {
|
||||
order.push(`hold ${playlistId}`);
|
||||
await remove();
|
||||
order.push(`release ${playlistId}`);
|
||||
}
|
||||
);
|
||||
|
||||
await store.dbDeletePlaylist('p1');
|
||||
await store.dbDeleteAllPlaylists();
|
||||
|
||||
expect(order).toEqual([
|
||||
'hold p1',
|
||||
'DELETE /playlists/p1',
|
||||
'release p1',
|
||||
'hold undefined',
|
||||
'DELETE /playlists',
|
||||
'release undefined',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
SERVER_PAYLOAD_FIELDS,
|
||||
SERVER_REPLACED_ETAG_HEADER,
|
||||
type ElectronBridgeCountResult,
|
||||
type ElectronBridgeResult,
|
||||
type Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type PlaylistDocumentStore,
|
||||
PlaylistWriteConflict,
|
||||
} from '../playlist-storage-backend';
|
||||
import { ServerApiError, type ServerApiClient } from './server-api-client';
|
||||
|
||||
/** The version a served copy came from; JSON never sends it. */
|
||||
const SERVED_VERSION = Symbol('servedVersion');
|
||||
|
||||
function servedVersion(playlist: Playlist): string | null {
|
||||
const version = (playlist as unknown as Record<symbol, unknown>)[
|
||||
SERVED_VERSION
|
||||
];
|
||||
return typeof version === 'string' ? version : null;
|
||||
}
|
||||
|
||||
interface CachedDocument {
|
||||
readonly etag: string;
|
||||
readonly document: Playlist;
|
||||
}
|
||||
|
||||
type ServerApi = Pick<
|
||||
ServerApiClient,
|
||||
'request' | 'getJson' | 'readJson' | 'sendJson'
|
||||
>;
|
||||
|
||||
/**
|
||||
* `PlaylistDocumentStore` over the server storage API. Full documents are
|
||||
* cached by ETag; every read hands out a private copy and remembers which
|
||||
* ETag its payload objects came from, so an upsert whose payload is one of
|
||||
* them omits it (`keepPayload=1`). `PlaylistsService` builds a new object
|
||||
* per mutation, so cached documents are never mutated in place.
|
||||
*/
|
||||
export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
private readonly cache = new Map<string, CachedDocument>();
|
||||
/** `id|version` a write was refused on (another device wrote since). */
|
||||
private readonly refused = new Set<string>();
|
||||
/** Served payload object → the ETag of the document it came from. */
|
||||
private readonly servedPayloads = new WeakMap<object, string>();
|
||||
|
||||
constructor(
|
||||
private readonly api: ServerApi,
|
||||
/**
|
||||
* Runs the server delete of a playlist (without an id, of every
|
||||
* playlist), so user-data writes cannot land after it.
|
||||
*/
|
||||
private readonly deleting: (
|
||||
playlistId: string | undefined,
|
||||
remove: () => Promise<unknown>
|
||||
) => Promise<void> = async (_, remove) => void (await remove())
|
||||
) {}
|
||||
|
||||
dbGetAppPlaylists(): Promise<Playlist[]> {
|
||||
return this.api.getJson<Playlist[]>('/playlists');
|
||||
}
|
||||
|
||||
dbGetAppPlaylistMetas(): Promise<Playlist[]> {
|
||||
return this.api.getJson<Playlist[]>('/playlists?view=meta');
|
||||
}
|
||||
|
||||
async dbGetAppPlaylist(playlistId: string): Promise<Playlist | null> {
|
||||
const cached = this.cache.get(playlistId);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.api.request(
|
||||
`/playlists/${encodeURIComponent(playlistId)}`,
|
||||
cached ? { headers: { 'If-None-Match': cached.etag } } : {}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError && error.status === 404) {
|
||||
this.cache.delete(playlistId);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status === 304 && cached) {
|
||||
return this.serve(cached.document, cached.etag);
|
||||
}
|
||||
const document = await this.api.readJson<Playlist>(response);
|
||||
const etag = this.remember(playlistId, response, document);
|
||||
return this.serve(document, etag);
|
||||
}
|
||||
|
||||
/** A mutation: built on the version it read, refused if it changed. */
|
||||
dbUpsertAppPlaylist(playlist: Playlist): Promise<ElectronBridgeResult> {
|
||||
return this.upsert(playlist, true);
|
||||
}
|
||||
|
||||
/** Imports and migrations: written as they are, outside any retry. */
|
||||
async dbUpsertAppPlaylists(
|
||||
playlists: Playlist[]
|
||||
): Promise<ElectronBridgeCountResult> {
|
||||
for (const playlist of playlists) {
|
||||
await this.upsert(playlist, false);
|
||||
}
|
||||
return { success: true, count: playlists.length };
|
||||
}
|
||||
|
||||
async dbDeletePlaylist(playlistId: string): Promise<ElectronBridgeResult> {
|
||||
await this.deleting(playlistId, () =>
|
||||
this.api.request(`/playlists/${encodeURIComponent(playlistId)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
);
|
||||
this.cache.delete(playlistId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async dbDeleteAllPlaylists(): Promise<ElectronBridgeResult> {
|
||||
await this.deleting(undefined, () =>
|
||||
this.api.request('/playlists', { method: 'DELETE' })
|
||||
);
|
||||
this.cache.clear();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async dbGetAppState(key: string): Promise<string | null> {
|
||||
const body = await this.api.getJson<{ value: string | null }>(
|
||||
`/app-state/${encodeURIComponent(key)}`
|
||||
);
|
||||
return body.value;
|
||||
}
|
||||
|
||||
async dbSetAppState(
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<ElectronBridgeResult> {
|
||||
await this.api.sendJson(
|
||||
'PUT',
|
||||
`/app-state/${encodeURIComponent(key)}`,
|
||||
{
|
||||
value,
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async upsert(
|
||||
playlist: Playlist,
|
||||
conditional: boolean
|
||||
): Promise<ElectronBridgeResult> {
|
||||
const id = playlist?._id;
|
||||
if (!id) {
|
||||
// A mutation that ran after its playlist was deleted.
|
||||
throw new Error('A playlist without an id cannot be stored.');
|
||||
}
|
||||
const path = `/playlists/${encodeURIComponent(id)}`;
|
||||
const payloadEtag = this.servedPayloadEtag(playlist);
|
||||
// The version it was read at: of its payload, else of the copy it
|
||||
// was built from (portal documents have no payload). A retry on the
|
||||
// same copy, not read again after a refusal, writes as before.
|
||||
const read = payloadEtag ?? servedVersion(playlist);
|
||||
const headers: Record<string, string> =
|
||||
conditional && read && !this.refused.has(`${id}|${read}`)
|
||||
? { 'If-Match': read }
|
||||
: {};
|
||||
try {
|
||||
if (payloadEtag !== null) {
|
||||
const response = await this.keepPayload(
|
||||
playlist,
|
||||
payloadEtag,
|
||||
headers
|
||||
);
|
||||
if (response) {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const response = await this.api.sendJson(
|
||||
'PUT',
|
||||
path,
|
||||
playlist,
|
||||
payloadEtag === null ? headers : {}
|
||||
);
|
||||
this.remember(id, response, playlist);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError && error.status === 412) {
|
||||
this.refused.add(`${id}|${read}`);
|
||||
this.cache.delete(id);
|
||||
throw new PlaylistWriteConflict(id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A create-only upload (the move from browser storage): false when the
|
||||
* server has a playlist with that id already, uploaded meanwhile by
|
||||
* another tab or device, which the caller merges into instead.
|
||||
*/
|
||||
async dbCreateAppPlaylist(playlist: Playlist): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.api.sendJson(
|
||||
'PUT',
|
||||
`/playlists/${encodeURIComponent(playlist._id)}`,
|
||||
playlist,
|
||||
{ 'If-None-Match': '*' }
|
||||
);
|
||||
this.remember(playlist._id, response, playlist);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError && error.status === 412) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` when the server has no payload to keep (write it in full). */
|
||||
private async keepPayload(
|
||||
playlist: Playlist,
|
||||
payloadEtag: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<Response | null> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.api.sendJson(
|
||||
'PUT',
|
||||
`/playlists/${encodeURIComponent(playlist._id)}?keepPayload=1`,
|
||||
withoutPayload(playlist),
|
||||
headers
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ServerApiError &&
|
||||
error.code === 'payload_missing'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// The server kept its stored payload; it is ours only if nothing
|
||||
// replaced it since we read it.
|
||||
const replaced = response.headers.get(SERVER_REPLACED_ETAG_HEADER);
|
||||
if (replaced && sameEtag(replaced, payloadEtag)) {
|
||||
this.remember(playlist._id, response, playlist);
|
||||
} else {
|
||||
this.cache.delete(playlist._id);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private remember(
|
||||
id: string,
|
||||
response: Response,
|
||||
document: Playlist
|
||||
): string | null {
|
||||
const etag = response.headers.get('etag');
|
||||
if (etag) {
|
||||
this.cache.set(id, { etag, document });
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
}
|
||||
return etag;
|
||||
}
|
||||
|
||||
private serve(document: Playlist, etag: string | null): Playlist {
|
||||
const copy = structuredClone(document);
|
||||
if (!etag) {
|
||||
return copy;
|
||||
}
|
||||
// Spreads copy it along, so a mutation built on this copy names it.
|
||||
(copy as unknown as Record<symbol, string>)[SERVED_VERSION] = etag;
|
||||
const record = copy as unknown as Record<string, unknown>;
|
||||
for (const field of SERVER_PAYLOAD_FIELDS) {
|
||||
const value = record[field];
|
||||
if (value && typeof value === 'object') {
|
||||
this.servedPayloads.set(value, etag);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** The ETag every payload field of `playlist` was served under, if any. */
|
||||
private servedPayloadEtag(playlist: Playlist): string | null {
|
||||
const record = playlist as unknown as Record<string, unknown>;
|
||||
const etags = new Set<string | undefined>();
|
||||
for (const field of SERVER_PAYLOAD_FIELDS) {
|
||||
const value = record[field];
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
etags.add(
|
||||
typeof value === 'object'
|
||||
? this.servedPayloads.get(value)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
const [etag] = etags;
|
||||
return etags.size === 1 && etag !== undefined ? etag : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** nginx's gzip turns ETags weak; the revision stays the same. */
|
||||
function sameEtag(left: string, right: string): boolean {
|
||||
return left.replace(/^W\//, '') === right.replace(/^W\//, '');
|
||||
}
|
||||
|
||||
function withoutPayload(playlist: Playlist): Record<string, unknown> {
|
||||
const copy = { ...playlist } as Record<string, unknown>;
|
||||
for (const field of SERVER_PAYLOAD_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistStorageBackend } from '../playlist-storage-backend';
|
||||
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
|
||||
import { writeServerAccessToken } from '../server-access-token';
|
||||
import { takeSlowStart } from '../server-slow-start';
|
||||
import { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
import { ServerStorageSession } from './server-storage-session';
|
||||
|
||||
describe('ServerStorageSession', () => {
|
||||
const fetchMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
// start() arms a 3 s startup race and a 30 s retry interval.
|
||||
jest.useFakeTimers();
|
||||
(globalThis as { fetch: unknown }).fetch = fetchMock;
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ entries: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
fetchMock.mockReset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
function setup(auth: 'none' | 'token', startedOffline = false) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([{ path: 'server-sign-in', children: [] }]),
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsSqlite: false },
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: {
|
||||
baseUrl: '/api',
|
||||
startedOffline,
|
||||
instance: {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
session: TestBed.inject(ServerStorageSession),
|
||||
backend: TestBed.inject(PlaylistStorageBackend),
|
||||
status: TestBed.inject(ServerStorageStatusService),
|
||||
};
|
||||
}
|
||||
|
||||
it('switches playlists to the server and hydrates user data', async () => {
|
||||
const { session, backend, status } = setup('none');
|
||||
|
||||
await session.start();
|
||||
|
||||
expect(backend.kind()).toBe('server');
|
||||
expect(status.state()).toBe('online');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/v1/user-state',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('stops at unauthorized when a token is required but missing', async () => {
|
||||
const { session, status } = setup('token');
|
||||
|
||||
await session.start();
|
||||
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the page closed on page hide also while sign-in is pending', async () => {
|
||||
const { session } = setup('token');
|
||||
const close = jest.spyOn(session.userData, 'close');
|
||||
|
||||
await session.start();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts with a stored token', async () => {
|
||||
writeServerAccessToken('t', 'i-1');
|
||||
const { session, status } = setup('token');
|
||||
|
||||
await session.start();
|
||||
|
||||
expect(status.state()).toBe('online');
|
||||
});
|
||||
|
||||
it("asks for a token when the stored one is another instance's", async () => {
|
||||
writeServerAccessToken('t', 'i-2');
|
||||
const { session, status } = setup('token');
|
||||
|
||||
await session.start();
|
||||
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('retries a write the server refused while it stays online', async () => {
|
||||
const { session, status } = setup('none');
|
||||
await session.start();
|
||||
const ok = fetchMock.getMockImplementation();
|
||||
fetchMock.mockImplementation(
|
||||
async (url: string, init?: { method?: string }) =>
|
||||
init?.method === 'PUT'
|
||||
? {
|
||||
status: 500,
|
||||
ok: false,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ code: 'x', message: 'down' }),
|
||||
}
|
||||
: ok?.(url, init)
|
||||
);
|
||||
|
||||
session.userData.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['x'] })
|
||||
);
|
||||
await session.userData.flush();
|
||||
expect(status.state()).toBe('online');
|
||||
expect(session.userData.hasUnsentWrites()).toBe(true);
|
||||
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ entries: {}, tag: 't' }),
|
||||
}));
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(session.userData.hasUnsentWrites()).toBe(false);
|
||||
});
|
||||
|
||||
it('reloads a page back from the back-forward cache whose writes another tab took', async () => {
|
||||
const { session } = setup('none');
|
||||
const reload = jest
|
||||
.spyOn(session as unknown as { reloadPage(): void }, 'reloadPage')
|
||||
.mockImplementation(() => undefined);
|
||||
await session.start();
|
||||
const resumed = jest
|
||||
.spyOn(session.userData, 'resumed')
|
||||
.mockReturnValue(true);
|
||||
const pageshow = (persisted: boolean) =>
|
||||
window.dispatchEvent(
|
||||
Object.assign(new Event('pageshow'), { persisted })
|
||||
);
|
||||
|
||||
pageshow(false);
|
||||
pageshow(true);
|
||||
expect(resumed).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
resumed.mockReturnValue(false);
|
||||
pageshow(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// So does a background tab the browser suspended as long.
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'visible',
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
delete (document as { visibilityState?: unknown }).visibilityState;
|
||||
|
||||
// And its retry timer, which may run before it is shown again.
|
||||
jest.advanceTimersByTime(30_000);
|
||||
expect(reload).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('goes to sign-in on every sign-out, also when already signed out', async () => {
|
||||
const { session } = setup('none');
|
||||
await session.start();
|
||||
const navigate = jest
|
||||
.spyOn(TestBed.inject(Router), 'navigateByUrl')
|
||||
.mockResolvedValue(false);
|
||||
|
||||
session.signOut();
|
||||
// A guard kept the page (unsaved Settings); the user signs out again.
|
||||
session.signOut();
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(2);
|
||||
expect(navigate).toHaveBeenLastCalledWith('/server-sign-in');
|
||||
});
|
||||
|
||||
describe('after losing the server', () => {
|
||||
const answer = (body: unknown) => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
it('checks the instance before the token and writes go out again', async () => {
|
||||
writeServerAccessToken('t', 'i-1');
|
||||
const { session, status } = setup('token');
|
||||
await session.start();
|
||||
expect(status.accessToken()).toBe('t');
|
||||
|
||||
status.reportOffline();
|
||||
|
||||
expect(status.accessToken()).toBeNull();
|
||||
fetchMock.mockClear();
|
||||
await expect(
|
||||
session.api.sendJson('PUT', '/user-state/a/b', { value: 1 })
|
||||
).rejects.toThrow();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
answer({ instanceId: 'i-1', entries: {} })
|
||||
);
|
||||
await session.refresh();
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/instance');
|
||||
expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty(
|
||||
'Authorization'
|
||||
);
|
||||
expect(status.accessToken()).toBe('t');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/v1/user-state',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('checks only the instance at once when it goes offline', async () => {
|
||||
const { session, status } = setup('none');
|
||||
await session.start();
|
||||
// The instance answers, its user data does not.
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
url.endsWith('/instance')
|
||||
? answer({ instanceId: 'i-1' })
|
||||
: { status: 503, ok: false, headers: new Headers() }
|
||||
);
|
||||
fetchMock.mockClear();
|
||||
|
||||
status.reportOffline();
|
||||
TestBed.tick();
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(status.isVerified()).toBe(true);
|
||||
// A failing read waits for the retry interval, not a loop.
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/v1/instance',
|
||||
]);
|
||||
});
|
||||
|
||||
it('checks at once only once per retry interval', async () => {
|
||||
const { session, status } = setup('none');
|
||||
await session.start();
|
||||
// Headers arrive, the body is cut off: success, then offline.
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => {
|
||||
throw new TypeError('body cut off');
|
||||
},
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
|
||||
status.reportOffline();
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
TestBed.tick();
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps checking an unverified page a late answer set online', async () => {
|
||||
writeServerAccessToken('t', 'i-1');
|
||||
const { session, status } = setup('token');
|
||||
await session.start();
|
||||
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||
status.reportOffline();
|
||||
TestBed.tick();
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
// A request sent before the outage is answered late.
|
||||
status.reportSuccess();
|
||||
expect(status.state()).toBe('online');
|
||||
expect(status.isVerified()).toBe(false);
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
answer({ instanceId: 'i-1', entries: {} })
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(30_000);
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/instance');
|
||||
expect(status.isVerified()).toBe(true);
|
||||
});
|
||||
|
||||
it('reloads when another instance answers at the address', async () => {
|
||||
const { session, status } = setup('none');
|
||||
const reload = jest
|
||||
.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
await session.start();
|
||||
status.reportOffline();
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(answer({ instanceId: 'other' }));
|
||||
|
||||
await session.refresh();
|
||||
|
||||
expect(reload).toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
'/api/v1/user-state',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after an offline start', () => {
|
||||
it('stays offline until the server answers, then reloads once', async () => {
|
||||
const { session, status } = setup('none', true);
|
||||
const reload = jest
|
||||
.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
await session.start();
|
||||
expect(status.state()).toBe('offline');
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ entries: {} }),
|
||||
});
|
||||
await session.refresh();
|
||||
await session.refresh();
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
// The reloaded page's probe waits as long as this request did.
|
||||
expect(takeSlowStart()).toBe(true);
|
||||
});
|
||||
|
||||
it('sends no write before the reload checks the instance', async () => {
|
||||
// A write this tab's last page load could not send.
|
||||
sessionStorage.setItem('iptvnator-server:page:i-1', 'last');
|
||||
localStorage.setItem(
|
||||
'iptvnator-server:unsent-writes:i-1:last',
|
||||
JSON.stringify([
|
||||
{
|
||||
namespace: 'xtream-favorites',
|
||||
playlistId: 'p1',
|
||||
value: ['x'],
|
||||
},
|
||||
])
|
||||
);
|
||||
const { session } = setup('none', true);
|
||||
jest.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
).mockImplementation(() => undefined);
|
||||
|
||||
await session.start();
|
||||
session.userData.flushWithKeepalive();
|
||||
await session.userData.flush();
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.map(([url, init]) => [
|
||||
url,
|
||||
init?.method ?? 'GET',
|
||||
])
|
||||
).toEqual([['/api/v1/instance', 'GET']]);
|
||||
expect(session.userData.hasUnsentWrites()).toBe(true);
|
||||
});
|
||||
|
||||
it('checks the instance before a token may go out', async () => {
|
||||
const { session, status } = setup('token', true);
|
||||
const reload = jest
|
||||
.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
await session.start();
|
||||
writeServerAccessToken('t', 'i-1');
|
||||
expect(status.accessToken()).toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ instanceId: 'other' }),
|
||||
});
|
||||
await expect(session.ensureVerified()).rejects.toThrow();
|
||||
expect(reload).toHaveBeenCalled();
|
||||
expect(status.accessToken()).toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ instanceId: 'i-1' }),
|
||||
});
|
||||
await session.ensureVerified();
|
||||
expect(status.accessToken()).toBe('t');
|
||||
});
|
||||
|
||||
it('never reloads after a normal start', async () => {
|
||||
const { session } = setup('none');
|
||||
const reload = jest
|
||||
.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await session.start();
|
||||
await session.refresh();
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { effect, inject, Injectable, untracked } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import type {
|
||||
ServerInstanceInfo,
|
||||
ServerStorageStatusResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistStorageBackend } from '../playlist-storage-backend';
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
} from '../server-access-token';
|
||||
import {
|
||||
SERVER_SIGN_IN_PATH,
|
||||
ServerStorageStatusService,
|
||||
} from '../server-storage-status.service';
|
||||
import {
|
||||
createBrowserSessionStorage,
|
||||
createBrowserUserDataStorage,
|
||||
} from '../user-data-storage';
|
||||
import { markSlowStart } from '../server-slow-start';
|
||||
import { ServerApiClient, type ServerApiError } from './server-api-client';
|
||||
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
import { ServerUserDataStorage } from './server-user-data-storage';
|
||||
|
||||
const RETRY_INTERVAL_MS = 30_000;
|
||||
const STARTUP_HYDRATE_WAIT_MS = 3_000;
|
||||
const NOTICE_REPEAT_MS = 5 * 60_000;
|
||||
|
||||
/** Server mode for this page: created only when the probe found server storage. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerStorageSession {
|
||||
private readonly config = inject(SERVER_STORAGE_CONFIG);
|
||||
private readonly status = inject(ServerStorageStatusService);
|
||||
private readonly backend = inject(PlaylistStorageBackend);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly router = inject(Router);
|
||||
private reloading = false;
|
||||
private lastQuickCheck = 0;
|
||||
private lastNotice = { key: '', at: 0 };
|
||||
|
||||
readonly api = new ServerApiClient({
|
||||
baseUrl: this.config.baseUrl,
|
||||
status: this.status,
|
||||
onWriteFailure: (error) => this.announceWriteFailure(error),
|
||||
// A remembered instance, or one that went offline, may answer as
|
||||
// another database: writes and the token wait until it is checked.
|
||||
holdWrites: () => !this.status.isVerified(),
|
||||
accessToken: () => this.status.accessToken(),
|
||||
});
|
||||
readonly documentStore = new ServerPlaylistDocumentStore(
|
||||
this.api,
|
||||
(playlistId, remove) => this.userData.deleting(playlistId, remove)
|
||||
);
|
||||
readonly userData = new ServerUserDataStorage(
|
||||
this.api,
|
||||
// Lazy: a browser that denies storage must not stop the app here.
|
||||
createBrowserUserDataStorage(),
|
||||
() => this.announceWriteFailure(),
|
||||
this.config.instance.instanceId ?? undefined,
|
||||
createBrowserSessionStorage()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (
|
||||
this.status.state() === 'unauthorized' &&
|
||||
!this.router.url.startsWith(SERVER_SIGN_IN_PATH)
|
||||
) {
|
||||
void this.router.navigateByUrl(SERVER_SIGN_IN_PATH);
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (
|
||||
this.status.state() === 'offline' &&
|
||||
!this.config.startedOffline
|
||||
) {
|
||||
untracked(() => this.checkSoon());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** App initializer: must run before the first playlist read. */
|
||||
async start(): Promise<void> {
|
||||
this.backend.useServerStore(this.documentStore);
|
||||
this.status.activate(
|
||||
this.config.instance,
|
||||
() => void this.refresh(),
|
||||
!this.config.startedOffline
|
||||
);
|
||||
// Also while sign-in is pending: this page may already hold another
|
||||
// page load's writes, so it must mark itself closed on page hide.
|
||||
this.listen();
|
||||
if (
|
||||
this.config.instance.auth === 'token' &&
|
||||
!readServerAccessToken(this.config.instance.instanceId)
|
||||
) {
|
||||
this.status.reportUnauthorized();
|
||||
return;
|
||||
}
|
||||
await Promise.race([
|
||||
this.refresh(),
|
||||
new Promise((resolve) =>
|
||||
setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gone offline, the token and writes wait for the instance check:
|
||||
* after a blip it passes at once, not at the next retry. Only the
|
||||
* check, and once per retry interval: a read or a check that keeps
|
||||
* failing (a body cut off after its headers) waits for the retry.
|
||||
*/
|
||||
private checkSoon(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastQuickCheck < RETRY_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this.lastQuickCheck = now;
|
||||
void this.ensureVerified().catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Re-reads synced user data; also the offline retry. */
|
||||
async refresh(): Promise<void> {
|
||||
if (this.config.startedOffline) {
|
||||
await this.reloadOnceReachable();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Back from offline: the instance first, as the read flushes.
|
||||
await this.ensureVerified();
|
||||
await this.userData.hydrate();
|
||||
} catch {
|
||||
// The API client already reported offline or unauthorized.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing was read at start: once the server answers, boot again, so
|
||||
* its instance is checked before anything is sent.
|
||||
*/
|
||||
private async reloadOnceReachable(): Promise<void> {
|
||||
try {
|
||||
await this.api.getJson<ServerInstanceInfo>('/instance');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this.reloading) {
|
||||
this.reloading = true;
|
||||
// It answers, maybe slower than the start probe waits.
|
||||
markSlowStart();
|
||||
this.reloadPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a token or a write goes out from a page started from memory,
|
||||
* or one that went offline: the instance answering at the address must
|
||||
* be this page's. Another one reloads the page, which starts against it.
|
||||
*/
|
||||
async ensureVerified(): Promise<void> {
|
||||
if (this.status.isVerified()) {
|
||||
return;
|
||||
}
|
||||
const { instanceId } =
|
||||
await this.api.getJson<ServerInstanceInfo>('/instance');
|
||||
if (instanceId !== this.config.instance.instanceId) {
|
||||
this.reloadPage();
|
||||
throw new Error('Another server answers at this address.');
|
||||
}
|
||||
this.status.markVerified();
|
||||
}
|
||||
|
||||
fetchStatus(): Promise<ServerStorageStatusResponse> {
|
||||
return this.api.getJson<ServerStorageStatusResponse>(
|
||||
'/instance/status'
|
||||
);
|
||||
}
|
||||
|
||||
signOut(): void {
|
||||
clearServerAccessToken();
|
||||
this.status.reportUnauthorized();
|
||||
// Also when signed out already: a guard (unsaved Settings) may have
|
||||
// kept the page here the first time.
|
||||
void this.router.navigateByUrl(SERVER_SIGN_IN_PATH);
|
||||
}
|
||||
|
||||
private listen(): void {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.userData.flushWithKeepalive();
|
||||
} else if (!this.userData.resumed()) {
|
||||
// Suspended long enough for another tab to take its writes.
|
||||
this.reloadPage();
|
||||
} else {
|
||||
void this.refresh();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
// Back from the back-forward cache: another tab may have taken
|
||||
// its writes over while it was frozen; then this page reloads.
|
||||
if (event.persisted && !this.userData.resumed()) {
|
||||
this.reloadPage();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
this.userData.flushWithKeepalive();
|
||||
// Not into the back-forward cache: the page is gone for good.
|
||||
if (!event.persisted) {
|
||||
this.userData.close();
|
||||
}
|
||||
});
|
||||
setInterval(() => {
|
||||
// A suspended tab's timers can run before it is shown again.
|
||||
if (!this.userData.resumed()) {
|
||||
this.reloadPage();
|
||||
return;
|
||||
}
|
||||
this.userData.touch();
|
||||
// Also online: a write the server refused (a 5xx) or a first
|
||||
// read that failed is not retried by anything else.
|
||||
// Online but unverified too: a late answer to a request sent
|
||||
// before the outage may have set it online again.
|
||||
const state = this.status.state();
|
||||
if (
|
||||
state === 'offline' ||
|
||||
(state === 'online' &&
|
||||
(this.userData.needsSync() || !this.status.isVerified()))
|
||||
) {
|
||||
void this.refresh();
|
||||
}
|
||||
}, RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Separate so tests can observe it. */
|
||||
protected reloadPage(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private announceWriteFailure(error?: ServerApiError): void {
|
||||
const key =
|
||||
error?.status === 413
|
||||
? 'SERVER_STORAGE.TOO_LARGE'
|
||||
: 'SERVER_STORAGE.WRITE_FAILED';
|
||||
// Retries must not repeat the same notice every 30 s.
|
||||
const now = Date.now();
|
||||
if (
|
||||
key === this.lastNotice.key &&
|
||||
now - this.lastNotice.at < NOTICE_REPEAT_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastNotice = { key, at: now };
|
||||
this.snackBar.open(this.translate.instant(key), undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface ServerStorageConfig {
|
||||
readonly instance: ServerInstanceInfo;
|
||||
/** Backend base URL as the PWA resolves it, e.g. `/api`. */
|
||||
readonly baseUrl: string;
|
||||
/**
|
||||
* The start-up probe got no answer and the instance came from this
|
||||
* browser's memory of the server: the page reloads once it answers.
|
||||
*/
|
||||
readonly startedOffline?: boolean;
|
||||
}
|
||||
|
||||
export const SERVER_STORAGE_CONFIG = new InjectionToken<ServerStorageConfig>(
|
||||
'SERVER_STORAGE_CONFIG'
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
markUnconfirmed,
|
||||
othersUnconfirmed,
|
||||
UNCONFIRMED_TTL_MS,
|
||||
} from './server-unsent-writes';
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
keys: () => [...values.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
describe('unconfirmed entries across tabs', () => {
|
||||
it('lists other live tabs only and lets a tab clear just its record', () => {
|
||||
const local = memoryStorage();
|
||||
markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 1_000);
|
||||
markUnconfirmed(local, 'db', 'b', ['xtream-favorites|p2'], 1_000);
|
||||
|
||||
expect([...othersUnconfirmed(local, 'db', 'a', 2_000)]).toEqual([
|
||||
'xtream-favorites|p2',
|
||||
]);
|
||||
|
||||
markUnconfirmed(local, 'db', 'b', [], 3_000);
|
||||
expect([...othersUnconfirmed(local, 'db', 'b', 3_000)]).toEqual([
|
||||
'xtream-favorites|p1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores the record of a tab that is gone', () => {
|
||||
const local = memoryStorage();
|
||||
markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 0);
|
||||
|
||||
expect(
|
||||
othersUnconfirmed(local, 'db', 'b', UNCONFIRMED_TTL_MS + 1).size
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import {
|
||||
isServerUserStateNamespace,
|
||||
SERVER_USER_STATE_NAMESPACES,
|
||||
type ServerUserStateNamespace,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import { cacheKey } from './server-user-data-cache';
|
||||
|
||||
/**
|
||||
* User-data writes the server has not confirmed, kept in `localStorage`
|
||||
* per page load: a failed write, or a page-hide write whose answer the page
|
||||
* did not live to see, is sent again after a reload (by the same tab) or
|
||||
* after the tab closed (by another, see `orphanedPages`), instead of being
|
||||
* replaced by the server's older value.
|
||||
*/
|
||||
export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes';
|
||||
/** Which server database the cached user data belongs to. */
|
||||
export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner';
|
||||
/** The tab's current page load, in the tab's own storage. */
|
||||
|
||||
const writesKey = (instanceId: string, page: string) =>
|
||||
`${UNSENT_WRITES_KEY}:${instanceId}:${page}`;
|
||||
/**
|
||||
* Which entries each page load has not had confirmed yet, so a re-read in
|
||||
* one tab keeps the cache values another tab is still sending. Each page
|
||||
* load writes only its own key, so tabs never overwrite each other's
|
||||
* record. A live tab renews it; one older than `UNCONFIRMED_TTL_MS`
|
||||
* belongs to a tab that is gone.
|
||||
*/
|
||||
export const UNCONFIRMED_KEY = 'iptvnator-server:unconfirmed';
|
||||
export const UNCONFIRMED_TTL_MS = 10 * 60 * 1000;
|
||||
const recordKey = (instanceId: string, page: string) =>
|
||||
`${UNCONFIRMED_KEY}:${instanceId}:${page}`;
|
||||
|
||||
/** Edits made before the first read of a visit, kept as before → after. */
|
||||
export const DEFERRED_EDITS_KEY = 'iptvnator-server:deferred-edits';
|
||||
const deferredKey = (instanceId: string, page: string) =>
|
||||
`${DEFERRED_EDITS_KEY}:${instanceId}:${page}`;
|
||||
|
||||
/**
|
||||
* Another database behind this address (a new volume, another backend):
|
||||
* its cache and unsent writes belong to the old one and must not be sent
|
||||
* to or shown for this one.
|
||||
*/
|
||||
export function claimCache(local: UserDataStorage, instanceId: string): void {
|
||||
try {
|
||||
const owner = local.getItem(CACHE_OWNER_KEY);
|
||||
if (owner === instanceId) {
|
||||
return;
|
||||
}
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
// Also the unscoped keys of earlier builds.
|
||||
local.removeItem(`iptvnator-server:${namespace}`);
|
||||
if (owner) {
|
||||
local.removeItem(cacheKey(owner, namespace));
|
||||
}
|
||||
}
|
||||
if (owner) {
|
||||
for (const prefix of PAGE_PREFIXES) {
|
||||
for (const page of pagesUnder(local, `${prefix}:${owner}:`)) {
|
||||
local.removeItem(`${prefix}:${owner}:${page}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
local.setItem(CACHE_OWNER_KEY, instanceId);
|
||||
} catch {
|
||||
// Storage unavailable: nothing cached to mix up.
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved edits: JSON drops an undefined side, which reads back as such. */
|
||||
type Steps = readonly {
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
readonly yields?: true;
|
||||
}[];
|
||||
|
||||
export interface UnsentWrite {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
/** `undefined` deletes the entry. */
|
||||
readonly value: unknown;
|
||||
/** The server version it is based on (see `PendingEntry.tag`). */
|
||||
readonly tag?: string | null;
|
||||
/** The edits since that version (see `PendingEntry.steps`). */
|
||||
readonly steps: Steps;
|
||||
}
|
||||
|
||||
interface StoredWrite {
|
||||
readonly namespace: string;
|
||||
readonly playlistId: string;
|
||||
readonly value?: unknown;
|
||||
readonly remove?: true;
|
||||
readonly tag?: string | null;
|
||||
readonly steps?: unknown;
|
||||
}
|
||||
|
||||
function readSteps(stored: unknown): Steps {
|
||||
return Array.isArray(stored)
|
||||
? stored
|
||||
.filter((step) => step && typeof step === 'object')
|
||||
.map(
|
||||
(step: {
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
yields?: unknown;
|
||||
}) => ({
|
||||
before: step.before,
|
||||
after: step.after,
|
||||
...(step.yields === true
|
||||
? { yields: true as const }
|
||||
: {}),
|
||||
})
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
const saveSteps = (steps: Steps) =>
|
||||
steps.map(({ before, after, yields }) => ({ before, after, yields }));
|
||||
|
||||
/**
|
||||
* Page loads whose record went stale: closed tabs nobody sends for. Saved
|
||||
* writes without any record are left alone: a tab that could save them but
|
||||
* not its record may still run, and only its own reload takes them over.
|
||||
*/
|
||||
export function orphanedPages(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string,
|
||||
now = Date.now()
|
||||
): string[] {
|
||||
return Object.entries(readRegistry(local, instanceId))
|
||||
.filter(
|
||||
([owner, record]) =>
|
||||
owner !== page &&
|
||||
!(now - Number(record.at) < UNCONFIRMED_TTL_MS)
|
||||
)
|
||||
.map(([owner]) => owner);
|
||||
}
|
||||
|
||||
/** Forgets a page load's saved writes, early edits and record. */
|
||||
export function removePage(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): void {
|
||||
try {
|
||||
local.removeItem(writesKey(instanceId, page));
|
||||
local.removeItem(deferredKey(instanceId, page));
|
||||
} catch {
|
||||
// Storage unavailable: nothing was saved there either.
|
||||
}
|
||||
markUnconfirmed(local, instanceId, page, []);
|
||||
}
|
||||
|
||||
export function readUnsentWrites(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): UnsentWrite[] {
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = JSON.parse(local.getItem(writesKey(instanceId, page)) ?? '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const writes: UnsentWrite[] = [];
|
||||
for (const write of Array.isArray(stored)
|
||||
? (stored as StoredWrite[])
|
||||
: []) {
|
||||
if (
|
||||
write &&
|
||||
isServerUserStateNamespace(write.namespace) &&
|
||||
typeof write.playlistId === 'string'
|
||||
) {
|
||||
writes.push({
|
||||
namespace: write.namespace,
|
||||
playlistId: write.playlistId,
|
||||
value: write.remove ? undefined : write.value,
|
||||
tag: write.tag,
|
||||
steps: readSteps(write.steps),
|
||||
});
|
||||
}
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
export function saveUnsentWrites(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string,
|
||||
writes: readonly UnsentWrite[]
|
||||
): boolean {
|
||||
try {
|
||||
if (writes.length === 0) {
|
||||
local.removeItem(writesKey(instanceId, page));
|
||||
return true;
|
||||
}
|
||||
const stored: StoredWrite[] = writes.map(
|
||||
({ namespace, playlistId, value, tag, steps }) => ({
|
||||
namespace,
|
||||
playlistId,
|
||||
...(value === undefined ? { remove: true } : { value }),
|
||||
...(tag === undefined ? {} : { tag }),
|
||||
steps: saveSteps(steps),
|
||||
})
|
||||
);
|
||||
local.setItem(writesKey(instanceId, page), JSON.stringify(stored));
|
||||
return true;
|
||||
} catch {
|
||||
// Storage unavailable or full: the queue stays in memory only.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** One write per entry, the newest by edit number. */
|
||||
export function newestWrites<T extends UnsentWrite & { readonly edit: number }>(
|
||||
writes: readonly T[]
|
||||
): T[] {
|
||||
const newest = new Map<string, T>();
|
||||
for (const write of writes) {
|
||||
const key = `${write.namespace}|${write.playlistId}`;
|
||||
const known = newest.get(key);
|
||||
if (!known || write.edit > known.edit) {
|
||||
newest.set(key, write);
|
||||
}
|
||||
}
|
||||
return [...newest.values()];
|
||||
}
|
||||
|
||||
export interface SavedDeferredEdit {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
readonly steps: Steps;
|
||||
}
|
||||
|
||||
export function readDeferredEdits(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): SavedDeferredEdit[] {
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = JSON.parse(
|
||||
local.getItem(deferredKey(instanceId, page)) ?? '[]'
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const edits: SavedDeferredEdit[] = [];
|
||||
for (const edit of Array.isArray(stored)
|
||||
? (stored as Record<string, unknown>[])
|
||||
: []) {
|
||||
const namespace = edit?.['namespace'];
|
||||
const playlistId = edit?.['playlistId'];
|
||||
if (
|
||||
typeof namespace === 'string' &&
|
||||
isServerUserStateNamespace(namespace) &&
|
||||
typeof playlistId === 'string'
|
||||
) {
|
||||
edits.push({
|
||||
namespace,
|
||||
playlistId,
|
||||
steps: readSteps(edit['steps']),
|
||||
});
|
||||
}
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
export function saveDeferredEdits(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string,
|
||||
edits: readonly SavedDeferredEdit[]
|
||||
): boolean {
|
||||
try {
|
||||
if (edits.length === 0) {
|
||||
local.removeItem(deferredKey(instanceId, page));
|
||||
return true;
|
||||
}
|
||||
local.setItem(
|
||||
deferredKey(instanceId, page),
|
||||
JSON.stringify(
|
||||
edits.map((edit) => ({
|
||||
namespace: edit.namespace,
|
||||
playlistId: edit.playlistId,
|
||||
steps: saveSteps(edit.steps),
|
||||
}))
|
||||
)
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// Storage unavailable or full: the edits stay in memory only.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `closed`: the page unloaded (not into the back-forward cache). */
|
||||
interface PageRecord {
|
||||
readonly at: number;
|
||||
readonly keys: string[];
|
||||
readonly closed?: boolean;
|
||||
}
|
||||
|
||||
/** Key prefixes of what a page load keeps, each followed by `:<instance>:<page>`. */
|
||||
const PAGE_PREFIXES = [UNCONFIRMED_KEY, UNSENT_WRITES_KEY, DEFERRED_EDITS_KEY];
|
||||
|
||||
/** The page loads of keys that start with `prefix`. */
|
||||
function pagesUnder(local: UserDataStorage, prefix: string): string[] {
|
||||
try {
|
||||
return (local.keys?.() ?? [])
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): PageRecord | null {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
local.getItem(recordKey(instanceId, page)) ?? 'null'
|
||||
) as PageRecord | null;
|
||||
return parsed && Array.isArray(parsed.keys) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRegistry(
|
||||
local: UserDataStorage,
|
||||
instanceId: string
|
||||
): Record<string, PageRecord> {
|
||||
const registry: Record<string, PageRecord> = Object.create(null);
|
||||
for (const page of pagesUnder(local, `${UNCONFIRMED_KEY}:${instanceId}:`)) {
|
||||
const record = readRecord(local, instanceId, page);
|
||||
if (record) {
|
||||
registry[page] = record;
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** Marks a page load's record as unloaded: a reload may take it over. */
|
||||
export function markClosed(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): void {
|
||||
const record = readRecord(local, instanceId, page);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
local.setItem(
|
||||
recordKey(instanceId, page),
|
||||
JSON.stringify({ ...record, closed: true })
|
||||
);
|
||||
} catch {
|
||||
// Storage full: the record goes stale instead.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a page load still runs: it has a fresh record and did not
|
||||
* unload. A duplicated tab copies the tab's page load but must not take
|
||||
* over its saved writes.
|
||||
*/
|
||||
export function pageIsLive(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
const record = readRecord(local, instanceId, page);
|
||||
return (
|
||||
!!record &&
|
||||
!record.closed &&
|
||||
now - Number(record.at) < UNCONFIRMED_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes only this page load's record (none without keys); true when one
|
||||
* now stands for it. A closed record stays closed: a page-hide write that
|
||||
* fails after `pagehide` rewrites it, and the reload must still see a page
|
||||
* that unloaded, not one that seems to run.
|
||||
*/
|
||||
export function markUnconfirmed(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
tabId: string,
|
||||
keys: readonly string[],
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
try {
|
||||
if (keys.length === 0) {
|
||||
local.removeItem(recordKey(instanceId, tabId));
|
||||
return false;
|
||||
}
|
||||
const closed = readRecord(local, instanceId, tabId)?.closed === true;
|
||||
local.setItem(
|
||||
recordKey(instanceId, tabId),
|
||||
JSON.stringify({
|
||||
at: now,
|
||||
keys: [...keys],
|
||||
...(closed ? { closed } : {}),
|
||||
})
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// Storage full: re-reads in other tabs lose this protection only.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a page load's record still stands (another tab may remove it). */
|
||||
export function hasRecord(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): boolean {
|
||||
try {
|
||||
return local.getItem(recordKey(instanceId, page)) !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Entry keys other live tabs have not had confirmed yet. */
|
||||
export function othersUnconfirmed(
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
tabId: string,
|
||||
now = Date.now()
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const [owner, record] of Object.entries(
|
||||
readRegistry(local, instanceId)
|
||||
)) {
|
||||
if (owner !== tabId && now - Number(record.at) < UNCONFIRMED_TTL_MS) {
|
||||
for (const key of record.keys) {
|
||||
keys.add(String(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
import { ServerApiError } from './server-api-client';
|
||||
import { keepPages } from './server-page-pointer';
|
||||
import {
|
||||
markClosed,
|
||||
markUnconfirmed,
|
||||
saveUnsentWrites,
|
||||
UNCONFIRMED_TTL_MS,
|
||||
} from './server-unsent-writes';
|
||||
import { ServerUserDataStorage } from './server-user-data-storage';
|
||||
import { fakeServer, memoryStorage } from './server-user-data.test-helpers';
|
||||
|
||||
/** Lets microtasks run until `condition` holds (a mocked call started). */
|
||||
async function until(condition: () => boolean): Promise<void> {
|
||||
for (let turn = 0; turn < 1000 && !condition(); turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
if (!condition()) {
|
||||
throw new Error('The awaited call never started');
|
||||
}
|
||||
}
|
||||
|
||||
describe('ServerUserDataStorage taking over other page loads', () => {
|
||||
it("keeps progress this tab saved over a closed tab's older edit", async () => {
|
||||
const at = (seconds: number) => [
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 1,
|
||||
positionSeconds: seconds,
|
||||
},
|
||||
];
|
||||
const server = fakeServer({
|
||||
'xtream-playback-positions': { p1: at(10) },
|
||||
});
|
||||
const shared = memoryStorage();
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
|
||||
const closed = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(new ServerApiError(0, 'network', 'offline'))
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await closed.hydrate();
|
||||
closed.setItem(
|
||||
'xtream-playback-positions',
|
||||
JSON.stringify({ p1: at(20) })
|
||||
);
|
||||
await closed.flush();
|
||||
closed.close();
|
||||
|
||||
// This tab saves newer progress before it adopts the closed one.
|
||||
const tab = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await tab.hydrate();
|
||||
tab.setItem(
|
||||
'xtream-playback-positions',
|
||||
JSON.stringify({ p1: at(30) })
|
||||
);
|
||||
// Sent on the server's value: the closed tab's write is unconfirmed.
|
||||
await tab.flush();
|
||||
await tab.flush();
|
||||
expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30));
|
||||
|
||||
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
|
||||
await tab.hydrate();
|
||||
await tab.flush();
|
||||
await tab.flush();
|
||||
clock.mockRestore();
|
||||
|
||||
expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30));
|
||||
expect(
|
||||
JSON.parse(tab.getItem('xtream-playback-positions') ?? '{}')['p1']
|
||||
).toEqual(at(30));
|
||||
});
|
||||
|
||||
it("sends a closed tab's saved write from an idle tab", async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
|
||||
// Read, and with nothing to sync it never reads again.
|
||||
const idle = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await idle.hydrate();
|
||||
const closed = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(new ServerApiError(0, 'network', 'offline'))
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await closed.hydrate();
|
||||
closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await closed.flush();
|
||||
closed.close();
|
||||
|
||||
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
|
||||
expect(idle.needsSync()).toBe(false);
|
||||
idle.touch();
|
||||
await idle.flush();
|
||||
await idle.flush();
|
||||
clock.mockRestore();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
|
||||
});
|
||||
|
||||
it('lets a page back from the back-forward cache give up writes another tab took', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
|
||||
const page = () =>
|
||||
new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(
|
||||
new ServerApiError(0, 'network', 'offline')
|
||||
)
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
// Two pages frozen in the cache with a write each, not closed.
|
||||
const adopted = page();
|
||||
await adopted.hydrate();
|
||||
adopted.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await adopted.flush();
|
||||
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
|
||||
const other = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await other.hydrate();
|
||||
await other.flush();
|
||||
await other.flush();
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
|
||||
const kept = page();
|
||||
await kept.hydrate();
|
||||
kept.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['S', 'X', 'Y'] })
|
||||
);
|
||||
await kept.flush();
|
||||
clock.mockRestore();
|
||||
|
||||
// Sent by the other tab: sending it again could undo newer edits.
|
||||
expect(adopted.resumed()).toBe(false);
|
||||
expect(kept.resumed()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a closed tab's edit giving way through repeated conflicts", async () => {
|
||||
const at = (id: number, seconds: number) => ({
|
||||
contentType: 'vod',
|
||||
contentXtreamId: id,
|
||||
positionSeconds: seconds,
|
||||
});
|
||||
const server = fakeServer({
|
||||
'xtream-playback-positions': { p1: [at(1, 10), at(2, 5)] },
|
||||
});
|
||||
const positions = () => server.state['xtream-playback-positions']['p1'];
|
||||
const shared = memoryStorage();
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
|
||||
const closed = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(new ServerApiError(0, 'network', 'offline'))
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await closed.hydrate();
|
||||
closed.setItem(
|
||||
'xtream-playback-positions',
|
||||
JSON.stringify({ p1: [at(1, 20), at(2, 5)] })
|
||||
);
|
||||
await closed.flush();
|
||||
closed.close();
|
||||
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
|
||||
const tab = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
|
||||
// Adopted; another device moves movie 2 before it is sent.
|
||||
await tab.hydrate();
|
||||
server.state['xtream-playback-positions']['p1'] = [at(1, 10), at(2, 6)];
|
||||
await tab.flush();
|
||||
// And movie 1 before the replayed write lands.
|
||||
server.state['xtream-playback-positions']['p1'] = [at(1, 30), at(2, 6)];
|
||||
await tab.flush();
|
||||
await tab.flush();
|
||||
clock.mockRestore();
|
||||
|
||||
expect(positions()).toEqual([at(1, 30), at(2, 6)]);
|
||||
});
|
||||
|
||||
it("keeps what another tab saved while this tab's read travelled", async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const a = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await a.hydrate();
|
||||
let answer = (): void => undefined;
|
||||
let asked = false;
|
||||
const b = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
// Answered with the state of when it was asked.
|
||||
getJson: jest.fn(() => {
|
||||
asked = true;
|
||||
const old = server.api.getJson();
|
||||
return new Promise((resolve) => {
|
||||
answer = () => resolve(old);
|
||||
});
|
||||
}),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
const reading = b.hydrate();
|
||||
// Its read is out before the other tab's write lands.
|
||||
await until(() => asked);
|
||||
a.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await a.flush();
|
||||
answer();
|
||||
await reading;
|
||||
|
||||
// Tab A edits the list as it reads it now.
|
||||
const read = JSON.parse(a.getItem('xtream-favorites') ?? '{}');
|
||||
a.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: [...read.p1, 'Y'] })
|
||||
);
|
||||
await a.flush();
|
||||
await a.flush();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X', 'Y']);
|
||||
});
|
||||
|
||||
it('sends nothing more once another tab took its writes over', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
|
||||
let online = false;
|
||||
const send = server.api.sendJson.getMockImplementation();
|
||||
if (!send) {
|
||||
throw new Error('The fake server answers sendJson');
|
||||
}
|
||||
const suspended = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
sendJson: jest.fn((...args: Parameters<typeof send>) =>
|
||||
online
|
||||
? send(...args)
|
||||
: Promise.reject(
|
||||
new ServerApiError(0, 'network', 'offline')
|
||||
)
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await suspended.hydrate();
|
||||
suspended.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['S', 'X'] })
|
||||
);
|
||||
await suspended.flush();
|
||||
// Suspended past the record lifetime: another tab takes it over,
|
||||
// sends it, and the user removes X there afterwards.
|
||||
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
|
||||
const other = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await other.hydrate();
|
||||
await other.flush();
|
||||
await other.flush();
|
||||
other.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] }));
|
||||
await other.flush();
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S']);
|
||||
|
||||
// Its retry timer runs before the tab is shown again.
|
||||
online = true;
|
||||
suspended.touch();
|
||||
await suspended.flush();
|
||||
await suspended.flush();
|
||||
clock.mockRestore();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S']);
|
||||
expect(suspended.resumed()).toBe(false);
|
||||
});
|
||||
|
||||
it("tells whether one playlist's writes were all taken", async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': {} });
|
||||
const send = server.api.sendJson.getMockImplementation();
|
||||
if (!send) {
|
||||
throw new Error('The fake server answers sendJson');
|
||||
}
|
||||
const tab = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
// The server refuses p2's entry as too large.
|
||||
sendJson: jest.fn(async (...args: Parameters<typeof send>) =>
|
||||
args[1].endsWith('/p2')
|
||||
? Promise.reject(
|
||||
new ServerApiError(413, 'too_large', 'big')
|
||||
)
|
||||
: send(...args)
|
||||
),
|
||||
} as never,
|
||||
memoryStorage(),
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await tab.hydrate();
|
||||
tab.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1'], p2: ['live:2'] })
|
||||
);
|
||||
|
||||
await expect(tab.flushed('p1')).resolves.toBe(true);
|
||||
await expect(tab.flushed('p2')).resolves.toBe(false);
|
||||
await expect(tab.flushed()).resolves.toBe(false);
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['live:1']);
|
||||
});
|
||||
|
||||
it('stays closed when a page-hide write fails after pagehide', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const local = memoryStorage();
|
||||
const tabStore = memoryStorage();
|
||||
const page = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
// Offline: the page-hide write fails at once.
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(new ServerApiError(0, 'network', 'offline'))
|
||||
),
|
||||
sendKeepalive: jest.fn(async () => ({
|
||||
status: 'failed' as const,
|
||||
})),
|
||||
} as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
await page.hydrate();
|
||||
page.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
|
||||
// pagehide: flush, then close; the failure is answered after.
|
||||
page.flushWithKeepalive();
|
||||
page.close();
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// The reload takes its writes over at once, not after ten minutes.
|
||||
const reloaded = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
await reloaded.hydrate();
|
||||
await reloaded.flush();
|
||||
await reloaded.flush();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
|
||||
});
|
||||
|
||||
it('keeps a write another tab confirmed while this read travelled', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const send = server.api.sendJson.getMockImplementation();
|
||||
if (!send) {
|
||||
throw new Error('The fake server answers sendJson');
|
||||
}
|
||||
let land = (): void => undefined;
|
||||
const a = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
// Tab A's write is still on its way when B asks.
|
||||
sendJson: jest.fn(
|
||||
(...args: Parameters<typeof send>) =>
|
||||
new Promise((resolve) => {
|
||||
land = () => resolve(send(...args));
|
||||
})
|
||||
),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await a.hydrate();
|
||||
a.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
const sending = a.flush();
|
||||
let answer = (): void => undefined;
|
||||
let asked = false;
|
||||
const b = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
getJson: jest.fn(() => {
|
||||
asked = true;
|
||||
const old = server.api.getJson();
|
||||
return new Promise((resolve) => {
|
||||
answer = () => resolve(old);
|
||||
});
|
||||
}),
|
||||
} as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
const reading = b.hydrate();
|
||||
// Its read is out before the other tab's write lands.
|
||||
await until(() => asked);
|
||||
// A's write lands (its record is cleared), then B's old answer.
|
||||
land();
|
||||
await sending;
|
||||
answer();
|
||||
await reading;
|
||||
|
||||
// Tab A removes S from the list as it reads it now.
|
||||
const read = JSON.parse(a.getItem('xtream-favorites') ?? '{}');
|
||||
a.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: read.p1.filter((id: string) => id !== 'S') })
|
||||
);
|
||||
land = () => undefined;
|
||||
const removing = a.flush();
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
land();
|
||||
await removing;
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['X']);
|
||||
});
|
||||
|
||||
it('finds a kept page load again on a second reload', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const local = memoryStorage();
|
||||
const tabStore = memoryStorage();
|
||||
let online = false;
|
||||
const send = server.api.sendJson.getMockImplementation();
|
||||
if (!send) {
|
||||
throw new Error('The fake server answers sendJson');
|
||||
}
|
||||
const api = {
|
||||
...server.api,
|
||||
sendJson: jest.fn((...args: Parameters<typeof send>) =>
|
||||
online
|
||||
? send(...args)
|
||||
: Promise.reject(
|
||||
new ServerApiError(0, 'network', 'offline')
|
||||
)
|
||||
),
|
||||
} as never;
|
||||
const first = new ServerUserDataStorage(
|
||||
api,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
await first.hydrate();
|
||||
first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await first.flush();
|
||||
first.close();
|
||||
// Its registry record is gone too: only this tab can find it.
|
||||
for (const key of local.keys()) {
|
||||
if (key.startsWith('iptvnator-server:unconfirmed:')) {
|
||||
local.removeItem(key);
|
||||
}
|
||||
}
|
||||
// Storage is full: the reloads cannot save their own copies.
|
||||
const setItem = local.setItem;
|
||||
local.setItem = (key: string, value: string) => {
|
||||
if (key.startsWith('iptvnator-server:unsent-writes:')) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
};
|
||||
const second = new ServerUserDataStorage(
|
||||
api,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
expect(second.hasUnsentWrites()).toBe(true);
|
||||
second.close();
|
||||
|
||||
// Reloaded again before any copy was saved.
|
||||
const third = new ServerUserDataStorage(
|
||||
api,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
expect(third.hasUnsentWrites()).toBe(true);
|
||||
local.setItem = setItem;
|
||||
online = true;
|
||||
await third.hydrate();
|
||||
await third.flush();
|
||||
await third.flush();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
|
||||
});
|
||||
|
||||
it("leaves a running tab's kept page loads to it in a duplicate", async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const local = memoryStorage();
|
||||
const tabStore = memoryStorage();
|
||||
const api = {
|
||||
...server.api,
|
||||
sendJson: jest.fn(() =>
|
||||
Promise.reject(new ServerApiError(0, 'network', 'offline'))
|
||||
),
|
||||
} as never;
|
||||
const page = (store: ReturnType<typeof memoryStorage>) =>
|
||||
new ServerUserDataStorage(api, local, jest.fn(), 'default', store);
|
||||
const first = page(tabStore);
|
||||
await first.hydrate();
|
||||
first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await first.flush();
|
||||
first.close();
|
||||
// The reload cannot save its copy: it keeps the first one's.
|
||||
const setItem = local.setItem;
|
||||
local.setItem = (key: string, value: string) => {
|
||||
if (key.startsWith('iptvnator-server:unsent-writes:')) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
};
|
||||
const running = page(tabStore);
|
||||
expect(running.hasUnsentWrites()).toBe(true);
|
||||
|
||||
// A duplicate copies the tab's session storage, pointer and all.
|
||||
const copied = memoryStorage();
|
||||
for (const key of tabStore.keys()) {
|
||||
copied.setItem(key, tabStore.getItem(key) ?? '');
|
||||
}
|
||||
const duplicate = page(copied);
|
||||
|
||||
// The running tab owns those writes and may hold newer ones.
|
||||
expect(duplicate.hasUnsentWrites()).toBe(false);
|
||||
running.close();
|
||||
duplicate.touch();
|
||||
expect(duplicate.hasUnsentWrites()).toBe(true);
|
||||
});
|
||||
|
||||
describe('a page load and the older one it kept', () => {
|
||||
// The older page added X; the newer one took that over and then
|
||||
// removed X again; neither copy could replace the other.
|
||||
function journals() {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const local = memoryStorage();
|
||||
const tag = `t${JSON.stringify(['S']).replace(/"/g, "'")}`;
|
||||
const entry = (steps: { before: unknown; after: unknown }[]) => [
|
||||
{
|
||||
namespace: 'xtream-favorites' as const,
|
||||
playlistId: 'p1',
|
||||
value: steps[steps.length - 1].after,
|
||||
tag,
|
||||
steps,
|
||||
},
|
||||
];
|
||||
saveUnsentWrites(
|
||||
local,
|
||||
'default',
|
||||
'older',
|
||||
entry([{ before: ['S'], after: ['S', 'X'] }])
|
||||
);
|
||||
saveUnsentWrites(
|
||||
local,
|
||||
'default',
|
||||
'newer',
|
||||
entry([
|
||||
{ before: ['S'], after: ['S', 'X'] },
|
||||
{ before: ['S', 'X'], after: ['S'] },
|
||||
])
|
||||
);
|
||||
// Another device adds Y meanwhile: the replay decides.
|
||||
server.state['xtream-favorites']['p1'] = ['S', 'Y'];
|
||||
return { server, local };
|
||||
}
|
||||
|
||||
async function send(page: ServerUserDataStorage) {
|
||||
await page.hydrate();
|
||||
await page.flush();
|
||||
await page.flush();
|
||||
}
|
||||
|
||||
it('replays them oldest first on a reload', async () => {
|
||||
const { server, local } = journals();
|
||||
const tabStore = memoryStorage();
|
||||
keepPages(tabStore, 'default', 'newer', ['older']);
|
||||
|
||||
await send(
|
||||
new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
)
|
||||
);
|
||||
|
||||
// X stays removed.
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Y']);
|
||||
});
|
||||
|
||||
it('replays them oldest first when a duplicate adopts them', async () => {
|
||||
const { server, local } = journals();
|
||||
const tabStore = memoryStorage();
|
||||
keepPages(tabStore, 'default', 'newer', ['older']);
|
||||
// The newer page load still runs when the duplicate opens.
|
||||
markUnconfirmed(local, 'default', 'newer', ['xtream-favorites|p1']);
|
||||
const duplicate = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
markClosed(local, 'default', 'newer');
|
||||
duplicate.touch();
|
||||
|
||||
await send(duplicate);
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Y']);
|
||||
});
|
||||
});
|
||||
|
||||
it('lands a page-hide batch before a flush scheduled in the same turn', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const keepalive = server.api.sendKeepalive.getMockImplementation();
|
||||
if (!keepalive) {
|
||||
throw new Error('The fake server answers sendKeepalive');
|
||||
}
|
||||
let land = (): void => undefined;
|
||||
let started = false;
|
||||
const tab = new ServerUserDataStorage(
|
||||
{
|
||||
...server.api,
|
||||
// The page-hide write is still on its way.
|
||||
sendKeepalive: jest.fn(
|
||||
(...args: Parameters<typeof keepalive>) =>
|
||||
new Promise((resolve) => {
|
||||
started = true;
|
||||
land = () => resolve(keepalive(...args));
|
||||
})
|
||||
),
|
||||
} as never,
|
||||
memoryStorage(),
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
await tab.hydrate();
|
||||
tab.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
const flushing = tab.flush();
|
||||
tab.flushWithKeepalive();
|
||||
// Removed again before either batch was answered.
|
||||
tab.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] }));
|
||||
await until(() => started);
|
||||
// The scheduled flush has its turn while the page-hide write travels.
|
||||
for (let turn = 0; turn < 10; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
land();
|
||||
await flushing;
|
||||
await tab.flush();
|
||||
await tab.flush();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
behind,
|
||||
type EditStep,
|
||||
splitEditKey,
|
||||
uncertain,
|
||||
UNKNOWN_TAG,
|
||||
yielding,
|
||||
} from './server-user-data-entries';
|
||||
import { replaySteps } from './server-user-data-edit';
|
||||
import type { UserDataWriteQueue } from './server-user-data-queue';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import type { DeferredEdit } from './server-user-data-edit';
|
||||
import {
|
||||
othersUnconfirmed,
|
||||
readDeferredEdits,
|
||||
readUnsentWrites,
|
||||
} from './server-unsent-writes';
|
||||
import type { UserDataCache } from './server-user-data-cache';
|
||||
|
||||
/**
|
||||
* A closed tab's saved edits, ahead of this tab's own (which may have been
|
||||
* made on them): sent with them, replayed first on a conflict, and only
|
||||
* where the entry is still as that tab saw it (`yields`): a change made
|
||||
* since, here or elsewhere, is newer. Returns the value to show.
|
||||
*/
|
||||
export function adoptEdits(
|
||||
writes: UserDataWriteQueue,
|
||||
namespace: ServerUserStateNamespace,
|
||||
playlistId: string,
|
||||
current: unknown,
|
||||
steps: readonly EditStep[],
|
||||
foreign: boolean
|
||||
): unknown {
|
||||
const key = `${namespace}|${playlistId}`;
|
||||
const adopted = yielding(steps);
|
||||
const queued = writes.isQueued(key) ? writes.unsent(key) : undefined;
|
||||
// Without a server version this page read, the write must conflict and
|
||||
// be replayed on the server's value, never overwrite it blindly.
|
||||
const unknown =
|
||||
foreign ||
|
||||
(queued ? queued.tag : writes.tags.of(namespace, playlistId)) ===
|
||||
undefined;
|
||||
if (queued) {
|
||||
// This tab's queued edits are newer: they replay last.
|
||||
const all = [...adopted, ...queued.steps];
|
||||
const value = replaySteps(queued.steps[0]?.before, all);
|
||||
writes.put({
|
||||
...queued,
|
||||
value,
|
||||
steps: all,
|
||||
tag: unknown ? UNKNOWN_TAG : queued.tag,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
const value = replaySteps(current, adopted);
|
||||
writes.queue(namespace, playlistId, value, value, { foreign: unknown });
|
||||
const entry = writes.unsent(key);
|
||||
if (entry) {
|
||||
writes.put({ ...entry, steps: [...adopted, ...entry.steps] });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Early edits made on this tab's own unsent write: kept as their own steps
|
||||
* after it, so a conflict replays them in order.
|
||||
*/
|
||||
export function extendUnsent(
|
||||
writes: UserDataWriteQueue,
|
||||
key: string,
|
||||
steps: readonly EditStep[]
|
||||
): void {
|
||||
const entry = writes.unsent(key);
|
||||
if (entry) {
|
||||
writes.put({
|
||||
...entry,
|
||||
value: replaySteps(entry.value, steps),
|
||||
steps: [
|
||||
...(writes.isQueued(key) ? entry.steps : behind(entry)),
|
||||
...steps,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reload of this tab: its last page load's saved writes are queued and
|
||||
* its early edits kept, both as this page's own.
|
||||
*/
|
||||
export function restorePage(
|
||||
writes: UserDataWriteQueue,
|
||||
deferred: Map<string, DeferredEdit>,
|
||||
local: UserDataStorage,
|
||||
instanceId: string,
|
||||
page: string
|
||||
): void {
|
||||
for (const write of readUnsentWrites(local, instanceId, page)) {
|
||||
const key = `${write.namespace}|${write.playlistId}`;
|
||||
const queued = writes.isQueued(key) ? writes.unsent(key) : undefined;
|
||||
if (queued) {
|
||||
// An older page load's copy came first: these steps follow it.
|
||||
writes.put({
|
||||
...queued,
|
||||
value: write.value,
|
||||
steps: [...queued.steps, ...uncertain(write.steps)],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
writes.queue(
|
||||
write.namespace,
|
||||
write.playlistId,
|
||||
write.value,
|
||||
undefined,
|
||||
{
|
||||
tag: write.tag,
|
||||
steps: write.steps,
|
||||
}
|
||||
);
|
||||
}
|
||||
for (const edit of readDeferredEdits(local, instanceId, page)) {
|
||||
const key = `${edit.namespace}|${edit.playlistId}`;
|
||||
deferred.set(key, {
|
||||
...edit,
|
||||
steps: [...(deferred.get(key)?.steps ?? []), ...edit.steps],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes until nothing (of `playlistId`) is queued or travelling, a few
|
||||
* rounds for conflict replays: false when some stays unsent (refused, or
|
||||
* offline).
|
||||
*/
|
||||
export async function settleWrites(
|
||||
writes: UserDataWriteQueue,
|
||||
playlistId?: string
|
||||
): Promise<boolean> {
|
||||
const unsent = () =>
|
||||
writes
|
||||
.unconfirmedKeys()
|
||||
.some(
|
||||
(key) =>
|
||||
playlistId === undefined ||
|
||||
splitEditKey(key)[1] === playlistId
|
||||
);
|
||||
for (let round = 0; round < 3 && unsent(); round += 1) {
|
||||
await writes.flush();
|
||||
}
|
||||
return !unsent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Another page load's saved writes and early edits, shown on what this tab
|
||||
* shows now and queued with its writes (also when the cache shows them).
|
||||
*/
|
||||
export function adoptPageEdits(
|
||||
writes: UserDataWriteQueue,
|
||||
cache: UserDataCache,
|
||||
from: {
|
||||
readonly local: UserDataStorage;
|
||||
readonly instanceId: string;
|
||||
readonly tabId: string;
|
||||
readonly page: string;
|
||||
}
|
||||
): void {
|
||||
const { local, instanceId, tabId, page } = from;
|
||||
const foreign = othersUnconfirmed(local, instanceId, tabId);
|
||||
for (const { namespace, playlistId, steps } of [
|
||||
...readDeferredEdits(local, instanceId, page),
|
||||
...readUnsentWrites(local, instanceId, page),
|
||||
]) {
|
||||
const value = adoptEdits(
|
||||
writes,
|
||||
namespace,
|
||||
playlistId,
|
||||
cache.map(namespace)[playlistId],
|
||||
steps,
|
||||
foreign.has(`${namespace}|${playlistId}`)
|
||||
);
|
||||
cache.setEntry(namespace, playlistId, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
SERVER_USER_STATE_NAMESPACES,
|
||||
type ServerUserStateNamespace,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import { changedEntries } from './server-user-data-edit';
|
||||
import { parseMap } from './server-user-data-entries';
|
||||
|
||||
/** Server-mode cache keys; the legacy unprefixed keys are never touched. */
|
||||
export const SERVER_CACHE_PREFIX = 'iptvnator-server:cache';
|
||||
|
||||
/**
|
||||
* One namespace's cache of one server instance: a tab still open on
|
||||
* another server (a changed backend URL) never writes over it.
|
||||
*/
|
||||
export function cacheKey(
|
||||
instanceId: string,
|
||||
namespace: ServerUserStateNamespace
|
||||
): string {
|
||||
return `${SERVER_CACHE_PREFIX}:${instanceId}:${namespace}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The synced namespaces as this browser shows them: in browser storage,
|
||||
* shared by the tabs, or in memory when it is full or refuses access, so
|
||||
* callers and the diff baseline always read the same map.
|
||||
*/
|
||||
export class UserDataCache {
|
||||
private readonly overflow = new Map<ServerUserStateNamespace, string>();
|
||||
|
||||
constructor(
|
||||
private readonly local: UserDataStorage,
|
||||
private readonly instanceId: string
|
||||
) {}
|
||||
|
||||
read(namespace: ServerUserStateNamespace): string | null {
|
||||
const kept = this.overflow.get(namespace);
|
||||
if (kept !== undefined) {
|
||||
return kept;
|
||||
}
|
||||
try {
|
||||
return this.local.getItem(cacheKey(this.instanceId, namespace));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` empties the namespace. */
|
||||
write(namespace: ServerUserStateNamespace, value: string | null): void {
|
||||
try {
|
||||
const key = cacheKey(this.instanceId, namespace);
|
||||
if (value === null) {
|
||||
this.local.removeItem(key);
|
||||
} else {
|
||||
this.local.setItem(key, value);
|
||||
}
|
||||
this.overflow.delete(namespace);
|
||||
} catch {
|
||||
this.overflow.set(namespace, value ?? '{}');
|
||||
}
|
||||
}
|
||||
|
||||
/** What every namespace holds now, to compare with later. */
|
||||
snapshot(): Map<ServerUserStateNamespace, string | null> {
|
||||
return new Map(
|
||||
SERVER_USER_STATE_NAMESPACES.map((namespace) => [
|
||||
namespace,
|
||||
this.read(namespace),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** Entries (`namespace|playlistId`) changed since `snapshot`. */
|
||||
changedSince(
|
||||
snapshot: ReadonlyMap<ServerUserStateNamespace, string | null>
|
||||
): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [namespace, before] of snapshot) {
|
||||
if (this.read(namespace) !== before) {
|
||||
for (const [playlistId] of changedEntries(
|
||||
parseMap(before ?? '{}'),
|
||||
this.map(namespace)
|
||||
)) {
|
||||
keys.push(`${namespace}|${playlistId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
map(namespace: ServerUserStateNamespace): Record<string, unknown> {
|
||||
return parseMap(this.read(namespace) ?? '{}');
|
||||
}
|
||||
|
||||
/** After a delete: one playlist's entries, without an id all of them. */
|
||||
forget(playlistId?: string): void {
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const map = this.map(namespace);
|
||||
if (playlistId === undefined) {
|
||||
this.write(namespace, null);
|
||||
} else if (playlistId in map) {
|
||||
delete map[playlistId];
|
||||
this.write(namespace, JSON.stringify(map));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `undefined` removes the playlist's entry. */
|
||||
setEntry(
|
||||
namespace: ServerUserStateNamespace,
|
||||
playlistId: string,
|
||||
value: unknown
|
||||
): void {
|
||||
const map = this.map(namespace);
|
||||
if (value === undefined) {
|
||||
delete map[playlistId];
|
||||
} else {
|
||||
map[playlistId] = value;
|
||||
}
|
||||
this.write(namespace, JSON.stringify(map));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { applyEdit, withoutDeleted } from './server-user-data-edit';
|
||||
|
||||
describe('applyEdit', () => {
|
||||
it('adds the new favorite to what the server already has', () => {
|
||||
expect(applyEdit(['live:1', 'live:2'], [], ['live:9'])).toEqual([
|
||||
'live:1',
|
||||
'live:2',
|
||||
'live:9',
|
||||
]);
|
||||
expect(applyEdit(['live:1'], undefined, ['live:9'])).toEqual([
|
||||
'live:1',
|
||||
'live:9',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes only what the edit removed and replaces changed items', () => {
|
||||
expect(
|
||||
applyEdit(
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 1,
|
||||
positionSeconds: 5,
|
||||
},
|
||||
'other',
|
||||
],
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 1,
|
||||
positionSeconds: 5,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 1,
|
||||
positionSeconds: 60,
|
||||
},
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
{ contentType: 'vod', contentXtreamId: 1, positionSeconds: 60 },
|
||||
'other',
|
||||
]);
|
||||
expect(applyEdit(['a', 'b'], ['a'], [])).toEqual(['b']);
|
||||
// Another device removed 'a'; this edit only added 'c'.
|
||||
expect(applyEdit(['b'], ['a', 'b'], ['a', 'b', 'c'])).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges maps by key and passes deletes through', () => {
|
||||
expect(applyEdit({ a: 1, b: 2 }, { a: 1 }, { c: 3 })).toEqual({
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
expect(applyEdit(['x'], ['x'], undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEdit of recently viewed items', () => {
|
||||
it('puts a replayed view ahead of older ones', () => {
|
||||
const view = (id: string, day: number) => ({
|
||||
id,
|
||||
viewedAt: `2026-10-0${day}T20:00:00.000Z`,
|
||||
});
|
||||
// Another device added a view meanwhile; this one is newer still.
|
||||
expect(
|
||||
applyEdit(
|
||||
[view('b', 2), view('a', 1)],
|
||||
[view('a', 1)],
|
||||
[view('c', 3), view('a', 1)]
|
||||
)
|
||||
).toEqual([view('c', 3), view('b', 2), view('a', 1)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEdit of a gone page (yields)', () => {
|
||||
it('changes only what is still as that page saw it', () => {
|
||||
// Map keys: `a` changed since, `b` did not.
|
||||
expect(
|
||||
applyEdit({ a: 3, b: 1 }, { a: 1, b: 1 }, { a: 2, b: 2 }, true)
|
||||
).toEqual({ a: 3, b: 2 });
|
||||
// List items by key: a changed item keeps its newer state.
|
||||
const item = (seconds: number) => ({
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 1,
|
||||
positionSeconds: seconds,
|
||||
});
|
||||
expect(applyEdit([item(30)], [item(10)], [item(20)], true)).toEqual([
|
||||
item(30),
|
||||
]);
|
||||
expect(applyEdit([item(10)], [item(10)], [item(20)], true)).toEqual([
|
||||
item(20),
|
||||
]);
|
||||
// Adds and removes still apply where nothing changed.
|
||||
expect(applyEdit(['a', 'b'], ['a', 'b'], ['b', 'c'], true)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a value or entry replaced since', () => {
|
||||
expect(applyEdit('new', 'old', 'mine', true)).toBe('new');
|
||||
expect(applyEdit('old', 'old', 'mine', true)).toBe('mine');
|
||||
expect(applyEdit(['x', 'y'], ['x'], undefined, true)).toEqual([
|
||||
'x',
|
||||
'y',
|
||||
]);
|
||||
expect(applyEdit(['x'], ['x'], undefined, true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withoutDeleted', () => {
|
||||
it('drops deleted playlists and keeps every other id', () => {
|
||||
const read = withoutDeleted(
|
||||
{
|
||||
entries: {
|
||||
'xtream-favorites': JSON.parse(
|
||||
'{"gone":["a"],"__proto__":["b"],"kept":["c"]}'
|
||||
),
|
||||
'xtream-recent-items': {},
|
||||
'xtream-collection-items': {},
|
||||
'xtream-playback-positions': {},
|
||||
},
|
||||
tags: {
|
||||
'xtream-favorites': JSON.parse(
|
||||
'{"gone":"t1","__proto__":"t2","kept":"t3"}'
|
||||
),
|
||||
'xtream-recent-items': {},
|
||||
'xtream-collection-items': {},
|
||||
'xtream-playback-positions': {},
|
||||
},
|
||||
},
|
||||
['gone']
|
||||
);
|
||||
|
||||
expect(Object.keys(read.entries['xtream-favorites'])).toEqual([
|
||||
'__proto__',
|
||||
'kept',
|
||||
]);
|
||||
expect(read.entries['xtream-favorites']['__proto__']).toEqual(['b']);
|
||||
expect(read.tags?.['xtream-favorites']['__proto__']).toBe('t2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
SERVER_USER_STATE_NAMESPACES,
|
||||
type ServerUserStateNamespace,
|
||||
type ServerUserStateResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
isViewHistory,
|
||||
itemKey,
|
||||
newestViewsFirst,
|
||||
} from './server-migration-merge';
|
||||
import { type EditStep, idMap } from './server-user-data-entries';
|
||||
|
||||
/** Edits made before the first read, applied once the server's value is known. */
|
||||
export interface DeferredEdit {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
readonly steps: readonly EditStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One namespace's cache after a read: the server's map, except entries the
|
||||
* server does not have yet (`keepCached`, from the cache) and edits made
|
||||
* before the first read (applied to the server's value).
|
||||
*/
|
||||
export function refreshedNamespace(
|
||||
server: Readonly<Record<string, unknown>>,
|
||||
cached: Readonly<Record<string, unknown>>,
|
||||
keepCached: ReadonlySet<string>,
|
||||
deferred: readonly DeferredEdit[],
|
||||
/** What an early edit was made on; the server's entry by default. */
|
||||
baseOf: (playlistId: string) => unknown = (playlistId) => server[playlistId]
|
||||
): Record<string, unknown> {
|
||||
const value = idMap(server);
|
||||
for (const playlistId of keepCached) {
|
||||
if (playlistId in cached) {
|
||||
value[playlistId] = cached[playlistId];
|
||||
} else {
|
||||
delete value[playlistId];
|
||||
}
|
||||
}
|
||||
for (const edit of deferred) {
|
||||
const merged = replaySteps(baseOf(edit.playlistId), edit.steps);
|
||||
if (merged === undefined) {
|
||||
delete value[edit.playlistId];
|
||||
} else {
|
||||
value[edit.playlistId] = merged;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one local edit (`before` → `after`, as the user made it against
|
||||
* an older or empty cache) to the server's current value: list items the
|
||||
* edit added or changed are set, items it removed are dropped, everything
|
||||
* else the server has stays. Maps work the same by key. A `yields` edit
|
||||
* (a gone page's) changes only what is still as it was `before`.
|
||||
*/
|
||||
export function applyEdit(
|
||||
server: unknown,
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
yields = false
|
||||
): unknown {
|
||||
const free = (now: unknown, was: unknown) => !yields || same(now, was);
|
||||
if (after === undefined) {
|
||||
return free(server, before) ? undefined : server;
|
||||
}
|
||||
if (
|
||||
Array.isArray(after) &&
|
||||
isListOrMissing(server) &&
|
||||
isListOrMissing(before)
|
||||
) {
|
||||
const was = new Map(
|
||||
(before ?? []).map((item) => [itemKey(item), item])
|
||||
);
|
||||
const now = new Map(after.map((item) => [itemKey(item), item]));
|
||||
const result = (server ?? []).filter((item) => {
|
||||
const key = itemKey(item);
|
||||
return !(was.has(key) && !now.has(key) && free(item, was.get(key)));
|
||||
});
|
||||
for (const [key, item] of now) {
|
||||
// Untouched by the edit: the server's state of it wins.
|
||||
if (same(was.get(key), item)) {
|
||||
continue;
|
||||
}
|
||||
const index = result.findIndex((entry) => itemKey(entry) === key);
|
||||
if (!free(index < 0 ? undefined : result[index], was.get(key))) {
|
||||
continue;
|
||||
}
|
||||
if (index < 0) {
|
||||
result.push(item);
|
||||
} else {
|
||||
result[index] = item;
|
||||
}
|
||||
}
|
||||
// History is cut at its limit from the end: a replayed view must
|
||||
// not land behind older ones.
|
||||
return isViewHistory(result) ? newestViewsFirst(result) : result;
|
||||
}
|
||||
if (isMap(after) && isMapOrMissing(server) && isMapOrMissing(before)) {
|
||||
const was = before ?? {};
|
||||
const result: Record<string, unknown> = { ...(server ?? {}) };
|
||||
for (const key of Object.keys(was)) {
|
||||
if (!(key in after) && free(result[key], was[key])) {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(after)) {
|
||||
if (!same(was[key], value) && free(result[key], was[key])) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return free(server, before) ? after : server;
|
||||
}
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function isMap(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isMapOrMissing(
|
||||
value: unknown
|
||||
): value is Record<string, unknown> | undefined {
|
||||
return value === undefined || isMap(value);
|
||||
}
|
||||
|
||||
function isListOrMissing(value: unknown): value is unknown[] | undefined {
|
||||
return value === undefined || Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Edits replayed on `server` in order. */
|
||||
export function replaySteps(
|
||||
server: unknown,
|
||||
steps: readonly EditStep[]
|
||||
): unknown {
|
||||
let value = server;
|
||||
for (const step of steps) {
|
||||
value = applyEdit(value, step.before, step.after, step.yields);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The playlist entries `next` changed against `previous`; removed ones as `undefined`. */
|
||||
export function changedEntries(
|
||||
previous: Readonly<Record<string, unknown>>,
|
||||
next: Readonly<Record<string, unknown>>
|
||||
): [string, unknown][] {
|
||||
const changed: [string, unknown][] = [];
|
||||
for (const [playlistId, value] of Object.entries(next)) {
|
||||
if (JSON.stringify(previous[playlistId]) !== JSON.stringify(value)) {
|
||||
changed.push([playlistId, value]);
|
||||
}
|
||||
}
|
||||
for (const playlistId of Object.keys(previous)) {
|
||||
if (!(playlistId in next)) {
|
||||
changed.push([playlistId, undefined]);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** The entries `next` changed against `read`, applied to `current`. */
|
||||
export function mergeEdits(
|
||||
current: Record<string, unknown>,
|
||||
read: Record<string, unknown>,
|
||||
next: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const merged = idMap(current);
|
||||
for (const id of new Set([...Object.keys(read), ...Object.keys(next)])) {
|
||||
if (JSON.stringify(read[id]) !== JSON.stringify(next[id])) {
|
||||
const value = applyEdit(current[id], read[id], next[id]);
|
||||
if (value === undefined) {
|
||||
delete merged[id];
|
||||
} else {
|
||||
merged[id] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server read without the playlists deleted after it was sent (an id of
|
||||
* `undefined` stands for every playlist), its maps as `idMap`s.
|
||||
*/
|
||||
export function withoutDeleted(
|
||||
read: ServerUserStateResponse,
|
||||
deleted: readonly (string | undefined)[]
|
||||
): ServerUserStateResponse {
|
||||
const keep = <T>(map: Record<string, T> = {}): Record<string, T> => {
|
||||
const kept = idMap<T>();
|
||||
for (const [id, value] of Object.entries(map)) {
|
||||
if (!deleted.some((gone) => gone === undefined || gone === id)) {
|
||||
kept[id] = value;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
const entries = {} as Record<string, Record<string, unknown>>;
|
||||
const tags = {} as Record<string, Record<string, string>>;
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
entries[namespace] = keep(read.entries[namespace]);
|
||||
tags[namespace] = keep(read.tags?.[namespace]) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
}
|
||||
return {
|
||||
entries: entries as ServerUserStateResponse['entries'],
|
||||
...(read.tags ? { tags: tags as ServerUserStateResponse['tags'] } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** One playlist entry of a synced user-data map, on its way to the server. */
|
||||
export interface PendingEntry {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
/** `undefined` deletes the entry. */
|
||||
readonly value: unknown;
|
||||
/** The edit this value came from; a retry never replaces a newer one. */
|
||||
readonly edit: number;
|
||||
/**
|
||||
* The server version `value` is based on: a tag, `null` for an entry
|
||||
* seen absent, `undefined` when unknown (an unconditional write).
|
||||
*/
|
||||
readonly tag?: string | null;
|
||||
/**
|
||||
* The edits since that version, oldest first: a conflict replays them
|
||||
* on the server's value in order. Kept apart where they cannot be one
|
||||
* before → after (another tab's change in between, or a write that may
|
||||
* or may not have landed), so adding and then removing an item is
|
||||
* never a no-op.
|
||||
*/
|
||||
readonly steps: readonly EditStep[];
|
||||
}
|
||||
|
||||
/** One edit as it was made: the entry before and after it. */
|
||||
export interface EditStep {
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
/**
|
||||
* The travelling write that carries it, until that one is answered;
|
||||
* `0` once a write that carried it was lost or saved for a later page:
|
||||
* it may have landed, so a later edit never joins it.
|
||||
*/
|
||||
readonly sent?: number;
|
||||
/**
|
||||
* Made on a page that is gone (a closed tab's saved edit): it changes
|
||||
* only what is still as that page saw it, never a change made since.
|
||||
*/
|
||||
readonly yields?: true;
|
||||
}
|
||||
|
||||
/** A travelling write's steps, marked as carried by it. */
|
||||
export function behind(entry: PendingEntry): EditStep[] {
|
||||
return entry.steps.map((step) => ({ ...step, sent: entry.edit }));
|
||||
}
|
||||
|
||||
/** Steps whose write may or may not have landed. */
|
||||
export function uncertain(steps: readonly EditStep[]): EditStep[] {
|
||||
return steps.map((step) => ({ ...step, sent: 0 }));
|
||||
}
|
||||
|
||||
/** A gone page's steps: uncertain, and giving way to later changes. */
|
||||
export function yielding(steps: readonly EditStep[]): EditStep[] {
|
||||
return steps.map((step) => ({ ...step, sent: 0, yields: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* `steps` plus the edit `before` → `after`: joined with the last step when
|
||||
* it continues it, kept apart otherwise.
|
||||
*/
|
||||
export function addStep(
|
||||
steps: readonly EditStep[],
|
||||
before: unknown,
|
||||
after: unknown
|
||||
): EditStep[] {
|
||||
const last = steps[steps.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.sent === undefined &&
|
||||
!last.yields &&
|
||||
JSON.stringify(last.after) === JSON.stringify(before)
|
||||
) {
|
||||
return [...steps.slice(0, -1), { before: last.before, after }];
|
||||
}
|
||||
return [...steps, { before, after }];
|
||||
}
|
||||
|
||||
/**
|
||||
* A version no entry ever has: a write based on what the cache shows but
|
||||
* the server may not have (another tab's unsent change) always conflicts
|
||||
* and is replayed on the server's value.
|
||||
*/
|
||||
export const UNKNOWN_TAG = '~unknown';
|
||||
|
||||
/** `If-Match` / `If-None-Match` for a write based on `tag`. */
|
||||
export function preconditionHeaders(
|
||||
tag: string | null | undefined
|
||||
): Record<string, string> {
|
||||
if (tag === undefined) {
|
||||
return {};
|
||||
}
|
||||
return tag === null ? { 'If-None-Match': '*' } : { 'If-Match': `"${tag}"` };
|
||||
}
|
||||
|
||||
export function entryKey(entry: PendingEntry): string {
|
||||
return `${entry.namespace}|${entry.playlistId}`;
|
||||
}
|
||||
|
||||
export function splitEditKey(key: string): [string, string] {
|
||||
const separator = key.indexOf('|');
|
||||
return [key.slice(0, separator), key.slice(separator + 1)];
|
||||
}
|
||||
|
||||
export function entryPath(entry: PendingEntry): string {
|
||||
return `/user-state/${entry.namespace}/${encodeURIComponent(entry.playlistId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A map keyed by playlist id, without a prototype: an id such as
|
||||
* `__proto__` is an ordinary key, both to set and to read when absent.
|
||||
*/
|
||||
export function idMap<T>(
|
||||
source: Readonly<Record<string, T>> = {}
|
||||
): Record<string, T> {
|
||||
return Object.assign(Object.create(null) as Record<string, T>, source);
|
||||
}
|
||||
|
||||
export function parseMap(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return idMap(
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
);
|
||||
} catch {
|
||||
return idMap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import type {
|
||||
ServerUserStateCurrent,
|
||||
ServerUserStateNamespace,
|
||||
ServerUserStateWriteResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PLAYLIST_MISSING,
|
||||
ServerApiError,
|
||||
type ServerApiClient,
|
||||
} from './server-api-client';
|
||||
import {
|
||||
entryKey,
|
||||
entryPath,
|
||||
addStep,
|
||||
behind,
|
||||
type EditStep,
|
||||
type PendingEntry,
|
||||
preconditionHeaders,
|
||||
uncertain,
|
||||
UNKNOWN_TAG,
|
||||
} from './server-user-data-entries';
|
||||
import { replaySteps } from './server-user-data-edit';
|
||||
import { UserStateTags } from './server-user-data-tags';
|
||||
import { newestWrites } from './server-unsent-writes';
|
||||
|
||||
const FLUSH_DELAY_MS = 500;
|
||||
|
||||
/** What the queue needs of the API client. */
|
||||
export type ServerApi = Pick<
|
||||
ServerApiClient,
|
||||
'readJson' | 'sendJson' | 'request' | 'sendKeepalive'
|
||||
>;
|
||||
|
||||
export interface UserDataQueueHooks {
|
||||
/** The queue or the writes travelling changed: persist them. */
|
||||
readonly changed: () => void;
|
||||
/** A conflict replay changed what an entry should show. */
|
||||
readonly show: (
|
||||
namespace: ServerUserStateNamespace,
|
||||
playlistId: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
readonly writeFailed: (error: unknown) => void;
|
||||
/** False while nothing may go out (another tab took the writes over). */
|
||||
readonly maySend?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The writes of `ServerUserDataStorage`: one entry per playlist and
|
||||
* namespace, sent in batches one after another, each based on the server
|
||||
* version (tag) it was read at. A `412` replays the edit (before → after)
|
||||
* on the server's current value; a failure keeps it queued unless a newer
|
||||
* edit replaced it.
|
||||
*/
|
||||
export class UserDataWriteQueue {
|
||||
/** Server version tag per entry; absent until the first read. */
|
||||
readonly tags = new UserStateTags();
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
/** Writes still travelling (normal or page-hide), by entry key. */
|
||||
private readonly inFlight = new Map<string, PendingEntry>();
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Batches go out one at a time, so an older write never lands last. */
|
||||
private sending: Promise<void> = Promise.resolve();
|
||||
/** A page-hide batch sent at once, which a scheduled flush waits for. */
|
||||
private keepaliveRun: Promise<void> = Promise.resolve();
|
||||
private writesInFlight = 0;
|
||||
/** Edit counter per `namespace|playlistId`, for re-reads to keep them. */
|
||||
private readonly edits = new Map<string, number>();
|
||||
private editCount = 0;
|
||||
/** Entries a delete is waiting on: they stay queued and are not sent. */
|
||||
private readonly held: ((key: string) => boolean)[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly api: ServerApi,
|
||||
private readonly hooks: UserDataQueueHooks
|
||||
) {}
|
||||
|
||||
get lastEdit(): number {
|
||||
return this.editCount;
|
||||
}
|
||||
|
||||
hasUnsent(): boolean {
|
||||
return this.pending.size > 0 || this.inFlight.size > 0;
|
||||
}
|
||||
|
||||
/** What the server has not confirmed, the newest write per entry. */
|
||||
unsentWrites(): PendingEntry[] {
|
||||
return newestWrites([
|
||||
...this.inFlight.values(),
|
||||
...this.pending.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
unconfirmedKeys(): string[] {
|
||||
return [...this.pending.keys(), ...this.inFlight.keys()];
|
||||
}
|
||||
|
||||
/** Entries a re-read must not replace: unconfirmed or edited since. */
|
||||
unsavedSince(edit: number): Set<string> {
|
||||
const keys = new Set(this.unconfirmedKeys());
|
||||
for (const [key, made] of this.edits) {
|
||||
if (made > edit) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Forgets edit numbers a re-read has covered. */
|
||||
settledUpTo(edit: number): void {
|
||||
for (const [key, made] of this.edits) {
|
||||
if (
|
||||
made <= edit &&
|
||||
!this.pending.has(key) &&
|
||||
!this.inFlight.has(key)
|
||||
) {
|
||||
this.edits.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an edit (`before` → `value`). A newer edit of an entry still
|
||||
* queued joins it; one behind a travelling write keeps that write's
|
||||
* steps (marked as sent) and tag. A `foreign` edit was made on another
|
||||
* tab's unsent change: its write must conflict and be replayed.
|
||||
*/
|
||||
queue(
|
||||
namespace: ServerUserStateNamespace,
|
||||
playlistId: string,
|
||||
value: unknown,
|
||||
before: unknown,
|
||||
base: {
|
||||
readonly tag?: string | null;
|
||||
readonly steps?: readonly EditStep[];
|
||||
readonly foreign?: boolean;
|
||||
} = {}
|
||||
): void {
|
||||
const key = `${namespace}|${playlistId}`;
|
||||
const queued = this.pending.get(key);
|
||||
const travelling = this.inFlight.get(key);
|
||||
const edit = ++this.editCount;
|
||||
let entry: PendingEntry;
|
||||
if (queued) {
|
||||
entry = {
|
||||
...queued,
|
||||
value,
|
||||
edit,
|
||||
steps: addStep(queued.steps, before, value),
|
||||
};
|
||||
} else if (travelling) {
|
||||
entry = {
|
||||
...travelling,
|
||||
value,
|
||||
edit,
|
||||
steps: [...behind(travelling), { before, after: value }],
|
||||
};
|
||||
} else {
|
||||
entry = {
|
||||
namespace,
|
||||
playlistId,
|
||||
value,
|
||||
edit,
|
||||
tag:
|
||||
base.tag !== undefined
|
||||
? base.tag
|
||||
: this.tags.of(namespace, playlistId),
|
||||
steps: base.steps
|
||||
? uncertain(base.steps)
|
||||
: [{ before, after: value }],
|
||||
};
|
||||
}
|
||||
if (base.foreign) {
|
||||
entry = { ...entry, tag: UNKNOWN_TAG };
|
||||
}
|
||||
this.pending.set(key, entry);
|
||||
this.edits.set(key, edit);
|
||||
}
|
||||
|
||||
/** The write of an entry: queued, else travelling. */
|
||||
unsent(key: string): PendingEntry | undefined {
|
||||
return this.pending.get(key) ?? this.inFlight.get(key);
|
||||
}
|
||||
|
||||
isQueued(key: string): boolean {
|
||||
return this.pending.has(key);
|
||||
}
|
||||
|
||||
/** Queues `entry` as this tab's newest edit of it. */
|
||||
put(entry: Omit<PendingEntry, 'edit'>): void {
|
||||
const key = entryKey(entry as PendingEntry);
|
||||
const edit = ++this.editCount;
|
||||
this.pending.set(key, { ...entry, edit });
|
||||
this.edits.set(key, edit);
|
||||
}
|
||||
|
||||
scheduleFlush(): void {
|
||||
if (this.pending.size === 0) {
|
||||
return;
|
||||
}
|
||||
// Saved at once: another tab's re-read must not replace them.
|
||||
this.hooks.changed();
|
||||
if (this.flushTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
}, FLUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Sends the queued entries after any batch still travelling. */
|
||||
flush(): Promise<void> {
|
||||
const run = this.sending.then(async () => {
|
||||
// A page-hide batch that went out at once in the meantime is
|
||||
// not chained on this one: its writes land first.
|
||||
await this.keepaliveRun;
|
||||
const batch = this.takePending();
|
||||
if (batch.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const entry of batch) {
|
||||
this.inFlight.set(entryKey(entry), entry);
|
||||
}
|
||||
this.writesInFlight += 1;
|
||||
try {
|
||||
await Promise.all(batch.map((entry) => this.send(entry)));
|
||||
} finally {
|
||||
this.writesInFlight -= 1;
|
||||
}
|
||||
});
|
||||
this.sending = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return this.sending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page hide: `keepalive` requests survive the page. They go out at once
|
||||
* unless a write is still travelling, which they must not overtake, and
|
||||
* later writes wait for them in turn.
|
||||
*/
|
||||
flushWithKeepalive(): void {
|
||||
// Saved first: the page may be gone before anything is answered.
|
||||
this.hooks.changed();
|
||||
if (this.pending.size === 0) {
|
||||
return;
|
||||
}
|
||||
// Idle: send synchronously, while the hiding page still runs. Behind
|
||||
// a travelling write the entries stay queued until it is answered,
|
||||
// so the version it created can still become their baseline.
|
||||
const now = this.writesInFlight === 0;
|
||||
const run = now
|
||||
? this.sendKeepaliveBatch()
|
||||
: this.sending.then(() => this.sendKeepaliveBatch());
|
||||
this.sending = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
if (now) {
|
||||
this.keepaliveRun = this.sending;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the entries whose key `matches` queued but unsent until the
|
||||
* returned release is called (a delete is running).
|
||||
*/
|
||||
hold(matches: (key: string) => boolean): () => void {
|
||||
this.held.push(matches);
|
||||
return () => {
|
||||
this.held.splice(this.held.indexOf(matches), 1);
|
||||
this.scheduleFlush();
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves once the writes travelling now have been answered. */
|
||||
whenSent(): Promise<void> {
|
||||
return this.sending;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a delete: forgets the queued edits whose key `matches`, their
|
||||
* edit numbers and their tags (all tags without a playlist id).
|
||||
*/
|
||||
drop(matches: (key: string) => boolean, playlistId?: string): void {
|
||||
for (const store of [this.pending, this.edits]) {
|
||||
for (const key of [...store.keys()]) {
|
||||
if (matches(key)) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.tags.forget(playlistId);
|
||||
this.hooks.changed();
|
||||
}
|
||||
|
||||
private takePending(): PendingEntry[] {
|
||||
if (this.flushTimer !== null) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
if (this.hooks.maySend?.() === false) {
|
||||
return [];
|
||||
}
|
||||
const batch: PendingEntry[] = [];
|
||||
for (const [key, entry] of this.pending) {
|
||||
if (!this.held.some((matches) => matches(key))) {
|
||||
batch.push(entry);
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back into the queue after a failed write, for the offline retry or
|
||||
* the next edit, unless the entry was edited since it was sent.
|
||||
*/
|
||||
private requeue(entry: PendingEntry): void {
|
||||
const key = entryKey(entry);
|
||||
if (this.edits.get(key) === entry.edit && !this.pending.has(key)) {
|
||||
this.pending.set(key, { ...entry, steps: uncertain(entry.steps) });
|
||||
}
|
||||
}
|
||||
|
||||
/** A newer write of the same entry may have replaced it meanwhile. */
|
||||
private retireInFlight(entry: PendingEntry): void {
|
||||
const key = entryKey(entry);
|
||||
if (this.inFlight.get(key)?.edit === entry.edit) {
|
||||
this.inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendKeepaliveBatch(): Promise<void> {
|
||||
const entries = this.takePending();
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
this.inFlight.set(entryKey(entry), entry);
|
||||
}
|
||||
this.hooks.changed();
|
||||
this.writesInFlight += 1;
|
||||
try {
|
||||
await Promise.all(
|
||||
entries.map((entry) => this.sendKeepalive(entry))
|
||||
);
|
||||
} finally {
|
||||
this.writesInFlight -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendKeepalive(entry: PendingEntry): Promise<void> {
|
||||
const path = entryPath(entry);
|
||||
const headers = preconditionHeaders(entry.tag);
|
||||
const outcome = await (entry.value === undefined
|
||||
? this.api.sendKeepalive('DELETE', path, undefined, headers)
|
||||
: this.api.sendKeepalive(
|
||||
'PUT',
|
||||
path,
|
||||
{ value: entry.value },
|
||||
headers
|
||||
));
|
||||
this.retireInFlight(entry);
|
||||
if (outcome.status === 'saved') {
|
||||
this.confirmed(entry, outcome.tag);
|
||||
} else if (outcome.status === 'conflict') {
|
||||
this.replayOnConflict(entry, outcome.current);
|
||||
} else if (outcome.status === 'gone') {
|
||||
this.gone(entry);
|
||||
} else {
|
||||
this.requeue(entry);
|
||||
}
|
||||
this.hooks.changed();
|
||||
}
|
||||
|
||||
private async send(entry: PendingEntry): Promise<void> {
|
||||
const headers = preconditionHeaders(entry.tag);
|
||||
try {
|
||||
const response =
|
||||
entry.value === undefined
|
||||
? await this.api.request(entryPath(entry), {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
: await this.api.sendJson(
|
||||
'PUT',
|
||||
entryPath(entry),
|
||||
{ value: entry.value },
|
||||
headers
|
||||
);
|
||||
const { tag } = await this.api
|
||||
.readJson<Partial<ServerUserStateWriteResponse>>(response)
|
||||
.catch(() => ({ tag: undefined }));
|
||||
this.retireInFlight(entry);
|
||||
this.confirmed(entry, tag);
|
||||
} catch (error) {
|
||||
this.retireInFlight(entry);
|
||||
if (
|
||||
error instanceof ServerApiError &&
|
||||
error.status === 412 &&
|
||||
error.current
|
||||
) {
|
||||
this.replayOnConflict(entry, error.current);
|
||||
} else if (
|
||||
error instanceof ServerApiError &&
|
||||
error.code === PLAYLIST_MISSING
|
||||
) {
|
||||
this.gone(entry);
|
||||
} else {
|
||||
this.requeue(entry);
|
||||
this.hooks.writeFailed(error);
|
||||
}
|
||||
}
|
||||
this.hooks.changed();
|
||||
}
|
||||
|
||||
/** The playlist was deleted elsewhere: its edit goes with it. */
|
||||
private gone(entry: PendingEntry): void {
|
||||
const key = entryKey(entry);
|
||||
if (this.edits.get(key) === entry.edit) {
|
||||
this.edits.delete(key);
|
||||
this.hooks.show(entry.namespace, entry.playlistId, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** The server took `entry`: the edit queued behind it builds on it. */
|
||||
/**
|
||||
* `entry` landed. Without a readable tag (a body cut off after its
|
||||
* headers) a conditional write leaves the version unknown: a newer edit
|
||||
* drops the landed steps all the same and conflicts, so only the edits
|
||||
* made after it replay. An unconditional one stays unconditional.
|
||||
*/
|
||||
private confirmed(
|
||||
entry: PendingEntry,
|
||||
read: string | null | undefined
|
||||
): void {
|
||||
const tag =
|
||||
read !== undefined || entry.tag === undefined ? read : UNKNOWN_TAG;
|
||||
if (tag !== undefined) {
|
||||
this.tags.update(entry.namespace, entry.playlistId, tag);
|
||||
}
|
||||
const key = entryKey(entry);
|
||||
const next = this.pending.get(key);
|
||||
if (next?.steps.some((step) => step.sent === entry.edit)) {
|
||||
// A foreign edit stays unknown: its baseline is not this value.
|
||||
this.pending.set(key, {
|
||||
...next,
|
||||
steps: next.steps.filter((step) => step.sent !== entry.edit),
|
||||
tag:
|
||||
next.tag === UNKNOWN_TAG || tag === undefined
|
||||
? next.tag
|
||||
: tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Another device changed the entry and `entry` did not land: replay its
|
||||
* steps on the server's current value (with the newer queued edit that
|
||||
* carries them, if any), show the result and send it on that version.
|
||||
*/
|
||||
private replayOnConflict(
|
||||
entry: PendingEntry,
|
||||
current: ServerUserStateCurrent
|
||||
): void {
|
||||
this.tags.update(entry.namespace, entry.playlistId, current.tag);
|
||||
const key = entryKey(entry);
|
||||
const latest =
|
||||
this.pending.get(key) ??
|
||||
(this.edits.get(key) === entry.edit ? entry : undefined);
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
const merged = replaySteps(current.value, latest.steps);
|
||||
this.hooks.show(entry.namespace, entry.playlistId, merged);
|
||||
if (
|
||||
latest === entry &&
|
||||
JSON.stringify(merged) === JSON.stringify(current.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// A closed tab's steps keep giving way to newer changes at the next
|
||||
// conflict; this page's own become one edit on top of them.
|
||||
const yielding = latest.steps.filter((step) => step.yields);
|
||||
this.pending.set(key, {
|
||||
...latest,
|
||||
value: merged,
|
||||
tag: current.tag,
|
||||
steps: [
|
||||
...yielding,
|
||||
{ before: replaySteps(current.value, yielding), after: merged },
|
||||
],
|
||||
});
|
||||
this.scheduleFlush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
import type { KeepaliveOutcome } from './server-api-client';
|
||||
import { ServerUserDataStorage } from './server-user-data-storage';
|
||||
|
||||
const FAVORITES_CACHE = 'iptvnator-server:cache:default:xtream-favorites';
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
keys: () => [...values.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
/** Lets chained promises run without advancing the fake timers. */
|
||||
/** The saved writes of the one page load that has any, for `instanceId`. */
|
||||
function savedWrites(
|
||||
local: ReturnType<typeof memoryStorage>,
|
||||
instanceId = 'default'
|
||||
): unknown[] {
|
||||
const prefix = `iptvnator-server:unsent-writes:${instanceId}:`;
|
||||
const key = [...local.values.keys()].find((name) =>
|
||||
name.startsWith(prefix)
|
||||
);
|
||||
return key ? JSON.parse(local.values.get(key) ?? '[]') : [];
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('ServerUserDataStorage', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
function setup() {
|
||||
const local = memoryStorage();
|
||||
const api = {
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: {
|
||||
'xtream-favorites': { p1: ['live:1'] },
|
||||
'xtream-recent-items': {},
|
||||
'xtream-collection-items': {},
|
||||
'xtream-playback-positions': {},
|
||||
},
|
||||
}),
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
request: jest.fn().mockResolvedValue(undefined),
|
||||
sendKeepalive: jest.fn().mockResolvedValue({ status: 'saved' }),
|
||||
readJson: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const onWriteFailure = jest.fn();
|
||||
const storage = new ServerUserDataStorage(
|
||||
api as never,
|
||||
local,
|
||||
onWriteFailure
|
||||
);
|
||||
return { api, local, storage, onWriteFailure };
|
||||
}
|
||||
|
||||
it('hydrates synced keys into a prefixed cache', async () => {
|
||||
const { local, storage } = setup();
|
||||
|
||||
await storage.hydrate();
|
||||
|
||||
expect(storage.getItem('xtream-favorites')).toBe('{"p1":["live:1"]}');
|
||||
expect(local.values.get(FAVORITES_CACHE)).toBe('{"p1":["live:1"]}');
|
||||
expect(local.values.has('xtream-favorites')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends only changed and removed playlist entries', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p2: ['live:9'] }));
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p2',
|
||||
{ value: ['live:9'] },
|
||||
{}
|
||||
);
|
||||
expect(api.request).toHaveBeenCalledWith(
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ method: 'DELETE', headers: {} }
|
||||
);
|
||||
expect(api.sendJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes other keys to plain storage', () => {
|
||||
const { api, local, storage } = setup();
|
||||
|
||||
storage.setItem('xtream-playlists', '[]');
|
||||
|
||||
expect(local.values.get('xtream-playlists')).toBe('[]');
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes pending entries with keepalive', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
|
||||
storage.setItem('xtream-recent-items', JSON.stringify({ p1: [] }));
|
||||
storage.flushWithKeepalive();
|
||||
|
||||
expect(api.sendKeepalive).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-recent-items/p1',
|
||||
{ value: [] },
|
||||
{}
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports failed sends', async () => {
|
||||
const { api, storage, onWriteFailure } = setup();
|
||||
await storage.hydrate();
|
||||
api.sendJson.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['live:2'] }));
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(onWriteFailure).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an edit made while a refresh is reading', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
let answer: (value: unknown) => void = () => undefined;
|
||||
api.getJson.mockReturnValueOnce(
|
||||
new Promise((resolve) => (answer = resolve))
|
||||
);
|
||||
|
||||
const refresh = storage.hydrate();
|
||||
await settle();
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1'], p2: ['live:2'] })
|
||||
);
|
||||
// The server's older snapshot arrives after the edit.
|
||||
answer({ entries: { 'xtream-favorites': { p1: ['live:1'] } } });
|
||||
await refresh;
|
||||
|
||||
expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({
|
||||
p1: ['live:1'],
|
||||
p2: ['live:2'],
|
||||
});
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p2',
|
||||
{ value: ['live:2'] },
|
||||
{}
|
||||
);
|
||||
|
||||
// The next edit does not send a delete for the kept entry.
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:3'], p2: ['live:2'] })
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(api.request).not.toHaveBeenCalledWith(
|
||||
'/user-state/xtream-favorites/p2',
|
||||
{ method: 'DELETE', headers: {} }
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a newer batch only after the older one finished', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
const order: string[] = [];
|
||||
let finishFirst: () => void = () => undefined;
|
||||
api.sendJson
|
||||
.mockImplementationOnce(
|
||||
(_method: string, _path: string, body: { value: unknown }) => {
|
||||
order.push(`start ${JSON.stringify(body.value)}`);
|
||||
return new Promise<void>((resolve) => {
|
||||
finishFirst = () => {
|
||||
order.push('end first');
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
)
|
||||
.mockImplementation(
|
||||
(_method: string, _path: string, body: { value: unknown }) => {
|
||||
order.push(`start ${JSON.stringify(body.value)}`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
);
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['a'] }));
|
||||
const first = storage.flush();
|
||||
await settle();
|
||||
// A newer edit while the first batch is still travelling.
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['b'] }));
|
||||
const second = storage.flush();
|
||||
await settle();
|
||||
expect(order).toEqual(['start ["a"]']);
|
||||
|
||||
finishFirst();
|
||||
await first;
|
||||
await second;
|
||||
expect(order).toEqual(['start ["a"]', 'end first', 'start ["b"]']);
|
||||
});
|
||||
|
||||
it('keeps a failed write for the next flush and through a re-read', async () => {
|
||||
const { api, storage, onWriteFailure } = setup();
|
||||
await storage.hydrate();
|
||||
api.sendJson.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:2'] })
|
||||
);
|
||||
await storage.flush();
|
||||
expect(onWriteFailure).toHaveBeenCalled();
|
||||
|
||||
// Back online: the re-read sends it first, so the server has it.
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: { 'xtream-favorites': { p1: ['live:1', 'live:2'] } },
|
||||
});
|
||||
await storage.hydrate();
|
||||
expect(api.sendJson).toHaveBeenLastCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:2'] },
|
||||
{}
|
||||
);
|
||||
expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({
|
||||
p1: ['live:1', 'live:2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an unsent edit when a re-read succeeds while writes fail', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
api.sendJson.mockRejectedValue(new Error('500'));
|
||||
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:2'] })
|
||||
);
|
||||
await storage.hydrate();
|
||||
|
||||
expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({
|
||||
p1: ['live:1', 'live:2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('holds keepalive writes until a travelling batch finished', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
let finish: () => void = () => undefined;
|
||||
api.sendJson.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => (finish = resolve))
|
||||
);
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['a'] }));
|
||||
const travelling = storage.flush();
|
||||
await settle();
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['b'] }));
|
||||
storage.flushWithKeepalive();
|
||||
expect(api.sendKeepalive).not.toHaveBeenCalled();
|
||||
|
||||
finish();
|
||||
await travelling;
|
||||
await settle();
|
||||
expect(api.sendKeepalive).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['b'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('queues a page-hide write again when the server did not take it', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
api.sendKeepalive.mockResolvedValueOnce({ status: 'failed' });
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['big'] }));
|
||||
storage.flushWithKeepalive();
|
||||
await settle();
|
||||
|
||||
// Back on the page: the re-read sends it normally and keeps it.
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: { 'xtream-favorites': { p1: ['big'] } },
|
||||
});
|
||||
await storage.hydrate();
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['big'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('runs re-reads one after another', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
let answerFirst: (value: unknown) => void = () => undefined;
|
||||
api.getJson.mockReturnValueOnce(
|
||||
new Promise((resolve) => (answerFirst = resolve))
|
||||
);
|
||||
|
||||
const first = storage.hydrate();
|
||||
const second = storage.hydrate();
|
||||
await settle();
|
||||
expect(api.getJson).toHaveBeenCalledTimes(2);
|
||||
|
||||
answerFirst({ entries: {} });
|
||||
await first;
|
||||
await second;
|
||||
expect(api.getJson).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('lets a newer write wait for a page-hide write still travelling', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
let acknowledge: (outcome: KeepaliveOutcome) => void = () => undefined;
|
||||
api.sendKeepalive.mockReturnValueOnce(
|
||||
new Promise<KeepaliveOutcome>((resolve) => (acknowledge = resolve))
|
||||
);
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['old'] }));
|
||||
storage.flushWithKeepalive();
|
||||
// Back on the page, a newer edit.
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['new'] }));
|
||||
const newer = storage.flush();
|
||||
await settle();
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
|
||||
acknowledge({ status: 'saved' });
|
||||
await newer;
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['new'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('applies an edit made before the first read to the server value', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
// The cache from the last visit; no read from the server yet.
|
||||
local.setItem(
|
||||
FAVORITES_CACHE,
|
||||
JSON.stringify({ p1: ['a'], p2: ['b'] })
|
||||
);
|
||||
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['a', 'c'], p2: ['b'] })
|
||||
);
|
||||
await storage.flush();
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
|
||||
// Meanwhile another device added 'live:1' and 'z'.
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: {
|
||||
'xtream-favorites': { p1: ['live:1', 'a'], p2: ['b', 'z'] },
|
||||
},
|
||||
});
|
||||
await storage.hydrate();
|
||||
await storage.flush();
|
||||
|
||||
expect(api.sendJson.mock.calls).toEqual([
|
||||
[
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'a', 'c'] },
|
||||
{},
|
||||
],
|
||||
]);
|
||||
expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({
|
||||
p1: ['live:1', 'a', 'c'],
|
||||
p2: ['b', 'z'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the server favorites when a new device adds one early', async () => {
|
||||
const { api, storage } = setup();
|
||||
let answer: (value: unknown) => void = () => undefined;
|
||||
api.getJson.mockReturnValueOnce(
|
||||
new Promise((resolve) => (answer = resolve))
|
||||
);
|
||||
|
||||
const firstRead = storage.hydrate();
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['live:9'] }));
|
||||
answer({
|
||||
entries: { 'xtream-favorites': { p1: ['live:1', 'live:2'] } },
|
||||
});
|
||||
await firstRead;
|
||||
await storage.flush();
|
||||
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:2', 'live:9'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('never retries an older page-hide write over a newer one', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
const acks: ((outcome: KeepaliveOutcome) => void)[] = [];
|
||||
api.sendKeepalive.mockImplementation(
|
||||
() => new Promise<KeepaliveOutcome>((resolve) => acks.push(resolve))
|
||||
);
|
||||
|
||||
for (const value of ['A', 'B', 'C']) {
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: [value] })
|
||||
);
|
||||
storage.flushWithKeepalive();
|
||||
await settle();
|
||||
// Each page-hide write waits for the previous one.
|
||||
acks[acks.length - 1]({
|
||||
status: value === 'B' ? 'failed' : 'saved',
|
||||
});
|
||||
await settle();
|
||||
}
|
||||
|
||||
await storage.flush();
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a value that does not fit the cache readable and consistent', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
await storage.hydrate();
|
||||
// The server now has a second playlist the cache cannot take.
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: {
|
||||
'xtream-favorites': { p1: ['live:1'], p2: ['live:2'] },
|
||||
},
|
||||
});
|
||||
const setItem = local.setItem;
|
||||
local.setItem = (key: string, value: string) => {
|
||||
if (key === FAVORITES_CACHE) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
};
|
||||
await storage.hydrate();
|
||||
|
||||
// The app reads, edits and writes back, as PwaCollectionStorage does.
|
||||
const current = JSON.parse(storage.getItem('xtream-favorites') ?? '');
|
||||
expect(current).toEqual({ p1: ['live:1'], p2: ['live:2'] });
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ ...current, p1: ['live:1', 'live:3'] })
|
||||
);
|
||||
await storage.flush();
|
||||
local.setItem = setItem;
|
||||
|
||||
expect(api.request).not.toHaveBeenCalled();
|
||||
expect(api.sendJson.mock.calls).toEqual([
|
||||
[
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:3'] },
|
||||
{},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps unconfirmed writes across a reload and sends them first', async () => {
|
||||
const first = setup();
|
||||
await first.storage.hydrate();
|
||||
first.api.sendJson.mockRejectedValue(new Error('offline'));
|
||||
first.storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:9'] })
|
||||
);
|
||||
await first.storage.flush();
|
||||
expect(first.storage.hasUnsentWrites()).toBe(true);
|
||||
expect(savedWrites(first.local)).toMatchObject([
|
||||
{
|
||||
namespace: 'xtream-favorites',
|
||||
playlistId: 'p1',
|
||||
value: ['live:1', 'live:9'],
|
||||
},
|
||||
]);
|
||||
|
||||
// Reload: the page unloads, a new instance over the same storage.
|
||||
first.storage.close();
|
||||
const api = {
|
||||
...first.api,
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': { p1: ['live:1', 'live:9'] } },
|
||||
}),
|
||||
};
|
||||
const reloaded = new ServerUserDataStorage(
|
||||
api as never,
|
||||
first.local,
|
||||
jest.fn()
|
||||
);
|
||||
await reloaded.hydrate();
|
||||
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:9'] },
|
||||
{}
|
||||
);
|
||||
expect(reloaded.hasUnsentWrites()).toBe(false);
|
||||
expect(savedWrites(first.local)).toEqual([]);
|
||||
});
|
||||
|
||||
it('saves a write still travelling when the page hides', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
await storage.hydrate();
|
||||
api.sendJson.mockReturnValueOnce(new Promise(() => undefined));
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['x'] }));
|
||||
void storage.flush();
|
||||
await settle();
|
||||
storage.flushWithKeepalive();
|
||||
|
||||
expect(savedWrites(local)).toMatchObject([
|
||||
{ namespace: 'xtream-favorites', playlistId: 'p1', value: ['x'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops cache and saved writes of another server database', () => {
|
||||
const local = memoryStorage();
|
||||
local.setItem('iptvnator-server:cache-owner', 'old-db');
|
||||
local.setItem(
|
||||
'iptvnator-server:cache:old-db:xtream-favorites',
|
||||
'{"p1":["a"]}'
|
||||
);
|
||||
// An earlier build's unscoped key.
|
||||
local.setItem('iptvnator-server:xtream-favorites', '{"p1":["a"]}');
|
||||
local.setItem(
|
||||
'iptvnator-server:unconfirmed:old-db:tab',
|
||||
JSON.stringify({ at: 0, keys: ['xtream-favorites|p1'] })
|
||||
);
|
||||
local.setItem(
|
||||
'iptvnator-server:unsent-writes:old-db:tab',
|
||||
JSON.stringify([
|
||||
{
|
||||
namespace: 'xtream-favorites',
|
||||
playlistId: 'p1',
|
||||
value: ['a'],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const storage = new ServerUserDataStorage(
|
||||
{} as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'new-db'
|
||||
);
|
||||
|
||||
expect(storage.hasUnsentWrites()).toBe(false);
|
||||
expect(local.values.has('iptvnator-server:xtream-favorites')).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
local.values.has('iptvnator-server:cache:old-db:xtream-favorites')
|
||||
).toBe(false);
|
||||
expect(savedWrites(local, 'old-db')).toEqual([]);
|
||||
expect(
|
||||
local.values.has('iptvnator-server:unconfirmed:old-db:tab')
|
||||
).toBe(false);
|
||||
expect(local.values.get('iptvnator-server:cache-owner')).toBe('new-db');
|
||||
});
|
||||
|
||||
it('tells, once a first read in flight settled, whether it read the server', async () => {
|
||||
const { api, storage } = setup();
|
||||
api.getJson.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
const failed = storage.hydrate().catch(() => undefined);
|
||||
await expect(storage.ready()).resolves.toBe(false);
|
||||
await failed;
|
||||
// It starts no read of its own: an offline page is not held up.
|
||||
expect(api.getJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
const read = storage.hydrate();
|
||||
await expect(storage.ready()).resolves.toBe(true);
|
||||
await read;
|
||||
await expect(storage.ready()).resolves.toBe(true);
|
||||
expect(api.getJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the server again when a backup asks for fresh data', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
api.getJson.mockClear();
|
||||
|
||||
// Another device may have changed it since: read again.
|
||||
await expect(storage.ready(true)).resolves.toBe(true);
|
||||
expect(api.getJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
api.getJson.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(storage.ready(true)).resolves.toBe(false);
|
||||
// Without `fresh` an earlier read is enough.
|
||||
await expect(storage.ready()).resolves.toBe(true);
|
||||
expect(api.getJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never writes over another server instance's cache", async () => {
|
||||
const local = memoryStorage();
|
||||
const tab = (instanceId: string, favorites: unknown) =>
|
||||
new ServerUserDataStorage(
|
||||
{
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': favorites },
|
||||
}),
|
||||
} as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
instanceId
|
||||
);
|
||||
// The tab of the old backend stays open after a new one started.
|
||||
const old = tab('old-db', { p1: ['old'] });
|
||||
const current = tab('new-db', { p1: ['new'] });
|
||||
await current.hydrate();
|
||||
|
||||
await old.hydrate();
|
||||
|
||||
expect(current.getItem('xtream-favorites')).toBe('{"p1":["new"]}');
|
||||
});
|
||||
|
||||
it('keeps a newer travelling write when an older one is answered', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
await storage.hydrate();
|
||||
let answerOlder: () => void = () => undefined;
|
||||
api.sendJson.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => (answerOlder = resolve))
|
||||
);
|
||||
api.sendKeepalive.mockReturnValueOnce(new Promise(() => undefined));
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['old'] }));
|
||||
const older = storage.flush();
|
||||
await settle();
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['new'] }));
|
||||
storage.flushWithKeepalive();
|
||||
answerOlder();
|
||||
await older;
|
||||
await settle();
|
||||
|
||||
expect(storage.hasUnsentWrites()).toBe(true);
|
||||
expect(savedWrites(local)).toMatchObject([
|
||||
{ namespace: 'xtream-favorites', playlistId: 'p1', value: ['new'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps two early additions to an empty entry', async () => {
|
||||
const { api, storage } = setup();
|
||||
let answer: (value: unknown) => void = () => undefined;
|
||||
api.getJson.mockReturnValueOnce(
|
||||
new Promise((resolve) => (answer = resolve))
|
||||
);
|
||||
|
||||
const firstRead = storage.hydrate();
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A'] }));
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A', 'B'] }));
|
||||
answer({ entries: { 'xtream-favorites': { p1: ['S'] } } });
|
||||
await firstRead;
|
||||
await storage.flush();
|
||||
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['S', 'A', 'B'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps early edits across a reload before the first read', async () => {
|
||||
const first = setup();
|
||||
first.api.getJson.mockReturnValueOnce(new Promise(() => undefined));
|
||||
void first.storage.hydrate();
|
||||
first.storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['mine'] })
|
||||
);
|
||||
first.storage.close();
|
||||
|
||||
const api = {
|
||||
...first.api,
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': { p1: ['theirs'] } },
|
||||
}),
|
||||
};
|
||||
const reloaded = new ServerUserDataStorage(
|
||||
api as never,
|
||||
first.local,
|
||||
jest.fn()
|
||||
);
|
||||
await reloaded.hydrate();
|
||||
await reloaded.flush();
|
||||
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['theirs', 'mine'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it("does not replace another tab's unconfirmed edit on a re-read", async () => {
|
||||
const shared = memoryStorage();
|
||||
const api = {
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': { p1: ['live:1'] } },
|
||||
}),
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
request: jest.fn().mockResolvedValue(undefined),
|
||||
sendKeepalive: jest.fn().mockResolvedValue({ status: 'saved' }),
|
||||
readJson: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const tab = () =>
|
||||
new ServerUserDataStorage(
|
||||
api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
const tabA = tab();
|
||||
const tabB = tab();
|
||||
await tabA.hydrate();
|
||||
await tabB.hydrate();
|
||||
|
||||
// Tab A's edit is queued, not confirmed; tab B re-reads.
|
||||
tabA.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:7'] })
|
||||
);
|
||||
await tabB.hydrate();
|
||||
|
||||
expect(JSON.parse(tabB.getItem('xtream-favorites') ?? '')).toEqual({
|
||||
p1: ['live:1', 'live:7'],
|
||||
});
|
||||
// Tab B never takes over tab A's saved writes.
|
||||
expect(api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps server favorites through a cache-full first read and a later edit', async () => {
|
||||
const { api, local, storage } = setup();
|
||||
local.setItem(FAVORITES_CACHE, '{"p1":["A"]}');
|
||||
storage.setItem('xtream-favorites', '{"p1":["A","B"]}');
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: { 'xtream-favorites': { p1: ['S'] } },
|
||||
});
|
||||
const setItem = local.setItem;
|
||||
local.setItem = (key: string, value: string) => {
|
||||
if (key === FAVORITES_CACHE) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
};
|
||||
await storage.hydrate();
|
||||
|
||||
const current = JSON.parse(storage.getItem('xtream-favorites') ?? '');
|
||||
storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: [...current.p1, 'C'] })
|
||||
);
|
||||
await storage.flush();
|
||||
local.setItem = setItem;
|
||||
|
||||
expect(api.sendJson).toHaveBeenLastCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['S', 'B', 'C'] },
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
import {
|
||||
isServerUserStateNamespace,
|
||||
SERVER_USER_STATE_NAMESPACES,
|
||||
type ServerUserStateNamespace,
|
||||
type ServerUserStateResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import type { ServerApiClient } from './server-api-client';
|
||||
import {
|
||||
addStep,
|
||||
idMap,
|
||||
parseMap,
|
||||
splitEditKey,
|
||||
UNKNOWN_TAG,
|
||||
} from './server-user-data-entries';
|
||||
import {
|
||||
adoptPageEdits,
|
||||
extendUnsent,
|
||||
restorePage,
|
||||
settleWrites,
|
||||
} from './server-user-data-adopt';
|
||||
import { keepPages, swapPage } from './server-page-pointer';
|
||||
import { UserDataCache } from './server-user-data-cache';
|
||||
import {
|
||||
changedEntries,
|
||||
type DeferredEdit,
|
||||
mergeEdits,
|
||||
refreshedNamespace,
|
||||
withoutDeleted,
|
||||
} from './server-user-data-edit';
|
||||
import { type ServerApi, UserDataWriteQueue } from './server-user-data-queue';
|
||||
import {
|
||||
claimCache,
|
||||
hasRecord,
|
||||
markClosed,
|
||||
markUnconfirmed,
|
||||
orphanedPages,
|
||||
pageIsLive,
|
||||
othersUnconfirmed,
|
||||
removePage,
|
||||
saveDeferredEdits,
|
||||
saveUnsentWrites,
|
||||
} from './server-unsent-writes';
|
||||
|
||||
/**
|
||||
* Write-through `USER_DATA_STORAGE` for server mode. Synced namespaces are
|
||||
* maps keyed by playlist id; a write compares the new map with the one the
|
||||
* caller read and sends only the playlist entries that changed, each based
|
||||
* on the server version it was read at. When another device changed the
|
||||
* entry meanwhile, the server answers `412` and the edit is replayed on its
|
||||
* current value (`UserDataWriteQueue`). Other keys use plain storage.
|
||||
*/
|
||||
export class ServerUserDataStorage implements UserDataStorage {
|
||||
private readonly writes: UserDataWriteQueue;
|
||||
private unsentSaved = false;
|
||||
/**
|
||||
* Edits made before the first successful read: the cache they were
|
||||
* made against may be empty or old, so they are replayed on the
|
||||
* server's value once it is known (`applyEdit`).
|
||||
*/
|
||||
private readonly deferred = new Map<string, DeferredEdit>();
|
||||
private hydrated = false;
|
||||
/** The previous page load in this tab, while it still looks live. */
|
||||
private awaited: string[] = [];
|
||||
/** A record stands for this page: a stale one is adopted elsewhere. */
|
||||
private recorded = false;
|
||||
/** Playlists deleted here, in order: a read sent earlier skips them. */
|
||||
private readonly deleted: (string | undefined)[] = [];
|
||||
/** Closed tabs' page loads this one took over (once each). */
|
||||
private readonly adopted = new Set<string>();
|
||||
/** Page loads taken over whose copy stays until this one saves. */
|
||||
private readonly retained = new Set<string>();
|
||||
/** This page load: names its saved writes and its registry record. */
|
||||
private readonly tabId = Math.random().toString(36).slice(2);
|
||||
private readonly cache: UserDataCache;
|
||||
/**
|
||||
* What `getItem` last handed out (or a write left) per namespace: the
|
||||
* map a caller edits, so its write is compared with that, not with
|
||||
* changes another tab or a re-read made to the cache since.
|
||||
*/
|
||||
private readonly handedOut = new Map<
|
||||
ServerUserStateNamespace,
|
||||
string | null
|
||||
>();
|
||||
/** Re-reads run one at a time, so an older one never lands last. */
|
||||
private hydration: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly api: ServerApi & Pick<ServerApiClient, 'getJson'>,
|
||||
private readonly local: UserDataStorage,
|
||||
onWriteFailure: (error: unknown) => void,
|
||||
/** Scopes the cache and saved writes to one server database. */
|
||||
private readonly instanceId = 'default',
|
||||
/**
|
||||
* The tab's own storage (`sessionStorage` in the app): remembers
|
||||
* its page load, so a reload takes over the writes it saved.
|
||||
*/
|
||||
private readonly tabStore: UserDataStorage = local
|
||||
) {
|
||||
this.cache = new UserDataCache(local, instanceId);
|
||||
this.writes = new UserDataWriteQueue(api, {
|
||||
changed: () => this.saveUnsent(),
|
||||
show: (namespace, playlistId, value) =>
|
||||
this.cache.setEntry(namespace, playlistId, value),
|
||||
writeFailed: onWriteFailure,
|
||||
maySend: () => !this.superseded(),
|
||||
});
|
||||
claimCache(local, instanceId);
|
||||
const [previous, ...kept] = swapPage(tabStore, instanceId, this.tabId);
|
||||
// Older page loads whose copies a reload kept come first.
|
||||
const pages = previous ? [...kept, previous] : kept;
|
||||
// A duplicated tab copies the page load of a tab that still runs,
|
||||
// and the ones it keeps; so does a reload whose close flag could not
|
||||
// be saved: `touch` adopts them once it no longer looks live.
|
||||
if (previous && pageIsLive(local, instanceId, previous)) {
|
||||
this.awaited = pages;
|
||||
} else {
|
||||
pages.forEach((page) => this.takeOver(page));
|
||||
}
|
||||
}
|
||||
|
||||
/** The page unloads: a reload of this tab may take its writes over. */
|
||||
close(): void {
|
||||
markClosed(this.local, this.instanceId, this.tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reload of this tab: the writes its last page load could not confirm
|
||||
* go out with the first flush, and the first read keeps them.
|
||||
*/
|
||||
private takeOver(page: string): void {
|
||||
restorePage(
|
||||
this.writes,
|
||||
this.deferred,
|
||||
this.local,
|
||||
this.instanceId,
|
||||
page
|
||||
);
|
||||
// Saved under this page load before the old one is forgotten; if
|
||||
// storage refuses the copy, the old one stays.
|
||||
const saved = [this.saveDeferred(), this.saveUnsent()].every(Boolean);
|
||||
this.publishUnconfirmed();
|
||||
this.release(page, saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved writes and early edits of closed tabs: shown on what this tab
|
||||
* shows now and queued with its writes, then forgotten there.
|
||||
*/
|
||||
private adoptClosedTabs(): void {
|
||||
for (const page of orphanedPages(
|
||||
this.local,
|
||||
this.instanceId,
|
||||
this.tabId
|
||||
).filter((page) => !this.adopted.has(page))) {
|
||||
this.adoptPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
/** Its edits go ahead of this tab's own, which may be newer. */
|
||||
private adoptPage(page: string): void {
|
||||
adoptPageEdits(this.writes, this.cache, {
|
||||
local: this.local,
|
||||
instanceId: this.instanceId,
|
||||
tabId: this.tabId,
|
||||
page,
|
||||
});
|
||||
this.adopted.add(page);
|
||||
// The other page's copy goes only once this tab saved its own.
|
||||
this.release(page, this.saveUnsent());
|
||||
this.writes.scheduleFlush();
|
||||
}
|
||||
|
||||
/** Tells other tabs this one is alive and still sending its writes. */
|
||||
touch(): void {
|
||||
const pages = this.awaited;
|
||||
const owner = pages[pages.length - 1];
|
||||
if (owner && !pageIsLive(this.local, this.instanceId, owner)) {
|
||||
// This page may have read and edited since: adopt, not restore.
|
||||
// Each adopted page goes ahead of what is queued: newest first.
|
||||
this.awaited = [];
|
||||
[...pages].reverse().forEach((page) => this.adoptPage(page));
|
||||
}
|
||||
// A tab with nothing to sync never re-reads, which is where closed
|
||||
// tabs' saved writes are otherwise picked up.
|
||||
if (this.hydrated) {
|
||||
this.adoptClosedTabs();
|
||||
}
|
||||
if (this.hasUnsentWrites()) {
|
||||
this.publishUnconfirmed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Back from the back-forward cache or a suspended background tab, where
|
||||
* its record could go stale: false when another tab took its writes
|
||||
* over meanwhile, which this page must then not send again (it reloads
|
||||
* instead).
|
||||
*/
|
||||
resumed(): boolean {
|
||||
this.publishUnconfirmed();
|
||||
return !this.superseded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Its record is gone: another tab took this page's writes over, so
|
||||
* nothing more of them goes out or is saved here.
|
||||
*/
|
||||
private superseded(): boolean {
|
||||
return (
|
||||
this.recorded && !hasRecord(this.local, this.instanceId, this.tabId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Not read yet, or holding writes the server has not taken. */
|
||||
needsSync(): boolean {
|
||||
return !this.hydrated || this.hasUnsentWrites();
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a first read in flight settled: whether reads show the server's
|
||||
* data. Starts no read, so an offline page is not held up; `fresh`
|
||||
* (backups) reads the server again and tells whether that succeeded.
|
||||
*/
|
||||
async ready(fresh = false): Promise<boolean> {
|
||||
if (fresh) {
|
||||
return this.hydrate().then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
await (this.hydrated ? undefined : this.hydration);
|
||||
return this.hydrated;
|
||||
}
|
||||
|
||||
/** Whether a write is queued, being retried, or still travelling. */
|
||||
hasUnsentWrites(): boolean {
|
||||
return this.writes.hasUnsent() || this.deferred.size > 0;
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
if (!isServerUserStateNamespace(key)) {
|
||||
return this.local.getItem(key);
|
||||
}
|
||||
const value = this.cache.read(key);
|
||||
this.handedOut.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.write(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.write(key, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's new map. When the cache changed since the caller read it
|
||||
* (another tab, a re-read), only the entries it changed are applied to
|
||||
* the cache as it is now; the rest stays. Other keys use plain storage.
|
||||
*/
|
||||
private write(namespace: string, value: string | null): void {
|
||||
if (!isServerUserStateNamespace(namespace)) {
|
||||
if (value === null) {
|
||||
this.local.removeItem(namespace);
|
||||
} else {
|
||||
this.local.setItem(namespace, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const current = this.cache.read(namespace);
|
||||
const before = parseMap(current ?? '{}');
|
||||
const read = this.handedOut.has(namespace)
|
||||
? (this.handedOut.get(namespace) ?? null)
|
||||
: current;
|
||||
let next = parseMap(value ?? '{}');
|
||||
if (read !== current) {
|
||||
next = mergeEdits(before, parseMap(read ?? '{}'), next);
|
||||
value = JSON.stringify(next);
|
||||
}
|
||||
this.cache.write(namespace, value);
|
||||
this.handedOut.set(namespace, value);
|
||||
this.queueChanges(namespace, next, before);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the cache with the server's data, after sending pending
|
||||
* writes. Entries edited while the read was in flight, and entries whose
|
||||
* write failed, keep their local value: the server does not have it yet.
|
||||
*/
|
||||
hydrate(): Promise<void> {
|
||||
const run = this.hydration.then(() => this.hydrateNow());
|
||||
this.hydration = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Sends the queued entries after any batch still travelling. */
|
||||
readonly flush = (): Promise<void> => this.writes.flush();
|
||||
|
||||
/** See `UserDataStorage.flushed`. */
|
||||
readonly flushed = (playlistId?: string) =>
|
||||
settleWrites(this.writes, playlistId);
|
||||
|
||||
/** Page hide: sends the queue with requests that survive the page. */
|
||||
flushWithKeepalive(): void {
|
||||
this.writes.flushWithKeepalive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the server delete of a playlist (without an id, of every
|
||||
* playlist). Its user-data edits wait meanwhile and writes already
|
||||
* travelling land first, so none recreates deleted data; a delete that
|
||||
* succeeded drops its queued and early edits and its cache, a failed
|
||||
* one leaves them to be sent as before.
|
||||
*/
|
||||
async deleting(
|
||||
playlistId: string | undefined,
|
||||
remove: () => Promise<unknown>
|
||||
): Promise<void> {
|
||||
const matches = (key: string) =>
|
||||
playlistId === undefined || splitEditKey(key)[1] === playlistId;
|
||||
const release = this.writes.hold(matches);
|
||||
try {
|
||||
await this.writes.whenSent();
|
||||
await remove();
|
||||
this.deleted.push(playlistId);
|
||||
for (const key of [...this.deferred.keys()].filter(matches)) {
|
||||
this.deferred.delete(key);
|
||||
}
|
||||
this.saveDeferred();
|
||||
this.writes.drop(matches, playlistId);
|
||||
this.cache.forget(playlistId);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateNow(): Promise<void> {
|
||||
// Edits from here on may miss the read: queued, or still travelling.
|
||||
const editsBefore = this.writes.lastEdit;
|
||||
const deletesBefore = this.deleted.length;
|
||||
await this.writes.flush();
|
||||
const sent = this.cache.snapshot();
|
||||
const foreign = () =>
|
||||
othersUnconfirmed(this.local, this.instanceId, this.tabId);
|
||||
const foreignBefore = foreign();
|
||||
const { entries, tags } = withoutDeleted(
|
||||
await this.api.getJson<ServerUserStateResponse>('/user-state'),
|
||||
this.deleted.slice(deletesBefore)
|
||||
);
|
||||
const unsaved = this.writes.unsavedSince(editsBefore);
|
||||
// In the shared cache: other tabs' writes unconfirmed when this read
|
||||
// went out or now (one confirmed meanwhile is newer than its answer),
|
||||
// and what a tab saved while it travelled.
|
||||
for (const key of [
|
||||
...foreignBefore,
|
||||
...foreign(),
|
||||
...this.cache.changedSince(sent),
|
||||
]) {
|
||||
unsaved.add(key);
|
||||
}
|
||||
const deferred = [...this.deferred.values()];
|
||||
// This tab's own unsent writes (recovered after a reload): an early
|
||||
// edit was made on them, not on the server's value.
|
||||
const own = new Set(this.writes.unconfirmedKeys());
|
||||
const base = new Map<string, unknown>();
|
||||
const refreshed = new Map<string, Record<string, unknown>>();
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const keepCached = new Set<string>();
|
||||
for (const key of unsaved) {
|
||||
const [editedNamespace, playlistId] = splitEditKey(key);
|
||||
if (editedNamespace === namespace) {
|
||||
keepCached.add(playlistId);
|
||||
}
|
||||
}
|
||||
const cached = this.cache.map(namespace);
|
||||
const early = deferred.filter(
|
||||
(edit) => edit.namespace === namespace
|
||||
);
|
||||
for (const { playlistId } of early) {
|
||||
const key = `${namespace}|${playlistId}`;
|
||||
base.set(
|
||||
key,
|
||||
own.has(key)
|
||||
? this.writes.unsent(key)?.value
|
||||
: entries[namespace]?.[playlistId]
|
||||
);
|
||||
}
|
||||
const value = refreshedNamespace(
|
||||
entries[namespace] ?? {},
|
||||
cached,
|
||||
keepCached,
|
||||
early,
|
||||
(playlistId) => base.get(`${namespace}|${playlistId}`)
|
||||
);
|
||||
refreshed.set(namespace, value);
|
||||
// A value too big for browser storage stays readable in memory,
|
||||
// so callers and the diff baseline see the same map.
|
||||
this.cache.write(namespace, JSON.stringify(value));
|
||||
if (tags) {
|
||||
// A kept value is not what the server's tag describes: its
|
||||
// next write must conflict and be replayed on the server's.
|
||||
const fresh = idMap(tags[namespace]);
|
||||
for (const playlistId of keepCached) {
|
||||
fresh[playlistId] = UNKNOWN_TAG;
|
||||
}
|
||||
this.writes.tags.set(namespace, fresh);
|
||||
}
|
||||
}
|
||||
this.writes.settledUpTo(editsBefore);
|
||||
if (!this.hydrated) {
|
||||
// From here edits go straight to the queue.
|
||||
this.hydrated = true;
|
||||
this.deferred.clear();
|
||||
for (const edit of deferred) {
|
||||
const key = `${edit.namespace}|${edit.playlistId}`;
|
||||
if (own.has(key)) {
|
||||
extendUnsent(this.writes, key, edit.steps);
|
||||
} else {
|
||||
this.writes.queue(
|
||||
edit.namespace,
|
||||
edit.playlistId,
|
||||
refreshed.get(edit.namespace)?.[edit.playlistId],
|
||||
base.get(key)
|
||||
);
|
||||
}
|
||||
}
|
||||
this.saveDeferred();
|
||||
this.writes.scheduleFlush();
|
||||
}
|
||||
this.adoptClosedTabs();
|
||||
}
|
||||
|
||||
private queueChanges(
|
||||
namespace: ServerUserStateNamespace,
|
||||
next: Record<string, unknown>,
|
||||
previous: Record<string, unknown>
|
||||
): void {
|
||||
// The cache shows other tabs' unsent changes too.
|
||||
const foreign = this.hydrated
|
||||
? othersUnconfirmed(this.local, this.instanceId, this.tabId)
|
||||
: new Set<string>();
|
||||
const change = (playlistId: string, value: unknown) => {
|
||||
if (this.hydrated) {
|
||||
this.writes.queue(
|
||||
namespace,
|
||||
playlistId,
|
||||
value,
|
||||
previous[playlistId],
|
||||
{ foreign: foreign.has(`${namespace}|${playlistId}`) }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${namespace}|${playlistId}`;
|
||||
this.deferred.set(key, {
|
||||
namespace,
|
||||
playlistId,
|
||||
steps: addStep(
|
||||
this.deferred.get(key)?.steps ?? [],
|
||||
previous[playlistId],
|
||||
value
|
||||
),
|
||||
});
|
||||
this.saveDeferred();
|
||||
this.publishUnconfirmed();
|
||||
};
|
||||
for (const [playlistId, value] of changedEntries(previous, next)) {
|
||||
change(playlistId, value);
|
||||
}
|
||||
this.writes.scheduleFlush();
|
||||
}
|
||||
|
||||
/** Persists what the server has not confirmed; skips idle rewrites. */
|
||||
/** False when browser storage refused the copy. */
|
||||
private saveUnsent(): boolean {
|
||||
if (this.superseded()) {
|
||||
return false;
|
||||
}
|
||||
// A newer edit can be queued while an older one is still travelling.
|
||||
const writes = this.writes.unsentWrites();
|
||||
if (writes.length === 0 && !this.unsentSaved) {
|
||||
return true;
|
||||
}
|
||||
const saved = saveUnsentWrites(
|
||||
this.local,
|
||||
this.instanceId,
|
||||
this.tabId,
|
||||
writes
|
||||
);
|
||||
this.unsentSaved = writes.length > 0 || !saved;
|
||||
this.publishUnconfirmed();
|
||||
if (saved && this.retained.size > 0 && this.saveDeferred()) {
|
||||
// This page's copy now stands for the ones it kept.
|
||||
for (const page of this.retained) {
|
||||
removePage(this.local, this.instanceId, page);
|
||||
}
|
||||
this.retained.clear();
|
||||
keepPages(this.tabStore, this.instanceId, this.tabId, []);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Forgets another page load's copy, or keeps it until this one saves. */
|
||||
private release(page: string, saved: boolean): void {
|
||||
if (saved) {
|
||||
removePage(this.local, this.instanceId, page);
|
||||
} else {
|
||||
this.retained.add(page);
|
||||
keepPages(this.tabStore, this.instanceId, this.tabId, [
|
||||
...this.retained,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private saveDeferred(): boolean {
|
||||
return saveDeferredEdits(this.local, this.instanceId, this.tabId, [
|
||||
...this.deferred.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
private publishUnconfirmed(): void {
|
||||
// Superseded, it stays so: the record is not written again.
|
||||
this.recorded =
|
||||
this.superseded() ||
|
||||
markUnconfirmed(this.local, this.instanceId, this.tabId, [
|
||||
...this.writes.unconfirmedKeys(),
|
||||
...this.deferred.keys(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
|
||||
import { idMap } from './server-user-data-entries';
|
||||
|
||||
/** The server version tag of each user-state entry, by namespace. */
|
||||
export class UserStateTags {
|
||||
/** Absent for a namespace until the first read. */
|
||||
private readonly tags = new Map<
|
||||
ServerUserStateNamespace,
|
||||
Record<string, string>
|
||||
>();
|
||||
|
||||
set(
|
||||
namespace: ServerUserStateNamespace,
|
||||
tags: Readonly<Record<string, string>>
|
||||
): void {
|
||||
this.tags.set(namespace, idMap(tags));
|
||||
}
|
||||
|
||||
/** `undefined` until the server's tags are known (or not offered). */
|
||||
of(
|
||||
namespace: ServerUserStateNamespace,
|
||||
playlistId: string
|
||||
): string | null | undefined {
|
||||
const tags = this.tags.get(namespace);
|
||||
return tags ? (tags[playlistId] ?? null) : undefined;
|
||||
}
|
||||
|
||||
/** `null`: the entry is gone. */
|
||||
update(
|
||||
namespace: ServerUserStateNamespace,
|
||||
playlistId: string,
|
||||
tag: string | null
|
||||
): void {
|
||||
const tags = this.tags.get(namespace);
|
||||
if (!tags) {
|
||||
return;
|
||||
}
|
||||
if (tag === null) {
|
||||
delete tags[playlistId];
|
||||
} else {
|
||||
tags[playlistId] = tag;
|
||||
}
|
||||
}
|
||||
|
||||
/** After a delete: one playlist's tags, without an id all of them. */
|
||||
forget(playlistId?: string): void {
|
||||
for (const [namespace, tags] of this.tags) {
|
||||
if (playlistId === undefined) {
|
||||
this.tags.set(namespace, {});
|
||||
} else {
|
||||
delete tags[playlistId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ServerApiError } from './server-api-client';
|
||||
|
||||
/** Browser storage in memory, with the `keys` listing tabs use. */
|
||||
export function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
keys: () => [...values.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
/** A server that keeps entries, tags them by content and refuses stale tags. */
|
||||
export function fakeServer(
|
||||
initial: Record<string, Record<string, unknown>> = {}
|
||||
) {
|
||||
const state: Record<string, Record<string, unknown>> = {
|
||||
'xtream-favorites': {},
|
||||
'xtream-recent-items': {},
|
||||
'xtream-collection-items': {},
|
||||
'xtream-playback-positions': {},
|
||||
...structuredClone(initial),
|
||||
};
|
||||
/** Playlists deleted on the server: their user state is refused. */
|
||||
const deleted = new Set<string>();
|
||||
const tagOf = (value: unknown) =>
|
||||
value === undefined
|
||||
? null
|
||||
: `t${JSON.stringify(value).replace(/"/g, "'")}`;
|
||||
const answer = (body: unknown) =>
|
||||
({ json: async () => body }) as unknown as Response;
|
||||
const write = (
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
value?: unknown
|
||||
) => {
|
||||
const [, , namespace, playlistId] = path.split('/');
|
||||
if (value !== undefined && deleted.has(playlistId)) {
|
||||
throw new ServerApiError(404, 'playlist_missing', 'gone');
|
||||
}
|
||||
const current = state[namespace][playlistId];
|
||||
const expected =
|
||||
headers['If-None-Match'] === '*'
|
||||
? null
|
||||
: headers['If-Match']?.replace(/^"|"$/g, '');
|
||||
if (expected !== undefined && expected !== tagOf(current)) {
|
||||
throw new ServerApiError(412, 'conflict', 'changed', {
|
||||
...(current === undefined ? {} : { value: current }),
|
||||
tag: tagOf(current),
|
||||
});
|
||||
}
|
||||
if (value === undefined) {
|
||||
delete state[namespace][playlistId];
|
||||
} else {
|
||||
state[namespace][playlistId] = value;
|
||||
}
|
||||
return answer({ tag: tagOf(value) });
|
||||
};
|
||||
const api = {
|
||||
getJson: jest.fn(async () => {
|
||||
const tags: Record<string, Record<string, string>> = {};
|
||||
for (const [namespace, entries] of Object.entries(state)) {
|
||||
tags[namespace] = Object.fromEntries(
|
||||
Object.entries(entries).map(([id, value]) => [
|
||||
id,
|
||||
tagOf(value) as string,
|
||||
])
|
||||
);
|
||||
}
|
||||
return { entries: structuredClone(state), tags };
|
||||
}),
|
||||
sendJson: jest.fn(
|
||||
async (
|
||||
_method: string,
|
||||
path: string,
|
||||
body: { value: unknown },
|
||||
headers: Record<string, string> = {}
|
||||
) => write(path, headers, body.value)
|
||||
),
|
||||
request: jest.fn(
|
||||
async (path: string, init: { headers?: Record<string, string> }) =>
|
||||
write(path, init.headers ?? {})
|
||||
),
|
||||
readJson: jest.fn((response: Response) => response.json()),
|
||||
sendKeepalive: jest.fn(
|
||||
async (
|
||||
_method: string,
|
||||
path: string,
|
||||
body?: { value: unknown },
|
||||
headers: Record<string, string> = {}
|
||||
) => {
|
||||
try {
|
||||
const response = write(path, headers, body?.value);
|
||||
const { tag } = await response.json();
|
||||
return { status: 'saved' as const, tag };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ServerApiError &&
|
||||
error.code === 'playlist_missing'
|
||||
) {
|
||||
return { status: 'gone' as const };
|
||||
}
|
||||
return error instanceof ServerApiError && error.current
|
||||
? {
|
||||
status: 'conflict' as const,
|
||||
current: error.current,
|
||||
}
|
||||
: { status: 'failed' as const };
|
||||
}
|
||||
}
|
||||
),
|
||||
};
|
||||
return { state, api, deleted };
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/** The slice of the Web Storage API that PWA user-data stores rely on. */
|
||||
/**
|
||||
* The slice of the Web Storage API that PWA user-data stores rely on, plus
|
||||
* the stored keys where the store can list them, and where writes reach a
|
||||
* source later (server mode): `ready`, which waits for a first read in
|
||||
* flight and tells whether one succeeded (`fresh`: reads again, for
|
||||
* backups that must see the source's current data), and `flushed`, which sends what
|
||||
* is queued and tells whether all of it (of one playlist) was taken.
|
||||
*/
|
||||
export type UserDataStorage = Pick<
|
||||
Storage,
|
||||
'getItem' | 'setItem' | 'removeItem'
|
||||
>;
|
||||
> & {
|
||||
keys?(): string[];
|
||||
ready?(fresh?: boolean): Promise<boolean>;
|
||||
flushed?(playlistId?: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `localStorage` behind an adapter that reads the global only inside each
|
||||
@@ -17,6 +28,26 @@ export function createBrowserUserDataStorage(): UserDataStorage {
|
||||
getItem: (key) => globalThis.localStorage.getItem(key),
|
||||
setItem: (key, value) => globalThis.localStorage.setItem(key, value),
|
||||
removeItem: (key) => globalThis.localStorage.removeItem(key),
|
||||
keys: () => {
|
||||
const storage = globalThis.localStorage;
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key !== null) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The same lazy adapter over `sessionStorage`: one per tab, kept on reload. */
|
||||
export function createBrowserSessionStorage(): UserDataStorage {
|
||||
return {
|
||||
getItem: (key) => globalThis.sessionStorage.getItem(key),
|
||||
setItem: (key, value) => globalThis.sessionStorage.setItem(key, value),
|
||||
removeItem: (key) => globalThis.sessionStorage.removeItem(key),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user