From 4f0884da667644807fa38cdfc7680a6586a1a149 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 05:41:29 +0200 Subject: [PATCH] fix(xtream): guard conflicted favorites and explicit replacements --- .../electron-backend-e2e/src/favorites.e2e.ts | 39 +++++ .../src/xtream-favorites-restore.e2e.ts | 42 +++++ docs/architecture/portal-detail-navigation.md | 4 +- .../xtream-portal-compatibility.md | 10 ++ .../data-sources/pwa-collection-identity.ts | 16 +- .../data-sources/pwa-collection-storage.ts | 27 ++-- .../lib/data-sources/pwa-favorite-write.ts | 61 +++++++ .../pwa-xtream-collection-identity.spec.ts | 153 ++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 29 ++-- .../xtream-data-source.interface.ts | 3 +- .../src/lib/dashboard-data.service.spec.ts | 4 +- .../src/lib/dashboard-data.service.ts | 10 +- 12 files changed, 361 insertions(+), 37 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/pwa-favorite-write.ts diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 12760d385..59b232d72 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -720,6 +720,10 @@ test.describe('Electron Favorites', () => { name: sourcePortalTitle, }); await waitForXtreamWorkspaceReady(app.mainWindow); + const sourcePath = new URL(app.mainWindow.url()).pathname; + const sourceId = decodeURIComponent( + sourcePath.split('/xtreams/')[1].split('/')[0] + ); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) @@ -807,6 +811,41 @@ test.describe('Electron Favorites', () => { ); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + // Missing favorites must remain visible even while Host is active. + await app.mainWindow.evaluate(async (playlistId) => { + for (const type of ['movie', 'series'] as const) { + await window.electron.dbClearXtreamImportCache( + playlistId, + type + ); + await window.electron.dbSetAppState( + `xtream-import-status:${playlistId}:${type}`, + 'completed' + ); + } + }, sourceId); + 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 expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); + await expectVisibleContentCardTitle(app.mainWindow, title); + await expect( + app.mainWindow.locator('app-content-hero') + ).toHaveCount(0); + await expect( + playlistSwitcherTitle(app.mainWindow) + ).toContainText(hostPortalTitle); + await expect( + contentCardByTitle(app.mainWindow, title).first() + ).toContainText('Not in the current catalog'); + } } finally { await closeElectronApp(app); } diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts index b4c8cecb0..4d02bd8f0 100644 --- a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -28,6 +28,14 @@ for (const identity of ['missing', 'conflicting'] as const) { }); expect(response.ok()).toBeTruthy(); const movie = (await response.json())[0]; + let detailRequests = 0; + page.on('request', (request) => { + if ( + new URL(request.url()).searchParams.get('action') === + 'get_vod_info' + ) + detailRequests++; + }); await page.route('**/localhost:3000/xtream**', async (route) => { const original = new URL(route.request().url()); if (original.searchParams.get('action') !== 'get_vod_streams') { @@ -130,6 +138,18 @@ for (const identity of ['missing', 'conflicting'] as const) { added_at: favorite.addedAt, position: 3, }); + if (identity === 'conflicting') { + const card = page.getByRole('button', { + name: favorite.snapshot.title, + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.dispatchEvent('click'); + await card.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('app-content-hero')).toHaveCount(0); + expect(detailRequests).toBe(0); + } await page.goto('/workspace/settings/backup'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export', exact: true }).click(); @@ -142,6 +162,28 @@ for (const identity of ['missing', 'conflicting'] as const) { entry.exportedId === playlistId ).userState.favorites ).toEqual([favorite]); + if (identity === 'conflicting') { + await page.goto('/workspace/global-favorites'); + const card = page + .locator('app-content-card') + .filter({ hasText: favorite.snapshot.title }); + await card + .getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + .click(); + await expect(card).toHaveCount(0); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-favorites') ?? '{}' + )[id], + playlistId + ) + ).toEqual([]); + } }); } diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 1beca35e2..773c629d7 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -94,7 +94,9 @@ handler as the header Back. so the correct detail host still opens without switching playlist context. - Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and Stalker movies/series into the matching global collection route with detail - pre-opened. + pre-opened. Unavailable favorites instead open the all-playlists collection + filtered to that content type, with no detail handoff, so an active different + playlist cannot hide the saved entry. - The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for Xtream and Stalker series carry a one-shot season/episode resume target. The collection-owned detail consumes it after diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index a9b1750fe..678d35a62 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -110,6 +110,16 @@ 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. +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 +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 +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. 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 abb9ad299..eb3539f90 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 @@ -145,12 +145,14 @@ export function findCollectionItems( /** Catalog evidence must confirm every saved identity field before replacing metadata. */ export function collectionSnapshotMatchesContent( saved: XtreamContentItem, - current: XtreamContentItem | undefined + current: XtreamContentItem | undefined, + allowMissing = false ): boolean { - return ( - (saved.tmdb_id == null || saved.tmdb_id === current?.tmdb_id) && - (saved.release_year == null || - saved.release_year === current?.release_year) + return (['tmdb_id', 'release_year'] as const).every( + (key) => + saved[key] == null || + saved[key] === current?.[key] || + (allowMissing && current?.[key] == null) ); } @@ -160,10 +162,14 @@ function mergeCollectionItem( current: XtreamContentItem ): XtreamContentItem { if (!saved) return current; + if (!collectionSnapshotMatchesContent(saved, current, true)) { + return { ...saved, catalog_status: 'missing' }; + } if (!collectionSnapshotMatchesContent(saved, current)) return saved; return { ...saved, ...current, + catalog_status: 'present', title: current.title || saved.title, poster_url: current.poster_url || saved.poster_url, backdrop_url: current.backdrop_url || saved.backdrop_url, 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 fe0aa5f90..b225c7300 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 @@ -13,12 +13,6 @@ import { } from '@iptvnator/services'; import { XtreamContentItem } from './xtream-data-source.interface'; -const STORAGE_KEYS = { - COLLECTION_ITEMS: XTREAM_USER_DATA_KEYS.collectionItems, - FAVORITES: XTREAM_USER_DATA_KEYS.favorites, - RECENT_ITEMS: XTREAM_USER_DATA_KEYS.recentItems, -}; - /** Synchronous persistence and migration using only snapshots and cached identity evidence. */ export class PwaCollectionStorage { constructor( @@ -57,7 +51,7 @@ export class PwaCollectionStorage { getFavoritesFromStorage(): Record { try { - const data = this.storage.getItem(STORAGE_KEYS.FAVORITES); + const data = this.storage.getItem(XTREAM_USER_DATA_KEYS.favorites); return normalizeFavoriteStorage(data ? JSON.parse(data) : {}); } catch { return {}; @@ -65,7 +59,10 @@ export class PwaCollectionStorage { } saveFavoritesToStorage(favorites: Record): void { - this.storage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites)); + this.storage.setItem( + XTREAM_USER_DATA_KEYS.favorites, + JSON.stringify(favorites) + ); } clearFavoritesForPlaylist(playlistId: string): void { @@ -131,7 +128,9 @@ export class PwaCollectionStorage { getRecentItemsFromStorage(): Record { try { - const data = this.storage.getItem(STORAGE_KEYS.RECENT_ITEMS); + const data = this.storage.getItem( + XTREAM_USER_DATA_KEYS.recentItems + ); return normalizeRecentStorage(data ? JSON.parse(data) : {}); } catch { return {}; @@ -142,7 +141,7 @@ export class PwaCollectionStorage { recentItems: Record ): void { this.storage.setItem( - STORAGE_KEYS.RECENT_ITEMS, + XTREAM_USER_DATA_KEYS.recentItems, JSON.stringify(recentItems) ); } @@ -158,7 +157,9 @@ export class PwaCollectionStorage { Record > { try { - const data = this.storage.getItem(STORAGE_KEYS.COLLECTION_ITEMS); + const data = this.storage.getItem( + XTREAM_USER_DATA_KEYS.collectionItems + ); const parsed = data ? JSON.parse(data) : {}; if (!parsed || typeof parsed !== 'object') { return {}; @@ -169,11 +170,11 @@ export class PwaCollectionStorage { } } - private saveCollectionItemsToStorage( + saveCollectionItemsToStorage( items: Record> ): void { this.storage.setItem( - STORAGE_KEYS.COLLECTION_ITEMS, + XTREAM_USER_DATA_KEYS.collectionItems, JSON.stringify(items) ); } 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 new file mode 100644 index 000000000..2b03459be --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-favorite-write.ts @@ -0,0 +1,61 @@ +import { + collectionSnapshotMatchesContent, + itemCollectionKey, + type CollectionKey, +} from './pwa-collection-identity'; +import type { PwaCollectionStorage } from './pwa-collection-storage'; +import type { XtreamContentItem } from './xtream-data-source.interface'; + +/** Loaded only for an explicit favorite action; all storage writes remain synchronous. */ +export function addPwaFavorite( + storage: PwaCollectionStorage, + playlistId: string, + key: CollectionKey, + current: XtreamContentItem | undefined, + matchingKeysFor: (ids: CollectionKey[]) => Set, + backdropUrl?: string +): void { + const allFavorites = storage.getFavoritesFromStorage(); + const ids = (allFavorites[playlistId] ??= []); + const matchingKeys = matchingKeysFor(ids); + if (!ids.some((id) => matchingKeys.has(id))) { + prepareReplacement(storage, playlistId, key, current, backdropUrl); + ids.push(key); + } + storage.saveFavoritesToStorage(allFavorites); + storage.getLocalFavorites(playlistId); + storage.saveCollectionItemSnapshot(playlistId, key, backdropUrl); +} + +/** Only a new explicit favorite can replace the identity of a removed favorite. */ +function prepareReplacement( + storage: PwaCollectionStorage, + playlistId: string, + key: CollectionKey, + current: XtreamContentItem | undefined, + backdropUrl?: string +): void { + if (!current) return; + const allItems = storage.getCollectionItemsFromStorage(); + const snapshots = allItems[playlistId] ?? {}; + const keys = Object.keys(snapshots).filter( + (storedKey) => itemCollectionKey(snapshots[storedKey]) === key + ); + if ( + keys.every((storedKey) => + collectionSnapshotMatchesContent(snapshots[storedKey], current) + ) + ) + return; + for (const storedKey of keys) { + snapshots[storedKey] = { + ...current, + backdrop_url: current.backdrop_url || backdropUrl?.trim(), + }; + } + // Required before adding the reference: quota must not revive the old identity. + storage.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: snapshots, + }); +} 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 bab500c1b..22477434c 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 @@ -10,6 +10,7 @@ import { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces'; import { of } from 'rxjs'; import { XtreamApiService } from '../services/xtream-api.service'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; +import { XtreamContentItem } from './xtream-data-source.interface'; const credentials = { serverUrl: 'http://localhost:3211', @@ -596,6 +597,11 @@ describe('PWA Xtream collection identity', () => { ); } await source.getRecentItems('p1'); + 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' ? { @@ -626,6 +632,153 @@ describe('PWA Xtream collection identity', () => { } ); + it.each([false, true])( + 'replaces an explicitly removed favorite with current catalog data (legacy=%s)', + async (legacy) => { + await source.restoreUserData('p1', { + favorites: [ + { + contentType: 'movie', + xtreamId: 42, + snapshot: { title: 'Original', tmdbId: 100 }, + }, + ], + 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 replacement = { + id: 42, + xtream_id: 42, + type: 'movie', + title: 'Replacement', + tmdb_id: 200, + poster_url: 'new.jpg', + } 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'); + expect((await source.getFavorites('p1'))[0].title).toBe('Original'); + await source.removeFavorite({ id: 42, type: 'movie' }, 'p1'); + const setItem = Storage.prototype.setItem; + const quota = 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.addFavorite({ id: 42, type: 'movie' }, 'p1') + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(await source.getFavorites('p1')).toEqual([]); + quota.mockRestore(); + await source.addFavorite({ id: 42, type: 'movie' }, 'p1'); + reload(); + expect(await source.getFavorites('p1')).toEqual([ + expect.objectContaining({ + title: 'Replacement', + tmdb_id: 200, + poster_url: 'new.jpg', + }), + ]); + } + ); + + it('retains concurrent explicit additions after loading the favorite writer', async () => { + await loadAll(); + await Promise.all( + types.map((type) => source.addFavorite({ id: 42, type }, 'p1')) + ); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).toEqual(types); + }); + + it('keeps a new favorite when a compatible Recent snapshot cannot be refreshed', async () => { + await loadAll(); + await source.addRecentItem({ id: 42, type: 'movie' }, 'p1'); + const setItem = Storage.prototype.setItem; + 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.addFavorite({ id: 42, type: 'movie' }, 'p1') + ).resolves.toBeUndefined(); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).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, + }, + }, + ], + 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', + }); + }); + 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 d5d484f78..4ae8311a2 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 @@ -716,23 +716,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return; } - const allFavorites = this.collectionStorage.getFavoritesFromStorage(); - if (!allFavorites[playlistId]) { - allFavorites[playlistId] = []; - } - const matchingKeys = this.getMatchingCollectionKeys( - playlistId, - allFavorites[playlistId], - normalizedContentId - ); - if (!allFavorites[playlistId].some((id) => matchingKeys.has(id))) { - allFavorites[playlistId].push(normalizedContentId); - } - this.collectionStorage.saveFavoritesToStorage(allFavorites); - this.collectionStorage.getLocalFavorites(playlistId); - this.collectionStorage.saveCollectionItemSnapshot( + const { addPwaFavorite } = await import('./pwa-favorite-write'); + addPwaFavorite( + this.collectionStorage, playlistId, normalizedContentId, + this.getCollectionItemsById( + playlistId, + [normalizedContentId], + true + ).get(normalizedContentId), + (ids) => + this.getMatchingCollectionKeys( + playlistId, + ids, + normalizedContentId + ), backdropUrl ); } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 5d770a305..55be7bf15 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -77,6 +77,8 @@ export interface XtreamContentItem { added_at?: string; viewed_at?: string; position?: number | null; + /** Last confirmed availability of a persisted collection snapshot. */ + catalog_status?: XtreamFavoriteCatalogStatus; // XtreamItem compatibility fields (optional for search/navigation) num?: number; @@ -100,7 +102,6 @@ export interface XtreamFavoriteItem extends Omit< id: number | null; category_id: number | string | null; favorite_id?: number; - catalog_status?: XtreamFavoriteCatalogStatus; } /** 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 e94dd8ed7..5192510e1 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 @@ -664,7 +664,9 @@ describe('DashboardDataService', () => { expect(service.getGlobalFavoriteLink(item)).toEqual([ '/workspace/global-favorites', ]); - expect(service.getGlobalFavoriteNavigationState(item)).toBeUndefined(); + expect(service.getGlobalFavoriteNavigationState(item)).toEqual({ + collectionViewState: { scope: 'all', selectedContentType: 'movie' }, + }); await service.removeGlobalFavorite(item); expect(dbServiceMock.removeFromFavorites).toHaveBeenCalledWith( { favoriteId: 71 }, 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 656323b00..207e99fca 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 @@ -49,6 +49,7 @@ import { toTimestamp, } from './dashboard-mappers'; import { + COLLECTION_VIEW_STATE_KEY, PORTAL_PLAYBACK_POSITIONS, WorkspaceNavigationTarget, } from '@iptvnator/portal/shared/util'; @@ -1006,7 +1007,14 @@ export class DashboardDataService { getGlobalFavoriteNavigationState( item: DashboardFavoriteItem ): WorkspaceNavigationTarget['state'] { - if (isCatalogUnavailable(item)) return undefined; + if (isCatalogUnavailable(item)) { + return { + [COLLECTION_VIEW_STATE_KEY]: { + scope: 'all', + selectedContentType: item.type, + }, + }; + } return getGlobalFavoriteNavigationStateUtil(item); }