Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase property names while the renderer contracts declare snake_case, so backup export dropped hidden-category IDs and restore degraded to a type-only match that hid every category. Project category ops to the declared wire shape, normalize restore state from untrusted sources (dropping entries without a numeric xtreamId), reject entries with missing user-state collections, and add full export→import round-trip coverage (unit manifest-equality + Electron e2e) plus regression tests. Closes #1017 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.8 KiB
Playlist Backup/Restore Architecture
This document describes the versioned playlist backup/restore flow used by the settings screen.
Entry Points
- UI:
/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts(embedded insettings.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 - Xtream pending restore storage:
/Users/4gray/Code/iptvnator/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: 1exportedAtincludeSecretssettings?.epgUrlsplaylists[]
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
rawM3ufromPlaylistsService.getRawPlaylistById() - Preserve source metadata when available:
- original source kind:
url,file, ortext - original URL
userAgent,referrer,originfilePathHintfor provenance only
- original source kind:
- 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:
serverUrlusernamepassword
- User state:
- hidden categories by
{ categoryType, xtreamId } - favorites by
{ contentType, xtreamId, addedAt?, position? } - recently viewed by
{ contentType, xtreamId, viewedAt } - playback positions as
PlaybackPositionData[]
- hidden categories by
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:
portalUrlmacAddressisFullStalkerPortalusernamepassworduserAgentreferrerorigin- serial/device/signature fields when present
- Exported user state:
- favorites snapshots
- recently viewed snapshots
Explicitly excluded:
stalkerTokenstalkerAccountInfo- 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:
- Validates the manifest kind/version before any writes.
- Rejects legacy raw
Playlist[]JSON blobs. - Builds stable source fingerprints for merge-vs-create decisions.
- Upserts playlists into app playlist storage.
- 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
exportedIdonly 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.
Electron restore behavior:
- Category import reads pending hidden-category state while saving categories.
- After content import, favorites/recent state is restored by typed
{ contentType, xtreamId }matching. - Playback positions are cleared and re-applied from backup state.
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.
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