diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts index fc99005c2..b3026bfda 100644 --- a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -200,6 +200,50 @@ for (const identity of ['missing', 'conflicting'] as const) { ) ).toBeVisible(); await expect(add).toBeVisible(); + await page + .getByRole('button', { name: /^Play / }) + .first() + .click(); + await expect + .poll(() => + page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-recent-items') ?? + '{}' + )[id]?.[0]?.collectionItem?.title, + playlistId + ) + ) + .toBe(movie.name); + await page.goto('/workspace/global-recent'); + await page.reload(); + const replacement = page.getByRole('button', { + name: movie.name, + exact: true, + }); + await expect(replacement).toBeVisible(); + await expect(replacement).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + await page.goto('/workspace/settings/backup'); + const playedDownload = page.waitForEvent('download'); + await page + .getByRole('button', { name: 'Export', exact: true }) + .click(); + const playedPath = await (await playedDownload).path(); + if (!playedPath) throw new Error('Played backup is missing'); + const playedEntry = JSON.parse( + await readFile(playedPath, 'utf8') + ).playlists.find( + (entry: { exportedId: string }) => + entry.exportedId === playlistId + ); + expect(playedEntry.userState.favorites).toEqual([favorite]); + expect( + playedEntry.userState.recentlyViewed[0].collectionItem + ).toMatchObject({ title: movie.name, tmdb_id: 999 }); await page.goto('/workspace/global-favorites'); const card = page .locator('app-content-card') diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index a592c0d14..920075531 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -126,9 +126,12 @@ favorite writer loads on demand; after loading, reference and snapshot writes run synchronously against fresh storage so concurrent additions are not lost. Browser status and add operations also reject known conflicts, so the replacement's heart cannot remove the retained favorite. Explicit collection removal still works. -A newly recorded playback can replace obsolete Recent-only metadata. It must save -a replacement snapshot before the Recent reference; failure leaves history unchanged -for retry. A snapshot still owned by a favorite remains protected during playback. +New playback keeps its own typed `collectionItem` on the Recent entry when the +shared snapshot belongs to another identity. Its metadata and viewing date are +saved atomically, independently of any retained favorite, and remain available +after reload, export/restore and deferred retries. Reading Recent never copies +that independent snapshot into the favorite cache. Older Recent entries still +use the shared cache; compatible optional caches keep their quota behavior. 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 31ee84857..e91d3f2bb 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 @@ -10,6 +10,7 @@ export interface StoredRecentItem { readonly id: CollectionKey; readonly viewedAt: string; readonly backdropUrl?: string; + readonly collectionItem?: XtreamContentItem; } export function typedCollectionRef( @@ -89,6 +90,14 @@ export function normalizeRecentStorage( id, viewedAt: item.viewedAt, ...normalizeStoredBackdrop(item), + ...(item?.collectionItem && + typeof item.collectionItem.title === 'string' && + (itemCollectionKey(item.collectionItem) === id || + (typeof id === 'number' && + item.collectionItem.xtream_id === id && + itemCollectionKey(item.collectionItem) !== null)) + ? { collectionItem: item.collectionItem } + : {}), }); } } 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 87c065890..79edb28c9 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 @@ -19,7 +19,8 @@ export class PwaCollectionStorage { private readonly resolveItems: ( playlistId: string, ids: readonly CollectionKey[], - catalogOnly?: boolean + catalogOnly?: boolean, + snapshots?: Record ) => Map, private readonly storage: UserDataStorage ) {} @@ -92,24 +93,6 @@ export class PwaCollectionStorage { ); } - /** A newly recorded playback may replace disposable history, never a retained favorite. */ - prepareRecentReplacement(playlistId: string, key: CollectionKey): void { - if (this.savedFavorite(playlistId, key)) return; - const saved = this.resolveItems(playlistId, [key]).get(key); - const current = this.resolveItems(playlistId, [key], true).get(key); - if ( - !saved || - !current || - collectionSnapshotMatchesContent(saved, current) - ) - return; - const allItems = this.getCollectionItemsFromStorage(); - this.saveCollectionItemsToStorage({ - ...allItems, - [playlistId]: { ...allItems[playlistId], [key]: current }, - }); - } - clearFavoritesForPlaylist(playlistId: string): void { const allFavorites = this.getFavoritesFromStorage(); delete allFavorites[playlistId]; @@ -119,22 +102,45 @@ export class PwaCollectionStorage { getLocalRecentItems(playlistId: string): XtreamContentItem[] { const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId] || []; + const snapshots = { + ...this.getCollectionItemsFromStorage()[playlistId], + }; + const independentKeys = new Set(); + for (const entry of playlistRecent) { + if (!entry.collectionItem) continue; + const key = itemCollectionKey(entry.collectionItem); + if (key !== null) { + snapshots[key] = entry.collectionItem; + independentKeys.add(entry.id); + } + } const contentById = this.resolveItems( playlistId, - playlistRecent.map((item) => item.id) + playlistRecent.map((item) => item.id), + false, + snapshots ); const migrated = deduplicateRecentItems( playlistRecent.map((entry) => { const item = contentById.get(entry.id); return item - ? { ...entry, id: itemCollectionKey(item) ?? entry.id } + ? { + ...entry, + id: itemCollectionKey(item) ?? entry.id, + ...(entry.collectionItem + ? { collectionItem: item } + : {}), + } : entry; }) ); if ( migrated.length !== playlistRecent.length || migrated.some( - (entry, index) => entry.id !== playlistRecent[index].id + (entry, index) => + entry.id !== playlistRecent[index].id || + entry.collectionItem !== + playlistRecent[index].collectionItem ) ) { allRecent[playlistId] = migrated; @@ -142,11 +148,18 @@ export class PwaCollectionStorage { this.saveRecentItemsToStorage(allRecent) ); } - this.persistCollectionSnapshots(playlistId, contentById); + this.persistCollectionSnapshots( + playlistId, + new Map( + [...contentById].filter(([key]) => !independentKeys.has(key)) + ) + ); const results: (XtreamContentItem & { viewed_at: string })[] = []; const migratedContent = this.resolveItems( playlistId, - migrated.map((item) => item.id) + migrated.map((item) => item.id), + false, + snapshots ); for (const recentEntry of migrated) { const item = migratedContent.get(recentEntry.id); 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 7798e46cb..d34f2198e 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 @@ -722,39 +722,19 @@ describe('PWA Xtream collection identity', () => { 'records replacement playback after removing obsolete Recent (quota=%s)', async (quota) => { const ref = { id: 42, type: 'movie' as const }; - await source.saveContent( + const [cached] = await source.getContent( 'p1', - [ - { - id: 42, - category_id: '1', - rating: '', - added: '', - poster_url: '', - xtream_id: 42, - type: 'movie', - title: 'Original', - tmdb_id: 100, - }, - ], + credentials, 'movie' ); + const original = cached as XtreamContentItem; + original.tmdb_id = 100; + const expected = { title: 'Replacement', tmdb_id: 200 }; + await source.saveContent('p1', [original], 'movie'); await source.addRecentItem(ref, 'p1'); await source.saveContent( 'p1', - [ - { - id: 42, - category_id: '1', - rating: '', - added: '', - poster_url: '', - xtream_id: 42, - type: 'movie', - title: 'Replacement', - tmdb_id: 200, - }, - ], + [{ ...original, ...expected }], 'movie' ); await source.getRecentItems('p1'); @@ -764,7 +744,7 @@ describe('PWA Xtream collection identity', () => { const failure = jest .spyOn(Storage.prototype, 'setItem') .mockImplementation(function (this: Storage, key, value) { - if (key === 'xtream-collection-items') + if (key === 'xtream-recent-items') throw new DOMException( 'full', 'QuotaExceededError' @@ -778,10 +758,9 @@ describe('PWA Xtream collection identity', () => { failure.mockRestore(); } await source.addRecentItem(ref, 'p1'); - expect((await source.getRecentItems('p1'))[0]).toMatchObject({ - title: 'Replacement', - tmdb_id: 200, - }); + expect((await source.getRecentItems('p1'))[0]).toMatchObject( + expected + ); reload(); api.getStreams.mockResolvedValue([]); const [item] = await source.getRecentItems('p1'); @@ -791,6 +770,74 @@ describe('PWA Xtream collection identity', () => { } ); + it('keeps played replacements separate from retained favorites through reload and backup restore', async () => { + const entry: XtreamPlaylistBackupEntry = { + portalType: 'xtream', + exportedId: 'p1', + title: 'Saved portal', + autoRefresh: false, + connection: credentials, + userState: { + hiddenCategories: [], + playbackPositions: [], + recentlyViewed: [], + favorites: types.map((contentType) => ({ + contentType, + xtreamId: 42, + snapshot: { title: `Original ${contentType}`, tmdbId: 100 }, + })), + }, + }; + await source.restoreUserData('p1', entry.userState); + api.getStreams.mockImplementation((_credentials, type) => + Promise.resolve([ + { + stream_id: 42, + series_id: 42, + name: `Replacement ${type}`, + category_id: '1', + tmdb_id: 200, + stream_icon: 'replacement.jpg', + }, + ]) + ); + await loadAll(); + for (const type of types) + await source.addRecentItem({ id: 42, type }, 'p1'); + const verify = async () => { + expect( + (await source.getFavorites('p1')).map((item) => item.title) + ).toEqual(types.map((type) => `Original ${type}`)); + const recent = await source.getRecentItems('p1'); + expect(recent).toHaveLength(3); + for (const item of recent) { + expect(item).toMatchObject({ + title: `Replacement ${item.type}`, + tmdb_id: 200, + poster_url: 'replacement.jpg', + }); + expect(item.catalog_status).not.toBe('missing'); + } + }; + await verify(); + reload(); + api.getStreams.mockResolvedValue([]); + await verify(); + const backup = new PwaXtreamBackupStorage( + TestBed.inject(USER_DATA_STORAGE) + ).exportInto(entry); + expect(backup.userState.favorites).toEqual(entry.userState.favorites); + localStorage.clear(); + reload(); + await new PwaXtreamBackupStorage( + TestBed.inject(USER_DATA_STORAGE) + ).restore('p1', backup.userState, backup); + await verify(); + const pending = await source.clearPlaylistContent('p1'); + await source.restoreUserData('p1', pending); + await verify(); + }); + it('retains concurrent explicit additions after loading the favorite writer', async () => { await loadAll(); await Promise.all( 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 dabca232c..920569d9a 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 @@ -39,6 +39,7 @@ import { import { CollectionKey, collectionKey, + collectionSnapshotMatchesContent, deduplicateRecentItems, typedCollectionRef, itemCollectionKey, @@ -97,8 +98,13 @@ 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, catalogOnly) => - this.getCollectionItemsById(playlistId, ids, catalogOnly), + (playlistId, ids, catalogOnly, snapshots) => + this.getCollectionItemsById( + playlistId, + ids, + catalogOnly, + snapshots + ), this.userDataStorage ); @@ -1032,12 +1038,30 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allRecent[playlistId].map((item) => item.id), normalizedContentId ); + const previous = allRecent[playlistId].find((r) => + matchingKeys.has(r.id) + ); allRecent[playlistId] = allRecent[playlistId].filter( (r) => !matchingKeys.has(r.id) ); // Add new entry at the beginning + const current = this.getCollectionItemsById( + playlistId, + [normalizedContentId], + true + ).get(normalizedContentId); + const saved = this.getCollectionItemsById(playlistId, [ + normalizedContentId, + ]).get(normalizedContentId); + const collectionItem = + current && + (previous?.collectionItem || + (saved && !collectionSnapshotMatchesContent(saved, current))) + ? current + : previous?.collectionItem; allRecent[playlistId].unshift({ + ...(collectionItem ? { collectionItem } : {}), id: normalizedContentId, viewedAt: new Date().toISOString(), ...(normalizedBackdropUrl @@ -1048,10 +1072,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Keep only last 50 items allRecent[playlistId] = allRecent[playlistId].slice(0, 50); - this.collectionStorage.prepareRecentReplacement( - playlistId, - normalizedContentId - ); this.collectionStorage.saveRecentItemsToStorage(allRecent); this.collectionStorage.getLocalRecentItems(playlistId); this.collectionStorage.saveCollectionItemSnapshot( @@ -1110,7 +1130,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getCollectionItemsById( playlistId: string, ids: readonly CollectionKey[], - catalogOnly = false + catalogOnly = false, + snapshots?: Record ): Map { if (!ids.length) return new Map(); const cached = ([] as XtreamContentItem[]).concat( @@ -1125,9 +1146,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { cached, catalogOnly ? {} - : (this.collectionStorage.getCollectionItemsFromStorage()[ - playlistId - ] ?? {}), + : (snapshots ?? + this.collectionStorage.getCollectionItemsFromStorage()[ + playlistId + ] ?? + {}), this.contentTypes.every((type) => this.contentCache.has(`${playlistId}-${type}-content`) ) @@ -1374,6 +1397,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return { ...identity, viewedAt: item.viewedAt, + ...(item.collectionItem + ? { + collectionItem: { + ...item.collectionItem, + type: identity.contentType, + }, + } + : {}), ...normalizeStoredBackdrop(item), }; }) diff --git a/libs/services/src/lib/pwa-xtream-backup-storage.ts b/libs/services/src/lib/pwa-xtream-backup-storage.ts index f0c29a53b..3beaf90ec 100644 --- a/libs/services/src/lib/pwa-xtream-backup-storage.ts +++ b/libs/services/src/lib/pwa-xtream-backup-storage.ts @@ -66,6 +66,13 @@ export class PwaXtreamBackupStorage { recent.set(ref.key, { ...ref.item, viewedAt, + ...(snapshotsByKey([item['collectionItem']])[ref.key] + ? { + collectionItem: snapshotsByKey([ + item['collectionItem'], + ])[ref.key], + } + : {}), ...backdropOf(item), }); } @@ -181,6 +188,13 @@ export class PwaXtreamBackupStorage { recent.push({ id, viewedAt: item.viewedAt, + ...(snapshotsByKey([item.collectionItem])[id] + ? { + collectionItem: snapshotsByKey([ + item.collectionItem, + ])[id], + } + : {}), ...backdropOf(item), }); } diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index ba889d257..b6fc5cd2c 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -70,6 +70,8 @@ export interface XtreamBackupRecentlyViewedItem { * reads it. */ backdropUrl?: string; + /** Browser view metadata, independent of a retained favorite with the same provider key. */ + collectionItem?: XtreamBackupCollectionItem; } /**