fix(pwa): persist xtream collection snapshots

This commit is contained in:
4gray committed 2026-05-21 20:00:38 +03:00
1 parent 3ccbfe4f99
commit ed0b6833d6
4 files changed
+295 -37

No files matched your search

@@ -365,6 +365,9 @@ describe('PwaXtreamDataSource', () => {
const stored = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0];
const storedSnapshot = JSON.parse(
localStorage.getItem('xtream-collection-items') || '{}'
)['playlist-1']['202'];
expect(stored).toEqual(
expect.objectContaining({
id: 202,
@@ -372,6 +375,13 @@ describe('PwaXtreamDataSource', () => {
viewedAt: before,
})
);
expect(storedSnapshot).toEqual(
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
title: 'Movie One',
xtream_id: 202,
})
);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
@@ -380,4 +390,71 @@ describe('PwaXtreamDataSource', () => {
}),
]);
});
it('uses stored collection snapshots for fresh-session favorites and recent items', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
name: 'Xtream PWA',
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
type: 'xtream',
});
apiService.getStreams.mockResolvedValue([
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
},
]);
await dataSource.getContent('playlist-1', credentials, 'movie');
await dataSource.addFavorite(202, 'playlist-1');
await dataSource.addRecentItem(
202,
'playlist-1',
'https://example.com/backdrop.png'
);
const storedCollectionItems = JSON.parse(
localStorage.getItem('xtream-collection-items') || '{}'
);
expect(storedCollectionItems['playlist-1']['202']).toEqual(
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
})
);
TestBed.resetTestingModule();
apiService = {
getStreams: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
dataSource = TestBed.inject(PwaXtreamDataSource);
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
}),
]);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
title: 'Movie One',
xtream_id: 202,
}),
]);
expect(apiService.getStreams).not.toHaveBeenCalled();
});
});
@@ -28,6 +28,7 @@ import {
* LocalStorage keys for PWA persistence
*/
const STORAGE_KEYS = {
COLLECTION_ITEMS: 'xtream-collection-items',
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
PLAYLISTS: 'xtream-playlists',
@@ -122,6 +123,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.savePlaylistsToStorage(filtered);
// Also clear favorites and recent items for this playlist
this.clearCollectionItemsForPlaylist(playlistId);
this.clearFavoritesForPlaylist(playlistId);
this.clearRecentItemsForPlaylist(playlistId);
this.clearPlaybackPositionsForPlaylist(playlistId);
@@ -471,31 +473,16 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistFavorites
);
// Match favorites with cached content
const results: XtreamCachedContentItem[] = [];
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = this.getItemIdentity(item);
if (playlistFavorites.includes(itemId)) {
results.push(item);
}
}
}
return results as XtreamContentItem[];
return Array.from(
this.getCollectionItemsById(playlistId, playlistFavorites).values()
);
}
async addFavorite(
contentId: number,
playlistId: string,
// PWA uses localStorage with no content table, so backdrop persistence
// is electron-only. Accept the param for interface parity.
_backdropUrl?: string
backdropUrl?: string
): Promise<void> {
void _backdropUrl;
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
@@ -509,6 +496,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
allFavorites[playlistId].push(normalizedContentId);
}
this.saveFavoritesToStorage(allFavorites);
this.saveCollectionItemSnapshot(
playlistId,
normalizedContentId,
backdropUrl
);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
@@ -698,24 +690,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistRecent.map((item) => item.id)
);
// Match recent items with cached content
const results: (XtreamCachedContentItem & { viewed_at: string })[] = [];
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = this.getItemIdentity(item);
const recentEntry = playlistRecent.find((r) => r.id === itemId);
if (recentEntry) {
results.push({
...item,
backdrop_url:
recentEntry.backdropUrl ?? item.backdrop_url,
viewed_at: recentEntry.viewedAt,
});
}
const contentById = this.getCollectionItemsById(
playlistId,
playlistRecent.map((item) => item.id)
);
const results: (XtreamContentItem & { viewed_at: string })[] = [];
for (const recentEntry of playlistRecent) {
const item = contentById.get(recentEntry.id);
if (!item) {
continue;
}
results.push({
...item,
backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url,
viewed_at: recentEntry.viewedAt,
});
}
// Sort by viewed_at descending
@@ -762,6 +752,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
allRecent[playlistId] = allRecent[playlistId].slice(0, 50);
this.saveRecentItemsToStorage(allRecent);
this.saveCollectionItemSnapshot(
playlistId,
normalizedContentId,
normalizedBackdropUrl
);
}
async removeRecentItem(
@@ -892,6 +887,130 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return backdropUrl ? { backdropUrl } : {};
}
private getCollectionItemsFromStorage(): Record<
string,
Record<string, XtreamContentItem>
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const parsed = data ? JSON.parse(data) : {};
if (!parsed || typeof parsed !== 'object') {
return {};
}
return parsed as Record<string, Record<string, XtreamContentItem>>;
} catch {
return {};
}
}
private saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
localStorage.setItem(
STORAGE_KEYS.COLLECTION_ITEMS,
JSON.stringify(items)
);
}
private clearCollectionItemsForPlaylist(playlistId: string): void {
const allItems = this.getCollectionItemsFromStorage();
delete allItems[playlistId];
this.saveCollectionItemsToStorage(allItems);
}
private saveCollectionItemSnapshot(
playlistId: string,
contentId: number,
backdropUrl?: string
): void {
const item = this.findCachedContentItemById(playlistId, contentId);
if (!item) {
return;
}
const normalizedBackdropUrl = backdropUrl?.trim();
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId] ?? {};
playlistItems[String(contentId)] = {
...item,
...(normalizedBackdropUrl && !item.backdrop_url
? { backdrop_url: normalizedBackdropUrl }
: {}),
};
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: playlistItems,
});
}
private setCollectionItemBackdropIfMissing(
playlistId: string,
contentId: number,
backdropUrl: string
): void {
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId];
const item = playlistItems?.[String(contentId)];
if (!item || item.backdrop_url) {
return;
}
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: {
...playlistItems,
[String(contentId)]: {
...item,
backdrop_url: backdropUrl,
},
},
});
}
private getCollectionItemsById(
playlistId: string,
ids: readonly number[]
): Map<number, XtreamContentItem> {
const idSet = new Set(ids);
const results = new Map<number, XtreamContentItem>();
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = this.getItemIdentity(item, type);
if (idSet.has(itemId)) {
results.set(itemId, item as XtreamContentItem);
}
}
}
const storedItems = this.getCollectionItemsFromStorage()[playlistId];
if (!storedItems) {
return results;
}
for (const id of ids) {
if (!results.has(id) && storedItems[String(id)]) {
results.set(id, storedItems[String(id)]);
}
}
return results;
}
private findCachedContentItemById(
playlistId: string,
contentId: number
): XtreamContentItem | null {
return (
this.getCollectionItemsById(playlistId, [contentId]).get(
contentId
) ?? null
);
}
private async hydrateStoredCollectionContent(
playlistId: string,
ids: readonly number[]
@@ -996,6 +1115,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
);
}
this.setCollectionItemBackdropIfMissing(
playlistId,
normalizedContentId,
normalizedBackdropUrl
);
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId];
if (!playlistRecent) {
@@ -388,6 +388,33 @@ describe('DashboardDataService', () => {
);
});
it('does not load Stalker playlists through the PWA Xtream favorites path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
]);
await service.reloadGlobalFavorites();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1);
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
@@ -660,6 +687,33 @@ describe('DashboardDataService', () => {
);
});
it('does not load Stalker playlists through the PWA Xtream recent path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
]);
await service.reloadGlobalRecentItems();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1);
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
playlistsSignal.set([
{
@@ -464,7 +464,9 @@ export class DashboardDataService {
}
private getXtreamPlaylists(): PlaylistMeta[] {
return this.playlists().filter((playlist) => !!playlist.serverUrl);
return this.playlists().filter(
(playlist) => !!playlist.serverUrl && !playlist.macAddress
);
}
private mapPwaXtreamRecentItem(