fix(xtream): validate favorite mutations and fresh recent identity

This commit is contained in:
4gray committed 2026-10-14 06:08:05 +02:00
1 parent a190c0c6ee
commit c38bbe946c
7 files changed
+215 -13

No files matched your search

@@ -180,6 +180,26 @@ for (const identity of ['missing', 'conflicting'] as const) {
await page.keyboard.press('Enter');
await expect(page.locator('app-content-hero')).toHaveCount(0);
expect(detailRequests).toBe(0);
// Opening the actual replacement from its catalog must not expose a
// Remove heart that could delete the retained, incompatible favorite.
await page.goto(`${playlistPath}/vod`);
await page
.locator(`[data-category-id="${movie.category_id}"]`)
.first()
.click();
await page.getByText(movie.name, { exact: true }).first().click();
const add = page
.getByRole('button', { name: 'Add to favorites', exact: true })
.first();
await expect(add).toBeVisible();
await add.click();
await expect(
page.getByText(
'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.',
{ exact: true }
)
).toBeVisible();
await expect(add).toBeVisible();
await page.goto('/workspace/global-favorites');
const card = page
.locator('app-content-card')
@@ -104,8 +104,9 @@ cache, preserving metadata when the archive returns to Electron.
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.
artwork and metadata. Identity numbers use the shared Electron metadata normalizer: numeric strings
match numbers, and empty/zero placeholders are unknown. 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
@@ -123,6 +124,11 @@ 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.
Browser status and add operations also reject known conflicts, so the replacement's
heart cannot remove the retained favorite. Explicit collection removal still works.
A newly recorded playback can replace obsolete Recent-only metadata. It must save
a replacement snapshot before the Recent reference; failure leaves history unchanged
for retry. A snapshot still owned by a favorite remains protected during playback.
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
@@ -1,3 +1,4 @@
import { normalizeContentMetadataPatch } from '@iptvnator/shared/interfaces';
import {
TypedXtreamCollectionRef,
XTREAM_CONTENT_TYPES,
@@ -157,14 +158,27 @@ export function collectionSnapshotMatchesContent(
current: XtreamContentItem | undefined,
allowMissing = false
): boolean {
const known = normalizeCollectionIdentity(saved);
const actual = normalizeCollectionIdentity(current);
return (['tmdb_id', 'release_year'] as const).every(
(key) =>
saved[key] == null ||
saved[key] === current?.[key] ||
(allowMissing && current?.[key] == null)
known[key] == null ||
known[key] === actual[key] ||
(allowMissing && actual[key] == null)
);
}
/** Match Electron's metadata boundary, including numeric strings and unknown placeholders. */
export function normalizeCollectionIdentity(
item: XtreamContentItem | undefined
) {
const identity = normalizeContentMetadataPatch({
tmdbId: item?.tmdb_id ?? undefined,
releaseYear: item?.release_year ?? undefined,
});
return { tmdb_id: identity?.tmdbId, release_year: identity?.releaseYear };
}
/** Keep the durable snapshot separate until the actual catalog row confirms it. */
function mergeCollectionItem(
saved: XtreamContentItem | undefined,
@@ -182,8 +196,12 @@ function mergeCollectionItem(
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,
tmdb_id:
normalizeCollectionIdentity(current).tmdb_id ??
normalizeCollectionIdentity(saved).tmdb_id,
release_year:
normalizeCollectionIdentity(current).release_year ??
normalizeCollectionIdentity(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,
@@ -65,6 +65,51 @@ export class PwaCollectionStorage {
);
}
private savedFavorite(
playlistId: string,
key: CollectionKey
): XtreamContentItem | undefined {
return [
...this.resolveItems(
playlistId,
this.getFavoritesFromStorage()[playlistId] ?? []
).values(),
].find((item) => itemCollectionKey(item) === key);
}
hasFavoriteIdentityConflict(
playlistId: string,
key: CollectionKey
): boolean {
const saved = this.savedFavorite(playlistId, key);
return (
!!saved &&
!collectionSnapshotMatchesContent(
saved,
this.resolveItems(playlistId, [key], true).get(key),
true
)
);
}
/** A newly recorded playback may replace disposable history, never a retained favorite. */
prepareRecentReplacement(playlistId: string, key: CollectionKey): void {
if (this.savedFavorite(playlistId, key)) return;
const saved = this.resolveItems(playlistId, [key]).get(key);
const current = this.resolveItems(playlistId, [key], true).get(key);
if (
!saved ||
!current ||
collectionSnapshotMatchesContent(saved, current)
)
return;
const allItems = this.getCollectionItemsFromStorage();
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: { ...allItems[playlistId], [key]: current },
});
}
clearFavoritesForPlaylist(playlistId: string): void {
const allFavorites = this.getFavoritesFromStorage();
delete allFavorites[playlistId];
@@ -1,3 +1,4 @@
import { XTREAM_FAVORITE_IDENTITY_CONFLICT } from '@iptvnator/shared/interfaces';
import {
collectionSnapshotMatchesContent,
itemCollectionKey,
@@ -15,6 +16,9 @@ export function addPwaFavorite(
matchingKeysFor: (ids: CollectionKey[]) => Set<CollectionKey>,
backdropUrl?: string
): void {
if (storage.hasFavoriteIdentityConflict(playlistId, key)) {
throw new Error(XTREAM_FAVORITE_IDENTITY_CONFLICT);
}
const allFavorites = storage.getFavoritesFromStorage();
const ids = (allFavorites[playlistId] ??= []);
const matchingKeys = matchingKeysFor(ids);
@@ -524,6 +524,9 @@ describe('PWA Xtream collection identity', () => {
'tmdb conflict',
'year conflict',
'confirmed',
'numeric strings',
'zero',
'empty',
])(
'protects imported identities through %s catalog rows, recent writes and re-export',
async (identity) => {
@@ -568,7 +571,13 @@ describe('PWA Xtream collection identity', () => {
tmdb_id:
identity === 'tmdb conflict'
? 999
: 100 + position,
: identity === 'numeric strings'
? String(100 + position)
: identity === 'zero'
? '0'
: identity === 'empty'
? ''
: 100 + position,
}
: {}),
...(identity !== 'missing' && identity !== 'partial'
@@ -576,7 +585,13 @@ describe('PWA Xtream collection identity', () => {
release_year:
identity === 'year conflict'
? 2026
: 1995 + position,
: identity === 'numeric strings'
? String(1995 + position)
: identity === 'zero'
? 0
: identity === 'empty'
? ''
: 1995 + position,
}
: {}),
},
@@ -597,13 +612,17 @@ describe('PWA Xtream collection identity', () => {
);
}
await source.getRecentItems('p1');
if (['zero', 'empty'].includes(identity)) {
for (const item of await source.getFavorites('p1'))
expect(item.catalog_status).not.toBe('missing');
}
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'
['confirmed', 'numeric strings'].includes(identity)
? {
...favorite,
snapshot: {
@@ -665,8 +684,13 @@ describe('PWA Xtream collection identity', () => {
} 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');
// A replacement detail heart cannot remove or claim the original favorite.
expect(
await source.isFavorite({ id: 42, type: 'movie' }, 'p1')
).toBe(false);
await expect(
source.addFavorite({ id: 42, type: 'movie' }, 'p1')
).rejects.toThrow('XTREAM_FAVORITE_IDENTITY_CONFLICT');
expect((await source.getFavorites('p1'))[0].title).toBe('Original');
await source.removeFavorite({ id: 42, type: 'movie' }, 'p1');
const setItem = Storage.prototype.setItem;
@@ -694,6 +718,79 @@ describe('PWA Xtream collection identity', () => {
}
);
it.each([false, true])(
'records replacement playback after removing obsolete Recent (quota=%s)',
async (quota) => {
const ref = { id: 42, type: 'movie' as const };
await source.saveContent(
'p1',
[
{
id: 42,
category_id: '1',
rating: '',
added: '',
poster_url: '',
xtream_id: 42,
type: 'movie',
title: 'Original',
tmdb_id: 100,
},
],
'movie'
);
await source.addRecentItem(ref, 'p1');
await source.saveContent(
'p1',
[
{
id: 42,
category_id: '1',
rating: '',
added: '',
poster_url: '',
xtream_id: 42,
type: 'movie',
title: 'Replacement',
tmdb_id: 200,
},
],
'movie'
);
await source.getRecentItems('p1');
await source.removeRecentItem(ref, 'p1');
if (quota) {
const setItem = Storage.prototype.setItem;
const failure = 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.addRecentItem(ref, 'p1')
).rejects.toMatchObject({ name: 'QuotaExceededError' });
expect(read('xtream-recent-items').p1).toEqual([]);
failure.mockRestore();
}
await source.addRecentItem(ref, 'p1');
expect((await source.getRecentItems('p1'))[0]).toMatchObject({
title: 'Replacement',
tmdb_id: 200,
});
reload();
api.getStreams.mockResolvedValue([]);
const [item] = await source.getRecentItems('p1');
expect(item).toMatchObject({ title: 'Replacement', tmdb_id: 200 });
expect(item.catalog_status).not.toBe('missing');
expect(await source.getFavorites('p1')).toEqual([]);
}
);
it('retains concurrent explicit additions after loading the favorite writer', async () => {
await loadAll();
await Promise.all(
@@ -44,6 +44,7 @@ import {
itemCollectionKey,
findCollectionItems,
normalizeStoredBackdrop,
normalizeCollectionIdentity,
} from './pwa-collection-identity';
import { PwaCollectionStorage } from './pwa-collection-storage';
@@ -629,6 +630,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
...item,
...normalizeCollectionIdentity(item as XtreamContentItem),
added: item.added ?? item.last_modified ?? '',
category_id: item.category_id ?? '',
id: xtreamId,
@@ -781,7 +783,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
ids,
normalizedContentId
);
return ids.some((id) => matchingKeys.has(id));
return (
ids.some((id) => matchingKeys.has(id)) &&
!this.collectionStorage.hasFavoriteIdentityConflict(
playlistId,
normalizedContentId
)
);
}
// =========================================================================
@@ -1040,6 +1048,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Keep only last 50 items
allRecent[playlistId] = allRecent[playlistId].slice(0, 50);
this.collectionStorage.prepareRecentReplacement(
playlistId,
normalizedContentId
);
this.collectionStorage.saveRecentItemsToStorage(allRecent);
this.collectionStorage.getLocalRecentItems(playlistId);
this.collectionStorage.saveCollectionItemSnapshot(