From c38bbe946cf0861d6af7af46253180ca57d58614 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 06:08:05 +0200 Subject: [PATCH] fix(xtream): validate favorite mutations and fresh recent identity --- .../src/xtream-favorites-restore.e2e.ts | 20 ++++ .../xtream-portal-compatibility.md | 10 +- .../data-sources/pwa-collection-identity.ts | 28 ++++- .../data-sources/pwa-collection-storage.ts | 45 ++++++++ .../lib/data-sources/pwa-favorite-write.ts | 4 + .../pwa-xtream-collection-identity.spec.ts | 107 +++++++++++++++++- .../data-sources/pwa-xtream-data-source.ts | 14 ++- 7 files changed, 215 insertions(+), 13 deletions(-) diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts index 3b6212dd7..fc99005c2 100644 --- a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -180,6 +180,26 @@ for (const identity of ['missing', 'conflicting'] as const) { await page.keyboard.press('Enter'); await expect(page.locator('app-content-hero')).toHaveCount(0); expect(detailRequests).toBe(0); + // Opening the actual replacement from its catalog must not expose a + // Remove heart that could delete the retained, incompatible favorite. + await page.goto(`${playlistPath}/vod`); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await page.getByText(movie.name, { exact: true }).first().click(); + const add = page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .first(); + await expect(add).toBeVisible(); + await add.click(); + await expect( + page.getByText( + 'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.', + { exact: true } + ) + ).toBeVisible(); + await expect(add).toBeVisible(); 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 ead73c28f..a592c0d14 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -104,8 +104,9 @@ cache, preserving metadata when the archive returns to Electron. 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. +artwork and metadata. Identity numbers use the shared Electron metadata normalizer: numeric strings +match numbers, and empty/zero placeholders are unknown. 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 @@ -123,6 +124,11 @@ storage failure cannot silently resurrect the old title. Ordinary optional snapshot-cache writes retain their existing quota behavior. The explicit browser 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. 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 2020a9c5d..31ee84857 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 @@ -1,3 +1,4 @@ +import { normalizeContentMetadataPatch } from '@iptvnator/shared/interfaces'; import { TypedXtreamCollectionRef, XTREAM_CONTENT_TYPES, @@ -157,14 +158,27 @@ export function collectionSnapshotMatchesContent( current: XtreamContentItem | undefined, allowMissing = false ): boolean { + const known = normalizeCollectionIdentity(saved); + const actual = normalizeCollectionIdentity(current); return (['tmdb_id', 'release_year'] as const).every( (key) => - saved[key] == null || - saved[key] === current?.[key] || - (allowMissing && current?.[key] == null) + known[key] == null || + known[key] === actual[key] || + (allowMissing && actual[key] == null) ); } +/** Match Electron's metadata boundary, including numeric strings and unknown placeholders. */ +export function normalizeCollectionIdentity( + item: XtreamContentItem | undefined +) { + const identity = normalizeContentMetadataPatch({ + tmdbId: item?.tmdb_id ?? undefined, + releaseYear: item?.release_year ?? undefined, + }); + return { tmdb_id: identity?.tmdbId, release_year: identity?.releaseYear }; +} + /** Keep the durable snapshot separate until the actual catalog row confirms it. */ function mergeCollectionItem( saved: XtreamContentItem | undefined, @@ -182,8 +196,12 @@ function mergeCollectionItem( title: current.title || saved.title, poster_url: current.poster_url || saved.poster_url, backdrop_url: current.backdrop_url || saved.backdrop_url, - tmdb_id: current.tmdb_id ?? saved.tmdb_id, - release_year: current.release_year ?? saved.release_year, + tmdb_id: + normalizeCollectionIdentity(current).tmdb_id ?? + normalizeCollectionIdentity(saved).tmdb_id, + release_year: + normalizeCollectionIdentity(current).release_year ?? + normalizeCollectionIdentity(saved).release_year, original_title: current.original_title || saved.original_title, // Provider metadata never owns the user's favorite date/order. added_at: saved.added_at ?? current.added_at, 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 b225c7300..87c065890 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 @@ -65,6 +65,51 @@ export class PwaCollectionStorage { ); } + private savedFavorite( + playlistId: string, + key: CollectionKey + ): XtreamContentItem | undefined { + return [ + ...this.resolveItems( + playlistId, + this.getFavoritesFromStorage()[playlistId] ?? [] + ).values(), + ].find((item) => itemCollectionKey(item) === key); + } + + hasFavoriteIdentityConflict( + playlistId: string, + key: CollectionKey + ): boolean { + const saved = this.savedFavorite(playlistId, key); + return ( + !!saved && + !collectionSnapshotMatchesContent( + saved, + this.resolveItems(playlistId, [key], true).get(key), + true + ) + ); + } + + /** 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]; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-favorite-write.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-favorite-write.ts index 2b03459be..bf6447564 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-favorite-write.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-favorite-write.ts @@ -1,3 +1,4 @@ +import { XTREAM_FAVORITE_IDENTITY_CONFLICT } from '@iptvnator/shared/interfaces'; import { collectionSnapshotMatchesContent, itemCollectionKey, @@ -15,6 +16,9 @@ export function addPwaFavorite( matchingKeysFor: (ids: CollectionKey[]) => Set, backdropUrl?: string ): void { + if (storage.hasFavoriteIdentityConflict(playlistId, key)) { + throw new Error(XTREAM_FAVORITE_IDENTITY_CONFLICT); + } const allFavorites = storage.getFavoritesFromStorage(); const ids = (allFavorites[playlistId] ??= []); const matchingKeys = matchingKeysFor(ids); 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 93b60851d..7798e46cb 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 @@ -524,6 +524,9 @@ describe('PWA Xtream collection identity', () => { 'tmdb conflict', 'year conflict', 'confirmed', + 'numeric strings', + 'zero', + 'empty', ])( 'protects imported identities through %s catalog rows, recent writes and re-export', async (identity) => { @@ -568,7 +571,13 @@ describe('PWA Xtream collection identity', () => { tmdb_id: identity === 'tmdb conflict' ? 999 - : 100 + position, + : identity === 'numeric strings' + ? String(100 + position) + : identity === 'zero' + ? '0' + : identity === 'empty' + ? '' + : 100 + position, } : {}), ...(identity !== 'missing' && identity !== 'partial' @@ -576,7 +585,13 @@ describe('PWA Xtream collection identity', () => { release_year: identity === 'year conflict' ? 2026 - : 1995 + position, + : identity === 'numeric strings' + ? String(1995 + position) + : identity === 'zero' + ? 0 + : identity === 'empty' + ? '' + : 1995 + position, } : {}), }, @@ -597,13 +612,17 @@ describe('PWA Xtream collection identity', () => { ); } await source.getRecentItems('p1'); + if (['zero', 'empty'].includes(identity)) { + for (const item of await source.getFavorites('p1')) + expect(item.catalog_status).not.toBe('missing'); + } if (identity.includes('conflict')) { for (const item of await source.getFavorites('p1')) { expect(item).toMatchObject({ catalog_status: 'missing' }); } } const expected = favorites.map((favorite) => - identity === 'confirmed' + ['confirmed', 'numeric strings'].includes(identity) ? { ...favorite, snapshot: { @@ -665,8 +684,13 @@ describe('PWA Xtream collection identity', () => { } as XtreamContentItem; await source.saveContent('p1', [replacement], 'movie'); await source.getFavorites('p1'); - // Re-adding while retained must not replace the original saved identity. - await source.addFavorite({ id: 42, type: 'movie' }, 'p1'); + // A replacement detail heart cannot remove or claim the original favorite. + expect( + await source.isFavorite({ id: 42, type: 'movie' }, 'p1') + ).toBe(false); + await expect( + source.addFavorite({ id: 42, type: 'movie' }, 'p1') + ).rejects.toThrow('XTREAM_FAVORITE_IDENTITY_CONFLICT'); expect((await source.getFavorites('p1'))[0].title).toBe('Original'); await source.removeFavorite({ id: 42, type: 'movie' }, 'p1'); const setItem = Storage.prototype.setItem; @@ -694,6 +718,79 @@ describe('PWA Xtream collection identity', () => { } ); + it.each([false, true])( + 'records replacement playback after removing obsolete Recent (quota=%s)', + async (quota) => { + const ref = { id: 42, type: 'movie' as const }; + await source.saveContent( + 'p1', + [ + { + id: 42, + category_id: '1', + rating: '', + added: '', + poster_url: '', + xtream_id: 42, + type: 'movie', + title: 'Original', + tmdb_id: 100, + }, + ], + '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, + }, + ], + 'movie' + ); + await source.getRecentItems('p1'); + await source.removeRecentItem(ref, 'p1'); + if (quota) { + const setItem = Storage.prototype.setItem; + const failure = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key, value) { + if (key === 'xtream-collection-items') + throw new DOMException( + 'full', + 'QuotaExceededError' + ); + setItem.call(this, key, value); + }); + await expect( + source.addRecentItem(ref, 'p1') + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(read('xtream-recent-items').p1).toEqual([]); + failure.mockRestore(); + } + await source.addRecentItem(ref, 'p1'); + expect((await source.getRecentItems('p1'))[0]).toMatchObject({ + title: 'Replacement', + tmdb_id: 200, + }); + reload(); + api.getStreams.mockResolvedValue([]); + const [item] = await source.getRecentItems('p1'); + expect(item).toMatchObject({ title: 'Replacement', tmdb_id: 200 }); + expect(item.catalog_status).not.toBe('missing'); + expect(await source.getFavorites('p1')).toEqual([]); + } + ); + 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 4ae8311a2..dabca232c 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 @@ -44,6 +44,7 @@ import { itemCollectionKey, findCollectionItems, normalizeStoredBackdrop, + normalizeCollectionIdentity, } from './pwa-collection-identity'; import { PwaCollectionStorage } from './pwa-collection-storage'; @@ -629,6 +630,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return { ...item, + ...normalizeCollectionIdentity(item as XtreamContentItem), added: item.added ?? item.last_modified ?? '', category_id: item.category_id ?? '', id: xtreamId, @@ -781,7 +783,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ids, normalizedContentId ); - return ids.some((id) => matchingKeys.has(id)); + return ( + ids.some((id) => matchingKeys.has(id)) && + !this.collectionStorage.hasFavoriteIdentityConflict( + playlistId, + normalizedContentId + ) + ); } // ========================================================================= @@ -1040,6 +1048,10 @@ 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(