From 8bf92f9d65d2a4231047be5f6a207e97d95da4de Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:07:49 +0200 Subject: [PATCH] fix(backup): export and restore Xtream user state in the PWA (#1905) * fix(backup): export and restore Xtream user state in the PWA Without an Electron bridge the backup wrote empty Xtream favorites, history and positions, and import only parked the state until the playlist was next opened. PwaXtreamBackupStateService reads and writes the four PWA maps through USER_DATA_STORAGE, so self-hosted server storage is covered as well. Export converts typed keys, resolves legacy ids only through their snapshot, and fails on an unreadable map instead of writing []. Import applies at once through applyAndConsume, remapped to the imported playlist id; a failed write keeps the snapshot parked for the store. Title snapshots travel in a new optional collectionItems field that older builds ignore. The storage key names are shared with the portal data source as XTREAM_USER_DATA_KEYS. Co-Authored-By: Claude Opus 5.5 * fix(backup): write PWA title snapshots before the Xtream lists A browser restore that failed after its first list write kept the pending state parked for the Xtream store, but that replay carries no title snapshots, so they were lost. Merge them first; a damaged cache is replaced, while a storage that cannot be read still fails the entry. Co-Authored-By: Claude Opus 5.5 * fix(backup): keep the artwork of PWA recently viewed items The browser build saves a backdrop with each view and shows it ahead of the title snapshot's, but the export dropped it, so a restore showed older artwork. Carry it as an optional backdropUrl on the archive's recently viewed items; the desktop build ignores it. Co-Authored-By: Claude Opus 5.5 * fix(xtream): keep view artwork in the PWA pending restore state A failed browser restore is replayed by PwaXtreamDataSource.restoreUserData, which rewrote recently viewed items without their backdropUrl; a refresh dropped it the same way in clearPlaylistContent. Both now carry it. Co-Authored-By: Claude Opus 5.5 * fix(backup): restore PWA Xtream lists before the snapshot cache Writing the optional title snapshots first could take the storage space the favorites, history or positions needed, failing a restore that fits without them. The lists now go first, as the snapshot-storage contract asks; after a failed list write the snapshots are still merged for the store's replay, unless storage is full. Co-Authored-By: Claude Opus 5.5 * fix(backup): pick the Xtream backup path by the SQLite data source The backup chose its Xtream path by the mere presence of window.electron, while XTREAM_DATA_SOURCE falls back to the browser storage whenever the bridge lacks the Xtream SQLite API. On such a partial or stale preload the export read the wrong store and the restore took the SQLite path. Both now follow RuntimeCapabilitiesService.supportsXtreamSqliteDataSource. Also casts the test helper's prototype instance through unknown, which the spec typecheck requires. Co-Authored-By: Claude Opus 5.5 * fix(xtream): restore PWA playback positions under the target playlist A PWA restore that is replayed by the Xtream store kept each position's exported playlistId. When the backup was imported under another id, the VOD details page rejected those positions as belonging to a different playlist. restoreUserData now stamps the playlist it restores into. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/backup-pwa-xtream-user-state.md | 6 + docs/architecture/playlist-backup-restore.md | 48 +- docs/architecture/pwa-self-hosted.md | 6 +- .../data-sources/pwa-collection-storage.ts | 11 +- .../pwa-xtream-collection-identity.spec.ts | 58 ++ .../data-sources/pwa-xtream-data-source.ts | 15 +- ...playlist-backup.service.pwa-xtream.spec.ts | 669 ++++++++++++++++++ .../playlist-backup.service.roundtrip.spec.ts | 92 ++- .../playlist-backup.service.test-helpers.ts | 25 + .../src/lib/playlist-backup.service.ts | 42 +- .../lib/pwa-xtream-backup-state.service.ts | 297 ++++++++ libs/services/src/lib/user-data-storage.ts | 12 + .../src/lib/playlist-backup.interface.ts | 23 + 13 files changed, 1223 insertions(+), 81 deletions(-) create mode 100644 .changes/backup-pwa-xtream-user-state.md create mode 100644 libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts create mode 100644 libs/services/src/lib/pwa-xtream-backup-state.service.ts diff --git a/.changes/backup-pwa-xtream-user-state.md b/.changes/backup-pwa-xtream-user-state.md new file mode 100644 index 000000000..a2e8acec6 --- /dev/null +++ b/.changes/backup-pwa-xtream-user-state.md @@ -0,0 +1,6 @@ +--- +type: fix +area: backup +--- + +Backups made in the browser and self-hosted versions now include Xtream favorites, recently viewed items and resume positions. Restoring such a backup brings them back right away, under whichever playlist the portal is imported as. diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 0554de983..6e8014478 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -12,6 +12,8 @@ settings screen. - Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts` - Xtream pending restore storage: `libs/services/src/lib/xtream-pending-restore.service.ts` +- Browser (PWA) Xtream user state: + `libs/services/src/lib/pwa-xtream-backup-state.service.ts` ## Manifest Contract @@ -61,9 +63,12 @@ Xtream backups export only connection metadata plus portable user state. - User state: - hidden categories by `{ categoryType, xtreamId }` - favorites by `{ contentType, xtreamId, addedAt?, position? }` - - recently viewed by `{ contentType, xtreamId, viewedAt }` + - recently viewed by `{ contentType, xtreamId, viewedAt, backdropUrl? }` + (`backdropUrl` is written and read by the browser build only) - playback positions as `PlaybackPositionData[]` - optional VOD source pins by `{ matchKey, contentId, updatedAt? }` + - optional title snapshots `collectionItems` (browser build only; see + [Browser runtime](#browser-runtime)) An imported Xtream archive can wait for its catalog to download. Export reads that pending snapshot before consulting the catalog: its hidden categories, @@ -225,6 +230,47 @@ 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. +### Browser Runtime + +Whenever `XTREAM_DATA_SOURCE` resolves to `PwaXtreamDataSource` — the +browser, or a desktop bridge without the Xtream SQLite API +(`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`) — Xtream +favorites, recently viewed items, playback positions and title snapshots live +in the `USER_DATA_STORAGE` maps owned by `PwaXtreamDataSource` +(`XTREAM_USER_DATA_KEYS`; see +[PWA portal user data](./pwa-self-hosted.md#pwa-portal-user-data)). +`PwaXtreamBackupStateService` reads and writes them through that token, so the +self-hosted server storage is covered too. + +- Export converts each playlist's entries to the archive shape. Typed keys + (`movie:42`) carry their identity; a legacy bare id resolves only through + the snapshot stored under it, and is left out when none identifies it. + Recently viewed keeps the newest view per title, with the artwork saved + for that view as `backdropUrl`. `collectionItems` carries + the snapshots of exported favorites and history, and is omitted when there + are none. Hidden categories stay `[]` and `sourcePins` stays absent: the + browser tracks neither. +- An unreadable map (storage error, malformed JSON, a playlist entry that is + not a list) fails the export instead of being written as an empty list. +- A pending restore snapshot still wins over the stored maps, as on desktop. +- Import parks the restore state like the desktop does, then applies it at + once through `XtreamPendingRestoreService.applyAndConsume`: the browser has + no catalog cache to wait for. Favorites, history and positions of the + imported playlist id are replaced (positions get that id); other playlists' + entries are untouched. `collectionItems` merge into the playlist's + snapshots, keyed by typed identity; an archive without them leaves the + snapshots alone, malformed items are skipped, a damaged cache is replaced, + and a quota error on this cache does not fail the restore. +- The lists are written before the optional title snapshots, so the cache + never takes storage space they need (as for additions; see the + [snapshot-storage contract](./xtream-portal-compatibility.md)). A failed + write fails the entry and keeps the snapshot parked, so the Xtream store + replays the complete state when the playlist next opens. That replay + carries no title snapshots, so they are still merged after a failed list + write, unless storage is full and they would take room the replay needs. + +Older builds ignore `collectionItems`; the manifest version stays `1`. + ## Current UX The settings page now exports/imports “playlist backups” instead of the old diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index ebf92c5eb..f055674bf 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -272,7 +272,11 @@ and sidecar state in localStorage: `PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the `USER_DATA_STORAGE` token from `@iptvnator/services` (default: `localStorage`), never through `localStorage` directly, so a deployment can -substitute a server-synced storage. +substitute a server-synced storage. The key names are shared as +`XTREAM_USER_DATA_KEYS` with the playlist backup, which exports and restores +the favorites, recent items, positions and snapshots through the same token +(`PwaXtreamBackupStateService`; see +[backup/restore](./playlist-backup-restore.md#browser-runtime)). Entries should include a content snapshot when the item is added. Global collection routes and the dashboard can then restore titles, posters, content diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts index 59d3efec5..7ecee0678 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts @@ -6,13 +6,16 @@ import { normalizeFavoriteStorage, normalizeRecentStorage, } from './pwa-collection-identity'; -import type { UserDataStorage } from '@iptvnator/services'; +import { + XTREAM_USER_DATA_KEYS, + type UserDataStorage, +} from '@iptvnator/services'; import { XtreamContentItem } from './xtream-data-source.interface'; const STORAGE_KEYS = { - COLLECTION_ITEMS: 'xtream-collection-items', - FAVORITES: 'xtream-favorites', - RECENT_ITEMS: 'xtream-recent-items', + COLLECTION_ITEMS: XTREAM_USER_DATA_KEYS.collectionItems, + FAVORITES: XTREAM_USER_DATA_KEYS.favorites, + RECENT_ITEMS: XTREAM_USER_DATA_KEYS.recentItems, }; /** Synchronous persistence and migration using only snapshots and cached identity evidence. */ diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts index ef96a02d9..e88aa7578 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts @@ -342,6 +342,64 @@ describe('PWA Xtream collection identity', () => { ).toEqual(types); }); + it('restores playback positions under the playlist they are restored to', async () => { + await source.restoreUserData('p1', { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [ + { + contentXtreamId: 42, + contentType: 'vod', + positionSeconds: 90, + playlistId: 'exported-id', + updatedAt: '2026-10-01T12:00:00.000Z', + }, + ], + }); + + expect(await source.getPlaybackPosition('p1', 42, 'vod')).toEqual({ + contentXtreamId: 42, + contentType: 'vod', + positionSeconds: 90, + playlistId: 'p1', + updatedAt: '2026-10-01T12:00:00.000Z', + }); + }); + + it('keeps the artwork saved with each view through clear/restore', async () => { + await source.restoreUserData('p1', { + hiddenCategories: [], + playbackPositions: [], + favorites: [], + recentlyViewed: [ + { + contentType: 'movie', + xtreamId: 42, + viewedAt: '2026-10-01T12:00:00.000Z', + backdropUrl: 'https://img.example.com/still.jpg', + }, + ], + }); + expect(read('xtream-recent-items').p1).toEqual([ + { + id: 'movie:42', + viewedAt: '2026-10-01T12:00:00.000Z', + backdropUrl: 'https://img.example.com/still.jpg', + }, + ]); + + const backup = await source.clearPlaylistContent('p1'); + expect(backup.recentlyViewed).toEqual([ + { + contentType: 'movie', + xtreamId: 42, + viewedAt: '2026-10-01T12:00:00.000Z', + backdropUrl: 'https://img.example.com/still.jpg', + }, + ]); + }); + it('preserves ambiguous numeric legacy entries without selecting the last matching type', async () => { localStorage.setItem( 'xtream-favorites', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 2c8c8d380..af61c2906 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -22,6 +22,7 @@ import { ParentalLockService, PlaylistsService, USER_DATA_STORAGE, + XTREAM_USER_DATA_KEYS, } from '@iptvnator/services'; import { firstValueFrom } from 'rxjs'; import { @@ -42,6 +43,7 @@ import { typedCollectionRef, itemCollectionKey, findCollectionItems, + normalizeStoredBackdrop, } from './pwa-collection-identity'; import { PwaCollectionStorage } from './pwa-collection-storage'; @@ -50,7 +52,7 @@ import { PwaCollectionStorage } from './pwa-collection-storage'; */ const STORAGE_KEYS = { PLAYLISTS: 'xtream-playlists', - PLAYBACK_POSITIONS: 'xtream-playback-positions', + PLAYBACK_POSITIONS: XTREAM_USER_DATA_KEYS.playbackPositions, }; interface XtreamCachedContentItem { @@ -1356,6 +1358,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return { ...identity, viewedAt: item.viewedAt, + ...normalizeStoredBackdrop(item), }; }) .filter( @@ -1403,16 +1406,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource { type: item.contentType, }); if (id !== null) - recentItems[playlistId].push({ id, viewedAt: item.viewedAt }); + recentItems[playlistId].push({ + id, + viewedAt: item.viewedAt, + ...normalizeStoredBackdrop(item), + }); } this.collectionStorage.saveRecentItemsToStorage(recentItems); - // Restore playback positions + // Restore playback positions under this playlist: an archive's carry + // the id they were exported with, and the player matches on it. this.clearPlaybackPositionsForPlaylist(playlistId); const playbackPositions = this.getPlaybackPositionsFromStorage(); playbackPositions[playlistId] = restoreState.playbackPositions.map( (position) => ({ ...position, + playlistId, updatedAt: position.updatedAt ?? new Date().toISOString(), }) ); diff --git a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts new file mode 100644 index 000000000..7a1599452 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts @@ -0,0 +1,669 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + XtreamPlaylistBackupEntry, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylistBackupService, + createPwaXtreamBackupState, +} from './playlist-backup.service.test-helpers'; +import { existingXtreamPlaylist } from './playlist-backup.xtream-fixtures'; +import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service'; +import { + USER_DATA_STORAGE, + UserDataStorage, + XTREAM_USER_DATA_KEYS, +} from './user-data-storage'; + +/** + * The browser build keeps Xtream favorites, history, positions and title + * snapshots in USER_DATA_STORAGE rather than SQLite. These specs run the + * backup with no Electron bridge, against the maps exactly as + * `PwaXtreamDataSource` writes them. + */ +describe('PlaylistBackupService Xtream user state in the PWA', () => { + const KEYS = XTREAM_USER_DATA_KEYS; + const otherPlaylist = { + ...existingXtreamPlaylist, + _id: 'xtream-2', + title: 'Other Portal', + serverUrl: 'http://other.example.com', + username: 'other', + } as Playlist; + const heat = { + id: 501, + xtream_id: 501, + type: 'movie', + title: 'Heat', + poster_url: 'https://img.example.com/heat.jpg', + backdrop_url: 'https://img.example.com/heat-wide.jpg', + }; + // Written under a bare id by builds that predate typed keys. + const legacyShow = { id: 77, xtream_id: 77, type: 'series', title: 'Show' }; + const position = { + contentXtreamId: 501, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 3600, + playlistId: 'xtream-1', + updatedAt: '2026-07-05T20:00:00.000Z', + }; + + beforeEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + }); + + function seed(storage: UserDataStorage = localStorage): void { + const write = (key: string, value: unknown) => + storage.setItem(key, JSON.stringify(value)); + write(KEYS.favorites, { + // 88 is a bare id no snapshot identifies: live, movie or series. + 'xtream-1': ['movie:501', 77, 88], + 'xtream-2': ['live:9'], + }); + write(KEYS.recentItems, { + 'xtream-1': [ + { id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' }, + { + id: 'movie:501', + viewedAt: '2026-07-01T10:00:00.000Z', + backdropUrl: 'https://img.example.com/heat-old.jpg', + }, + { + id: 'movie:501', + viewedAt: '2026-07-03T10:00:00.000Z', + // Saved by an older build in snake_case. + backdrop_url: 'https://img.example.com/heat-still.jpg', + }, + ], + 'xtream-2': [ + { id: 'live:9', viewedAt: '2026-07-02T10:00:00.000Z' }, + ], + }); + write(KEYS.collectionItems, { + 'xtream-1': { + 'movie:501': heat, + '77': legacyShow, + 'movie:999': { xtream_id: 999, type: 'movie', title: 'Gone' }, + }, + 'xtream-2': { 'live:9': { xtream_id: 9, type: 'live' } }, + }); + write(KEYS.playbackPositions, { + 'xtream-1': [position], + 'xtream-2': [{ ...position, playlistId: 'xtream-2' }], + }); + } + + function read(key: string, storage: UserDataStorage = localStorage) { + return JSON.parse(storage.getItem(key) ?? 'null'); + } + + function pendingRestore(service: object) { + return ( + service as unknown as { + pendingRestoreService: { + getOrThrow(playlistId: string): unknown; + set(playlistId: string, state: unknown): unknown; + }; + } + ).pendingRestoreService; + } + + function xtreamOnly(manifest: PlaylistBackupManifestV1) { + return JSON.stringify({ + ...manifest, + playlists: [xtreamEntry(manifest)], + }); + } + + function playlistsService(playlists: Playlist[]) { + return { + getAllData: jest.fn(() => of(playlists)), + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + }; + } + + function xtreamEntry( + manifest: PlaylistBackupManifestV1, + exportedId = 'xtream-1' + ): XtreamPlaylistBackupEntry { + return manifest.playlists.find( + (entry) => entry.exportedId === exportedId + ) as XtreamPlaylistBackupEntry; + } + + async function exportSeeded(): Promise { + seed(); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([ + existingXtreamPlaylist, + otherPlaylist, + ]), + }); + return (await service.exportBackup()).manifest; + } + + describe('export', () => { + it('writes each playlist its own favorites, history, positions and snapshots', async () => { + const manifest = await exportSeeded(); + + expect(xtreamEntry(manifest).userState).toEqual({ + hiddenCategories: [], + favorites: [ + { contentType: 'movie', xtreamId: 501 }, + { contentType: 'series', xtreamId: 77 }, + ], + // One row per title, its newest view. + recentlyViewed: [ + { + contentType: 'live', + xtreamId: 601, + viewedAt: '2026-07-04T18:30:00.000Z', + }, + { + contentType: 'movie', + xtreamId: 501, + viewedAt: '2026-07-03T10:00:00.000Z', + // The history's own artwork, ahead of the snapshot's. + backdropUrl: 'https://img.example.com/heat-still.jpg', + }, + ], + playbackPositions: [position], + // Only what the lists reference, not every cached title. + collectionItems: [heat, legacyShow], + }); + expect(xtreamEntry(manifest, 'xtream-2').userState).toEqual({ + hiddenCategories: [], + favorites: [{ contentType: 'live', xtreamId: 9 }], + recentlyViewed: [ + { + contentType: 'live', + xtreamId: 9, + viewedAt: '2026-07-02T10:00:00.000Z', + }, + ], + playbackPositions: [{ ...position, playlistId: 'xtream-2' }], + collectionItems: [{ xtream_id: 9, type: 'live' }], + }); + // No pin store in the browser: absent, never an authoritative []. + expect(xtreamEntry(manifest).userState).not.toHaveProperty( + 'sourcePins' + ); + }); + + it('exports empty collections and no snapshots for a playlist with nothing stored', async () => { + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + + const { manifest } = await service.exportBackup(); + + expect(xtreamEntry(manifest).userState).toEqual({ + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }); + expect(xtreamEntry(manifest).userState).not.toHaveProperty( + 'collectionItems' + ); + }); + + it('exports a parked restore as it is instead of the stored lists', async () => { + seed(); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + const parked = { + hiddenCategories: [], + favorites: [{ contentType: 'series' as const, xtreamId: 3 }], + recentlyViewed: [], + playbackPositions: [], + }; + pendingRestore(service).set('xtream-1', parked); + + const { manifest } = await service.exportBackup(); + + expect(xtreamEntry(manifest).userState).toEqual(parked); + }); + + it.each([ + ['malformed JSON', '{"xtream-1": ['], + ['a value that is not a map', '["movie:501"]'], + [ + 'a playlist entry that is not a list', + '{"xtream-1": "movie:501"}', + ], + ])('refuses to export when the favorites hold %s', async (_, raw) => { + seed(); + localStorage.setItem(KEYS.favorites, raw); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + + await expect(service.exportBackup()).rejects.toThrow( + /xtream-favorites.*unreadable/ + ); + }); + + it('refuses to export when the storage itself cannot be read', async () => { + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: () => { + throw new DOMException('denied', 'SecurityError'); + }, + setItem: jest.fn(), + removeItem: jest.fn(), + }), + }); + + await expect(service.exportBackup()).rejects.toThrow('denied'); + }); + }); + + describe('import', () => { + it('restores every list under the id the playlist is imported as', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + // The same portal, already present under another id: a merge. + const local = { ...existingXtreamPlaylist, _id: 'local-xtream' }; + localStorage.setItem( + KEYS.favorites, + JSON.stringify({ + 'local-xtream': ['live:1'], + unrelated: ['movie:5'], + }) + ); + localStorage.setItem( + KEYS.collectionItems, + JSON.stringify({ + 'local-xtream': { + 'live:1': { xtream_id: 1, type: 'live' }, + }, + }) + ); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([local]), + }); + const summary = await service.importBackup(xtreamOnly(manifest)); + + expect(summary).toMatchObject({ merged: 1, failed: 0 }); + // The archive's lists replace the playlist's; others stay put. + expect(read(KEYS.favorites)).toEqual({ + 'local-xtream': ['movie:501', 'series:77'], + unrelated: ['movie:5'], + }); + expect(read(KEYS.recentItems)).toEqual({ + 'local-xtream': [ + { id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' }, + { + id: 'movie:501', + viewedAt: '2026-07-03T10:00:00.000Z', + backdropUrl: 'https://img.example.com/heat-still.jpg', + }, + ], + }); + expect(read(KEYS.playbackPositions)).toEqual({ + 'local-xtream': [{ ...position, playlistId: 'local-xtream' }], + }); + // Snapshots are a cache: merged, keyed by typed identity. + expect(read(KEYS.collectionItems)).toEqual({ + 'local-xtream': { + 'live:1': { xtream_id: 1, type: 'live' }, + 'movie:501': heat, + 'series:77': legacyShow, + }, + }); + // Applied at once, so nothing is left for the store to replay. + expect( + pendingRestore(service).getOrThrow('local-xtream') + ).toBeNull(); + }); + + it('round-trips the stored lists through export and import', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([]), + }); + + const summary = await service.importBackup( + JSON.stringify(manifest) + ); + expect(summary).toMatchObject({ imported: 2, failed: 0 }); + const reexport = createPlaylistBackupService({ + playlistsService: playlistsService([ + existingXtreamPlaylist, + otherPlaylist, + ]), + }); + + const { manifest: again } = await reexport.exportBackup(); + + expect(xtreamEntry(again).userState).toEqual( + xtreamEntry(manifest).userState + ); + expect(xtreamEntry(again, 'xtream-2').userState).toEqual( + xtreamEntry(manifest, 'xtream-2').userState + ); + }); + + it('leaves snapshots alone for an archive without any, and skips damaged ones', async () => { + const manifest = await exportSeeded(); + const entry = xtreamEntry(manifest); + const before = read(KEYS.collectionItems); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + + const withoutSnapshots = { + ...manifest, + playlists: [ + { + ...entry, + userState: { + ...entry.userState, + collectionItems: undefined, + }, + }, + ], + }; + await service.importBackup(JSON.stringify(withoutSnapshots)); + expect(read(KEYS.collectionItems)).toEqual(before); + + const damaged = { + ...manifest, + playlists: [ + { + ...entry, + userState: { + ...entry.userState, + collectionItems: [ + null, + { xtream_id: 5, type: 'radio' }, + { xtream_id: '6', type: 'movie' }, + ], + }, + }, + ], + }; + const summary = await service.importBackup(JSON.stringify(damaged)); + expect(summary.failed).toBe(0); + expect(read(KEYS.collectionItems)).toEqual(before); + }); + + it('drops entries no runtime could address', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + const entry = xtreamEntry(manifest); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + const hostile = { + ...manifest, + playlists: [ + { + ...entry, + userState: { + ...entry.userState, + favorites: [ + { contentType: 'radio', xtreamId: 1 }, + { contentType: 'movie', xtreamId: -4 }, + { contentType: 'movie', xtreamId: 501 }, + { contentType: 'movie', xtreamId: '501' }, + ], + recentlyViewed: [ + { contentType: 'live', xtreamId: 601 }, + ], + playbackPositions: [ + { contentXtreamId: 501, contentType: 'vod' }, + { ...position, updatedAt: undefined }, + ], + }, + }, + ], + }; + + await service.importBackup(JSON.stringify(hostile)); + + expect(read(KEYS.favorites)).toEqual({ + 'xtream-1': ['movie:501'], + }); + expect(read(KEYS.recentItems)).toEqual({ 'xtream-1': [] }); + const [restored] = read(KEYS.playbackPositions)['xtream-1']; + expect(read(KEYS.playbackPositions)['xtream-1']).toHaveLength(1); + expect(typeof restored.updatedAt).toBe('string'); + }); + + it('fails the entry and keeps the restore parked when a write fails', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + const storage = createBrowserBackedStorage(); + storage.setItem.mockImplementation((key: string, value: string) => { + if (key === KEYS.recentItems) throw new Error('disk full'); + localStorage.setItem(key, value); + }); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState(storage), + }); + + const summary = await service.importBackup(xtreamOnly(manifest)); + + expect(summary.failed).toBe(1); + expect(summary.errors[0]).toContain('disk full'); + // The Xtream store replays the whole state when the playlist opens. + expect( + pendingRestore(service).getOrThrow('xtream-1') + ).not.toBeNull(); + // That replay carries no titles, so they are merged regardless. + 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": {'); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + + expect( + (await service.importBackup(xtreamOnly(manifest))).failed + ).toBe(0); + expect(read(KEYS.collectionItems)).toEqual({ + 'xtream-1': { 'movie:501': heat, 'series:77': legacyShow }, + }); + + const denied = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState({ + getItem: () => { + throw new DOMException('denied', 'SecurityError'); + }, + setItem: jest.fn(), + removeItem: jest.fn(), + }), + }); + const summary = await denied.importBackup(xtreamOnly(manifest)); + expect(summary.failed).toBe(1); + expect(summary.errors[0]).toContain('denied'); + }); + + it('still restores the lists when the snapshots exceed the quota', async () => { + const manifest = await exportSeeded(); + localStorage.clear(); + const storage = createBrowserBackedStorage(); + storage.setItem.mockImplementation((key: string, value: string) => { + if (key === KEYS.collectionItems) { + throw new DOMException('full', 'QuotaExceededError'); + } + localStorage.setItem(key, value); + }); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState(storage), + }); + + const summary = await service.importBackup( + JSON.stringify(manifest) + ); + + expect(summary.failed).toBe(0); + expect(read(KEYS.favorites)['xtream-1']).toEqual([ + 'movie:501', + 'series:77', + ]); + expect(read(KEYS.collectionItems)).toBeNull(); + }); + }); + + describe('near the storage quota', () => { + const listKeys = [ + KEYS.favorites, + KEYS.recentItems, + KEYS.playbackPositions, + ]; + + /** Storage that refuses a write once all values exceed `budget`. */ + function quotaStorage(budget: number) { + const data = new Map(); + const used = () => + [...data].reduce((sum, [k, v]) => sum + k.length + v.length, 0); + const storage: UserDataStorage = { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + const previous = data.get(key); + data.set(key, value); + if (used() <= budget) return; + if (previous === undefined) data.delete(key); + else data.set(key, previous); + throw new DOMException('full', 'QuotaExceededError'); + }, + removeItem: (key) => void data.delete(key), + }; + return { data, storage }; + } + + async function importInto(budget: number) { + const manifest = await exportSeeded(); + const quota = quotaStorage(budget); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream: createPwaXtreamBackupState(quota.storage), + }); + const summary = await service.importBackup(xtreamOnly(manifest)); + return { summary, data: quota.data }; + } + + async function listsSize(): Promise { + const { data } = await importInto(Infinity); + return listKeys.reduce( + (sum, key) => sum + key.length + (data.get(key) ?? '').length, + 0 + ); + } + + it('restores the lists that fit without the optional snapshots', async () => { + const { summary, data } = await importInto(await listsSize()); + + expect(summary.failed).toBe(0); + expect(JSON.parse(data.get(KEYS.favorites) ?? '{}')).toEqual({ + 'xtream-1': ['movie:501', 'series:77'], + }); + expect(data.has(KEYS.collectionItems)).toBe(false); + }); + + it('leaves the space to the replay when a list does not fit', async () => { + const { summary, data } = await importInto((await listsSize()) - 1); + + expect(summary.failed).toBe(1); + // The snapshots would only take room the store's replay needs. + expect(data.has(KEYS.collectionItems)).toBe(false); + }); + }); + + it('follows the Xtream data source on a desktop bridge without its SQLite API', async () => { + // A partial or stale preload: XTREAM_DATA_SOURCE falls back to the + // browser storage, so the backup must read and restore that too. + (window as unknown as { electron?: unknown }).electron = {}; + const runtime = { supportsXtreamSqliteDataSource: false }; + const manifest = await (async () => { + seed(); + const exporter = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + runtime, + }); + return (await exporter.exportBackup()).manifest; + })(); + expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2); + + localStorage.clear(); + const importer = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + runtime, + }); + const summary = await importer.importBackup(xtreamOnly(manifest)); + + expect(summary.failed).toBe(0); + expect(read(KEYS.favorites)).toEqual({ + 'xtream-1': ['movie:501', 'series:77'], + }); + expect(pendingRestore(importer).getOrThrow('xtream-1')).toBeNull(); + }); + + it('reads and writes through USER_DATA_STORAGE, not localStorage', async () => { + // The self-hosted server mode provides a server-synced storage here. + const synced = new Map(); + const serverStorage: UserDataStorage = { + getItem: (key) => synced.get(key) ?? null, + setItem: (key, value) => void synced.set(key, value), + removeItem: (key) => void synced.delete(key), + }; + TestBed.configureTestingModule({ + providers: [ + { provide: USER_DATA_STORAGE, useValue: serverStorage }, + ], + }); + const pwaXtream = TestBed.inject(PwaXtreamBackupStateService); + seed(serverStorage); + const exporter = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + pwaXtream, + }); + + const { json, manifest } = await exporter.exportBackup(); + expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2); + + synced.clear(); + const importer = createPlaylistBackupService({ + playlistsService: playlistsService([]), + pwaXtream, + }); + await importer.importBackup(json); + + expect(read(KEYS.favorites, serverStorage)).toEqual({ + 'xtream-1': ['movie:501', 'series:77'], + }); + expect(localStorage.getItem(KEYS.favorites)).toBeNull(); + }); + + function createBrowserBackedStorage() { + return { + getItem: (key: string) => localStorage.getItem(key), + setItem: jest.fn(), + removeItem: (key: string) => localStorage.removeItem(key), + }; + } +}); diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index e518cc66f..53a5194e7 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -182,57 +182,49 @@ describe('PlaylistBackupService export → import round-trip', () => { return entry; } - it.each([true, false])( - 'preserves a fresh pending import on re-export (Electron: %s)', - async (electron) => { - const state = seedState(); - state.xtreamFavorites.push({ - xtream_id: 501, - type: 'series', - position: 1, - }); - const collaborators = createStatefulBackupCollaborators(state); - const service = createPlaylistBackupService(collaborators); - const original = await service.exportBackup(); - const originalState = xtreamEntry(original.manifest).userState; - expect(originalState.sourcePins).toHaveLength(1); - state.xtreamCategories = []; - state.xtreamFavorites = []; - state.xtreamRecent = []; - state.playbackPositions = []; - state.sourcePins = []; - collaborators.databaseService.getXtreamImportStatus = async () => - 'idle'; - if (!electron) delete electronWindow.electron; + // The browser build applies an import at once (see the PWA Xtream spec), + // so only the desktop leaves one pending while its catalog downloads. + it('preserves a fresh pending import on re-export', async () => { + const state = seedState(); + state.xtreamFavorites.push({ + xtream_id: 501, + type: 'series', + position: 1, + }); + const collaborators = createStatefulBackupCollaborators(state); + const service = createPlaylistBackupService(collaborators); + const original = await service.exportBackup(); + const originalState = xtreamEntry(original.manifest).userState; + expect(originalState.sourcePins).toHaveLength(1); + state.xtreamCategories = []; + state.xtreamFavorites = []; + state.xtreamRecent = []; + state.playbackPositions = []; + state.sourcePins = []; + collaborators.databaseService.getXtreamImportStatus = async () => + 'idle'; - const summary = await service.importBackup(original.json); - expect(summary.failed).toBe(0); - const snapshot = - collaborators.pendingRestoreService.getSnapshotOrThrow( - 'xtream-1' - ); - expect(snapshot).not.toBeNull(); - // Pending state must remain exportable even while catalog/storage - // reads are unavailable; it is already the complete restore input. - jest.spyOn( - collaborators.databaseService, - 'getFavorites' - ).mockRejectedValue(new Error('Catalog unavailable')); - jest.spyOn( - collaborators.playbackPositionService, - 'getAllPlaybackPositionsOrThrow' - ).mockRejectedValue(new Error('Position storage unavailable')); - const exported = await service.exportBackup(); - expect(xtreamEntry(exported.manifest).userState).toEqual( - originalState - ); - expect( - collaborators.pendingRestoreService.getSnapshotOrThrow( - 'xtream-1' - ) - ).toEqual(snapshot); - } - ); + const summary = await service.importBackup(original.json); + expect(summary.failed).toBe(0); + const snapshot = + collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1'); + expect(snapshot).not.toBeNull(); + // Pending state must remain exportable even while catalog/storage + // reads are unavailable; it is already the complete restore input. + jest.spyOn( + collaborators.databaseService, + 'getFavorites' + ).mockRejectedValue(new Error('Catalog unavailable')); + jest.spyOn( + collaborators.playbackPositionService, + 'getAllPlaybackPositionsOrThrow' + ).mockRejectedValue(new Error('Position storage unavailable')); + const exported = await service.exportBackup(); + expect(xtreamEntry(exported.manifest).userState).toEqual(originalState); + expect( + collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1') + ).toEqual(snapshot); + }); it.each([true, false])( 'honors empty pending collections and optional source pins (present: %s)', diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index 4a48344cf..4db4ec481 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -8,11 +8,29 @@ import { XtreamPendingRestoreState, } from '@iptvnator/shared/interfaces'; import { PlaylistBackupService } from './playlist-backup.service'; +import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service'; +import { + createBrowserUserDataStorage, + UserDataStorage, +} from './user-data-storage'; import { XtreamPendingRestoreService, XtreamPendingRestoreSnapshot, } from './xtream-pending-restore.service'; +/** + * The browser build's Xtream backup state over `storage` — jsdom's + * localStorage unless a spec passes its own — without Angular DI. + */ +export function createPwaXtreamBackupState( + storage: UserDataStorage = createBrowserUserDataStorage() +): PwaXtreamBackupStateService { + return Object.assign( + Object.create(PwaXtreamBackupStateService.prototype) as object, + { storage } + ) as unknown as PwaXtreamBackupStateService; +} + /** * Shared factory for PlaylistBackupService specs. Instantiates the service * without Angular DI and replaces every collaborator with jest mocks; @@ -132,6 +150,13 @@ export function createPlaylistBackupService( locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })), replacePlaylistLocks: jest.fn().mockResolvedValue(true), }, + pwaXtream: createPwaXtreamBackupState(), + // Specs pick the runtime by setting or deleting `window.electron`. + runtime: { + get supportsXtreamSqliteDataSource() { + return !!(window as unknown as { electron?: unknown }).electron; + }, + }, ...overrides, }); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 3078285ea..85498b79b 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -4,7 +4,9 @@ import { PlaylistsService } from './playlists.service'; import { SettingsStore } from './settings-store.service'; import { DatabaseService } from './database-electron.service'; import { VodSourcePinService } from './vod-source-pin.service'; +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; import { PlaybackPositionService } from './playback-position.service'; +import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service'; import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; import { ParentalLockService } from './parental-lock/parental-lock.service'; import { removesParentalLocks } from './parental-lock/parental-lock-store.util'; @@ -62,9 +64,6 @@ export class PlaylistBackupError extends Error { type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker'; -type XtreamContentType = XtreamBackupContentType; -type XtreamCategoryType = XtreamBackupCategoryType; - @Injectable({ providedIn: 'root', }) @@ -78,6 +77,8 @@ export class PlaylistBackupService { XtreamPendingRestoreService ); private readonly parentalLock = inject(ParentalLockService); + private readonly pwaXtream = inject(PwaXtreamBackupStateService); + private readonly runtime = inject(RuntimeCapabilitiesService); private backupImportTail?: Promise; async exportBackup(): Promise { @@ -332,7 +333,9 @@ export class PlaylistBackupService { }; } // An unavailable pin store is no opinion, never an authoritative []. - if (!this.hasElectronApi()) return entry; + // Same choice as XTREAM_DATA_SOURCE: user state lives where it reads. + if (!this.runtime.supportsXtreamSqliteDataSource) + return this.pwaXtream.exportInto(entry); const [ liveCategories, @@ -370,7 +373,7 @@ export class PlaylistBackupService { ...this.optionalLockedXtreamCategories(playlist._id), favorites: favorites.map((item) => ({ xtreamId: item.xtream_id, - contentType: item.type as XtreamContentType, + contentType: item.type as XtreamBackupContentType, ...(item.added_at ? { addedAt: item.added_at } : {}), ...(item.position !== undefined ? { position: item.position } @@ -378,7 +381,7 @@ export class PlaylistBackupService { })), recentlyViewed: recent.map((item) => ({ xtreamId: item.xtream_id, - contentType: item.type as XtreamContentType, + contentType: item.type as XtreamBackupContentType, viewedAt: item.viewed_at ?? new Date().toISOString(), })), playbackPositions: playbackPositions.map((item) => ({ @@ -933,21 +936,20 @@ export class PlaylistBackupService { ); } - if (!this.hasElectronApi()) { - return; - } - - this.playbackPositionService.assertSupportsAtomicReplacement(); - - if (!(await this.hasCompletedOfflineCache(playlistId))) { - return; + // Browser storage has no catalog cache to wait for: apply at once. + const sqlite = this.runtime.supportsXtreamSqliteDataSource; + if (sqlite) { + this.playbackPositionService.assertSupportsAtomicReplacement(); + if (!(await this.hasCompletedOfflineCache(playlistId))) return; } const restoreResult = await this.pendingRestoreService.applyAndConsume( playlistId, restoreSnapshot, - (pendingState) => - this.applyXtreamRestoreState(playlistId, pendingState) + async (state) => + sqlite + ? this.applyXtreamRestoreState(playlistId, state) + : this.pwaXtream.restore(playlistId, state, entry) ); if (restoreResult !== 'consumed') { throw new PlaylistBackupError( @@ -1213,9 +1215,9 @@ export class PlaylistBackupService { categories: Array<{ hidden: boolean; xtream_id: number; - type: XtreamCategoryType; + type: XtreamBackupCategoryType; }>, - categoryType: XtreamCategoryType + categoryType: XtreamBackupCategoryType ): XtreamPendingRestoreState['hiddenCategories'] { return categories .filter((category) => category.hidden) @@ -1359,8 +1361,4 @@ export class PlaylistBackupService { private getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } - - private hasElectronApi(): boolean { - return !!(window as Window & { electron?: unknown }).electron; - } } diff --git a/libs/services/src/lib/pwa-xtream-backup-state.service.ts b/libs/services/src/lib/pwa-xtream-backup-state.service.ts new file mode 100644 index 000000000..36287dbbd --- /dev/null +++ b/libs/services/src/lib/pwa-xtream-backup-state.service.ts @@ -0,0 +1,297 @@ +import { inject, Injectable } from '@angular/core'; +import { + PlaybackPositionData, + XtreamBackupCollectionItem, + XtreamBackupContentType, + XtreamBackupFavoriteItem, + XtreamBackupRecentlyViewedItem, + XtreamPendingRestoreState, + XtreamPlaylistBackupEntry, +} from '@iptvnator/shared/interfaces'; +import { USER_DATA_STORAGE, XTREAM_USER_DATA_KEYS } from './user-data-storage'; + +type StoredMap = Record; +type UserDataMap = keyof typeof XTREAM_USER_DATA_KEYS; + +interface ResolvedKey { + key: string; + item: { contentType: XtreamBackupContentType; xtreamId: number }; +} + +const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series']; + +/** + * Backup export and restore of the Xtream favorites, recently viewed items, + * playback positions and title snapshots the browser build keeps in + * USER_DATA_STORAGE. `PwaXtreamDataSource` owns these maps in a library this + * one cannot import, so their stored shapes are read here: keys are + * `":"`, or a bare provider id written by older builds. + */ +@Injectable({ providedIn: 'root' }) +export class PwaXtreamBackupStateService { + private readonly storage = inject(USER_DATA_STORAGE); + + /** + * The entry with the playlist's stored lists and snapshots filled in; + * hidden categories and pins, which the browser build does not track, + * stay as `entry` has them. An unreadable store fails the export: restore + * treats these lists as authoritative, so "could not read" written as + * `[]` would turn the archive into an instruction to delete. + */ + exportInto(entry: XtreamPlaylistBackupEntry): XtreamPlaylistBackupEntry { + const playlistId = entry.exportedId; + const stored = this.readMap('collectionItems')[playlistId]; + const snapshots = isRecord(stored) ? stored : {}; + + const favorites = new Map(); + for (const key of this.listFor('favorites', playlistId)) { + const ref = resolveKey(key, snapshots); + if (ref) favorites.set(ref.key, ref.item); + } + + // Newest view wins, artwork included, as in the data source. + const recent = new Map(); + for (const item of this.listFor('recentItems', playlistId)) { + if (!isRecord(item)) continue; + const ref = resolveKey(item['id'], snapshots); + const viewedAt = item['viewedAt']; + if (!ref || typeof viewedAt !== 'string') continue; + const previous = recent.get(ref.key)?.viewedAt; + if (!previous || Date.parse(viewedAt) > Date.parse(previous)) { + recent.set(ref.key, { + ...ref.item, + viewedAt, + ...backdropOf(item), + }); + } + } + + const byKey = snapshotsByKey(Object.values(snapshots)); + const collectionItems = [ + ...new Set([...favorites.keys(), ...recent.keys()]), + ] + .filter((key) => key in byKey) + .map((key) => byKey[key]); + + return { + ...entry, + userState: { + ...entry.userState, + favorites: [...favorites.values()], + recentlyViewed: [...recent.values()], + playbackPositions: this.listFor('playbackPositions', playlistId) + .filter(isPlaybackPosition) + .map((position) => ({ ...position })), + ...(collectionItems.length > 0 ? { collectionItems } : {}), + }, + }; + } + + /** + * Replaces the playlist's favorites, history and positions with the + * restore state, keyed and remapped to `playlistId`, then merges the + * archived title snapshots into the playlist's own. + * + * Lists first, so the optional cache never takes space they need. A + * failed write throws with earlier ones applied, and the caller keeps the + * pending snapshot for the Xtream store to replay on the next open. The + * replay carries no titles (they come from `entry`, not `state`), so they + * are still merged then, unless storage is full and they would crowd it. + */ + async restore( + playlistId: string, + state: XtreamPendingRestoreState, + entry: XtreamPlaylistBackupEntry + ): Promise { + // A user-supplied file: the field may be anything. + const archived: unknown = entry.userState.collectionItems; + const snapshots = snapshotsByKey( + Array.isArray(archived) ? archived : [] + ); + try { + this.replaceLists(playlistId, state); + } catch (error) { + if (!isQuotaExceeded(error)) { + try { + this.mergeSnapshots(playlistId, snapshots); + } catch { + // The list failure is the one to report. + } + } + throw error; + } + this.mergeSnapshots(playlistId, snapshots); + } + + private replaceLists( + playlistId: string, + state: XtreamPendingRestoreState + ): void { + 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)]); + + const recent: StoredMap[] = []; + for (const item of state.recentlyViewed) { + const id = typedKey(item.contentType, item.xtreamId); + if (id !== null && typeof item.viewedAt === 'string') { + recent.push({ + id, + viewedAt: item.viewedAt, + ...backdropOf(item), + }); + } + } + this.writeFor('recentItems', playlistId, recent); + + const now = new Date().toISOString(); + this.writeFor( + 'playbackPositions', + playlistId, + state.playbackPositions.filter(isPlaybackPosition).map((item) => ({ + ...item, + playlistId, + updatedAt: item.updatedAt ?? now, + })) + ); + } + + /** + * A display cache the data source itself writes on a best-effort basis: + * a damaged one is replaced, as the data source would, and a full + * storage skips it. Its titles return once the catalog loads. + */ + private mergeSnapshots( + playlistId: string, + incoming: Record + ): void { + if (Object.keys(incoming).length === 0) return; + const all = this.readMap('collectionItems', { replaceDamaged: true }); + const current = isRecord(all[playlistId]) ? all[playlistId] : {}; + try { + this.writeMap('collectionItems', { + ...all, + [playlistId]: { ...current, ...incoming }, + }); + } catch (error) { + if (!isQuotaExceeded(error)) throw error; + } + } + + private listFor(name: UserDataMap, playlistId: string): unknown[] { + const list = this.readMap(name)[playlistId]; + if (list === undefined) return []; + if (!Array.isArray(list)) { + throw new Error( + `Stored ${XTREAM_USER_DATA_KEYS[name]} of playlist "${playlistId}" are unreadable.` + ); + } + return list; + } + + private writeFor(name: UserDataMap, playlistId: string, list: unknown[]) { + this.writeMap(name, { ...this.readMap(name), [playlistId]: list }); + } + + /** + * Strict, unlike the data source: a storage error or a damaged value + * throws rather than read as "nothing stored", so a write never replaces + * entries it could not read. A cache may `replaceDamaged` values. + */ + private readMap( + name: UserDataMap, + { replaceDamaged = false } = {} + ): StoredMap { + const key = XTREAM_USER_DATA_KEYS[name]; + const raw = this.storage.getItem(key); + if (raw === null) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + if (isRecord(parsed)) return parsed; + if (replaceDamaged) return {}; + throw new Error(`Stored ${key} are unreadable.`); + } + + private writeMap(name: UserDataMap, map: StoredMap): void { + this.storage.setItem(XTREAM_USER_DATA_KEYS[name], JSON.stringify(map)); + } +} + +/** + * A typed key carries its own identity. A bare provider id resolves only + * through the snapshot stored under it, as the data source does without a + * complete catalog: ids repeat across live, movie and series, so a guess + * could restore a different title. An unresolvable id is left out. + */ +function resolveKey(key: unknown, snapshots: StoredMap): ResolvedKey | null { + if (typeof key === 'string' && key.includes(':')) { + const [type, id, extra] = key.split(':'); + return extra === undefined ? resolved(type, Number(id)) : null; + } + if (typeof key !== 'number' && typeof key !== 'string') return null; + const snapshot = snapshots[String(key)]; + return isRecord(snapshot) && snapshot['xtream_id'] === Number(key) + ? resolved(snapshot['type'], Number(key)) + : null; +} + +function resolved(type: unknown, xtreamId: number): ResolvedKey | null { + const key = typedKey(type, xtreamId); + if (key === null) return null; + const contentType = type as XtreamBackupContentType; + return { key, item: { contentType, xtreamId } }; +} + +function typedKey(type: unknown, xtreamId: unknown): string | null { + return CONTENT_TYPES.includes(type) && + typeof xtreamId === 'number' && + Number.isSafeInteger(xtreamId) && + xtreamId > 0 + ? `${type}:${xtreamId}` + : null; +} + +/** Snapshots by typed key, whatever key they were stored under. */ +function snapshotsByKey( + items: unknown[] +): Record { + const byKey: Record = {}; + for (const item of items) { + if (!isRecord(item)) continue; + const key = typedKey(item['type'], item['xtream_id']); + if (key !== null) byKey[key] = item as XtreamBackupCollectionItem; + } + return byKey; +} + +/** Artwork saved with a view, in either spelling the data source reads. */ +function backdropOf(item: object): { backdropUrl?: string } { + const record = item as StoredMap; + const value = record['backdropUrl'] ?? record['backdrop_url']; + return typeof value === 'string' && value.trim() + ? { backdropUrl: value.trim() } + : {}; +} + +function isPlaybackPosition(value: unknown): value is PlaybackPositionData { + return ( + isRecord(value) && + Number.isFinite(value['contentXtreamId']) && + (value['contentType'] === 'vod' || + value['contentType'] === 'episode') && + Number.isFinite(value['positionSeconds']) + ); +} + +function isQuotaExceeded(error: unknown): boolean { + return error instanceof DOMException && error.name === 'QuotaExceededError'; +} + +function isRecord(value: unknown): value is StoredMap { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/libs/services/src/lib/user-data-storage.ts b/libs/services/src/lib/user-data-storage.ts index 78552b563..647c2eaba 100644 --- a/libs/services/src/lib/user-data-storage.ts +++ b/libs/services/src/lib/user-data-storage.ts @@ -20,6 +20,18 @@ export function createBrowserUserDataStorage(): UserDataStorage { }; } +/** + * The PWA Xtream user-data maps in USER_DATA_STORAGE, each a JSON object + * keyed by playlist id. Shared with the playlist backup, which reads and + * writes them directly. + */ +export const XTREAM_USER_DATA_KEYS = { + favorites: 'xtream-favorites', + recentItems: 'xtream-recent-items', + collectionItems: 'xtream-collection-items', + playbackPositions: 'xtream-playback-positions', +} as const; + /** * Browser persistence for PWA user data such as Xtream favorites, recently * viewed items and playback positions. Defaults to `localStorage`; the diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index 4ddcab746..8fcb6ee96 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -62,6 +62,12 @@ export interface XtreamBackupRecentlyViewedItem { contentType: XtreamBackupContentType; xtreamId: number; viewedAt: string; + /** + * Artwork the browser build saved with this view, shown ahead of the + * title snapshot's. Optional; the desktop build neither writes nor + * reads it. + */ + backdropUrl?: string; } /** @@ -76,6 +82,17 @@ export interface XtreamBackupSourcePin { updatedAt?: string; } +/** + * A favorite or recently viewed title as the browser build last displayed it, + * so the restored lists show titles and artwork before the provider catalog + * is fetched again. Every other field is the item exactly as it was stored. + */ +export interface XtreamBackupCollectionItem { + type: XtreamBackupContentType; + xtream_id: number; + [field: string]: unknown; +} + export interface XtreamBackupUserState { hiddenCategories: XtreamBackupHiddenCategory[]; favorites: XtreamBackupFavoriteItem[]; @@ -83,6 +100,12 @@ export interface XtreamBackupUserState { playbackPositions: PlaybackPositionData[]; /** Optional: absent in archives written before multi-source existed. */ sourcePins?: XtreamBackupSourcePin[]; + /** + * Optional and written only by the browser build, which keeps no catalog + * between sessions. A cache, not user state: restore merges it into the + * playlist's snapshots, and the desktop build ignores it. + */ + collectionItems?: XtreamBackupCollectionItem[]; /** * Parental-lock categories, same shape as `hiddenCategories`. Optional * like `sourcePins`: absence means the archive predates the lock and