fix(xtream): retain conflict status across aliases and recent views

This commit is contained in:
4gray committed 2026-10-14 05:55:02 +02:00
1 parent 4f0884da66
commit a190c0c6ee
8 files changed
+203 -137

No files matched your search

@@ -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')
@@ -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
@@ -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<PlaylistMeta>,
])
);
xtreamDataSource.getRecentItems.mockResolvedValue([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
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', {
@@ -491,6 +491,7 @@ export class UnifiedRecentDataService {
item.xtream_id
),
name: item.title,
catalogStatus: item.catalog_status,
contentType,
sourceType: 'xtream',
playlistId,
@@ -106,7 +106,16 @@ export function findCollectionItems(
ids.map((key) => typedCollectionRef(key)?.id ?? key)
);
const typedItems = new Map<CollectionKey, XtreamContentItem>();
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)
@@ -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', {
@@ -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', {
@@ -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',