mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): confirm browser catalog identity before updating favorites
This commit is contained in:
1 parent
2d975e0b29
commit
8148f334e6
6 files changed
+302
-105
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
@@ -13,90 +14,136 @@ 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(
|
||||
for (const identity of ['missing', 'conflicting'] as const) {
|
||||
test(`@xtream @collections ${identity} catalog identity preserves saved favorites through reload and export`, 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 page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_vod_streams') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: Record<string, unknown>[];
|
||||
};
|
||||
for (const item of body.payload) {
|
||||
delete item['tmdb_id'];
|
||||
delete item['release_year'];
|
||||
if (identity === 'conflicting') {
|
||||
item['tmdb_id'] = 999;
|
||||
item['release_year'] = 2026;
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
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: 'Saved original favorite',
|
||||
posterUrl: '/assets/icons/icon-192x192.png',
|
||||
tmdbId: 949,
|
||||
releaseYear: 1995,
|
||||
originalTitle: 'Saved original title',
|
||||
},
|
||||
};
|
||||
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,
|
||||
({ 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(favorite.snapshot.title, { exact: true }).first()
|
||||
).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(favorite.snapshot.title, { 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({
|
||||
title: favorite.snapshot.title,
|
||||
poster_url: favorite.snapshot.posterUrl,
|
||||
tmdb_id: 949,
|
||||
release_year: 1995,
|
||||
original_title: 'Saved original title',
|
||||
added_at: favorite.addedAt,
|
||||
position: 3,
|
||||
});
|
||||
await page.goto('/workspace/settings/backup');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
const downloaded = await (await downloadPromise).path();
|
||||
if (!downloaded) throw new Error('Backup download is missing');
|
||||
const backup = JSON.parse(await readFile(downloaded, 'utf8'));
|
||||
expect(
|
||||
backup.playlists.find(
|
||||
(entry: { exportedId: string }) =>
|
||||
entry.exportedId === playlistId
|
||||
).userState.favorites
|
||||
).toEqual([favorite]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('@xtream @collections PWA retries retain saved favorite metadata when snapshot storage is full', async ({
|
||||
page,
|
||||
|
||||
@@ -101,11 +101,15 @@ 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.
|
||||
Catalog hydration keeps the saved snapshot separate from current provider rows.
|
||||
Only a row confirming every saved TMDB ID/year may refresh that snapshot;
|
||||
missing, partially confirmed or conflicting identity retains the original title,
|
||||
artwork and metadata. After confirmation, blank catalog fields retain saved
|
||||
values. The favorite's added date and order always belong to user state.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -142,12 +142,25 @@ export function findCollectionItems(
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Sparse provider rows refresh the cache without erasing saved backup fields. */
|
||||
/** Catalog evidence must confirm every saved identity field before replacing metadata. */
|
||||
export function collectionSnapshotMatchesContent(
|
||||
saved: XtreamContentItem,
|
||||
current: XtreamContentItem | undefined
|
||||
): boolean {
|
||||
return (
|
||||
(saved.tmdb_id == null || saved.tmdb_id === current?.tmdb_id) &&
|
||||
(saved.release_year == null ||
|
||||
saved.release_year === current?.release_year)
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep the durable snapshot separate until the actual catalog row confirms it. */
|
||||
function mergeCollectionItem(
|
||||
saved: XtreamContentItem | undefined,
|
||||
current: XtreamContentItem
|
||||
): XtreamContentItem {
|
||||
if (!saved) return current;
|
||||
if (!collectionSnapshotMatchesContent(saved, current)) return saved;
|
||||
return {
|
||||
...saved,
|
||||
...current,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CollectionKey,
|
||||
collectionSnapshotMatchesContent,
|
||||
StoredRecentItem,
|
||||
deduplicateRecentItems,
|
||||
itemCollectionKey,
|
||||
@@ -23,7 +24,8 @@ export class PwaCollectionStorage {
|
||||
constructor(
|
||||
private readonly resolveItems: (
|
||||
playlistId: string,
|
||||
ids: readonly CollectionKey[]
|
||||
ids: readonly CollectionKey[],
|
||||
catalogOnly?: boolean
|
||||
) => Map<CollectionKey, XtreamContentItem>,
|
||||
private readonly storage: UserDataStorage
|
||||
) {}
|
||||
@@ -240,7 +242,9 @@ export class PwaCollectionStorage {
|
||||
const playlistItems = allItems[playlistId] ?? {};
|
||||
playlistItems[String(contentId)] = {
|
||||
...item,
|
||||
...(normalizedBackdropUrl && !item.backdrop_url
|
||||
...(normalizedBackdropUrl &&
|
||||
!item.backdrop_url &&
|
||||
this.canEnrichSnapshot(playlistId, contentId, item)
|
||||
? { backdrop_url: normalizedBackdropUrl }
|
||||
: {}),
|
||||
};
|
||||
@@ -250,6 +254,18 @@ export class PwaCollectionStorage {
|
||||
});
|
||||
}
|
||||
|
||||
private canEnrichSnapshot(
|
||||
playlistId: string,
|
||||
key: CollectionKey,
|
||||
saved: XtreamContentItem
|
||||
): boolean {
|
||||
// A merged collection row carries saved identity, so it is not confirmation.
|
||||
return collectionSnapshotMatchesContent(
|
||||
saved,
|
||||
this.resolveItems(playlistId, [key], true).get(key)
|
||||
);
|
||||
}
|
||||
|
||||
setCollectionItemBackdropIfMissing(
|
||||
playlistId: string,
|
||||
contentId: CollectionKey,
|
||||
@@ -258,7 +274,11 @@ export class PwaCollectionStorage {
|
||||
const allItems = this.getCollectionItemsFromStorage();
|
||||
const playlistItems = allItems[playlistId];
|
||||
const item = playlistItems?.[String(contentId)];
|
||||
if (!item || item.backdrop_url) {
|
||||
if (
|
||||
!item ||
|
||||
item.backdrop_url ||
|
||||
!this.canEnrichSnapshot(playlistId, contentId, item)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -517,6 +517,115 @@ describe('PWA Xtream collection identity', () => {
|
||||
).toEqual(favorites);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'missing',
|
||||
'partial',
|
||||
'tmdb conflict',
|
||||
'year conflict',
|
||||
'confirmed',
|
||||
])(
|
||||
'protects imported identities through %s catalog rows, recent writes and re-export',
|
||||
async (identity) => {
|
||||
const favorites = types.map((contentType, position) => ({
|
||||
contentType,
|
||||
xtreamId: 42,
|
||||
position,
|
||||
addedAt: '2026-10-01T12:00:00.000Z',
|
||||
snapshot: {
|
||||
title: `Saved ${contentType}`,
|
||||
posterUrl: `https://img.example.com/saved-${contentType}.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);
|
||||
api.getStreams.mockImplementation((_credentials, type) => {
|
||||
const position = types.indexOf(type);
|
||||
return Promise.resolve([
|
||||
{
|
||||
stream_id: 42,
|
||||
series_id: 42,
|
||||
category_id: '1',
|
||||
name: `Returning ${type}`,
|
||||
stream_icon: `https://img.example.com/returning-${type}.jpg`,
|
||||
...(identity !== 'missing'
|
||||
? {
|
||||
tmdb_id:
|
||||
identity === 'tmdb conflict'
|
||||
? 999
|
||||
: 100 + position,
|
||||
}
|
||||
: {}),
|
||||
...(identity !== 'missing' && identity !== 'partial'
|
||||
? {
|
||||
release_year:
|
||||
identity === 'year conflict'
|
||||
? 2026
|
||||
: 1995 + position,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
await loadAll();
|
||||
// Recent and detail enrichment can run before Favorites is read.
|
||||
for (const type of types) {
|
||||
await source.addRecentItem(
|
||||
{ id: 42, type },
|
||||
'p1',
|
||||
'https://img.example.com/returning-wide.jpg'
|
||||
);
|
||||
await source.setContentMetadataIfMissing(
|
||||
{ id: 42, type },
|
||||
'p1',
|
||||
{ backdropUrl: 'https://img.example.com/detail-wide.jpg' }
|
||||
);
|
||||
}
|
||||
await source.getRecentItems('p1');
|
||||
const expected = favorites.map((favorite) =>
|
||||
identity === 'confirmed'
|
||||
? {
|
||||
...favorite,
|
||||
snapshot: {
|
||||
...favorite.snapshot,
|
||||
title: `Returning ${favorite.contentType}`,
|
||||
posterUrl: `https://img.example.com/returning-${favorite.contentType}.jpg`,
|
||||
backdropUrl:
|
||||
'https://img.example.com/detail-wide.jpg',
|
||||
},
|
||||
}
|
||||
: favorite
|
||||
);
|
||||
expect(
|
||||
(await source.getFavorites('p1')).map((item) => item.title)
|
||||
).toEqual(expected.map((item) => item.snapshot.title));
|
||||
const exportFavorites = () =>
|
||||
new PwaXtreamBackupStorage(
|
||||
TestBed.inject(USER_DATA_STORAGE)
|
||||
).exportInto(entry).userState.favorites;
|
||||
expect(exportFavorites()).toEqual(expected);
|
||||
reload();
|
||||
api.getStreams.mockResolvedValue([]);
|
||||
await source.getRecentItems('p1');
|
||||
await source.getFavorites('p1');
|
||||
expect(exportFavorites()).toEqual(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the artwork saved with each view through clear/restore', async () => {
|
||||
await source.restoreUserData('p1', {
|
||||
hiddenCategories: [],
|
||||
|
||||
@@ -96,7 +96,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private readonly contentTypes = ['live', 'movie', 'series'] as const;
|
||||
private readonly userDataStorage = inject(USER_DATA_STORAGE);
|
||||
private readonly collectionStorage = new PwaCollectionStorage(
|
||||
(playlistId, ids) => this.getCollectionItemsById(playlistId, ids),
|
||||
(playlistId, ids, catalogOnly) =>
|
||||
this.getCollectionItemsById(playlistId, ids, catalogOnly),
|
||||
this.userDataStorage
|
||||
);
|
||||
|
||||
@@ -1097,21 +1098,25 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
|
||||
private getCollectionItemsById(
|
||||
playlistId: string,
|
||||
ids: readonly CollectionKey[]
|
||||
ids: readonly CollectionKey[],
|
||||
catalogOnly = false
|
||||
): Map<CollectionKey, XtreamContentItem> {
|
||||
if (!ids.length) return new Map();
|
||||
const cached: XtreamContentItem[] = [];
|
||||
for (const type of this.contentTypes) {
|
||||
const content =
|
||||
this.contentCache.get(`${playlistId}-${type}-content`) ?? [];
|
||||
for (const item of content) cached.push(item as XtreamContentItem);
|
||||
}
|
||||
const cached = ([] as XtreamContentItem[]).concat(
|
||||
...this.contentTypes.map(
|
||||
(type) =>
|
||||
(this.contentCache.get(`${playlistId}-${type}-content`) ??
|
||||
[]) as XtreamContentItem[]
|
||||
)
|
||||
);
|
||||
return findCollectionItems(
|
||||
ids,
|
||||
cached,
|
||||
this.collectionStorage.getCollectionItemsFromStorage()[
|
||||
playlistId
|
||||
] ?? {},
|
||||
catalogOnly
|
||||
? {}
|
||||
: (this.collectionStorage.getCollectionItemsFromStorage()[
|
||||
playlistId
|
||||
] ?? {}),
|
||||
this.contentTypes.every((type) =>
|
||||
this.contentCache.has(`${playlistId}-${type}-content`)
|
||||
)
|
||||
@@ -1388,9 +1393,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<void> {
|
||||
void options;
|
||||
const { restorePwaXtreamUserData } = await import(
|
||||
'./pwa-xtream-restore'
|
||||
);
|
||||
const { restorePwaXtreamUserData } =
|
||||
await import('./pwa-xtream-restore');
|
||||
await restorePwaXtreamUserData(
|
||||
this.userDataStorage,
|
||||
playlistId,
|
||||
|
||||
Reference in new issue
Block a user