fix(xtream): preserve collection metadata and dashboard handoffs

This commit is contained in:
4gray committed 2026-10-14 04:39:50 +02:00
1 parent 29b0333fde
commit 2d975e0b29
11 files changed
+308 -34

No files matched your search

@@ -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
@@ -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,
}) => {
@@ -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
@@ -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
@@ -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<UnifiedCollectionItem[]>([
{
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();
}
);
});
@@ -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,
@@ -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',
@@ -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),
},
@@ -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<number, XtreamContentItem | null>();
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
@@ -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: [],
@@ -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',