Files
iptvnator/docs/architecture/playlist-backup-restore.md
T
4grayandClaude Opus 5 7c1b433d47 fix(portals): absent is not empty, and every start claims the generation
Four more from review, three of them defects in last round's fixes.

The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.

`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.

Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.

And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:13:40 +02:00

204 lines
6.8 KiB
Markdown

# 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 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`
- 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: 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[]`
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:
- `stalkerToken`
- `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.
`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 cleared and re-applied from backup state.
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.
## 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