diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts index 2c5f66b6b..b4c8cecb0 100644 --- a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import { expect, test } from './fixtures'; import { addXtreamPortal, @@ -13,90 +14,136 @@ test.beforeEach(async ({ page, request }) => { await interceptXtreamRequests(page); }); -test('@xtream @collections catalog browsing preserves imported favorite metadata after reload', async ({ - page, - request, -}) => { - const response = await request.get(`${MOCK_SERVER}/player_api.php`, { - params: { - username: DEFAULT_USERNAME, - password: DEFAULT_PASSWORD, - action: 'get_vod_streams', - }, - }); - expect(response.ok()).toBeTruthy(); - const movie = (await response.json())[0]; - await addXtreamPortal(page, { name: 'Saved Metadata 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: movie.stream_id, - addedAt: '2026-01-02T12:00:00.000Z', - position: 3, - snapshot: { - title: movie.name, - tmdbId: 949, - releaseYear: 1995, - originalTitle: 'Saved original title', - }, - }; - await page.evaluate( - ({ playlistId, favorite }) => - localStorage.setItem( - `xtream-restore-${playlistId}`, - JSON.stringify({ - hiddenCategories: [], - favorites: [favorite], - recentlyViewed: [], - playbackPositions: [], - }) - ), - { playlistId, favorite } - ); - await page.reload(); - await expect - .poll(() => - page.evaluate( - (id) => localStorage.getItem(`xtream-restore-${id}`), - playlistId - ) - ) - .toBeNull(); - await page - .locator(`[data-category-id="${movie.category_id}"]`) - .first() - .click(); - await expect( - page.getByText(movie.name, { exact: true }).first() - ).toBeVisible(); - // Keep the catalog in memory while opening Favorites, exercising the merge. - await page.getByRole('link', { name: 'Global favorites', exact: true }).click(); - await expect( - page.getByText(movie.name, { exact: true }).first() - ).toBeVisible(); - await page.reload(); - await expect( - page.getByText(movie.name, { exact: true }).first() - ).toBeVisible(); - expect( +for (const identity of ['missing', 'conflicting'] as const) { + test(`@xtream @collections ${identity} catalog identity preserves saved favorites through reload and export`, async ({ + page, + request, + }) => { + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + expect(response.ok()).toBeTruthy(); + const movie = (await response.json())[0]; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + if (original.searchParams.get('action') !== 'get_vod_streams') { + await route.fallback(); + return; + } + const mockUrl = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') mockUrl.searchParams.set(key, value); + }); + const response = await route.fetch({ url: mockUrl.toString() }); + const body = (await response.json()) as { + payload: Record[]; + }; + for (const item of body.payload) { + delete item['tmdb_id']; + delete item['release_year']; + if (identity === 'conflicting') { + item['tmdb_id'] = 999; + item['release_year'] = 2026; + } + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Saved Metadata 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: movie.stream_id, + addedAt: '2026-01-02T12:00:00.000Z', + position: 3, + snapshot: { + title: 'Saved original favorite', + posterUrl: '/assets/icons/icon-192x192.png', + tmdbId: 949, + releaseYear: 1995, + originalTitle: 'Saved original title', + }, + }; await page.evaluate( - ({ playlistId, key }) => - JSON.parse( - localStorage.getItem('xtream-collection-items') ?? '{}' - )[playlistId]?.[key], - { playlistId, key: `movie:${movie.stream_id}` } - ) - ).toMatchObject({ - tmdb_id: 949, - release_year: 1995, - original_title: 'Saved original title', - added_at: favorite.addedAt, - position: 3, + ({ playlistId, favorite }) => + localStorage.setItem( + `xtream-restore-${playlistId}`, + JSON.stringify({ + hiddenCategories: [], + favorites: [favorite], + recentlyViewed: [], + playbackPositions: [], + }) + ), + { playlistId, favorite } + ); + await page.reload(); + await expect + .poll(() => + page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ) + .toBeNull(); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await expect( + page.getByText(movie.name, { exact: true }).first() + ).toBeVisible(); + // Keep the catalog in memory while opening Favorites, exercising the merge. + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }).first() + ).toBeVisible(); + await page.reload(); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }).first() + ).toBeVisible(); + expect( + await page.evaluate( + ({ playlistId, key }) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[playlistId]?.[key], + { playlistId, key: `movie:${movie.stream_id}` } + ) + ).toMatchObject({ + title: favorite.snapshot.title, + poster_url: favorite.snapshot.posterUrl, + tmdb_id: 949, + release_year: 1995, + original_title: 'Saved original title', + added_at: favorite.addedAt, + position: 3, + }); + await page.goto('/workspace/settings/backup'); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const downloaded = await (await downloadPromise).path(); + if (!downloaded) throw new Error('Backup download is missing'); + const backup = JSON.parse(await readFile(downloaded, 'utf8')); + expect( + backup.playlists.find( + (entry: { exportedId: string }) => + entry.exportedId === playlistId + ).userState.favorites + ).toEqual([favorite]); }); -}); +} test('@xtream @collections PWA retries retain saved favorite metadata when snapshot storage is full', async ({ page, diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 369a1eed8..a9b1750fe 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -101,11 +101,15 @@ 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. -Catalog hydration merges sparse provider rows with saved collection metadata; -absent artwork and identity fields retain their saved values. The favorite's -added date and order belong to user state and never come from the provider. -Favorites and Recent reads/writes share this resolution, so browsing before -re-exporting cannot strip imported fields. +Catalog hydration keeps the saved snapshot separate from current provider rows. +Only a row confirming every saved TMDB ID/year may refresh that snapshot; +missing, partially confirmed or conflicting identity retains the original title, +artwork and metadata. After confirmation, blank catalog fields retain saved +values. The favorite's added date and order always belong to user state. +Favorites and Recent reads/writes share this resolution. Recent/detail backdrop +writes check the actual catalog evidence, never the resolved collection row +which already carries saved identity. Browsing, reload and re-export therefore +cannot replace an imported snapshot merely because a provider reused its ID. 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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts index 36eaa8397..abb9ad299 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts @@ -142,12 +142,25 @@ export function findCollectionItems( return results; } -/** Sparse provider rows refresh the cache without erasing saved backup fields. */ +/** Catalog evidence must confirm every saved identity field before replacing metadata. */ +export function collectionSnapshotMatchesContent( + saved: XtreamContentItem, + current: XtreamContentItem | undefined +): boolean { + return ( + (saved.tmdb_id == null || saved.tmdb_id === current?.tmdb_id) && + (saved.release_year == null || + saved.release_year === current?.release_year) + ); +} + +/** Keep the durable snapshot separate until the actual catalog row confirms it. */ function mergeCollectionItem( saved: XtreamContentItem | undefined, current: XtreamContentItem ): XtreamContentItem { if (!saved) return current; + if (!collectionSnapshotMatchesContent(saved, current)) return saved; return { ...saved, ...current, 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 7ecee0678..fe0aa5f90 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 @@ -1,5 +1,6 @@ import { CollectionKey, + collectionSnapshotMatchesContent, StoredRecentItem, deduplicateRecentItems, itemCollectionKey, @@ -23,7 +24,8 @@ export class PwaCollectionStorage { constructor( private readonly resolveItems: ( playlistId: string, - ids: readonly CollectionKey[] + ids: readonly CollectionKey[], + catalogOnly?: boolean ) => Map, private readonly storage: UserDataStorage ) {} @@ -240,7 +242,9 @@ export class PwaCollectionStorage { const playlistItems = allItems[playlistId] ?? {}; playlistItems[String(contentId)] = { ...item, - ...(normalizedBackdropUrl && !item.backdrop_url + ...(normalizedBackdropUrl && + !item.backdrop_url && + this.canEnrichSnapshot(playlistId, contentId, item) ? { backdrop_url: normalizedBackdropUrl } : {}), }; @@ -250,6 +254,18 @@ export class PwaCollectionStorage { }); } + private canEnrichSnapshot( + playlistId: string, + key: CollectionKey, + saved: XtreamContentItem + ): boolean { + // A merged collection row carries saved identity, so it is not confirmation. + return collectionSnapshotMatchesContent( + saved, + this.resolveItems(playlistId, [key], true).get(key) + ); + } + setCollectionItemBackdropIfMissing( playlistId: string, contentId: CollectionKey, @@ -258,7 +274,11 @@ export class PwaCollectionStorage { const allItems = this.getCollectionItemsFromStorage(); const playlistItems = allItems[playlistId]; const item = playlistItems?.[String(contentId)]; - if (!item || item.backdrop_url) { + if ( + !item || + item.backdrop_url || + !this.canEnrichSnapshot(playlistId, contentId, item) + ) { return; } 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 55810cf5d..bab500c1b 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 @@ -517,6 +517,115 @@ describe('PWA Xtream collection identity', () => { ).toEqual(favorites); }); + it.each([ + 'missing', + 'partial', + 'tmdb conflict', + 'year conflict', + 'confirmed', + ])( + 'protects imported identities through %s catalog rows, recent writes and re-export', + async (identity) => { + const 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/saved-${contentType}.jpg`, + tmdbId: 100 + position, + releaseYear: 1995 + position, + originalTitle: `Original ${contentType}`, + }, + })); + const entry: XtreamPlaylistBackupEntry = { + portalType: 'xtream', + exportedId: 'p1', + title: 'Saved portal', + autoRefresh: false, + connection: credentials, + userState: { + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + favorites, + }, + }; + await source.restoreUserData('p1', entry.userState); + api.getStreams.mockImplementation((_credentials, type) => { + const position = types.indexOf(type); + return Promise.resolve([ + { + stream_id: 42, + series_id: 42, + category_id: '1', + name: `Returning ${type}`, + stream_icon: `https://img.example.com/returning-${type}.jpg`, + ...(identity !== 'missing' + ? { + tmdb_id: + identity === 'tmdb conflict' + ? 999 + : 100 + position, + } + : {}), + ...(identity !== 'missing' && identity !== 'partial' + ? { + release_year: + identity === 'year conflict' + ? 2026 + : 1995 + position, + } + : {}), + }, + ]); + }); + await loadAll(); + // Recent and detail enrichment can run before Favorites is read. + for (const type of types) { + await source.addRecentItem( + { id: 42, type }, + 'p1', + 'https://img.example.com/returning-wide.jpg' + ); + await source.setContentMetadataIfMissing( + { id: 42, type }, + 'p1', + { backdropUrl: 'https://img.example.com/detail-wide.jpg' } + ); + } + await source.getRecentItems('p1'); + const expected = favorites.map((favorite) => + identity === 'confirmed' + ? { + ...favorite, + snapshot: { + ...favorite.snapshot, + title: `Returning ${favorite.contentType}`, + posterUrl: `https://img.example.com/returning-${favorite.contentType}.jpg`, + backdropUrl: + 'https://img.example.com/detail-wide.jpg', + }, + } + : favorite + ); + expect( + (await source.getFavorites('p1')).map((item) => item.title) + ).toEqual(expected.map((item) => item.snapshot.title)); + const exportFavorites = () => + new PwaXtreamBackupStorage( + TestBed.inject(USER_DATA_STORAGE) + ).exportInto(entry).userState.favorites; + expect(exportFavorites()).toEqual(expected); + reload(); + api.getStreams.mockResolvedValue([]); + await source.getRecentItems('p1'); + await source.getFavorites('p1'); + expect(exportFavorites()).toEqual(expected); + } + ); + 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 4898dc84e..d5d484f78 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 @@ -96,7 +96,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private readonly contentTypes = ['live', 'movie', 'series'] as const; private readonly userDataStorage = inject(USER_DATA_STORAGE); private readonly collectionStorage = new PwaCollectionStorage( - (playlistId, ids) => this.getCollectionItemsById(playlistId, ids), + (playlistId, ids, catalogOnly) => + this.getCollectionItemsById(playlistId, ids, catalogOnly), this.userDataStorage ); @@ -1097,21 +1098,25 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getCollectionItemsById( playlistId: string, - ids: readonly CollectionKey[] + ids: readonly CollectionKey[], + catalogOnly = false ): Map { if (!ids.length) return new Map(); - const cached: XtreamContentItem[] = []; - for (const type of this.contentTypes) { - const content = - this.contentCache.get(`${playlistId}-${type}-content`) ?? []; - for (const item of content) cached.push(item as XtreamContentItem); - } + const cached = ([] as XtreamContentItem[]).concat( + ...this.contentTypes.map( + (type) => + (this.contentCache.get(`${playlistId}-${type}-content`) ?? + []) as XtreamContentItem[] + ) + ); return findCollectionItems( ids, cached, - this.collectionStorage.getCollectionItemsFromStorage()[ - playlistId - ] ?? {}, + catalogOnly + ? {} + : (this.collectionStorage.getCollectionItemsFromStorage()[ + playlistId + ] ?? {}), this.contentTypes.every((type) => this.contentCache.has(`${playlistId}-${type}-content`) ) @@ -1388,9 +1393,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; - const { restorePwaXtreamUserData } = await import( - './pwa-xtream-restore' - ); + const { restorePwaXtreamUserData } = + await import('./pwa-xtream-restore'); await restorePwaXtreamUserData( this.userDataStorage, playlistId,