fix(pwa): hydrate xtream user collections

This commit is contained in:
4gray committed 2026-05-21 19:35:36 +03:00
1 parent 1ea6914f98
commit ec098f98b1
8 files changed
+404 -38

No files matched your search

@@ -36,6 +36,7 @@ describe('UnifiedRecentDataService', () => {
};
let xtreamDataSource: {
addRecentItem: jest.Mock;
clearRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
@@ -149,6 +150,7 @@ describe('UnifiedRecentDataService', () => {
};
xtreamDataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
@@ -396,6 +398,48 @@ describe('UnifiedRecentDataService', () => {
]);
});
it('clears Xtream recent localStorage during global PWA clear', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsService.getAllPlaylists.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'm3u-1',
title: 'M3U List',
},
{
_id: 'stalker-1',
title: 'Stalker Portal',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
] satisfies Partial<Playlist>[])
);
await service.clearRecentItems('all');
expect(dbService.clearGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(xtreamDataSource.clearRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('m3u-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('stalker-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).not.toHaveBeenCalledWith('xtream-1');
});
it('keeps Stalker radio recent items in the live collection with radio metadata', async () => {
store.select.mockReturnValue(
of([
@@ -197,12 +197,12 @@ export class UnifiedRecentDataService {
)) as Playlist[];
await Promise.all(
playlists
.filter(
(playlist) =>
Boolean(playlist.macAddress) || !playlist.serverUrl
)
.map(async (playlist) => {
playlists.map(async (playlist) => {
if (!window.electron && this.isXtreamPlaylist(playlist)) {
await this.xtreamDataSource.clearRecentItems(playlist._id);
}
if (this.isPlaylistBackedRecentPlaylist(playlist)) {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(
playlist._id
@@ -212,7 +212,8 @@ export class UnifiedRecentDataService {
playlist._id,
updatedPlaylist
);
})
}
})
);
}
@@ -695,4 +696,16 @@ export class UnifiedRecentDataService {
): boolean {
return !playlist.serverUrl && !playlist.macAddress;
}
private isXtreamPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.serverUrl) && !playlist.macAddress;
}
private isPlaylistBackedRecentPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.macAddress) || this.isM3uPlaylist(playlist);
}
}
@@ -452,6 +452,18 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
);
}
async setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void> {
void playlistId;
await this.dbService.setContentBackdropIfMissing(
contentId,
backdropUrl
);
}
// =========================================================================
// Playback Position Operations
// =========================================================================
@@ -255,4 +255,104 @@ describe('PwaXtreamDataSource', () => {
[]
);
});
it('hydrates stored favorites and recent items from the API when content cache is cold', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
name: 'Xtream PWA',
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
type: 'xtream',
});
apiService.getStreams.mockImplementation(
(_credentials: XtreamCredentials, type: string) =>
Promise.resolve(
type === 'movie'
? [
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
},
]
: []
)
);
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ 'playlist-1': [202] })
);
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
'playlist-1': [
{
id: 202,
viewedAt: '2026-05-21T12:00:00.000Z',
},
],
})
);
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({
title: 'Movie One',
viewed_at: '2026-05-21T12:00:00.000Z',
xtream_id: 202,
}),
]);
expect(apiService.getStreams).toHaveBeenCalledWith(
credentials,
'movie'
);
});
it('backfills PWA recent-item backdrop metadata without rewriting recency', async () => {
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.addRecentItem(202, 'playlist-1');
const before = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0].viewedAt;
await dataSource.setContentBackdropIfMissing(
202,
'playlist-1',
' https://example.com/backdrop.png '
);
const stored = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0];
expect(stored).toEqual(
expect.objectContaining({
id: 202,
backdropUrl: 'https://example.com/backdrop.png',
viewedAt: before,
})
);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
title: 'Movie One',
xtream_id: 202,
}),
]);
});
});
@@ -36,6 +36,7 @@ const STORAGE_KEYS = {
interface XtreamCachedContentItem {
readonly added?: string;
readonly backdrop_url?: string | null;
readonly category_id?: string | number;
readonly cover?: string;
readonly cover_big?: string;
@@ -59,6 +60,7 @@ interface XtreamCachedContentItem {
interface StoredRecentItem {
readonly id: number;
readonly viewedAt: string;
readonly backdropUrl?: string;
}
/**
@@ -70,6 +72,7 @@ interface StoredRecentItem {
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
// In-memory cache for the current session
private categoryCache = new Map<string, XtreamCategory[]>();
@@ -391,10 +394,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
const allFavorites = this.getFavoritesFromStorage();
const playlistFavorites = allFavorites[playlistId] || [];
await this.hydrateStoredCollectionContent(
playlistId,
playlistFavorites
);
// Match favorites with cached content
const results: XtreamCachedContentItem[] = [];
for (const type of ['live', 'movie', 'series']) {
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
@@ -614,10 +621,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
await this.hydrateStoredCollectionContent(
playlistId,
playlistRecent.map((item) => item.id)
);
// Match recent items with cached content
const results: (XtreamCachedContentItem & { viewed_at: string })[] = [];
for (const type of ['live', 'movie', 'series']) {
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
@@ -627,6 +638,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
if (recentEntry) {
results.push({
...item,
backdrop_url:
recentEntry.backdropUrl ?? item.backdrop_url,
viewed_at: recentEntry.viewedAt,
});
}
@@ -648,11 +661,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
_backdropUrl?: string
): Promise<void> {
void _backdropUrl;
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const normalizedBackdropUrl = _backdropUrl?.trim();
const allRecent = this.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
@@ -668,6 +681,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
allRecent[playlistId].unshift({
id: normalizedContentId,
viewedAt: new Date().toISOString(),
...(normalizedBackdropUrl
? { backdropUrl: normalizedBackdropUrl }
: {}),
});
// Keep only last 50 items
@@ -768,6 +784,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const rawItem = item as {
readonly id?: unknown;
readonly viewedAt?: unknown;
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
};
const id = this.normalizeStoredId(rawItem.id);
if (
@@ -780,6 +798,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
id,
viewedAt: rawItem.viewedAt,
...this.normalizeStoredBackdrop(rawItem),
};
})
.filter((item): item is StoredRecentItem => item !== null);
@@ -788,6 +807,59 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return normalized;
}
private normalizeStoredBackdrop(item: {
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
}): Pick<StoredRecentItem, 'backdropUrl'> | Record<string, never> {
const value = item.backdropUrl ?? item.backdrop_url;
if (typeof value !== 'string') {
return {};
}
const backdropUrl = value.trim();
return backdropUrl ? { backdropUrl } : {};
}
private async hydrateStoredCollectionContent(
playlistId: string,
ids: readonly number[]
): Promise<void> {
if (ids.length === 0) {
return;
}
const missingTypes = this.contentTypes.filter(
(type) => !this.contentCache.has(`${playlistId}-${type}-content`)
);
if (missingTypes.length === 0) {
return;
}
const playlist = await this.getPlaylist(playlistId);
if (!playlist) {
return;
}
const credentials: XtreamCredentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
await Promise.all(
missingTypes.map(async (type) => {
try {
await this.getContent(playlistId, credentials, type);
} catch (error) {
this.logger.warn(
'Failed to hydrate stored PWA Xtream collection content',
{ playlistId, type, error }
);
}
})
);
}
// =========================================================================
// Content Lookup
// =========================================================================
@@ -818,6 +890,61 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return null;
}
async setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedBackdropUrl = backdropUrl.trim();
if (normalizedContentId == null || !normalizedBackdropUrl) {
return;
}
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey);
if (!content) {
continue;
}
this.contentCache.set(
cacheKey,
content.map((item) => {
const itemId = this.getItemIdentity(item, type);
if (itemId !== normalizedContentId || item.backdrop_url) {
return item;
}
return {
...item,
backdrop_url: normalizedBackdropUrl,
};
})
);
}
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId];
if (!playlistRecent) {
return;
}
this.saveRecentItemsToStorage({
...allRecent,
[playlistId]: playlistRecent.map((item) => {
if (item.id !== normalizedContentId || item.backdropUrl) {
return item;
}
return {
...item,
backdropUrl: normalizedBackdropUrl,
};
}),
});
}
private findContentIdentity(
playlistId: string,
xtreamId: number,
@@ -359,6 +359,16 @@ export interface IXtreamDataSource {
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContentItem | null>;
/**
* Persist a backdrop URL for an already-known content item without changing
* favorites or recent ordering.
*/
setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void>;
// =========================================================================
// Playback Position Operations
// =========================================================================
@@ -21,6 +21,7 @@ describe('withRecentItems', () => {
const originalElectron = window.electron;
let store: InstanceType<typeof TestRecentItemsStore>;
let databaseService: {
clearGlobalRecentlyViewed: jest.Mock;
clearPlaylistRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
@@ -33,6 +34,11 @@ describe('withRecentItems', () => {
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
};
let playlistsService: {
clearPlaylistRecentlyViewed: jest.Mock;
getAllPlaylists: jest.Mock;
};
beforeEach(() => {
@@ -42,6 +48,7 @@ describe('withRecentItems', () => {
});
databaseService = {
clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined),
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([
@@ -76,6 +83,13 @@ describe('withRecentItems', () => {
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
};
playlistsService = {
clearPlaylistRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
};
TestBed.configureTestingModule({
@@ -91,12 +105,7 @@ describe('withRecentItems', () => {
},
{
provide: PlaylistsService,
useValue: {
clearPlaylistRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
},
useValue: playlistsService,
},
],
});
@@ -220,7 +229,7 @@ describe('withRecentItems', () => {
});
it('backfills a backdrop without rewriting recent ordering', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -234,18 +243,21 @@ describe('withRecentItems', () => {
backdropUrl: ' https://example.com/krypton-backdrop.png ',
});
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(
databaseService.setContentBackdropIfMissing
).toHaveBeenCalledWith(
expect(dataSource.setContentBackdropIfMissing).toHaveBeenCalledWith(
3941697,
'playlist-1',
'https://example.com/krypton-backdrop.png'
);
expect(dataSource.addRecentItem).not.toHaveBeenCalled();
expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled();
expect(
databaseService.setContentBackdropIfMissing
).not.toHaveBeenCalled();
});
it('clears recent items through the active data source in PWA', async () => {
@@ -262,6 +274,46 @@ describe('withRecentItems', () => {
expect(store.recentItems()).toEqual([]);
});
it('clears Xtream recent items during global PWA clear', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsService.getAllPlaylists.mockReturnValue(
of([
{
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'm3u-1',
},
{
_id: 'stalker-1',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
])
);
await store.clearGlobalRecentlyViewed();
expect(
databaseService.clearGlobalRecentlyViewed
).not.toHaveBeenCalled();
expect(dataSource.clearRecentItems).toHaveBeenCalledWith('xtream-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('m3u-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('stalker-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).not.toHaveBeenCalledWith('xtream-1');
expect(store.recentItems()).toEqual([]);
});
it('removes recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
@@ -148,10 +148,6 @@ export const withRecentItems = function () {
playlist: Signal<{ id: string }>;
backdropUrl?: string;
}): Promise<void> {
if (!window.electron) {
return;
}
const playlistId = playlist().id;
const normalizedXtreamId = Number(xtreamId);
const normalizedBackdropUrl = backdropUrl?.trim();
@@ -164,7 +160,7 @@ export const withRecentItems = function () {
return;
}
const content = await dbService.getContentByXtreamId(
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
@@ -173,8 +169,9 @@ export const withRecentItems = function () {
return;
}
await dbService.setContentBackdropIfMissing(
await dataSource.setContentBackdropIfMissing(
content.id,
playlistId,
normalizedBackdropUrl
);
},
@@ -276,24 +273,35 @@ export const withRecentItems = function () {
},
async clearGlobalRecentlyViewed() {
try {
await dbService.clearGlobalRecentlyViewed();
if (window.electron) {
await dbService.clearGlobalRecentlyViewed();
}
const playlists = (await firstValueFrom(
playlistsService.getAllPlaylists()
)) as Playlist[];
await Promise.all(
playlists
.filter(
(playlist) =>
Boolean(playlist.macAddress) ||
!playlist.serverUrl
)
.map((playlist) =>
firstValueFrom(
playlists.map(async (playlist) => {
if (
!window.electron &&
playlist.serverUrl &&
!playlist.macAddress
) {
await dataSource.clearRecentItems(
playlist._id
);
}
if (
Boolean(playlist.macAddress) ||
!playlist.serverUrl
) {
await firstValueFrom(
playlistsService.clearPlaylistRecentlyViewed(
playlist._id
)
)
)
);
}
})
);
patchState(store, { recentItems: [] });
} catch (error) {