diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts index 4d02bd8f0..3b6212dd7 100644 --- a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -88,7 +88,13 @@ for (const identity of ['missing', 'conflicting'] as const) { JSON.stringify({ hiddenCategories: [], favorites: [favorite], - recentlyViewed: [], + recentlyViewed: [ + { + contentType: favorite.contentType, + xtreamId: favorite.xtreamId, + viewedAt: '2026-01-02T12:00:00.000Z', + }, + ], playbackPositions: [], }) ), @@ -163,6 +169,17 @@ for (const identity of ['missing', 'conflicting'] as const) { ).userState.favorites ).toEqual([favorite]); if (identity === 'conflicting') { + await page.goto('/workspace/global-recent'); + const recent = page.getByRole('button', { + name: favorite.snapshot.title, + exact: true, + }); + await expect(recent).toHaveAttribute('aria-disabled', 'true'); + await recent.dispatchEvent('click'); + await recent.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('app-content-hero')).toHaveCount(0); + expect(detailRequests).toBe(0); 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 678d35a62..ead73c28f 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -113,7 +113,10 @@ cannot replace an imported snapshot merely because a provider reused its ID. A known browser identity conflict marks the retained snapshot unavailable, so collection and dashboard detail actions cannot open the replacement under the saved title. This survives reload and clears only when the saved identity is -confirmed again. Explicit removal remains available. After removal, deliberately +confirmed again. Legacy numeric aliases are deduplicated separately, with the +canonical typed snapshot taking precedence; stored copies never count as catalog +confirmation. Recent collection and dashboard mappings carry the same conflict +status into their detail guards. Explicit removal remains available. After removal, deliberately favoriting the current item may replace the old snapshot (including legacy key aliases); that replacement must persist before the new reference is saved, so storage failure cannot silently resurrect the old title. Ordinary optional diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 50c1b5e48..7fa013b12 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -402,55 +402,60 @@ describe('UnifiedRecentDataService', () => { ); }); - it('loads Xtream playlist recent items through the active data source in PWA', async () => { - Object.defineProperty(window, 'electron', { - value: undefined, - configurable: true, - }); - store.select.mockReturnValue( - of([ + it.each(['present', 'missing', 'unknown'] as const)( + 'carries %s PWA Xtream availability into Recent', + async (catalogStatus) => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getRecentItems.mockResolvedValue([ { - _id: 'xtream-1', - title: 'Xtream PWA', - serverUrl: 'https://example.com', - } satisfies Partial, - ]) - ); - xtreamDataSource.getRecentItems.mockResolvedValue([ - { - id: 202, - category_id: 20, - title: 'Movie One', - type: 'movie', - poster_url: 'movie.png', - backdrop_url: 'backdrop.png', - xtream_id: 202, - viewed_at: '2026-05-21T12:00:00.000Z', - }, - ]); + id: 202, + catalog_status: catalogStatus, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + backdrop_url: 'backdrop.png', + xtream_id: 202, + viewed_at: '2026-05-21T12:00:00.000Z', + }, + ]); - const items = await service.getRecentItems( - 'playlist', - 'xtream-1', - 'xtream' - ); + const items = await service.getRecentItems( + 'playlist', + 'xtream-1', + 'xtream' + ); - expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( - 'xtream-1' - ); - expect(dbService.getRecentItems).not.toHaveBeenCalled(); - expect(items).toEqual([ - expect.objectContaining({ - uid: 'xtream::xtream-1::movie:202', - sourceType: 'xtream', - contentType: 'movie', - name: 'Movie One', - playlistName: 'Xtream PWA', - posterUrl: 'movie.png', - viewedAt: '2026-05-21T12:00:00.000Z', - }), - ]); - }); + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbService.getRecentItems).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + catalogStatus, + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + viewedAt: '2026-05-21T12:00:00.000Z', + }), + ]); + } + ); it('does not load Stalker portals through the PWA Xtream global recent path', async () => { Object.defineProperty(window, 'electron', { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 2a085b4eb..bcd57144b 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -491,6 +491,7 @@ export class UnifiedRecentDataService { item.xtream_id ), name: item.title, + catalogStatus: item.catalog_status, contentType, sourceType: 'xtream', playlistId, 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 eb3539f90..2020a9c5d 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 @@ -106,7 +106,16 @@ export function findCollectionItems( ids.map((key) => typedCollectionRef(key)?.id ?? key) ); const typedItems = new Map(); - for (const item of [...Object.values(snapshots), ...cached]) { + // Stored aliases are not fresh catalog evidence. Prefer the canonical typed + // snapshot, including any conflict retained since its legacy alias was copied. + for (const [storedKey, item] of Object.entries(snapshots)) { + if (!item || !wantedIds.has(item.xtream_id)) continue; + const key = itemCollectionKey(item); + if (key !== null && (storedKey === key || !typedItems.has(key))) { + typedItems.set(key, item); + } + } + for (const item of cached) { if (!item || !wantedIds.has(item.xtream_id)) continue; const key = itemCollectionKey(item); if (key !== null) 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 22477434c..93b60851d 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 @@ -725,59 +725,70 @@ describe('PWA Xtream collection identity', () => { ).toEqual(['movie']); }); - it('keeps known conflicts unavailable through reload until the saved identity returns', async () => { - await source.restoreUserData('p1', { - favorites: [ - { - contentType: 'movie', - xtreamId: 42, - snapshot: { - title: 'Original', - tmdbId: 100, - releaseYear: 1995, + it.each([false, true])( + 'keeps conflicts unavailable through stored aliases until identity returns (legacy=%s)', + async (legacy) => { + await source.restoreUserData('p1', { + favorites: [ + { + contentType: 'movie', + xtreamId: 42, + snapshot: { + title: 'Original', + tmdbId: 100, + releaseYear: 1995, + }, }, - }, - ], - hiddenCategories: [], - recentlyViewed: [], - playbackPositions: [], - }); - const returning = { - id: 42, - xtream_id: 42, - type: 'movie', - title: 'Returning', - tmdb_id: 200, - release_year: 1995, - } as XtreamContentItem; - await source.saveContent('p1', [returning], 'movie'); - expect((await source.getFavorites('p1'))[0]).toMatchObject({ - catalog_status: 'missing', - title: 'Original', - }); - reload(); - expect((await source.getFavorites('p1'))[0]).toMatchObject({ - catalog_status: 'missing', - }); - await source.saveContent( - 'p1', - [{ ...returning, tmdb_id: undefined }], - 'movie' - ); - expect((await source.getFavorites('p1'))[0]).toMatchObject({ - catalog_status: 'missing', - title: 'Original', - }); - await source.saveContent( - 'p1', - [{ ...returning, tmdb_id: 100 }], - 'movie' - ); - expect((await source.getFavorites('p1'))[0]).toMatchObject({ - catalog_status: 'present', - title: 'Returning', - }); - }); + ], + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + }); + if (legacy) { + const snapshots = read('xtream-collection-items'); + snapshots.p1[42] = snapshots.p1['movie:42']; + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify(snapshots) + ); + } + const returning = { + id: 42, + xtream_id: 42, + type: 'movie', + title: 'Returning', + tmdb_id: 200, + release_year: 1995, + } as XtreamContentItem; + await source.saveContent('p1', [returning], 'movie'); + expect((await source.getFavorites('p1'))[0]).toMatchObject({ + catalog_status: 'missing', + title: 'Original', + }); + reload(); + expect((await source.getFavorites('p1'))[0]).toMatchObject({ + catalog_status: 'missing', + }); + await source.saveContent( + 'p1', + [{ ...returning, tmdb_id: undefined }], + 'movie' + ); + expect((await source.getFavorites('p1'))[0]).toMatchObject({ + catalog_status: 'missing', + title: 'Original', + }); + await source.saveContent( + 'p1', + [{ ...returning, tmdb_id: 100 }], + 'movie' + ); + expect((await source.getFavorites('p1'))[0]).toMatchObject({ + catalog_status: 'present', + title: 'Returning', + }); + } + ); it('keeps the artwork saved with each view through clear/restore', async () => { await source.restoreUserData('p1', { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 5192510e1..1c17c7b3f 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -13,7 +13,10 @@ import { Playlist, PlaylistMeta, } from '@iptvnator/shared/interfaces'; -import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; +import { + PORTAL_PLAYBACK_POSITIONS, + getOpenCollectionDetailItemState, +} from '@iptvnator/portal/shared/util'; import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { DashboardDataService, @@ -2558,44 +2561,60 @@ describe('DashboardDataService', () => { ).toEqual(['xtream-1', 'stalker-1', 'm3u-1']); }); - it('includes PWA Xtream recent items from the active data source', async () => { - Object.defineProperty(window, 'electron', { - value: undefined, - configurable: true, - }); - xtreamDataSourceMock.getRecentItems.mockResolvedValue([ - { - id: 91, - category_id: 18, - title: 'Recent Movie', - rating: '7.8', - viewed_at: '2026-04-21T10:00:00.000Z', - poster_url: 'https://example.com/recent-movie.png', - xtream_id: 7001, - type: 'movie', - }, - ]); - - await service.reloadGlobalRecentItems(); - - expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( - 'xtream-1' - ); - expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled(); - expect(service.globalRecentItems()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ + it.each(['present', 'missing', 'unknown'] as const)( + 'carries %s PWA Recent availability through the dashboard handoff', + async (catalogStatus) => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([ + { id: 91, + catalog_status: catalogStatus, + category_id: 18, title: 'Recent Movie', - type: 'movie', - playlist_id: 'xtream-1', - playlist_name: 'Xtream Playlist', - source: 'xtream', + rating: '7.8', viewed_at: '2026-04-21T10:00:00.000Z', - }), - ]) - ); - }); + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect( + dbServiceMock.getGlobalRecentlyViewed + ).not.toHaveBeenCalled(); + expect(service.globalRecentItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 91, + catalogStatus, + title: 'Recent Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + viewed_at: '2026-04-21T10:00:00.000Z', + }), + ]) + ); + const item = service + .globalRecentItems() + .find((row) => row.id === 91); + if (!item) throw new Error('Missing recent item'); + expect( + getOpenCollectionDetailItemState( + service.getRecentItemDetailNavigationState(item) + )?.item.catalogStatus + ).toBe(catalogStatus); + } + ); it('loads PWA Xtream recent items for multiple playlists in parallel', async () => { Object.defineProperty(window, 'electron', { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 207e99fca..f3ac11ada 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -688,6 +688,7 @@ export class DashboardDataService { return { id: item.id, title: item.title, + catalogStatus: item.catalog_status, type: this.normalizeXtreamActivityType(item.type), playlist_id: playlist._id, playlist_name: playlist.title || 'Xtream',