mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): retain conflict status across aliases and recent views
This commit is contained in:
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
|
||||
|
||||
+51
-46
@@ -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)
|
||||
|
||||
+63
-52
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user