fix(pwa): preserve Xtream snapshots during refresh

This commit is contained in:
4gray committed 2026-05-16 02:01:41 +02:00
1 parent 8f7d4a5f18
commit a18baa35f2
2 files changed
+191 -48

No files matched your search

@@ -42,6 +42,20 @@ describe('PwaXtreamDataSource', () => {
dataSource = TestBed.inject(PwaXtreamDataSource);
});
function createFreshDataSource(): PwaXtreamDataSource {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
return TestBed.inject(PwaXtreamDataSource);
}
it('finds VOD cached under vod when callers request movie content', async () => {
await dataSource.getContent('playlist-1', credentials, 'vod');
@@ -73,17 +87,63 @@ describe('PwaXtreamDataSource', () => {
await dataSource.addFavorite(20203, 'playlist-1');
await dataSource.addRecentItem(20203, 'playlist-1');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
const freshDataSource = TestBed.inject(PwaXtreamDataSource);
const freshDataSource = createFreshDataSource();
await expect(
freshDataSource.getFavorites('playlist-1')
).resolves.toEqual([
expect.objectContaining({
stream_id: 20203,
title: 'Movie',
}),
]);
await expect(
freshDataSource.getRecentItems('playlist-1')
).resolves.toEqual([
expect.objectContaining({
stream_id: 20203,
title: 'Movie',
viewed_at: expect.any(String),
}),
]);
});
it('keeps PWA favorites in restore state when only stored snapshots are available', async () => {
await dataSource.getContent('playlist-1', credentials, 'vod');
await dataSource.addFavorite(20203, 'playlist-1');
await dataSource.addRecentItem(20203, 'playlist-1');
const freshDataSource = createFreshDataSource();
const restoreState =
await freshDataSource.clearPlaylistContent('playlist-1');
expect(restoreState.favorites).toEqual([
expect.objectContaining({
contentType: 'movie',
xtreamId: 20203,
}),
]);
expect(restoreState.recentlyViewed).toEqual([
expect.objectContaining({
contentType: 'movie',
xtreamId: 20203,
viewedAt: expect.any(String),
}),
]);
});
it('restores PWA favorites and recent items with snapshots after content refresh', async () => {
await dataSource.getContent('playlist-1', credentials, 'vod');
await dataSource.addFavorite(20203, 'playlist-1');
await dataSource.addRecentItem(20203, 'playlist-1');
const restoreState =
await dataSource.clearPlaylistContent('playlist-1');
await dataSource.restoreUserData('playlist-1', restoreState);
const freshDataSource = createFreshDataSource();
await expect(
freshDataSource.getFavorites('playlist-1')
@@ -7,7 +7,6 @@ import {
XtreamSerieItem,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
CategoryType,
StreamType,
@@ -47,6 +46,7 @@ interface XtreamCachedContentItem {
readonly stream_display_name?: string;
readonly stream_id?: number;
readonly stream_icon?: string;
readonly stream_type?: string;
readonly title?: string;
readonly type?: string;
readonly viewed_at?: string;
@@ -69,6 +69,15 @@ interface StoredRecentItem {
}
type PwaContentCacheType = 'live' | 'movie' | 'vod' | 'series';
type PwaRestoreFavoriteItem = XtreamPendingRestoreState['favorites'][number] & {
readonly backdropUrl?: string;
readonly content?: XtreamCachedContentItem;
};
type PwaRestoreRecentItem =
XtreamPendingRestoreState['recentlyViewed'][number] & {
readonly backdropUrl?: string;
readonly content?: XtreamCachedContentItem;
};
/**
* PWA implementation of the Xtream data source.
@@ -78,7 +87,6 @@ type PwaContentCacheType = 'live' | 'movie' | 'vod' | 'series';
@Injectable({ providedIn: 'root' })
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly logger = createLogger('PwaXtreamDataSource');
// In-memory cache for the current session
private categoryCache = new Map<string, XtreamCategory[]>();
@@ -227,7 +235,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
void categoryIds;
void hidden;
// Category visibility is not supported in PWA mode
this.logger.warn('Category visibility not supported in PWA mode');
console.warn(
'[PwaXtreamDataSource]',
'Category visibility not supported in PWA mode'
);
}
// =========================================================================
@@ -770,11 +781,26 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return null;
}
private findStoredContentIdentity(
content: XtreamCachedContentItem | undefined,
fallbackId: number
): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null {
if (!content) {
return null;
}
const xtreamId = this.resolveContentId(content) ?? fallbackId;
const contentType = this.resolveContentType(content);
return contentType ? { contentType, xtreamId } : null;
}
private findCachedContentById(
playlistId: string,
contentId: number
contentId: number,
contentType?: 'live' | 'movie' | 'series'
): XtreamCachedContentItem | undefined {
for (const type of this.getContentCacheLookupTypes()) {
for (const type of this.getContentCacheLookupTypes(contentType)) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const found = content.find(
@@ -789,6 +815,25 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return undefined;
}
private resolveContentType(
item: XtreamCachedContentItem
): 'live' | 'movie' | 'series' | null {
if (item.series_id != null) {
return 'series';
}
const type = String(item.type ?? item.stream_type ?? '').toLowerCase();
if (type === 'live' || type === 'series') {
return type;
}
if (type === 'movie' || type === 'vod') {
return 'movie';
}
return null;
}
private resolveContentId(item: XtreamCachedContentItem): number | null {
const id = Number(item.stream_id ?? item.series_id ?? item.id);
return Number.isFinite(id) && id > 0 ? id : null;
@@ -808,24 +853,39 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
await this.getAllPlaybackPositions(playlistId);
const typedFavorites = (favorites[playlistId] || [])
.map((favorite) =>
this.findContentIdentity(
playlistId,
this.getStoredFavoriteId(favorite)
)
)
.filter(
(
value
): value is {
contentType: 'live' | 'movie' | 'series';
xtreamId: number;
} => value !== null
);
.map<PwaRestoreFavoriteItem | null>((favorite) => {
const favoriteId = this.getStoredFavoriteId(favorite);
const content =
this.getStoredFavoriteContent(favorite) ??
this.findCachedContentById(playlistId, favoriteId);
const identity =
this.findStoredContentIdentity(content, favoriteId) ??
this.findContentIdentity(playlistId, favoriteId);
if (!identity) {
return null;
}
return {
...identity,
addedAt: this.getStoredFavoriteAddedAt(favorite),
backdropUrl:
this.getStoredFavoriteBackdropUrl(favorite) ??
content?.backdrop_url ??
undefined,
content,
};
})
.filter((value): value is PwaRestoreFavoriteItem => value !== null);
const typedRecentlyViewed = (recentItems[playlistId] || [])
.map((item) => {
const identity = this.findContentIdentity(playlistId, item.id);
.map<PwaRestoreRecentItem | null>((item) => {
const content =
item.content ??
this.findCachedContentById(playlistId, item.id);
const identity =
this.findStoredContentIdentity(content, item.id) ??
this.findContentIdentity(playlistId, item.id);
if (!identity) {
return null;
@@ -834,17 +894,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
...identity,
viewedAt: item.viewedAt,
backdropUrl: item.backdropUrl ?? content?.backdrop_url,
content,
};
})
.filter(
(
value
): value is {
contentType: 'live' | 'movie' | 'series';
xtreamId: number;
viewedAt: string;
} => value !== null
);
.filter((value): value is PwaRestoreRecentItem => value !== null);
// Clear in-memory cache
this.clearCacheForPlaylist(playlistId);
@@ -865,17 +919,46 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
void options;
// Restore favorites
const favorites = this.getFavoritesFromStorage();
favorites[playlistId] = restoreState.favorites.map(
(item) => item.xtreamId
);
favorites[playlistId] = restoreState.favorites.map((item) => {
const pwaItem = item as PwaRestoreFavoriteItem;
const content =
pwaItem.content ??
this.findCachedContentById(
playlistId,
item.xtreamId,
item.contentType
);
return {
id: item.xtreamId,
addedAt: item.addedAt ?? new Date().toISOString(),
backdropUrl:
pwaItem.backdropUrl ?? content?.backdrop_url ?? undefined,
content,
};
});
this.saveFavoritesToStorage(favorites);
// Restore recent items
const recentItems = this.getRecentItemsFromStorage();
recentItems[playlistId] = restoreState.recentlyViewed.map((item) => ({
id: item.xtreamId,
viewedAt: item.viewedAt,
}));
recentItems[playlistId] = restoreState.recentlyViewed.map((item) => {
const pwaItem = item as PwaRestoreRecentItem;
const content =
pwaItem.content ??
this.findCachedContentById(
playlistId,
item.xtreamId,
item.contentType
);
return {
id: item.xtreamId,
viewedAt: item.viewedAt,
backdropUrl:
pwaItem.backdropUrl ?? content?.backdrop_url ?? undefined,
content,
};
});
this.saveRecentItemsToStorage(recentItems);
// Restore playback positions