mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(xtream): separate viewed replacements from retained favorites
This commit is contained in:
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);
|
||||
|
||||
+79
-32
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user