mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(xtream): validate favorite mutations and fresh recent identity
This commit is contained in:
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);
|
||||
|
||||
+102
-5
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user