mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): preserve collection metadata and dashboard handoffs
This commit is contained in:
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
|
||||
|
||||
+108
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
+2
-33
@@ -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
|
||||
|
||||
+48
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user