Files
iptvnator/docs/architecture/playlist-backup-restore.md
T
4gray 2fa03c8efc 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
2026-10-10 16:27:01 +02:00

240 lines
9.1 KiB
Markdown

# Playlist Backup/Restore Architecture
This document describes the versioned playlist backup/restore flow used by the
settings screen.
## Entry Points
- UI: `apps/web/src/app/settings/settings-backup-section.component.ts`
(embedded in `settings.component.html`), with the file read/handoff in
`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:
`libs/services/src/lib/xtream-pending-restore.service.ts`
## Manifest Contract
Backups are versioned JSON manifests, not raw `Playlist[]` dumps and not SQLite
database snapshots.
Top-level shape:
- `kind: "iptvnator-playlist-backup"`
- `version: 1`
- `exportedAt`
- `includeSecrets`
- `settings?.epgUrls`
- `playlists[]`
The manifest is portable across machines because it stores playlist definitions
and portable user state, while excluding cache-only database content.
## Export Scope
### M3U
M3U backups are self-contained.
- Always export canonical `rawM3u` from `PlaylistsService.getRawPlaylistById()`
- Preserve source metadata when available:
- original source kind: `url`, `file`, or `text`
- original URL
- `userAgent`, `referrer`, `origin`
- `filePathHint` for provenance only
- Export playlist-scoped user state:
- favorites by channel URL
- recently viewed M3U items
- hidden group titles
The embedded raw text is the canonical restore artifact. The internal parsed
playlist object graph is not the backup format.
### Xtream
Xtream backups export only connection metadata plus portable user state.
- Connection metadata:
- `serverUrl`
- `username`
- `password`
- User state:
- hidden categories by `{ categoryType, xtreamId }`
- 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:
- cached categories/content rows
- import-status flags and other app-state cache markers
- downloads
### Stalker
Stalker backups export connection metadata plus playlist-scoped favorites/recent
state.
- Exported connection fields:
- `portalUrl`
- `macAddress`
- `isFullStalkerPortal`
- `username`
- `password`
- `userAgent`
- `referrer`
- `origin`
- serial/device/signature fields when present
- Exported user state:
- favorites snapshots
- recently viewed snapshots
Explicitly excluded — session state, as opposed to the connection definition:
- `stalkerToken`
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the profile-advertised cadence)
- `stalkerAccountInfo`
- playback positions in v1
### App Settings
Only EPG source URLs are backed up at the app-settings level.
- Exported: `settings.epgUrls`
- Excluded: cached EPG database content
## Import Flow
The settings backup facade (`settings-backup.facade.ts`, driven by
`settings-backup-section.component.ts`) reads the file (`file.text()`) and
hands its contents to `PlaylistBackupService.importBackup()`.
The service:
1. Validates the manifest kind/version before any writes.
2. Rejects legacy raw `Playlist[]` JSON blobs.
3. Builds stable source fingerprints for merge-vs-create decisions.
4. Upserts playlists into app playlist storage.
5. Restores provider-specific user state.
Fingerprint rules:
- M3U URL playlists: normalized URL
- M3U without URL: hash of canonical `rawM3u`
- Xtream: normalized `serverUrl + username`
- Stalker: normalized `portalUrl + macAddress`
If a fingerprint matches an existing playlist:
- keep the existing playlist ID
- update mutable metadata from the backup
- replace playlist-scoped state with the backup payload
If no fingerprint matches:
- create a new playlist
- reuse `exportedId` only when it is unused
- otherwise generate a new UUID
## Xtream Restore Contract
Xtream restore is type-aware end to end. The app no longer stores plain
`xtream_id[]` arrays for refresh/import restore because IDs can collide across
`live`, `movie`, and `series`.
Runtime contract:
- shared shape: `XtreamPendingRestoreState`
- persisted in local storage by playlist ID
- consumed by:
- Xtream refresh actions
- settings backup import
- Xtream content initialization
Restore state originates from untrusted sources (user-supplied backup files,
stale localStorage entries), so every read and write goes through
`normalizeXtreamPendingRestoreState` (`libs/shared/interfaces`). Entries in
`hiddenCategories`, `favorites`, and `recentlyViewed` without a usable numeric
`xtreamId` are dropped rather than restored: backups exported by builds
affected by issue #1017 contain ID-less hidden-category entries, and matching
them against category rows would otherwise degrade to a type-only comparison
that hides every category of that type. Category rows themselves cross the DB
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.
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
`[]`, because restore treats a PRESENT collection as authoritative and clears
the playlist's existing pins before applying it. Materializing an empty array
would turn "this archive predates pins" into "this archive says there are
none": archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage and only a wrong type is rejected. A pin is carried
under the playlist it points AT — exporting it anywhere else would restore a
preference for a portal the archive never contained. Its `matchKey` identifies
the film rather than the portal, so it survives untouched; only the playlist id
is remapped to the imported copy. Pins whose match key or content id is
unusable are dropped, since writing one would occupy the unique key of a film
it does not describe.
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 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. 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
The settings page now exports/imports “playlist backups” instead of the old
raw JSON application dump.
- Export filename:
`iptvnator-playlist-backup-YYYY-MM-DD.json`
- Import summary reports:
- imported
- merged
- skipped
- failed