fix(xtream): separate viewed replacements from retained favorites

This commit is contained in:
4gray committed 2026-10-14 06:27:33 +02:00
1 parent c38bbe946c
commit c69076ca2b
8 files changed
+232 -69

No files matched your search

@@ -200,6 +200,50 @@ for (const identity of ['missing', 'conflicting'] as const) {
)
).toBeVisible();
await expect(add).toBeVisible();
await page
.getByRole('button', { name: /^Play / })
.first()
.click();
await expect
.poll(() =>
page.evaluate(
(id) =>
JSON.parse(
localStorage.getItem('xtream-recent-items') ??
'{}'
)[id]?.[0]?.collectionItem?.title,
playlistId
)
)
.toBe(movie.name);
await page.goto('/workspace/global-recent');
await page.reload();
const replacement = page.getByRole('button', {
name: movie.name,
exact: true,
});
await expect(replacement).toBeVisible();
await expect(replacement).not.toHaveAttribute(
'aria-disabled',
'true'
);
await page.goto('/workspace/settings/backup');
const playedDownload = page.waitForEvent('download');
await page
.getByRole('button', { name: 'Export', exact: true })
.click();
const playedPath = await (await playedDownload).path();
if (!playedPath) throw new Error('Played backup is missing');
const playedEntry = JSON.parse(
await readFile(playedPath, 'utf8')
).playlists.find(
(entry: { exportedId: string }) =>
entry.exportedId === playlistId
);
expect(playedEntry.userState.favorites).toEqual([favorite]);
expect(
playedEntry.userState.recentlyViewed[0].collectionItem
).toMatchObject({ title: movie.name, tmdb_id: 999 });
await page.goto('/workspace/global-favorites');
const card = page
.locator('app-content-card')
@@ -126,9 +126,12 @@ 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.
New playback keeps its own typed `collectionItem` on the Recent entry when the
shared snapshot belongs to another identity. Its metadata and viewing date are
saved atomically, independently of any retained favorite, and remain available
after reload, export/restore and deferred retries. Reading Recent never copies
that independent snapshot into the favorite cache. Older Recent entries still
use the shared cache; compatible optional caches keep their quota behavior.
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
@@ -10,6 +10,7 @@ export interface StoredRecentItem {
readonly id: CollectionKey;
readonly viewedAt: string;
readonly backdropUrl?: string;
readonly collectionItem?: XtreamContentItem;
}
export function typedCollectionRef(
@@ -89,6 +90,14 @@ export function normalizeRecentStorage(
id,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
...(item?.collectionItem &&
typeof item.collectionItem.title === 'string' &&
(itemCollectionKey(item.collectionItem) === id ||
(typeof id === 'number' &&
item.collectionItem.xtream_id === id &&
itemCollectionKey(item.collectionItem) !== null))
? { collectionItem: item.collectionItem }
: {}),
});
}
}
@@ -19,7 +19,8 @@ export class PwaCollectionStorage {
private readonly resolveItems: (
playlistId: string,
ids: readonly CollectionKey[],
catalogOnly?: boolean
catalogOnly?: boolean,
snapshots?: Record<string, XtreamContentItem>
) => Map<CollectionKey, XtreamContentItem>,
private readonly storage: UserDataStorage
) {}
@@ -92,24 +93,6 @@ export class PwaCollectionStorage {
);
}
/** 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];
@@ -119,22 +102,45 @@ export class PwaCollectionStorage {
getLocalRecentItems(playlistId: string): XtreamContentItem[] {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
const snapshots = {
...this.getCollectionItemsFromStorage()[playlistId],
};
const independentKeys = new Set<CollectionKey>();
for (const entry of playlistRecent) {
if (!entry.collectionItem) continue;
const key = itemCollectionKey(entry.collectionItem);
if (key !== null) {
snapshots[key] = entry.collectionItem;
independentKeys.add(entry.id);
}
}
const contentById = this.resolveItems(
playlistId,
playlistRecent.map((item) => item.id)
playlistRecent.map((item) => item.id),
false,
snapshots
);
const migrated = deduplicateRecentItems(
playlistRecent.map((entry) => {
const item = contentById.get(entry.id);
return item
? { ...entry, id: itemCollectionKey(item) ?? entry.id }
? {
...entry,
id: itemCollectionKey(item) ?? entry.id,
...(entry.collectionItem
? { collectionItem: item }
: {}),
}
: entry;
})
);
if (
migrated.length !== playlistRecent.length ||
migrated.some(
(entry, index) => entry.id !== playlistRecent[index].id
(entry, index) =>
entry.id !== playlistRecent[index].id ||
entry.collectionItem !==
playlistRecent[index].collectionItem
)
) {
allRecent[playlistId] = migrated;
@@ -142,11 +148,18 @@ export class PwaCollectionStorage {
this.saveRecentItemsToStorage(allRecent)
);
}
this.persistCollectionSnapshots(playlistId, contentById);
this.persistCollectionSnapshots(
playlistId,
new Map(
[...contentById].filter(([key]) => !independentKeys.has(key))
)
);
const results: (XtreamContentItem & { viewed_at: string })[] = [];
const migratedContent = this.resolveItems(
playlistId,
migrated.map((item) => item.id)
migrated.map((item) => item.id),
false,
snapshots
);
for (const recentEntry of migrated) {
const item = migratedContent.get(recentEntry.id);
@@ -722,39 +722,19 @@ describe('PWA Xtream collection identity', () => {
'records replacement playback after removing obsolete Recent (quota=%s)',
async (quota) => {
const ref = { id: 42, type: 'movie' as const };
await source.saveContent(
const [cached] = await source.getContent(
'p1',
[
{
id: 42,
category_id: '1',
rating: '',
added: '',
poster_url: '',
xtream_id: 42,
type: 'movie',
title: 'Original',
tmdb_id: 100,
},
],
credentials,
'movie'
);
const original = cached as XtreamContentItem;
original.tmdb_id = 100;
const expected = { title: 'Replacement', tmdb_id: 200 };
await source.saveContent('p1', [original], '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,
},
],
[{ ...original, ...expected }],
'movie'
);
await source.getRecentItems('p1');
@@ -764,7 +744,7 @@ describe('PWA Xtream collection identity', () => {
const failure = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (this: Storage, key, value) {
if (key === 'xtream-collection-items')
if (key === 'xtream-recent-items')
throw new DOMException(
'full',
'QuotaExceededError'
@@ -778,10 +758,9 @@ describe('PWA Xtream collection identity', () => {
failure.mockRestore();
}
await source.addRecentItem(ref, 'p1');
expect((await source.getRecentItems('p1'))[0]).toMatchObject({
title: 'Replacement',
tmdb_id: 200,
});
expect((await source.getRecentItems('p1'))[0]).toMatchObject(
expected
);
reload();
api.getStreams.mockResolvedValue([]);
const [item] = await source.getRecentItems('p1');
@@ -791,6 +770,74 @@ describe('PWA Xtream collection identity', () => {
}
);
it('keeps played replacements separate from retained favorites through reload and backup restore', async () => {
const entry: XtreamPlaylistBackupEntry = {
portalType: 'xtream',
exportedId: 'p1',
title: 'Saved portal',
autoRefresh: false,
connection: credentials,
userState: {
hiddenCategories: [],
playbackPositions: [],
recentlyViewed: [],
favorites: types.map((contentType) => ({
contentType,
xtreamId: 42,
snapshot: { title: `Original ${contentType}`, tmdbId: 100 },
})),
},
};
await source.restoreUserData('p1', entry.userState);
api.getStreams.mockImplementation((_credentials, type) =>
Promise.resolve([
{
stream_id: 42,
series_id: 42,
name: `Replacement ${type}`,
category_id: '1',
tmdb_id: 200,
stream_icon: 'replacement.jpg',
},
])
);
await loadAll();
for (const type of types)
await source.addRecentItem({ id: 42, type }, 'p1');
const verify = async () => {
expect(
(await source.getFavorites('p1')).map((item) => item.title)
).toEqual(types.map((type) => `Original ${type}`));
const recent = await source.getRecentItems('p1');
expect(recent).toHaveLength(3);
for (const item of recent) {
expect(item).toMatchObject({
title: `Replacement ${item.type}`,
tmdb_id: 200,
poster_url: 'replacement.jpg',
});
expect(item.catalog_status).not.toBe('missing');
}
};
await verify();
reload();
api.getStreams.mockResolvedValue([]);
await verify();
const backup = new PwaXtreamBackupStorage(
TestBed.inject(USER_DATA_STORAGE)
).exportInto(entry);
expect(backup.userState.favorites).toEqual(entry.userState.favorites);
localStorage.clear();
reload();
await new PwaXtreamBackupStorage(
TestBed.inject(USER_DATA_STORAGE)
).restore('p1', backup.userState, backup);
await verify();
const pending = await source.clearPlaylistContent('p1');
await source.restoreUserData('p1', pending);
await verify();
});
it('retains concurrent explicit additions after loading the favorite writer', async () => {
await loadAll();
await Promise.all(
@@ -39,6 +39,7 @@ import {
import {
CollectionKey,
collectionKey,
collectionSnapshotMatchesContent,
deduplicateRecentItems,
typedCollectionRef,
itemCollectionKey,
@@ -97,8 +98,13 @@ 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, catalogOnly) =>
this.getCollectionItemsById(playlistId, ids, catalogOnly),
(playlistId, ids, catalogOnly, snapshots) =>
this.getCollectionItemsById(
playlistId,
ids,
catalogOnly,
snapshots
),
this.userDataStorage
);
@@ -1032,12 +1038,30 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
allRecent[playlistId].map((item) => item.id),
normalizedContentId
);
const previous = allRecent[playlistId].find((r) =>
matchingKeys.has(r.id)
);
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => !matchingKeys.has(r.id)
);
// Add new entry at the beginning
const current = this.getCollectionItemsById(
playlistId,
[normalizedContentId],
true
).get(normalizedContentId);
const saved = this.getCollectionItemsById(playlistId, [
normalizedContentId,
]).get(normalizedContentId);
const collectionItem =
current &&
(previous?.collectionItem ||
(saved && !collectionSnapshotMatchesContent(saved, current)))
? current
: previous?.collectionItem;
allRecent[playlistId].unshift({
...(collectionItem ? { collectionItem } : {}),
id: normalizedContentId,
viewedAt: new Date().toISOString(),
...(normalizedBackdropUrl
@@ -1048,10 +1072,6 @@ 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(
@@ -1110,7 +1130,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private getCollectionItemsById(
playlistId: string,
ids: readonly CollectionKey[],
catalogOnly = false
catalogOnly = false,
snapshots?: Record<string, XtreamContentItem>
): Map<CollectionKey, XtreamContentItem> {
if (!ids.length) return new Map();
const cached = ([] as XtreamContentItem[]).concat(
@@ -1125,9 +1146,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
cached,
catalogOnly
? {}
: (this.collectionStorage.getCollectionItemsFromStorage()[
playlistId
] ?? {}),
: (snapshots ??
this.collectionStorage.getCollectionItemsFromStorage()[
playlistId
] ??
{}),
this.contentTypes.every((type) =>
this.contentCache.has(`${playlistId}-${type}-content`)
)
@@ -1374,6 +1397,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
...identity,
viewedAt: item.viewedAt,
...(item.collectionItem
? {
collectionItem: {
...item.collectionItem,
type: identity.contentType,
},
}
: {}),
...normalizeStoredBackdrop(item),
};
})
@@ -66,6 +66,13 @@ export class PwaXtreamBackupStorage {
recent.set(ref.key, {
...ref.item,
viewedAt,
...(snapshotsByKey([item['collectionItem']])[ref.key]
? {
collectionItem: snapshotsByKey([
item['collectionItem'],
])[ref.key],
}
: {}),
...backdropOf(item),
});
}
@@ -181,6 +188,13 @@ export class PwaXtreamBackupStorage {
recent.push({
id,
viewedAt: item.viewedAt,
...(snapshotsByKey([item.collectionItem])[id]
? {
collectionItem: snapshotsByKey([
item.collectionItem,
])[id],
}
: {}),
...backdropOf(item),
});
}
@@ -70,6 +70,8 @@ export interface XtreamBackupRecentlyViewedItem {
* reads it.
*/
backdropUrl?: string;
/** Browser view metadata, independent of a retained favorite with the same provider key. */
collectionItem?: XtreamBackupCollectionItem;
}
/**