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