fix(xtream): preserve pending backups and restore positions atomically (#1873)

* fix(xtream): preserve pending backups and restore positions atomically

* fix(xtream): retain sparse duplicate playback metadata on restore

* fix(xtream): require atomic restore in SQLite capability gate

* fix(xtream): preflight atomic restore without changing storage
This commit is contained in:
4gray authored and GitHub committed 2026-10-10 16:27:01 +02:00
1 parent 2b400cb81d
commit 2fa03c8efc
25 files changed
+1037 -142

No files matched your search

+40 -12
View File
@@ -5,13 +5,13 @@ settings screen.
## Entry Points
- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts`
- UI: `apps/web/src/app/settings/settings-backup-section.component.ts`
(embedded in `settings.component.html`), with the file read/handoff in
`/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup.facade.ts`
- Backup service: `/Users/4gray/Code/iptvnator/libs/services/src/lib/playlist-backup.service.ts`
- Manifest types: `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
`apps/web/src/app/settings/settings-backup.facade.ts`
- Backup service: `libs/services/src/lib/playlist-backup.service.ts`
- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
- Xtream pending restore storage:
`/Users/4gray/Code/iptvnator/libs/services/src/lib/xtream-pending-restore.service.ts`
`libs/services/src/lib/xtream-pending-restore.service.ts`
## Manifest Contract
@@ -63,6 +63,19 @@ Xtream backups export only connection metadata plus portable user state.
- favorites by `{ contentType, xtreamId, addedAt?, position? }`
- recently viewed by `{ contentType, xtreamId, viewedAt }`
- playback positions as `PlaybackPositionData[]`
- optional VOD source pins by `{ matchKey, contentId, updatedAt? }`
An imported Xtream archive can wait for its catalog to download. Export reads
that pending snapshot before consulting the catalog: its hidden categories,
favorites, history, and positions are authoritative, including empty lists.
Pending pins are authoritative when present; an absent `sourcePins` field keeps
the existing store's pins when that store is available, or stays absent when it
is unavailable. Re-export neither applies nor consumes the pending snapshot,
so it also works before the portal has been opened and in browser runtimes.
Parental category locks still come from the current lock store.
A failed pending-storage or playback-position read fails export. An unreadable
collection must never be serialized as a successful empty collection.
Explicitly excluded:
@@ -164,11 +177,10 @@ worker IPC boundary in the snake_case wire shape declared by
`XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project
their Drizzle rows explicitly to keep that contract true.
Clearing the playlist's existing pins goes through a dedicated
delete-by-playlist operation, not the keyed clear: that one caps its key list
to bound an IN clause, so a playlist with more pinned movies than the cap kept
the surplus while still reporting success. A failure now fails the entry
rather than leaving the union of old and archived pins.
Replacing the playlist's pins deletes by playlist and writes the replacements
in one transaction. It does not use the capped keyed-clear operation, which
could leave surplus pins behind. Any replacement failure fails the entry and
retains the pending snapshot for retry.
`sourcePins` (VOD multi-source) is the one **optional** collection, and the
normalizer preserves that: an absent field stays absent rather than becoming
@@ -190,12 +202,28 @@ Electron restore behavior:
1. Category import reads pending hidden-category state while saving categories.
2. After content import, favorites/recent state is restored by typed
`{ contentType, xtreamId }` matching.
3. Playback positions are cleared and re-applied from backup state.
3. Playback positions are replaced for the destination playlist in one SQLite
transaction through `DB_REPLACE_ALL_PLAYBACK_POSITIONS`. Empty means clear;
a failed insert rolls back the deletion and all earlier inserts.
4. VOD source pins are re-applied against the IMPORTED playlist id.
For existing Xtream playlists with a fully populated offline cache, backup
import applies the restore immediately. Otherwise the typed restore payload is
left pending until the next Xtream initialization/import.
left pending until the next Xtream initialization/import. Both paths use strict
playback-position replacement: unavailable storage, rejected IPC, or an
unsuccessful response fail the restore and leave the pending snapshot intact.
Atomic restore support is checked separately from the ordinary Xtream SQLite
capability gate. An older preload missing the replacement method keeps using
SQLite for playback and backup export. Import parks the snapshot first, then
rejects unsupported atomic restore before checking catalog completeness or
writing category visibility, collections, positions, or pins. Deferred restore
performs the same preflight before applying user state. The retained snapshot
can be retried once atomic replacement becomes available. Ordinary playback
keeps its existing capability checks and best-effort persistence API.
The whole restore is not one database transaction; already applied
categories/favorites/history can
remain after a later failure, but the snapshot remains available to retry the
complete restore. It is consumed only after every restore step succeeds.
## Current UX
+14
View File
@@ -661,6 +661,20 @@ is unavailable or the SQLite playlist migration has not completed.
4. `DB_GET_RECENT_PLAYBACK_POSITIONS`
5. `DB_GET_ALL_PLAYBACK_POSITIONS`
6. `DB_CLEAR_PLAYBACK_POSITION`
7. `DB_REPLACE_ALL_PLAYBACK_POSITIONS`
Backup restore uses `DB_REPLACE_ALL_PLAYBACK_POSITIONS` to replace only the
specified playlist's positions in one synchronous transaction. Every insert
and the delete use `.run()` so an insertion failure rolls the whole replacement
back. An empty replacement deliberately clears that playlist. Renderer backup
callers propagate read/write errors and retain pending restore state for retry;
normal playback persistence keeps its best-effort behavior. The renderer checks
atomic replacement support at the start of the immediate or deferred restore
pass, before updating existing categories, collections, positions, or pins.
Missing support fails restore with the pending snapshot retained; it does not
switch ordinary Xtream storage or backup export away from SQLite. Deferred
catalog import can still seed newly imported categories from pending visibility
state before that restore pass runs.
## SQLite Concurrency Rules