fix(xtream): guard conflicted favorites and explicit replacements

This commit is contained in:
4gray committed 2026-10-14 05:41:29 +02:00
1 parent 8148f334e6
commit 4f0884da66
12 files changed
+361 -37

No files matched your search

@@ -720,6 +720,10 @@ test.describe('Electron Favorites', () => {
name: sourcePortalTitle,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const sourcePath = new URL(app.mainWindow.url()).pathname;
const sourceId = decodeURIComponent(
sourcePath.split('/xtreams/')[1].split('/')[0]
);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
@@ -807,6 +811,41 @@ test.describe('Electron Favorites', () => {
);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
// Missing favorites must remain visible even while Host is active.
await app.mainWindow.evaluate(async (playlistId) => {
for (const type of ['movie', 'series'] as const) {
await window.electron.dbClearXtreamImportCache(
playlistId,
type
);
await window.electron.dbSetAppState(
`xtream-import-status:${playlistId}:${type}`,
'completed'
);
}
}, sourceId);
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 expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expectVisibleContentCardTitle(app.mainWindow, title);
await expect(
app.mainWindow.locator('app-content-hero')
).toHaveCount(0);
await expect(
playlistSwitcherTitle(app.mainWindow)
).toContainText(hostPortalTitle);
await expect(
contentCardByTitle(app.mainWindow, title).first()
).toContainText('Not in the current catalog');
}
} finally {
await closeElectronApp(app);
}
@@ -28,6 +28,14 @@ for (const identity of ['missing', 'conflicting'] as const) {
});
expect(response.ok()).toBeTruthy();
const movie = (await response.json())[0];
let detailRequests = 0;
page.on('request', (request) => {
if (
new URL(request.url()).searchParams.get('action') ===
'get_vod_info'
)
detailRequests++;
});
await page.route('**/localhost:3000/xtream**', async (route) => {
const original = new URL(route.request().url());
if (original.searchParams.get('action') !== 'get_vod_streams') {
@@ -130,6 +138,18 @@ for (const identity of ['missing', 'conflicting'] as const) {
added_at: favorite.addedAt,
position: 3,
});
if (identity === 'conflicting') {
const card = page.getByRole('button', {
name: favorite.snapshot.title,
exact: true,
});
await expect(card).toHaveAttribute('aria-disabled', 'true');
await card.dispatchEvent('click');
await card.focus();
await page.keyboard.press('Enter');
await expect(page.locator('app-content-hero')).toHaveCount(0);
expect(detailRequests).toBe(0);
}
await page.goto('/workspace/settings/backup');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export', exact: true }).click();
@@ -142,6 +162,28 @@ for (const identity of ['missing', 'conflicting'] as const) {
entry.exportedId === playlistId
).userState.favorites
).toEqual([favorite]);
if (identity === 'conflicting') {
await page.goto('/workspace/global-favorites');
const card = page
.locator('app-content-card')
.filter({ hasText: favorite.snapshot.title });
await card
.getByRole('button', {
name: 'Remove from favorites',
exact: true,
})
.click();
await expect(card).toHaveCount(0);
expect(
await page.evaluate(
(id) =>
JSON.parse(
localStorage.getItem('xtream-favorites') ?? '{}'
)[id],
playlistId
)
).toEqual([]);
}
});
}
@@ -94,7 +94,9 @@ handler as the header Back.
so the correct detail host still opens without switching playlist context.
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
Stalker movies/series into the matching global collection route with detail
pre-opened.
pre-opened. Unavailable favorites instead open the all-playlists collection
filtered to that content type, with no detail handoff, so an active different
playlist cannot hide the saved entry.
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume
episode" ⋮ action for Xtream and Stalker series carry a one-shot
season/episode resume target. The collection-owned detail consumes it after
@@ -110,6 +110,16 @@ 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.
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
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
snapshot-cache writes retain their existing quota behavior. The explicit browser
favorite writer loads on demand; after loading, reference and snapshot writes
run synchronously against fresh storage so concurrent additions are not lost.
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
@@ -145,12 +145,14 @@ export function findCollectionItems(
/** Catalog evidence must confirm every saved identity field before replacing metadata. */
export function collectionSnapshotMatchesContent(
saved: XtreamContentItem,
current: XtreamContentItem | undefined
current: XtreamContentItem | undefined,
allowMissing = false
): boolean {
return (
(saved.tmdb_id == null || saved.tmdb_id === current?.tmdb_id) &&
(saved.release_year == null ||
saved.release_year === current?.release_year)
return (['tmdb_id', 'release_year'] as const).every(
(key) =>
saved[key] == null ||
saved[key] === current?.[key] ||
(allowMissing && current?.[key] == null)
);
}
@@ -160,10 +162,14 @@ function mergeCollectionItem(
current: XtreamContentItem
): XtreamContentItem {
if (!saved) return current;
if (!collectionSnapshotMatchesContent(saved, current, true)) {
return { ...saved, catalog_status: 'missing' };
}
if (!collectionSnapshotMatchesContent(saved, current)) return saved;
return {
...saved,
...current,
catalog_status: 'present',
title: current.title || saved.title,
poster_url: current.poster_url || saved.poster_url,
backdrop_url: current.backdrop_url || saved.backdrop_url,
@@ -13,12 +13,6 @@ import {
} from '@iptvnator/services';
import { XtreamContentItem } from './xtream-data-source.interface';
const STORAGE_KEYS = {
COLLECTION_ITEMS: XTREAM_USER_DATA_KEYS.collectionItems,
FAVORITES: XTREAM_USER_DATA_KEYS.favorites,
RECENT_ITEMS: XTREAM_USER_DATA_KEYS.recentItems,
};
/** Synchronous persistence and migration using only snapshots and cached identity evidence. */
export class PwaCollectionStorage {
constructor(
@@ -57,7 +51,7 @@ export class PwaCollectionStorage {
getFavoritesFromStorage(): Record<string, CollectionKey[]> {
try {
const data = this.storage.getItem(STORAGE_KEYS.FAVORITES);
const data = this.storage.getItem(XTREAM_USER_DATA_KEYS.favorites);
return normalizeFavoriteStorage(data ? JSON.parse(data) : {});
} catch {
return {};
@@ -65,7 +59,10 @@ export class PwaCollectionStorage {
}
saveFavoritesToStorage(favorites: Record<string, CollectionKey[]>): void {
this.storage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
this.storage.setItem(
XTREAM_USER_DATA_KEYS.favorites,
JSON.stringify(favorites)
);
}
clearFavoritesForPlaylist(playlistId: string): void {
@@ -131,7 +128,9 @@ export class PwaCollectionStorage {
getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
try {
const data = this.storage.getItem(STORAGE_KEYS.RECENT_ITEMS);
const data = this.storage.getItem(
XTREAM_USER_DATA_KEYS.recentItems
);
return normalizeRecentStorage(data ? JSON.parse(data) : {});
} catch {
return {};
@@ -142,7 +141,7 @@ export class PwaCollectionStorage {
recentItems: Record<string, StoredRecentItem[]>
): void {
this.storage.setItem(
STORAGE_KEYS.RECENT_ITEMS,
XTREAM_USER_DATA_KEYS.recentItems,
JSON.stringify(recentItems)
);
}
@@ -158,7 +157,9 @@ export class PwaCollectionStorage {
Record<string, XtreamContentItem>
> {
try {
const data = this.storage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const data = this.storage.getItem(
XTREAM_USER_DATA_KEYS.collectionItems
);
const parsed = data ? JSON.parse(data) : {};
if (!parsed || typeof parsed !== 'object') {
return {};
@@ -169,11 +170,11 @@ export class PwaCollectionStorage {
}
}
private saveCollectionItemsToStorage(
saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
this.storage.setItem(
STORAGE_KEYS.COLLECTION_ITEMS,
XTREAM_USER_DATA_KEYS.collectionItems,
JSON.stringify(items)
);
}
@@ -0,0 +1,61 @@
import {
collectionSnapshotMatchesContent,
itemCollectionKey,
type CollectionKey,
} from './pwa-collection-identity';
import type { PwaCollectionStorage } from './pwa-collection-storage';
import type { XtreamContentItem } from './xtream-data-source.interface';
/** Loaded only for an explicit favorite action; all storage writes remain synchronous. */
export function addPwaFavorite(
storage: PwaCollectionStorage,
playlistId: string,
key: CollectionKey,
current: XtreamContentItem | undefined,
matchingKeysFor: (ids: CollectionKey[]) => Set<CollectionKey>,
backdropUrl?: string
): void {
const allFavorites = storage.getFavoritesFromStorage();
const ids = (allFavorites[playlistId] ??= []);
const matchingKeys = matchingKeysFor(ids);
if (!ids.some((id) => matchingKeys.has(id))) {
prepareReplacement(storage, playlistId, key, current, backdropUrl);
ids.push(key);
}
storage.saveFavoritesToStorage(allFavorites);
storage.getLocalFavorites(playlistId);
storage.saveCollectionItemSnapshot(playlistId, key, backdropUrl);
}
/** Only a new explicit favorite can replace the identity of a removed favorite. */
function prepareReplacement(
storage: PwaCollectionStorage,
playlistId: string,
key: CollectionKey,
current: XtreamContentItem | undefined,
backdropUrl?: string
): void {
if (!current) return;
const allItems = storage.getCollectionItemsFromStorage();
const snapshots = allItems[playlistId] ?? {};
const keys = Object.keys(snapshots).filter(
(storedKey) => itemCollectionKey(snapshots[storedKey]) === key
);
if (
keys.every((storedKey) =>
collectionSnapshotMatchesContent(snapshots[storedKey], current)
)
)
return;
for (const storedKey of keys) {
snapshots[storedKey] = {
...current,
backdrop_url: current.backdrop_url || backdropUrl?.trim(),
};
}
// Required before adding the reference: quota must not revive the old identity.
storage.saveCollectionItemsToStorage({
...allItems,
[playlistId]: snapshots,
});
}
@@ -10,6 +10,7 @@ import { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { XtreamApiService } from '../services/xtream-api.service';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
import { XtreamContentItem } from './xtream-data-source.interface';
const credentials = {
serverUrl: 'http://localhost:3211',
@@ -596,6 +597,11 @@ describe('PWA Xtream collection identity', () => {
);
}
await source.getRecentItems('p1');
if (identity.includes('conflict')) {
for (const item of await source.getFavorites('p1')) {
expect(item).toMatchObject({ catalog_status: 'missing' });
}
}
const expected = favorites.map((favorite) =>
identity === 'confirmed'
? {
@@ -626,6 +632,153 @@ describe('PWA Xtream collection identity', () => {
}
);
it.each([false, true])(
'replaces an explicitly removed favorite with current catalog data (legacy=%s)',
async (legacy) => {
await source.restoreUserData('p1', {
favorites: [
{
contentType: 'movie',
xtreamId: 42,
snapshot: { title: 'Original', tmdbId: 100 },
},
],
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 replacement = {
id: 42,
xtream_id: 42,
type: 'movie',
title: 'Replacement',
tmdb_id: 200,
poster_url: 'new.jpg',
} as XtreamContentItem;
await source.saveContent('p1', [replacement], 'movie');
await source.getFavorites('p1');
// Re-adding while retained must not replace the original saved identity.
await source.addFavorite({ id: 42, type: 'movie' }, 'p1');
expect((await source.getFavorites('p1'))[0].title).toBe('Original');
await source.removeFavorite({ id: 42, type: 'movie' }, 'p1');
const setItem = Storage.prototype.setItem;
const quota = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (this: Storage, key, value) {
if (key === 'xtream-collection-items')
throw new DOMException('full', 'QuotaExceededError');
setItem.call(this, key, value);
});
await expect(
source.addFavorite({ id: 42, type: 'movie' }, 'p1')
).rejects.toMatchObject({ name: 'QuotaExceededError' });
expect(await source.getFavorites('p1')).toEqual([]);
quota.mockRestore();
await source.addFavorite({ id: 42, type: 'movie' }, 'p1');
reload();
expect(await source.getFavorites('p1')).toEqual([
expect.objectContaining({
title: 'Replacement',
tmdb_id: 200,
poster_url: 'new.jpg',
}),
]);
}
);
it('retains concurrent explicit additions after loading the favorite writer', async () => {
await loadAll();
await Promise.all(
types.map((type) => source.addFavorite({ id: 42, type }, 'p1'))
);
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).toEqual(types);
});
it('keeps a new favorite when a compatible Recent snapshot cannot be refreshed', async () => {
await loadAll();
await source.addRecentItem({ id: 42, type: 'movie' }, 'p1');
const setItem = Storage.prototype.setItem;
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
this: Storage,
key,
value
) {
if (key === 'xtream-collection-items')
throw new DOMException('full', 'QuotaExceededError');
setItem.call(this, key, value);
});
await expect(
source.addFavorite({ id: 42, type: 'movie' }, 'p1')
).resolves.toBeUndefined();
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).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,
},
},
],
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',
});
});
it('keeps the artwork saved with each view through clear/restore', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
@@ -716,23 +716,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return;
}
const allFavorites = this.collectionStorage.getFavoritesFromStorage();
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
}
const matchingKeys = this.getMatchingCollectionKeys(
playlistId,
allFavorites[playlistId],
normalizedContentId
);
if (!allFavorites[playlistId].some((id) => matchingKeys.has(id))) {
allFavorites[playlistId].push(normalizedContentId);
}
this.collectionStorage.saveFavoritesToStorage(allFavorites);
this.collectionStorage.getLocalFavorites(playlistId);
this.collectionStorage.saveCollectionItemSnapshot(
const { addPwaFavorite } = await import('./pwa-favorite-write');
addPwaFavorite(
this.collectionStorage,
playlistId,
normalizedContentId,
this.getCollectionItemsById(
playlistId,
[normalizedContentId],
true
).get(normalizedContentId),
(ids) =>
this.getMatchingCollectionKeys(
playlistId,
ids,
normalizedContentId
),
backdropUrl
);
}
@@ -77,6 +77,8 @@ export interface XtreamContentItem {
added_at?: string;
viewed_at?: string;
position?: number | null;
/** Last confirmed availability of a persisted collection snapshot. */
catalog_status?: XtreamFavoriteCatalogStatus;
// XtreamItem compatibility fields (optional for search/navigation)
num?: number;
@@ -100,7 +102,6 @@ export interface XtreamFavoriteItem extends Omit<
id: number | null;
category_id: number | string | null;
favorite_id?: number;
catalog_status?: XtreamFavoriteCatalogStatus;
}
/**
@@ -664,7 +664,9 @@ describe('DashboardDataService', () => {
expect(service.getGlobalFavoriteLink(item)).toEqual([
'/workspace/global-favorites',
]);
expect(service.getGlobalFavoriteNavigationState(item)).toBeUndefined();
expect(service.getGlobalFavoriteNavigationState(item)).toEqual({
collectionViewState: { scope: 'all', selectedContentType: 'movie' },
});
await service.removeGlobalFavorite(item);
expect(dbServiceMock.removeFromFavorites).toHaveBeenCalledWith(
{ favoriteId: 71 },
@@ -49,6 +49,7 @@ import {
toTimestamp,
} from './dashboard-mappers';
import {
COLLECTION_VIEW_STATE_KEY,
PORTAL_PLAYBACK_POSITIONS,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
@@ -1006,7 +1007,14 @@ export class DashboardDataService {
getGlobalFavoriteNavigationState(
item: DashboardFavoriteItem
): WorkspaceNavigationTarget['state'] {
if (isCatalogUnavailable(item)) return undefined;
if (isCatalogUnavailable(item)) {
return {
[COLLECTION_VIEW_STATE_KEY]: {
scope: 'all',
selectedContentType: item.type,
},
};
}
return getGlobalFavoriteNavigationStateUtil(item);
}