diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts new file mode 100644 index 000000000..93b75945c --- /dev/null +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -0,0 +1,114 @@ +import { expect, test } from './fixtures'; +import { + addXtreamPortal, + interceptXtreamRequests, + MOCK_SERVER, +} from './xtream-series-playback.fixture'; + +test.beforeEach(async ({ page, request }) => { + await request.post(`${MOCK_SERVER}/reset`); + await page.goto('/'); + await interceptXtreamRequests(page); +}); + +test('@xtream @collections PWA retries retain saved favorite metadata when snapshot storage is full', async ({ + page, +}) => { + await addXtreamPortal(page, { name: 'Pending Favorites Portal' }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID in the portal route'); + const playlistId = decodeURIComponent(rawId); + const favorite = { + contentType: 'movie', + xtreamId: 987654, + addedAt: '2026-01-02T12:00:00.000Z', + position: 3, + snapshot: { + title: 'Saved offline movie', + posterUrl: '/assets/icons/icon-192x192.png', + tmdbId: 949, + releaseYear: 1995, + }, + }; + await page.goto('/'); + await page.evaluate( + ({ playlistId, favorite }) => { + localStorage.setItem( + `xtream-restore-${playlistId}`, + JSON.stringify({ + hiddenCategories: [], + favorites: [favorite], + recentlyViewed: [], + playbackPositions: [], + }) + ); + sessionStorage.setItem('block-favorite-snapshot', '1'); + }, + { playlistId, favorite } + ); + await page.addInitScript(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (this: Storage, key, value) { + if ( + this === localStorage && + key === 'xtream-collection-items' && + sessionStorage.getItem('block-favorite-snapshot') === '1' + ) { + sessionStorage.setItem('favorite-snapshot-attempted', '1'); + throw new DOMException('full', 'QuotaExceededError'); + } + return setItem.call(this, key, value); + }; + }); + await page.goto(`${playlistPath}/vod`); + await expect + .poll(() => + page.evaluate(() => + sessionStorage.getItem('favorite-snapshot-attempted') + ) + ) + .toBe('1'); + expect( + await page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ).not.toBeNull(); + await page.evaluate(() => + sessionStorage.removeItem('block-favorite-snapshot') + ); + await page.reload(); + await expect + .poll(() => + page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ) + .toBeNull(); + await page.goto(`${playlistPath}/favorites`); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }) + ).toBeVisible(); + await page.reload(); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }) + ).toBeVisible(); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id]?.['movie:987654'], + playlistId + ) + ).toMatchObject({ + title: favorite.snapshot.title, + poster_url: favorite.snapshot.posterUrl, + tmdb_id: 949, + release_year: 1995, + added_at: favorite.addedAt, + position: 3, + }); +}); diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 2667ceebf..fb6edb0f2 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -250,6 +250,18 @@ context panel use, out of that barrel for the same reason, and 22 KB, used only by the lazy settings page) out of the `@iptvnator/services` barrel. +`@iptvnator/services/pwa-xtream-backup-storage` exposes the framework-free browser +backup persistence implementation without re-exporting it from the eager services +barrel. The injected backup service wraps it; deferred restore loads it through a +local dynamic-import module with the data source's storage. Its map keys live in +a separate pure module so it does not import the Angular storage token at runtime. +Electron favorites likewise load their +legacy pending-conversion helper only when a favorites operation needs it. +Those lazy helpers value-import the pure normalizer through +`@iptvnator/shared/interfaces/xtream-restore-state`; their remaining contracts +use type-only imports so a deferred conversion does not load the interfaces +barrel's Angular/IndexedDB dependencies. + The web app's root shell (`app.component.ts`, `app.config.ts` and the services they construct) is on the renderer's initial path, where a barrel costs its whole library: esbuild keeps every Angular component module a barrel diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 322ad7d23..76c32a0c7 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -101,6 +101,14 @@ Browser imports convert desktop favorite snapshots into typed collection-cache rows so saved titles and artwork remain visible offline. Browser exports include the normalized nested favorite snapshots as well as their optional collection cache, preserving metadata when the archive returns to Electron. +Initial browser imports and deferred retries share `PwaXtreamBackupStorage`; +`PwaXtreamBackupStateService` supplies its injected storage for the importer. +Both write the lists first and require the nested favorite snapshots to persist +before consuming pending state. A quota or storage failure keeps the archive +parked for another attempt, including when the provider returns an empty catalog. +Legacy browser-only `collectionItems` remain an optional cache; their quota +failure does not invalidate a restore with no durable nested favorite snapshots. +The PWA retry loads that storage implementation on demand through a local module. Both Xtream data-source and portal-activity capabilities require the pending favorite conversion bridge; activity reads also require playlist lookup. The 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 e88aa7578..4262954a2 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 @@ -1,5 +1,12 @@ import { TestBed } from '@angular/core/testing'; -import { ParentalLockService, PlaylistsService } from '@iptvnator/services'; +import { + ParentalLockService, + PlaylistsService, + USER_DATA_STORAGE, + XtreamPendingRestoreService, +} from '@iptvnator/services'; +import { PwaXtreamBackupStorage } from '@iptvnator/services/pwa-xtream-backup-storage'; +import { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces'; import { of } from 'rxjs'; import { XtreamApiService } from '../services/xtream-api.service'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; @@ -367,6 +374,101 @@ describe('PWA Xtream collection identity', () => { }); }); + it.each(['xtream-favorites', 'xtream-collection-items'])( + 'retains saved metadata through %s quota failure, replay, offline reads and re-export', + async (failedKey) => { + const entry: XtreamPlaylistBackupEntry = { + portalType: 'xtream', + exportedId: 'p1', + title: 'Saved portal', + autoRefresh: false, + connection: credentials, + userState: { + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + favorites: types.map((contentType, position) => ({ + contentType, + xtreamId: 42, + position, + addedAt: '2026-10-01T12:00:00.000Z', + snapshot: { + title: `Saved ${contentType}`, + posterUrl: `https://img.example.com/${contentType}.jpg`, + backdropUrl: `https://img.example.com/${contentType}-wide.jpg`, + tmdbId: 100 + position, + releaseYear: 1995 + position, + originalTitle: `Original ${contentType}`, + }, + })), + }, + }; + const pending = TestBed.inject(XtreamPendingRestoreService); + const parked = pending.set('p1', entry.userState); + if (!parked) throw new Error('Expected parked restore'); + const backup = new PwaXtreamBackupStorage( + TestBed.inject(USER_DATA_STORAGE) + ); + let blockedKey = failedKey; + const setItem = Storage.prototype.setItem; + jest.spyOn(Storage.prototype, 'setItem').mockImplementation( + function (this: Storage, key, value) { + if (key === blockedKey) + throw new DOMException('full', 'QuotaExceededError'); + setItem.call(this, key, value); + } + ); + await expect( + pending.applyAndConsume('p1', parked, (state) => + backup.restore('p1', state, entry) + ) + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(pending.getOrThrow('p1')?.favorites).toEqual( + entry.userState.favorites + ); + + // Lists fit now, but the sole saved metadata still must not be discarded. + blockedKey = 'xtream-collection-items'; + await expect( + pending.applyAndConsume('p1', parked, (state) => + source.restoreUserData('p1', state) + ) + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(pending.getOrThrow('p1')?.favorites).toEqual( + entry.userState.favorites + ); + + blockedKey = ''; + await expect( + pending.applyAndConsume('p1', parked, (state) => + source.restoreUserData('p1', state) + ) + ).resolves.toBe('consumed'); + expect(pending.getOrThrow('p1')).toBeNull(); + api.getStreams.mockResolvedValue([]); + reload(); + const items = await source.getFavorites('p1'); + expect( + items.map(({ title, type, xtream_id }) => ({ + title, + type, + xtream_id, + })) + ).toEqual( + types.map((type) => ({ + title: `Saved ${type}`, + type, + xtream_id: 42, + })) + ); + expect( + new PwaXtreamBackupStorage( + TestBed.inject(USER_DATA_STORAGE) + ).exportInto(entry).userState.favorites + ).toEqual(entry.userState.favorites); + } + ); + it('keeps the artwork saved with each view through clear/restore', async () => { await source.restoreUserData('p1', { hiddenCategories: [], 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 af61c2906..4898dc84e 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 @@ -1388,44 +1388,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; - // Restore favorites - const favorites = this.collectionStorage.getFavoritesFromStorage(); - favorites[playlistId] = restoreState.favorites - .map((item) => - collectionKey({ id: item.xtreamId, type: item.contentType }) - ) - .filter((key): key is CollectionKey => key !== null); - this.collectionStorage.saveFavoritesToStorage(favorites); - - // Restore recent items - const recentItems = this.collectionStorage.getRecentItemsFromStorage(); - recentItems[playlistId] = []; - for (const item of restoreState.recentlyViewed) { - const id = collectionKey({ - id: item.xtreamId, - type: item.contentType, - }); - if (id !== null) - recentItems[playlistId].push({ - id, - viewedAt: item.viewedAt, - ...normalizeStoredBackdrop(item), - }); - } - this.collectionStorage.saveRecentItemsToStorage(recentItems); - - // 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(), - }) + const { restorePwaXtreamUserData } = await import( + './pwa-xtream-restore' + ); + await restorePwaXtreamUserData( + this.userDataStorage, + playlistId, + restoreState ); - this.savePlaybackPositionsToStorage(playbackPositions); } // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-restore.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-restore.ts new file mode 100644 index 000000000..7820a83fa --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-restore.ts @@ -0,0 +1,12 @@ +import type { UserDataStorage } from '@iptvnator/services'; +import { PwaXtreamBackupStorage } from '@iptvnator/services/pwa-xtream-backup-storage'; +import type { XtreamPendingRestoreState } from '@iptvnator/shared/interfaces'; + +/** Load backup persistence only for a restore, sharing the initial import contract. */ +export function restorePwaXtreamUserData( + storage: UserDataStorage, + playlistId: string, + state: XtreamPendingRestoreState +): Promise { + return new PwaXtreamBackupStorage(storage).restore(playlistId, state); +} diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index d7e04883e..e0898c087 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -200,7 +200,7 @@ describe('DatabaseService browser guards', () => { } else { const getItem = Storage.prototype.getItem; jest.spyOn(Storage.prototype, 'getItem').mockImplementation( - function (key) { + function (this: Storage, key) { if (key === 'xtream-restore-broken') return unavailable(); return getItem.call(this, key); diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index c3d3ed8df..5b8b732f7 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -1,7 +1,3 @@ -import { - assertXtreamFavoriteRestoreSupported, - materializePendingXtreamFavorites, -} from './xtream-pending-favorites'; import type { ElectronBridgeXtreamFavorite, XtreamFavoriteRemovalRef, @@ -306,6 +302,9 @@ export class DatabaseService { restoreId?: string ): Promise { if (favorites.length === 0) return; + const { assertXtreamFavoriteRestoreSupported } = await import( + './xtream-pending-favorites' + ); assertXtreamFavoriteRestoreSupported(window.electron); const result = await window.electron.dbRestoreXtreamFavorites( playlistId, @@ -1074,3 +1073,13 @@ export class DatabaseService { } } } + +/** Defer legacy conversion code until a favorites operation actually needs it. */ +async function materializePendingXtreamFavorites( + ...args: Parameters< + typeof import('./xtream-pending-favorites').materializePendingXtreamFavorites + > +): Promise { + const converter = await import('./xtream-pending-favorites'); + return converter.materializePendingXtreamFavorites(...args); +} 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 index 1f7e30986..1c23de3fa 100644 --- a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts @@ -535,7 +535,7 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { expect( pendingRestore(service).getOrThrow('xtream-1') ).not.toBeNull(); - // That replay carries no titles, so they are merged regardless. + // Cache-only rows are not carried by pending state, so preserve them here too. expect(read(KEYS.collectionItems)).toEqual({ 'xtream-1': { 'movie:501': heat, 'series:77': legacyShow }, }); @@ -570,10 +570,46 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { expect(summary.errors[0]).toContain('denied'); }); - it('still restores the lists when the snapshots exceed the quota', async () => { + it('keeps durable favorite snapshots parked when their write exceeds 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(xtreamOnly(manifest)); + expect(summary.failed).toBe(1); + expect( + pendingRestore(service).getOrThrow('xtream-1') + ).toMatchObject({ + favorites: xtreamEntry(manifest).userState.favorites, + }); + const { manifest: reexport } = await service.exportBackup(); + expect(xtreamEntry(reexport).userState.favorites).toEqual( + xtreamEntry(manifest).userState.favorites + ); + }); + + it('still restores legacy lists when their optional cache exceeds the quota', async () => { + const manifest = await exportSeeded(); + // Old browser archives carry only optional collectionItems, not durable snapshots. + for (const entry of manifest.playlists) { + if (entry.portalType === 'xtream') + entry.userState.favorites = entry.userState.favorites.map( + ({ contentType, xtreamId }) => ({ + contentType, + xtreamId, + }) + ); + } + localStorage.clear(); + const storage = createBrowserBackedStorage(); storage.setItem.mockImplementation((key: string, value: string) => { if (key === KEYS.collectionItems) { throw new DOMException('full', 'QuotaExceededError'); @@ -627,6 +663,10 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { async function importInto(budget: number) { const manifest = await exportSeeded(); + const entry = xtreamEntry(manifest); + entry.userState.favorites = entry.userState.favorites.map( + ({ contentType, xtreamId }) => ({ contentType, xtreamId }) + ); const quota = quotaStorage(budget); const service = createPlaylistBackupService({ playlistsService: playlistsService([existingXtreamPlaylist]), diff --git a/libs/services/src/lib/pwa-xtream-backup-state.service.ts b/libs/services/src/lib/pwa-xtream-backup-state.service.ts index f8dbf7163..8527247b4 100644 --- a/libs/services/src/lib/pwa-xtream-backup-state.service.ts +++ b/libs/services/src/lib/pwa-xtream-backup-state.service.ts @@ -1,361 +1,11 @@ import { inject, Injectable } from '@angular/core'; -import { - PlaybackPositionData, - normalizeXtreamPendingRestoreState, - XtreamBackupCollectionItem, - XtreamBackupContentType, - XtreamBackupFavoriteItem, - XtreamBackupRecentlyViewedItem, - XtreamPendingRestoreState, - XtreamPlaylistBackupEntry, -} from '@iptvnator/shared/interfaces'; -import { USER_DATA_STORAGE, XTREAM_USER_DATA_KEYS } from './user-data-storage'; +import { PwaXtreamBackupStorage } from './pwa-xtream-backup-storage'; +import { USER_DATA_STORAGE } 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. - */ +/** Angular owner of the shared browser import/retry persistence contract. */ @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 byKey = snapshotsByKey(Object.values(snapshots)); - - const favorites = new Map(); - for (const key of this.listFor('favorites', playlistId)) { - const ref = resolveKey(key, snapshots); - if (ref) - favorites.set( - ref.key, - favoriteWithSnapshot(ref, byKey[ref.key]) - ); - } - - // 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 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. - * Browser-only cache rows come from `entry`; desktop favorite snapshots - * also travel in `state`. Merge both unless a full store would crowd out - * the lists that still need to be restored. - */ - 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 : [] - ); - for (const favorite of state.favorites) { - const saved = favorite.snapshot; - const key = typedKey(favorite.contentType, favorite.xtreamId); - if (!saved || key === null) continue; - snapshots[key] = { - ...snapshots[key], - id: favorite.xtreamId, - xtream_id: favorite.xtreamId, - type: favorite.contentType, - title: saved.title, - ...(saved.posterUrl !== undefined - ? { poster_url: saved.posterUrl } - : {}), - ...(saved.backdropUrl !== undefined - ? { backdrop_url: saved.backdropUrl } - : {}), - ...(saved.tmdbId !== undefined - ? { tmdb_id: saved.tmdbId } - : {}), - ...(saved.releaseYear !== undefined - ? { release_year: saved.releaseYear } - : {}), - ...(saved.originalTitle !== undefined - ? { original_title: saved.originalTitle } - : {}), - ...(favorite.addedAt !== undefined - ? { added_at: favorite.addedAt } - : {}), - ...(favorite.position !== undefined - ? { position: favorite.position } - : {}), - }; - } - 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)); +export class PwaXtreamBackupStateService extends PwaXtreamBackupStorage { + constructor() { + super(inject(USER_DATA_STORAGE)); } } - -/** Share desktop's allowlisted snapshot fields while retaining browser cache rows. */ -function favoriteWithSnapshot( - ref: ResolvedKey, - saved?: XtreamBackupCollectionItem -): XtreamBackupFavoriteItem { - if (!saved) return ref.item; - return normalizeXtreamPendingRestoreState({ - favorites: [ - { - ...ref.item, - addedAt: saved['added_at'], - position: saved['position'], - snapshot: { - title: saved['title'], - posterUrl: saved['poster_url'], - backdropUrl: saved['backdrop_url'], - tmdbId: saved['tmdb_id'], - releaseYear: saved['release_year'], - originalTitle: saved['original_title'], - }, - }, - ], - }).favorites[0]; -} - -/** - * 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/pwa-xtream-backup-storage.ts b/libs/services/src/lib/pwa-xtream-backup-storage.ts new file mode 100644 index 000000000..f0c29a53b --- /dev/null +++ b/libs/services/src/lib/pwa-xtream-backup-storage.ts @@ -0,0 +1,366 @@ +import type { + PlaybackPositionData, + XtreamBackupCollectionItem, + XtreamBackupContentType, + XtreamBackupFavoriteItem, + XtreamBackupRecentlyViewedItem, + XtreamPendingRestoreState, + XtreamPlaylistBackupEntry, +} from '@iptvnator/shared/interfaces'; +import { normalizeXtreamPendingRestoreState } from '@iptvnator/shared/interfaces/xtream-restore-state'; +import type { UserDataStorage } from './user-data-storage'; +import { XTREAM_USER_DATA_KEYS } from './xtream-user-data-keys'; + +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. + */ +export class PwaXtreamBackupStorage { + constructor(private readonly storage: UserDataStorage) {} + + /** + * 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 byKey = snapshotsByKey(Object.values(snapshots)); + + const favorites = new Map(); + for (const key of this.listFor('favorites', playlistId)) { + const ref = resolveKey(key, snapshots); + if (ref) + favorites.set( + ref.key, + favoriteWithSnapshot(ref, byKey[ref.key]) + ); + } + + // 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 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. + * Browser-only cache rows come from `entry`; durable favorite snapshots + * also travel in `state` through deferred retries. A durable snapshot + * write must succeed before the caller consumes that pending state. + */ + 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 : [] + ); + for (const favorite of state.favorites) { + const saved = favorite.snapshot; + const key = typedKey(favorite.contentType, favorite.xtreamId); + if (!saved || key === null) continue; + snapshots[key] = { + ...snapshots[key], + id: favorite.xtreamId, + xtream_id: favorite.xtreamId, + type: favorite.contentType, + title: saved.title, + ...(saved.posterUrl !== undefined + ? { poster_url: saved.posterUrl } + : {}), + ...(saved.backdropUrl !== undefined + ? { backdrop_url: saved.backdropUrl } + : {}), + ...(saved.tmdbId !== undefined + ? { tmdb_id: saved.tmdbId } + : {}), + ...(saved.releaseYear !== undefined + ? { release_year: saved.releaseYear } + : {}), + ...(saved.originalTitle !== undefined + ? { original_title: saved.originalTitle } + : {}), + ...(favorite.addedAt !== undefined + ? { added_at: favorite.addedAt } + : {}), + ...(favorite.position !== undefined + ? { position: favorite.position } + : {}), + }; + } + 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, + state.favorites.some((favorite) => !!favorite.snapshot) + ); + } + + 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. Durable nested favorite snapshots are the exception: + * they may be the only saved metadata and must stay pending on failure. + */ + private mergeSnapshots( + playlistId: string, + incoming: Record, + required = false + ): 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 (required || !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)); + } +} + +/** Share desktop's allowlisted snapshot fields while retaining browser cache rows. */ +function favoriteWithSnapshot( + ref: ResolvedKey, + saved?: XtreamBackupCollectionItem +): XtreamBackupFavoriteItem { + if (!saved) return ref.item; + return normalizeXtreamPendingRestoreState({ + favorites: [ + { + ...ref.item, + addedAt: saved['added_at'], + position: saved['position'], + snapshot: { + title: saved['title'], + posterUrl: saved['poster_url'], + backdropUrl: saved['backdrop_url'], + tmdbId: saved['tmdb_id'], + releaseYear: saved['release_year'], + originalTitle: saved['original_title'], + }, + }, + ], + }).favorites[0]; +} + +/** + * 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 647c2eaba..e3be5fe01 100644 --- a/libs/services/src/lib/user-data-storage.ts +++ b/libs/services/src/lib/user-data-storage.ts @@ -20,17 +20,7 @@ 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; +export { XTREAM_USER_DATA_KEYS } from './xtream-user-data-keys'; /** * Browser persistence for PWA user data such as Xtream favorites, recently diff --git a/libs/services/src/lib/xtream-pending-favorites.ts b/libs/services/src/lib/xtream-pending-favorites.ts index 5a271c4da..d655369d7 100644 --- a/libs/services/src/lib/xtream-pending-favorites.ts +++ b/libs/services/src/lib/xtream-pending-favorites.ts @@ -1,8 +1,8 @@ import { - ElectronBridgeApi, getXtreamPendingRestoreStorageKey, normalizeXtreamPendingRestoreState, -} from '@iptvnator/shared/interfaces'; +} from '@iptvnator/shared/interfaces/xtream-restore-state'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; type FavoriteRestoreSupport = Partial< Pick diff --git a/libs/services/src/lib/xtream-user-data-keys.ts b/libs/services/src/lib/xtream-user-data-keys.ts new file mode 100644 index 000000000..8e5a3cce0 --- /dev/null +++ b/libs/services/src/lib/xtream-user-data-keys.ts @@ -0,0 +1,11 @@ +/** + * 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; diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f5bd9c80..15e59cddc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -133,9 +133,15 @@ "@iptvnator/services/playlist-backup": [ "libs/services/src/lib/playlist-backup.service.ts" ], + "@iptvnator/services/pwa-xtream-backup-storage": [ + "libs/services/src/lib/pwa-xtream-backup-storage.ts" + ], "@iptvnator/shared/interfaces": [ "libs/shared/interfaces/src/index.ts" ], + "@iptvnator/shared/interfaces/xtream-restore-state": [ + "libs/shared/interfaces/src/lib/xtream-restore-state.util.ts" + ], "@iptvnator/shared/interfaces/ipc-commands": [ "libs/shared/interfaces/src/lib/ipc-commands.ts" ],