diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 2d2ce175b..12760d385 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -748,6 +748,23 @@ test.describe('Electron Favorites', () => { .first() .click(); await waitForXtreamWorkspaceReady(app.mainWindow); + + // The dashboard includes Source favorites while Host stays active. + // A handoff must load all rows before checking saved availability. + for (const title of [movieTitle, seriesTitle]) { + await goToDashboard(app.mainWindow); + await app.mainWindow + .getByTestId('dashboard-favorite-vod-rail-card') + .filter({ hasText: title }) + .first() + .click(); + await expectInlineCollectionDetail(app.mainWindow, { + pathname: /\/workspace\/global-favorites$/, + title, + playlistTitle: hostPortalTitle, + }); + } + await goToDashboard(app.mainWindow); await openPlaylistFavorites(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( hostPortalTitle diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts index 93b75945c..2c5f66b6b 100644 --- a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -1,6 +1,8 @@ import { expect, test } from './fixtures'; import { addXtreamPortal, + DEFAULT_PASSWORD, + DEFAULT_USERNAME, interceptXtreamRequests, MOCK_SERVER, } from './xtream-series-playback.fixture'; @@ -11,6 +13,91 @@ 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( + 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, + }); +}); + test('@xtream @collections PWA retries retain saved favorite metadata when snapshot storage is full', async ({ page, }) => { diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 6a18a5cc5..1beca35e2 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -333,6 +333,10 @@ Dashboard behavior to preserve: Xtream movie/series items into `/workspace/global-favorites` or `/workspace/global-recent` with collection detail pre-opened from navigation state. +- Global detail handoffs carry `All playlists` scope in collection view state. + The active playlist stays unchanged, but availability is checked against + the target favorite's current row even when it belongs to another source. + Missing or removed favorites still cannot reopen through stale history. - When a series recent (Xtream, or a Stalker row that `resolvePortalActivityWatchKind` answers `series` for) has a saved episode position, the dashboard hero and Continue Watching card should include that diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 76c32a0c7..369a1eed8 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -101,6 +101,11 @@ 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. 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/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.spec.ts new file mode 100644 index 000000000..a6adb2a75 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.spec.ts @@ -0,0 +1,108 @@ +import { computed, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { + getGlobalFavoriteNavigation, + ScopeToggleService, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { of } from 'rxjs'; +import { createCollectionDetailState } from './unified-collection-detail-state'; +import { CollectionViewStateHistory } from './unified-collection-history'; +import { createCollectionScopeState } from './unified-collection-scope'; + +describe('dashboard favorite detail handoff', () => { + afterEach(() => window.history.replaceState({}, document.title)); + + it.each(['movie', 'series'] as const)( + 'validates a %s from another playlist against the global collection', + (type) => { + const favorite = { + id: 77, + favorite_id: 11, + title: 'Saved title', + type, + source: 'xtream' as const, + playlist_id: 'source-B', + playlist_name: 'Source B', + xtream_id: 77, + category_id: 12, + poster_url: '', + added_at: '2026-10-01T00:00:00.000Z', + catalog_status: 'present' as const, + }; + window.history.replaceState( + getGlobalFavoriteNavigation(favorite).state, + document.title + ); + const paramMap = convertToParamMap({}); + TestBed.configureTestingModule({ + providers: [ + { + provide: Router, + useValue: { url: '/workspace/global-favorites' }, + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { queryParamMap: paramMap }, + queryParamMap: of(paramMap), + }, + }, + { + provide: ScopeToggleService, + useValue: { getScope: () => signal('playlist') }, + }, + ], + }); + const current = signal([ + { + uid: `xtream::source-B::${type}:77`, + name: favorite.title, + contentType: type, + sourceType: 'xtream', + playlistId: 'source-B', + playlistName: 'Source B', + xtreamId: 77, + favoriteId: 11, + catalogStatus: 'present', + }, + ]); + const detail = TestBed.runInInjectionContext(() => { + const history = new CollectionViewStateHistory(); + const scope = createCollectionScopeState({ + scopeKey: signal('favorites'), + playlistId: signal('active-A'), + defaultScope: signal('playlist'), + historyScope: computed(() => history.current()?.scope), + }); + return createCollectionDetailState({ + mode: signal('favorites'), + items: computed(() => + scope.effective() === 'all' ? current() : [] + ), + isLoading: signal(false), + portalType: signal(undefined), + detailTemplate: signal({}), + onOpen: jest.fn(), + onClear: jest.fn(), + }); + }); + detail.syncFromHistory(); + expect(detail.item()).toEqual(current()[0]); + + // The same handoff must not trust stale dashboard availability. + current.set([{ ...current()[0], catalogStatus: 'missing' }]); + detail.syncFromHistory(); + expect(detail.item()).toBeNull(); + + window.history.replaceState( + getGlobalFavoriteNavigation(favorite).state, + document.title + ); + current.set([]); + detail.syncFromHistory(); + expect(detail.item()).toBeNull(); + } + ); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 6d4e534ae..94c17550f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -800,39 +800,6 @@ describe('UnifiedCollectionPageComponent', () => { expect(fixture.componentInstance.allItems()).toEqual(liveItems); }); - it('does not open a stale history detail when the favorite is now missing', async () => { - const item: UnifiedCollectionItem = { - uid: 'xtream::xtream-1::movie:77', - name: 'ABC', - contentType: 'movie', - sourceType: 'xtream', - playlistId: 'xtream-1', - playlistName: 'Xtream', - xtreamId: 77, - favoriteId: 11, - catalogStatus: 'present', - }; - favoritesData.getFavorites.mockResolvedValue([ - { ...item, catalogStatus: 'missing' }, - ]); - window.history.replaceState( - { [OPEN_COLLECTION_DETAIL_STATE_KEY]: { item } }, - document.title - ); - const hostFixture = TestBed.createComponent( - HostUnifiedCollectionPageComponent - ); - hostFixture.detectChanges(); - await hostFixture.whenStable(); - hostFixture.detectChanges(); - expect( - hostFixture.nativeElement.querySelector('.detail-probe') - ).toBeNull(); - expect( - hostFixture.componentInstance.pageComponent?.selectedDetailItem() - ).toBeNull(); - }); - it('opens inline detail from history state when a detail template is projected', async () => { const item: UnifiedCollectionItem = { uid: 'xtream::xtream-1::77', @@ -948,6 +915,7 @@ describe('UnifiedCollectionPageComponent', () => { ['/workspace', 'global-favorites'], { state: { + [COLLECTION_VIEW_STATE_KEY]: { scope: 'all' }, [OPEN_COLLECTION_DETAIL_STATE_KEY]: { item: expect.objectContaining({ xtreamId: 103 }), seriesResume, @@ -1071,6 +1039,7 @@ describe('UnifiedCollectionPageComponent', () => { ['/workspace', 'global-recent'], { state: { + [COLLECTION_VIEW_STATE_KEY]: { scope: 'all' }, [OPEN_COLLECTION_DETAIL_STATE_KEY]: { item: expect.objectContaining({ uid: item.uid, diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts index 426f221ff..b1e97d05f 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -109,6 +109,7 @@ describe('workspace-portal-navigation', () => { expect(navigation).toEqual({ link: ['/workspace', 'global-favorites'], state: { + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ uid: 'stalker::stalker-1::movie-7', @@ -179,6 +180,7 @@ describe('workspace-portal-navigation', () => { expect(navigation).toEqual({ link: ['/workspace', 'global-recent'], state: { + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ contentType: 'series', diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 69aa7135e..22092558d 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -268,6 +268,9 @@ export function buildGlobalCollectionDetailNavigationTarget( mode === 'favorites' ? 'global-favorites' : 'global-recent', ], state: { + // The active playlist can differ from the dashboard/cross-portal item. + // Validate its current availability against all saved rows. + [COLLECTION_VIEW_STATE_KEY]: { scope: 'all' }, [OPEN_COLLECTION_DETAIL_STATE_KEY]: buildOpenCollectionDetailItemState(item, seriesResume), }, 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 4b0c87a67..36eaa8397 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 @@ -109,7 +109,8 @@ export function findCollectionItems( for (const item of [...Object.values(snapshots), ...cached]) { if (!item || !wantedIds.has(item.xtream_id)) continue; const key = itemCollectionKey(item); - if (key !== null) typedItems.set(key, item); + if (key !== null) + typedItems.set(key, mergeCollectionItem(typedItems.get(key), item)); } const uniqueIds = new Map(); for (const item of cached) { @@ -141,6 +142,27 @@ export function findCollectionItems( return results; } +/** Sparse provider rows refresh the cache without erasing saved backup fields. */ +function mergeCollectionItem( + saved: XtreamContentItem | undefined, + current: XtreamContentItem +): XtreamContentItem { + if (!saved) return current; + return { + ...saved, + ...current, + 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, + 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, + position: saved.position ?? current.position, + }; +} + export function deduplicateRecentItems( items: StoredRecentItem[], keyForItem: (item: StoredRecentItem) => CollectionKey = (item) => item.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 4262954a2..55810cf5d 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 @@ -469,6 +469,54 @@ describe('PWA Xtream collection identity', () => { } ); + it('keeps imported favorite fields through sparse catalog reads, recent writes, reload and export', async () => { + const favorites = types.map((contentType, position) => ({ + contentType, + xtreamId: 42, + position, + addedAt: '2026-10-01T12:00:00.000Z', + snapshot: { + title: `${contentType} 42`, + 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 entry: XtreamPlaylistBackupEntry = { + portalType: 'xtream', + exportedId: 'p1', + title: 'Saved portal', + autoRefresh: false, + connection: credentials, + userState: { + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + favorites, + }, + }; + await source.restoreUserData('p1', entry.userState); + await loadAll(); + const loaded = await source.getFavorites('p1'); + expect(loaded.map((item) => item.poster_url)).toEqual( + favorites.map((item) => item.snapshot.posterUrl) + ); + for (const type of types) { + await source.addRecentItem({ id: 42, type }, 'p1'); + } + await source.getRecentItems('p1'); + reload(); + api.getStreams.mockResolvedValue([]); + await source.getFavorites('p1'); + expect( + new PwaXtreamBackupStorage( + TestBed.inject(USER_DATA_STORAGE) + ).exportInto(entry).userState.favorites + ).toEqual(favorites); + }); + it('keeps the artwork saved with each view through clear/restore', async () => { await source.restoreUserData('p1', { hiddenCategories: [], 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 d2f31116f..e94dd8ed7 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 @@ -243,6 +243,8 @@ describe('DashboardDataService', () => { beforeEach(() => { Object.defineProperty(window, 'electron', { value: { + dbGetPlaylist: jest.fn(), + dbRestoreXtreamFavorites: jest.fn(), dbGetRecentlyViewed: jest.fn(), dbClearRecentlyViewed: jest.fn(), dbGetAllGlobalFavorites: jest.fn(), @@ -867,6 +869,7 @@ describe('DashboardDataService', () => { 'global-favorites', ]); expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({ + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ uid: 'xtream::xtream-1::movie:5001', @@ -1131,6 +1134,7 @@ describe('DashboardDataService', () => { expect(seriesPos?.seasonNumber).toBe(2); expect(seriesPos?.episodeNumber).toBe(4); expect(service.getRecentItemNavigationState(series)).toEqual({ + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ contentType: 'series', @@ -1353,6 +1357,7 @@ describe('DashboardDataService', () => { expect.objectContaining({ link: ['/workspace', 'global-recent'], state: { + collectionViewState: { scope: 'all' }, openCollectionDetailItem: expect.objectContaining({ item: expect.objectContaining({ sourceType: 'stalker', @@ -1425,6 +1430,7 @@ describe('DashboardDataService', () => { // The episode id must not be promoted to a series id: no resume // target, and the detail item keeps its original identifier. expect(service.getRecentItemNavigationState(series)).toEqual({ + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ contentType: 'series', @@ -1526,6 +1532,7 @@ describe('DashboardDataService', () => { expect(position?.seasonNumber).toBe(3); expect(position?.episodeNumber).toBe(7); expect(service.getRecentItemNavigationState(series)).toEqual({ + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ contentType: 'series', @@ -1747,6 +1754,7 @@ describe('DashboardDataService', () => { expect( service.getRecentItemResumeNavigation(nextEpisode)?.state ).toMatchObject({ + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { seriesResume: { seriesXtreamId: 200, @@ -2819,6 +2827,7 @@ describe('DashboardDataService', () => { 'global-recent', ]); expect(service.getRecentItemNavigationState(movieItem!)).toEqual({ + collectionViewState: { scope: 'all' }, openCollectionDetailItem: { item: expect.objectContaining({ uid: 'xtream::xtream-1::movie:7001',