fix(pwa): restore xtream user collections

This commit is contained in:
4gray committed 2026-05-21 19:23:25 +03:00
1 parent 362595525d
commit 1d4d793aac
17 files changed
+1339 -205

No files matched your search

@@ -10,6 +10,7 @@ import {
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
@@ -24,6 +25,12 @@ describe('UnifiedFavoritesDataService', () => {
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
};
let xtreamDataSource: {
addFavorite: jest.Mock;
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
removeFavorite: jest.Mock;
};
let store: {
dispatch: jest.Mock;
select: jest.Mock;
@@ -111,6 +118,12 @@ describe('UnifiedFavoritesDataService', () => {
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
};
xtreamDataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
store = {
dispatch: jest.fn(),
select: jest.fn(() =>
@@ -141,6 +154,10 @@ describe('UnifiedFavoritesDataService', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSource,
},
{
provide: PlaylistsService,
useValue: playlistsService,
@@ -223,6 +240,52 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('loads Xtream playlist favorites through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
xtreamDataSource.getFavorites.mockResolvedValue([
{
id: 202,
category_id: 20,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
xtream_id: 202,
added_at: '2026-05-21T12:00:00.000Z',
},
]);
const items = await service.getFavorites(
'playlist',
'xtream-1',
'xtream'
);
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1');
expect(databaseService.getFavorites).not.toHaveBeenCalled();
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:202',
sourceType: 'xtream',
contentType: 'movie',
name: 'Movie One',
playlistName: 'Xtream PWA',
posterUrl: 'movie.png',
}),
]);
});
it('preserves persisted M3U favorites order when extracting playlist favorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -400,6 +463,37 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSource.getContentByXtreamId.mockResolvedValue(null);
await service.addFavorite({
uid: 'xtream::xtream-1::movie:101',
name: 'Xtream Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
posterUrl: 'movie.png',
xtreamId: 101,
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith(
101,
'xtream-1',
'movie'
);
expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith(
101,
'xtream-1',
'movie.png'
);
expect(electronApi.dbAddFavorite).not.toHaveBeenCalled();
});
it('adds Stalker favorites through portal favorites', async () => {
await service.addFavorite({
uid: 'stalker::stalker-1::101',
@@ -25,6 +25,10 @@ import {
xtreamContentType,
XtreamFavoriteRow,
} from '@iptvnator/portal/shared/util';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -43,6 +47,7 @@ export class UnifiedFavoritesDataService {
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly translate = inject(TranslateService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getFavorites(
scope: CollectionScope,
@@ -86,12 +91,20 @@ export class UnifiedFavoritesDataService {
break;
}
case 'xtream':
if (window.electron && item.contentId != null) {
if (item.contentId == null) {
return;
}
if (window.electron) {
await window.electron.dbRemoveFavorite(
item.contentId,
item.playlistId
);
return;
}
await this.xtreamDataSource.removeFavorite(
item.contentId,
item.playlistId
);
break;
case 'stalker': {
const sourceItemId = item.uid.split('::')[2];
@@ -134,26 +147,25 @@ export class UnifiedFavoritesDataService {
private async addXtreamFavorite(
item: UnifiedCollectionItem
): Promise<void> {
if (!window.electron) {
return;
}
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
? await this.resolveXtreamContentId(item)
: null);
if (contentId == null) {
return;
}
if (!window.electron) {
await this.xtreamDataSource.addFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
return;
}
await window.electron.dbAddFavorite(
contentId,
item.playlistId,
@@ -346,6 +358,22 @@ export class UnifiedFavoritesDataService {
): Promise<void> {
const electron = window.electron;
if (!electron) {
await Promise.all(
items
.filter(
(
item
): item is UnifiedCollectionItem & {
readonly contentId: number;
} => item.contentId != null
)
.map((item) =>
this.xtreamDataSource.removeFavorite(
item.contentId,
item.playlistId
)
)
);
return;
}
@@ -486,6 +514,17 @@ export class UnifiedFavoritesDataService {
}
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((p) => p._id && p.serverUrl)) {
results.push(
...(await this.getXtreamPlaylistFavorites(meta._id))
);
}
return results;
}
if (!window.electron?.dbGetAllGlobalFavorites) return [];
try {
const rows =
@@ -499,10 +538,17 @@ export class UnifiedFavoritesDataService {
private async getXtreamPlaylistFavorites(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
if (!window.electron) return [];
try {
const rows = await this.dbService.getFavorites(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
const rows =
await this.xtreamDataSource.getFavorites(playlistId);
return rows.map((row) =>
this.mapXtreamContentItem(row, playlistId, meta?.title)
);
}
const rows = await this.dbService.getFavorites(playlistId);
return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({
...this.mapXtreamRow(r),
playlistId,
@@ -513,6 +559,31 @@ export class UnifiedFavoritesDataService {
}
}
private async resolveXtreamContentId(
item: UnifiedCollectionItem
): Promise<number | null> {
if (item.xtreamId == null) {
return null;
}
if (!window.electron) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? item.xtreamId;
}
const content = await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? null;
}
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
const ct = xtreamContentType(row.type);
return {
@@ -535,6 +606,33 @@ export class UnifiedFavoritesDataService {
};
}
private mapXtreamContentItem(
item: XtreamContentItem,
playlistId: string,
playlistName?: string
): UnifiedCollectionItem {
const ct = xtreamContentType(item.type);
return {
uid: buildXtreamCollectionUid(playlistId, ct, item.xtream_id),
name: item.title,
contentType: ct,
sourceType: 'xtream',
playlistId,
playlistName: playlistName ?? item.playlist_name ?? 'Xtream',
logo: ct === 'live' ? (item.poster_url ?? null) : null,
posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null,
xtreamId: item.xtream_id,
categoryId: item.category_id,
tvgId: ct === 'live' ? String(item.xtream_id) : undefined,
rating: item.rating ?? undefined,
addedAt:
normalizeStalkerDate(item.added_at ?? item.added) ||
new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.id,
};
}
private async getStalkerAllFavorites(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
@@ -9,6 +9,7 @@ import {
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { UnifiedRecentDataService } from './unified-recent-data.service';
describe('UnifiedRecentDataService', () => {
@@ -33,6 +34,12 @@ describe('UnifiedRecentDataService', () => {
addRecentItem: jest.Mock;
getContentByXtreamId: jest.Mock;
};
let xtreamDataSource: {
addRecentItem: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
};
const playlistMeta = {
_id: 'm3u-1',
@@ -87,6 +94,10 @@ describe('UnifiedRecentDataService', () => {
];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
store = {
select: jest.fn(() => of([playlistMeta])),
dispatch: jest.fn(),
@@ -136,6 +147,12 @@ describe('UnifiedRecentDataService', () => {
addRecentItem: jest.fn().mockResolvedValue(true),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
xtreamDataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
@@ -143,6 +160,10 @@ describe('UnifiedRecentDataService', () => {
{ provide: Store, useValue: store },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: DatabaseService, useValue: dbService },
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSource,
},
],
});
@@ -235,6 +256,43 @@ describe('UnifiedRecentDataService', () => {
);
});
it('uses the Xtream id as the PWA recent key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSource.getContentByXtreamId.mockResolvedValue(null);
const item = {
uid: 'xtream::xtream-1::movie:290',
name: 'PWA Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 290,
} satisfies UnifiedCollectionItem;
const recorded = await service.recordLivePlayback(item);
expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'xtream-1',
'movie'
);
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
290,
'xtream-1'
);
expect(dbService.addRecentItem).not.toHaveBeenCalled();
expect(recorded).toEqual(
expect.objectContaining({
contentId: 290,
viewedAt: expect.any(String),
})
);
});
it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => {
store.select.mockReturnValue(
of([
@@ -288,6 +346,56 @@ describe('UnifiedRecentDataService', () => {
);
});
it('loads Xtream playlist recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
xtreamDataSource.getRecentItems.mockResolvedValue([
{
id: 202,
category_id: 20,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
backdrop_url: 'backdrop.png',
xtream_id: 202,
viewed_at: '2026-05-21T12:00:00.000Z',
},
]);
const items = await service.getRecentItems(
'playlist',
'xtream-1',
'xtream'
);
expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(dbService.getRecentItems).not.toHaveBeenCalled();
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:202',
sourceType: 'xtream',
contentType: 'movie',
name: 'Movie One',
playlistName: 'Xtream PWA',
posterUrl: 'movie.png',
viewedAt: '2026-05-21T12:00:00.000Z',
}),
]);
});
it('keeps Stalker radio recent items in the live collection with radio metadata', async () => {
store.select.mockReturnValue(
of([
@@ -26,6 +26,10 @@ import {
UnifiedCollectionItem,
xtreamContentType,
} from '@iptvnator/portal/shared/util';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
@@ -36,6 +40,7 @@ export class UnifiedRecentDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getRecentItems(
scope: CollectionScope,
@@ -55,6 +60,14 @@ export class UnifiedRecentDataService {
return;
}
if (!window.electron) {
await this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
);
return;
}
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
@@ -126,7 +139,18 @@ export class UnifiedRecentDataService {
const tasks: Promise<unknown>[] = [];
if (xtreamBatch.length > 0) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
if (window.electron) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
} else {
tasks.push(
...xtreamBatch.map((item) =>
this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
)
)
);
}
}
for (const [playlistId, identities] of groupedByPlaylist) {
@@ -153,7 +177,11 @@ export class UnifiedRecentDataService {
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
await this.dbService.clearPlaylistRecentItems(playlistId);
if (window.electron) {
await this.dbService.clearPlaylistRecentItems(playlistId);
} else {
await this.xtreamDataSource.clearRecentItems(playlistId);
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
);
@@ -161,7 +189,9 @@ export class UnifiedRecentDataService {
return;
}
await this.dbService.clearGlobalRecentlyViewed();
if (window.electron) {
await this.dbService.clearGlobalRecentlyViewed();
}
const playlists = (await firstValueFrom(
this.playlistsService.getAllPlaylists()
)) as Playlist[];
@@ -223,17 +253,21 @@ export class UnifiedRecentDataService {
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
? await this.resolveXtreamContentId(item)
: null);
if (contentId != null) {
await this.dbService.addRecentItem(contentId, item.playlistId);
if (window.electron) {
await this.dbService.addRecentItem(
contentId,
item.playlistId
);
} else {
await this.xtreamDataSource.addRecentItem(
contentId,
item.playlistId
);
}
}
return {
@@ -309,6 +343,17 @@ export class UnifiedRecentDataService {
}
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
(playlist) => playlist._id && playlist.serverUrl
)) {
results.push(...(await this.getXtreamPlaylistRecent(meta._id)));
}
return results;
}
try {
const rows = await this.dbService.getGlobalRecentlyViewed();
return (rows || []).map((row) => ({
@@ -340,9 +385,16 @@ export class UnifiedRecentDataService {
playlistId: string
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.dbService.getRecentItems(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
const rows =
await this.xtreamDataSource.getRecentItems(playlistId);
return rows.map((row) =>
this.mapXtreamContentItem(row, playlistId, meta?.title)
);
}
const rows = await this.dbService.getRecentItems(playlistId);
return (rows || []).map((row) => ({
uid: buildXtreamCollectionUid(
playlistId,
@@ -368,6 +420,60 @@ export class UnifiedRecentDataService {
}
}
private async resolveXtreamContentId(
item: UnifiedCollectionItem
): Promise<number | null> {
if (item.xtreamId == null) {
return null;
}
if (!window.electron) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? item.xtreamId;
}
const content = await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? null;
}
private mapXtreamContentItem(
item: XtreamContentItem,
playlistId: string,
playlistName?: string
): UnifiedCollectionItem {
const contentType = xtreamContentType(item.type);
return {
uid: buildXtreamCollectionUid(
playlistId,
contentType,
item.xtream_id
),
name: item.title,
contentType,
sourceType: 'xtream',
playlistId,
playlistName: playlistName ?? item.playlist_name ?? 'Xtream',
logo: contentType === 'live' ? (item.poster_url ?? null) : null,
posterUrl:
contentType !== 'live' ? (item.poster_url ?? null) : null,
xtreamId: item.xtream_id,
categoryId: item.category_id,
tvgId: contentType === 'live' ? String(item.xtream_id) : undefined,
contentId: item.id,
viewedAt: normalizeStalkerDate(item.viewed_at),
};
}
private async getM3uGlobalRecent(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
@@ -138,4 +138,50 @@ describe('PwaXtreamDataSource', () => {
})
);
});
it('matches legacy string favorite and recent ids against numeric content identities', async () => {
apiService.getStreams.mockResolvedValue([
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
added: '2',
},
]);
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 dataSource.getContent('playlist-1', credentials, 'movie');
await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe(
true
);
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,
}),
]);
});
});
@@ -417,35 +417,50 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
_backdropUrl?: string
): Promise<void> {
void _backdropUrl;
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allFavorites = this.getFavoritesFromStorage();
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
}
if (!allFavorites[playlistId].includes(contentId)) {
allFavorites[playlistId].push(contentId);
if (!allFavorites[playlistId].includes(normalizedContentId)) {
allFavorites[playlistId].push(normalizedContentId);
}
this.saveFavoritesToStorage(allFavorites);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allFavorites = this.getFavoritesFromStorage();
if (allFavorites[playlistId]) {
allFavorites[playlistId] = allFavorites[playlistId].filter(
(id: number) => id !== contentId
(id: number) => id !== normalizedContentId
);
}
this.saveFavoritesToStorage(allFavorites);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return false;
}
const allFavorites = this.getFavoritesFromStorage();
return (allFavorites[playlistId] || []).includes(contentId);
return (allFavorites[playlistId] || []).includes(normalizedContentId);
}
private getFavoritesFromStorage(): Record<string, number[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
@@ -634,6 +649,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
_backdropUrl?: string
): Promise<void> {
void _backdropUrl;
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allRecent = this.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
allRecent[playlistId] = [];
@@ -641,12 +661,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Remove existing entry if present
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => r.id !== contentId
(r) => r.id !== normalizedContentId
);
// Add new entry at the beginning
allRecent[playlistId].unshift({
id: contentId,
id: normalizedContentId,
viewedAt: new Date().toISOString(),
});
@@ -660,10 +680,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
contentId: number,
playlistId: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allRecent = this.getRecentItemsFromStorage();
if (allRecent[playlistId]) {
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => r.id !== contentId
(r) => r.id !== normalizedContentId
);
}
this.saveRecentItemsToStorage(allRecent);
@@ -676,7 +701,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
return data ? JSON.parse(data) : {};
return this.normalizeRecentStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
@@ -697,6 +722,70 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.saveRecentItemsToStorage(allRecent);
}
private normalizeStoredId(value: unknown): number | null {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
private normalizeFavoriteStorage(value: unknown): Record<string, number[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, number[]> = {};
Object.entries(value as Record<string, unknown>).forEach(
([playlistId, ids]) => {
if (!Array.isArray(ids)) {
return;
}
normalized[playlistId] = ids
.map((id) => this.normalizeStoredId(id))
.filter((id): id is number => id !== null);
}
);
return normalized;
}
private normalizeRecentStorage(
value: unknown
): Record<string, StoredRecentItem[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, StoredRecentItem[]> = {};
Object.entries(value as Record<string, unknown>).forEach(
([playlistId, items]) => {
if (!Array.isArray(items)) {
return;
}
normalized[playlistId] = items
.map((item) => {
const rawItem = item as {
readonly id?: unknown;
readonly viewedAt?: unknown;
};
const id = this.normalizeStoredId(rawItem.id);
if (
id == null ||
typeof rawItem.viewedAt !== 'string'
) {
return null;
}
return {
id,
viewedAt: rawItem.viewedAt,
};
})
.filter((item): item is StoredRecentItem => item !== null);
}
);
return normalized;
}
// =========================================================================
// Content Lookup
// =========================================================================
@@ -47,6 +47,7 @@ export interface XtreamContentItem {
rating: string;
added: string;
poster_url: string;
backdrop_url?: string | null;
epg_channel_id?: string | null;
tv_archive?: number | null;
tv_archive_duration?: number | null;
@@ -0,0 +1,80 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import { FavoritesService } from './favorites.service';
describe('FavoritesService', () => {
let service: FavoritesService;
let dataSource: {
addFavorite: jest.Mock;
getFavorites: jest.Mock;
isFavorite: jest.Mock;
removeFavorite: jest.Mock;
};
beforeEach(() => {
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getFavorites: jest.fn().mockResolvedValue([
{
id: 202,
type: 'movie',
title: 'Movie One',
poster_url: 'movie.png',
added_at: '2026-05-21T12:00:00.000Z',
category_id: '20',
xtream_id: 202,
},
]),
isFavorite: jest.fn().mockResolvedValue(true),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
FavoritesService,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
],
});
service = TestBed.inject(FavoritesService);
});
it('uses the active Xtream data source for favorite mutations and reads', async () => {
await service.addToFavorites({
content_id: 202,
playlist_id: 'playlist-1',
backdrop_url: 'backdrop.jpg',
});
await service.removeFromFavorites(202, 'playlist-1');
await expect(service.isFavorite(202, 'playlist-1')).resolves.toBe(true);
await expect(
firstValueFrom(service.getFavorites('playlist-1'))
).resolves.toEqual([
expect.objectContaining({
content_id: 202,
playlist_id: 'playlist-1',
type: 'movie',
title: 'Movie One',
poster_url: 'movie.png',
category_id: 20,
xtream_id: 202,
}),
]);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
202,
'playlist-1',
'backdrop.jpg'
);
expect(dataSource.removeFavorite).toHaveBeenCalledWith(
202,
'playlist-1'
);
expect(dataSource.isFavorite).toHaveBeenCalledWith(202, 'playlist-1');
expect(dataSource.getFavorites).toHaveBeenCalledWith('playlist-1');
});
});
@@ -1,20 +1,25 @@
import { inject, Injectable } from '@angular/core';
import { from, map, Observable } from 'rxjs';
import { DatabaseService } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import { FavoriteItem } from './favorite-item.interface';
function normalizeCategoryId(categoryId: string | number): number {
const numericCategoryId = Number(categoryId);
return Number.isFinite(numericCategoryId) ? numericCategoryId : 0;
}
@Injectable({
providedIn: 'root',
})
export class FavoritesService {
private dbService = inject(DatabaseService);
private dataSource = inject(XTREAM_DATA_SOURCE);
async addToFavorites(item: {
content_id: number;
playlist_id: string;
backdrop_url?: string;
}): Promise<void> {
await this.dbService.addToFavorites(
await this.dataSource.addFavorite(
item.content_id,
item.playlist_id,
item.backdrop_url
@@ -25,15 +30,15 @@ export class FavoritesService {
contentId: number,
playlistId: string
): Promise<void> {
await this.dbService.removeFromFavorites(contentId, playlistId);
await this.dataSource.removeFavorite(contentId, playlistId);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
return await this.dbService.isFavorite(contentId, playlistId);
return await this.dataSource.isFavorite(contentId, playlistId);
}
getFavorites(playlistId: string): Observable<FavoriteItem[]> {
return from(this.dbService.getFavorites(playlistId)).pipe(
return from(this.dataSource.getFavorites(playlistId)).pipe(
map((items) =>
items.map((item) => ({
content_id: item.id,
@@ -42,7 +47,7 @@ export class FavoritesService {
title: item.title,
poster_url: item.poster_url,
added_at: item.added_at,
category_id: item.category_id,
category_id: normalizeCategoryId(item.category_id),
xtream_id: item.xtream_id,
}))
)
@@ -1,7 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import { FavoritesService } from './services/favorites.service';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withFavorites } from './with-favorites.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -16,36 +15,29 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
const TestFavoritesStore = signalStore(withFavorites());
describe('withFavorites', () => {
const originalElectron = window.electron;
let store: InstanceType<typeof TestFavoritesStore>;
let databaseService: {
let dataSource: {
addFavorite: jest.Mock;
getContentByXtreamId: jest.Mock;
};
let favoritesService: {
addToFavorites: jest.Mock;
isFavorite: jest.Mock;
removeFromFavorites: jest.Mock;
removeFavorite: jest.Mock;
};
beforeEach(() => {
databaseService = {
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
};
favoritesService = {
addToFavorites: jest.fn().mockResolvedValue(undefined),
isFavorite: jest.fn().mockResolvedValue(false),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: FavoritesService,
useValue: favoritesService,
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
],
});
@@ -53,8 +45,16 @@ describe('withFavorites', () => {
store = TestBed.inject(TestFavoritesStore);
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
it('looks favorites up with the requested content type before adding one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -63,21 +63,72 @@ describe('withFavorites', () => {
const result = await store.toggleFavorite(290, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(favoritesService.addToFavorites).toHaveBeenCalledWith({
content_id: 3941697,
playlist_id: 'playlist-1',
});
expect(dataSource.addFavorite).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
);
expect(result).toBe(true);
expect(store.isFavorite()).toBe(true);
});
it('uses the Xtream ID as the PWA favorite key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
const result = await store.toggleFavorite(
1767451,
'playlist-1',
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
expect(result).toBe(true);
expect(store.isFavorite()).toBe(true);
});
it('normalizes route-param Xtream IDs before using the PWA favorite fallback', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
const result = await store.toggleFavorite(
'1767451',
'playlist-1',
'movie'
);
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
1767451,
'playlist-1',
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
expect(result).toBe(true);
});
it('looks favorites up with the requested content type before removing one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3867578,
title: 'SE: V Film Premiere FHD',
type: 'live',
@@ -87,12 +138,12 @@ describe('withFavorites', () => {
const result = await store.toggleFavorite(290, 'playlist-1', 'live');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'live'
);
expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith(
expect(dataSource.removeFavorite).toHaveBeenCalledWith(
3867578,
'playlist-1'
);
@@ -101,25 +152,43 @@ describe('withFavorites', () => {
});
it('checks favorite state against the matching content type', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3829429,
title: 'Dragon Ball Heroes',
type: 'series',
xtream_id: 31,
});
favoritesService.isFavorite.mockResolvedValue(true);
dataSource.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus(31, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
31,
'playlist-1',
'series'
);
expect(favoritesService.isFavorite).toHaveBeenCalledWith(
expect(dataSource.isFavorite).toHaveBeenCalledWith(
3829429,
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
});
it('checks PWA favorite state against the Xtream ID when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
dataSource.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie');
expect(dataSource.isFavorite).toHaveBeenCalledWith(
1767451,
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
});
});
@@ -5,9 +5,8 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import { FavoritesService } from './services/favorites.service';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
export const withFavorites = function () {
const logger = createLogger('withFavorites');
@@ -15,85 +14,94 @@ export const withFavorites = function () {
withState({
isFavorite: false,
}),
withMethods(
(
store,
dbService = inject(DatabaseService),
favoritesService = inject(FavoritesService)
) => ({
async toggleFavorite(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
async toggleFavorite(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
) {
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
) {
if (!xtreamId || !playlistId) {
return false;
}
return false;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron ? normalizedXtreamId : null);
if (contentId == null) {
logger.error(
'Content not found for xtream ID',
normalizedXtreamId
);
if (!content) {
logger.error(
'Content not found for xtream ID',
xtreamId
);
return false;
}
return false;
}
const currentStatus = store.isFavorite();
const currentStatus = store.isFavorite();
if (currentStatus) {
// Remove from favorites
await favoritesService.removeFromFavorites(
content.id,
playlistId
);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await favoritesService.addToFavorites({
content_id: content.id,
playlist_id: playlistId,
backdrop_url: backdropUrl,
});
patchState(store, { isFavorite: true });
return true;
}
},
if (currentStatus) {
// Remove from favorites
await dataSource.removeFavorite(contentId, playlistId);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await dataSource.addFavorite(
contentId,
playlistId,
backdropUrl
);
patchState(store, { isFavorite: true });
return true;
}
},
async checkFavoriteStatus(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
async checkFavoriteStatus(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
) {
if (!xtreamId || !playlistId) {
patchState(store, { isFavorite: false });
return;
}
patchState(store, { isFavorite: false });
return;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
);
if (!content) {
patchState(store, { isFavorite: false });
return;
}
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron ? normalizedXtreamId : null);
const isFavorite = await favoritesService.isFavorite(
content.id,
playlistId
);
if (contentId == null) {
patchState(store, { isFavorite: false });
return;
}
patchState(store, { isFavorite });
},
})
)
const isFavorite = await dataSource.isFavorite(
contentId,
playlistId
);
patchState(store, { isFavorite });
},
}))
);
};
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withRecentItems } from './with-recent-items';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -17,13 +18,22 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
const TestRecentItemsStore = signalStore(withRecentItems());
describe('withRecentItems', () => {
const originalElectron = window.electron;
let store: InstanceType<typeof TestRecentItemsStore>;
let databaseService: {
addRecentItem: jest.Mock;
clearPlaylistRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
};
let dataSource: {
addRecentItem: jest.Mock;
clearRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
};
beforeEach(() => {
Object.defineProperty(window, 'electron', {
@@ -32,7 +42,7 @@ describe('withRecentItems', () => {
});
databaseService = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([
{
@@ -46,8 +56,27 @@ describe('withRecentItems', () => {
category_id: 17,
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
};
dataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([
{
id: 3941697,
title: 'Krypton',
type: 'series',
poster_url: 'https://example.com/krypton.png',
backdrop_url: 'https://example.com/krypton-backdrop.png',
viewed_at: '2026-04-21T20:42:27.000Z',
xtream_id: 290,
category_id: 17,
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
@@ -56,12 +85,16 @@ describe('withRecentItems', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: PlaylistsService,
useValue: {
clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(
of(undefined)
),
clearPlaylistRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
},
},
@@ -71,8 +104,16 @@ describe('withRecentItems', () => {
store = TestBed.inject(TestRecentItemsStore);
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
it('looks recent items up with the requested content type before saving one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -86,12 +127,12 @@ describe('withRecentItems', () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(databaseService.addRecentItem).toHaveBeenCalledWith(
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
@@ -107,8 +148,56 @@ describe('withRecentItems', () => {
]);
});
it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
store.addRecentItem({
xtreamId: 1767451,
contentType: 'movie',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
expect(databaseService.getRecentItems).not.toHaveBeenCalled();
});
it('normalizes route-param Xtream IDs before using the PWA recent fallback', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
store.addRecentItem({
xtreamId: '1767451',
contentType: 'movie',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
1767451,
'playlist-1',
'movie'
);
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
});
it('forwards backdrop urls on recent-item saves', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -123,7 +212,7 @@ describe('withRecentItems', () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(databaseService.addRecentItem).toHaveBeenCalledWith(
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
'https://example.com/krypton-backdrop.png'
@@ -156,6 +245,42 @@ describe('withRecentItems', () => {
3941697,
'https://example.com/krypton-backdrop.png'
);
expect(databaseService.addRecentItem).not.toHaveBeenCalled();
expect(dataSource.addRecentItem).not.toHaveBeenCalled();
});
it('clears recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.clearRecentItems({ id: 'playlist-1' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.clearRecentItems).toHaveBeenCalledWith('playlist-1');
expect(databaseService.clearPlaylistRecentItems).not.toHaveBeenCalled();
expect(store.recentItems()).toEqual([]);
});
it('removes recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.removeRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1'
);
expect(databaseService.removeRecentItem).not.toHaveBeenCalled();
expect(store.recentItems()).toEqual([
expect.objectContaining({
id: 3941697,
title: 'Krypton',
}),
]);
});
});
@@ -14,6 +14,7 @@ import {
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
export interface RecentlyViewedItem extends PortalRecentItem {
/** @deprecated Redundant — always equals `id`. Retained for compat. */
@@ -29,7 +30,7 @@ function mapDbRecentItem(
backdrop_url?: string | null;
viewed_at?: string;
xtream_id: number;
category_id: number;
category_id: number | string;
},
playlistId: string
): RecentlyViewedItem {
@@ -53,11 +54,11 @@ export const withRecentItems = function () {
withState({
recentItems: [],
}),
withMethods((store, dbService = inject(DatabaseService)) => ({
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
loadRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
const items = await dbService.getRecentItems(
const items = await dataSource.getRecentItems(
playlist.id
);
return items.map((item) =>
@@ -74,10 +75,11 @@ export const withRecentItems = function () {
(
store,
dbService = inject(DatabaseService),
playlistsService = inject(PlaylistsService)
playlistsService = inject(PlaylistsService),
dataSource = inject(XTREAM_DATA_SOURCE)
) => ({
addRecentItem: rxMethod<{
xtreamId: number;
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
backdropUrl?: string;
@@ -90,30 +92,49 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}) => {
const playlistId = playlist().id;
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
);
if (content) {
await dbService.addRecentItem(
content.id,
playlistId,
backdropUrl
);
const playlistId = playlist().id;
const normalizedXtreamId = Number(xtreamId);
if (
!playlistId ||
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0
) {
return;
}
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
await dbService.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
const content =
await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron
? normalizedXtreamId
: null);
if (contentId != null) {
await dataSource.addRecentItem(
contentId,
playlistId,
backdropUrl
);
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
await dataSource.getRecentItems(
playlistId
);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
}
}
})
)
)
),
async backfillContentBackdrop({
@@ -122,7 +143,7 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}: {
xtreamId: number;
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
backdropUrl?: string;
@@ -132,13 +153,19 @@ export const withRecentItems = function () {
}
const playlistId = playlist().id;
const normalizedXtreamId = Number(xtreamId);
const normalizedBackdropUrl = backdropUrl?.trim();
if (!playlistId || !normalizedBackdropUrl) {
if (
!playlistId ||
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!normalizedBackdropUrl
) {
return;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
normalizedXtreamId,
playlistId,
contentType
);
@@ -154,9 +181,13 @@ export const withRecentItems = function () {
clearRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
await dbService.clearPlaylistRecentItems(
playlist.id
);
if (window.electron) {
await dbService.clearPlaylistRecentItems(
playlist.id
);
} else {
await dataSource.clearRecentItems(playlist.id);
}
patchState(store, { recentItems: [] });
})
)
@@ -167,13 +198,20 @@ export const withRecentItems = function () {
}>(
pipe(
switchMap(async ({ itemId, playlistId }) => {
await dbService.removeRecentItem(
itemId,
playlistId
);
if (window.electron) {
await dbService.removeRecentItem(
itemId,
playlistId
);
} else {
await dataSource.removeRecentItem(
itemId,
playlistId
);
}
// Reload recent items to update UI
const items =
await dbService.getRecentItems(playlistId);
await dataSource.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
@@ -189,14 +227,16 @@ export const withRecentItems = function () {
const playlists = (await firstValueFrom(
playlistsService.getAllPlaylists()
)) as Playlist[];
const playlistBackedItems =
buildPlaylistRecentItems(playlists, {
const playlistBackedItems = buildPlaylistRecentItems(
playlists,
{
stalker: 'Stalker Portal',
m3u: 'M3U',
}).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
}
).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
const normalizedXtream: RecentlyViewedItem[] = (
xtreamItems || []
@@ -41,6 +41,37 @@ describe('DatabaseService browser guards', () => {
await expect(
service.getContentByXtreamId(20229, 'playlist-1', 'movie')
).resolves.toBeNull();
await expect(service.getGlobalRecentlyViewed()).resolves.toEqual([]);
await expect(service.getGlobalFavorites()).resolves.toEqual([]);
await expect(service.getAllGlobalFavorites()).resolves.toEqual([]);
await expect(
service.clearGlobalRecentlyViewed()
).resolves.toBeUndefined();
await expect(service.addToFavorites(20229, 'playlist-1')).resolves.toBe(
false
);
await expect(
service.removeFromFavorites(20229, 'playlist-1')
).resolves.toBe(false);
await expect(service.isFavorite(20229, 'playlist-1')).resolves.toBe(
false
);
await expect(service.getFavorites('playlist-1')).resolves.toEqual([]);
await expect(service.getRecentItems('playlist-1')).resolves.toEqual([]);
await expect(service.addRecentItem(20229, 'playlist-1')).resolves.toBe(
false
);
await expect(
service.clearPlaylistRecentItems('playlist-1')
).resolves.toBe(false);
await expect(
service.removeRecentItem(20229, 'playlist-1')
).resolves.toBe(false);
await expect(
service.removeRecentItemsBatch([
{ contentId: 20229, playlistId: 'playlist-1' },
])
).resolves.toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
@@ -627,6 +627,10 @@ export class DatabaseService {
* Get recently viewed items
*/
async getGlobalRecentlyViewed(): Promise<GlobalRecentItem[]> {
if (typeof window.electron?.dbGetRecentlyViewed !== 'function') {
return [];
}
try {
const items = await window.electron.dbGetRecentlyViewed();
return items || [];
@@ -640,6 +644,10 @@ export class DatabaseService {
* Get global favorites across all playlists
*/
async getGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
if (typeof window.electron?.dbGetGlobalFavorites !== 'function') {
return [];
}
try {
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
@@ -653,6 +661,10 @@ export class DatabaseService {
* Get global favorites across all playlists (all content types)
*/
async getAllGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
if (typeof window.electron?.dbGetAllGlobalFavorites !== 'function') {
return [];
}
try {
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
@@ -666,6 +678,10 @@ export class DatabaseService {
* Clear recently viewed items
*/
async clearGlobalRecentlyViewed(): Promise<void> {
if (typeof window.electron?.dbClearRecentlyViewed !== 'function') {
return;
}
try {
await window.electron.dbClearRecentlyViewed();
} catch (error) {
@@ -706,6 +722,10 @@ export class DatabaseService {
playlistId: string,
backdropUrl?: string
): Promise<boolean> {
if (typeof window.electron?.dbAddFavorite !== 'function') {
return false;
}
try {
await window.electron.dbAddFavorite(
contentId,
@@ -726,6 +746,10 @@ export class DatabaseService {
contentId: number,
playlistId: string
): Promise<boolean> {
if (typeof window.electron?.dbRemoveFavorite !== 'function') {
return false;
}
try {
await window.electron.dbRemoveFavorite(contentId, playlistId);
return true;
@@ -739,6 +763,10 @@ export class DatabaseService {
* Check if content is favorited
*/
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
if (typeof window.electron?.dbIsFavorite !== 'function') {
return false;
}
try {
return await window.electron.dbIsFavorite(contentId, playlistId);
} catch (error) {
@@ -751,6 +779,10 @@ export class DatabaseService {
* Get all favorites for a playlist
*/
async getFavorites(playlistId: string): Promise<XtreamContent[]> {
if (typeof window.electron?.dbGetFavorites !== 'function') {
return [];
}
try {
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
@@ -763,6 +795,10 @@ export class DatabaseService {
* Get recently viewed items for a specific playlist
*/
async getRecentItems(playlistId: string): Promise<XtreamContent[]> {
if (typeof window.electron?.dbGetRecentItems !== 'function') {
return [];
}
try {
return await window.electron.dbGetRecentItems(playlistId);
} catch (error) {
@@ -779,6 +815,10 @@ export class DatabaseService {
playlistId: string,
backdropUrl?: string
): Promise<boolean> {
if (typeof window.electron?.dbAddRecentItem !== 'function') {
return false;
}
try {
await window.electron.dbAddRecentItem(
contentId,
@@ -825,6 +865,10 @@ export class DatabaseService {
* Clear recently viewed for a specific playlist
*/
async clearPlaylistRecentItems(playlistId: string): Promise<boolean> {
if (typeof window.electron?.dbClearPlaylistRecentItems !== 'function') {
return false;
}
try {
await window.electron.dbClearPlaylistRecentItems(playlistId);
return true;
@@ -841,6 +885,10 @@ export class DatabaseService {
contentId: number,
playlistId: string
): Promise<boolean> {
if (typeof window.electron?.dbRemoveRecentItem !== 'function') {
return false;
}
try {
await window.electron.dbRemoveRecentItem(contentId, playlistId);
return true;
@@ -856,6 +904,10 @@ export class DatabaseService {
if (items.length === 0) {
return true;
}
if (typeof window.electron?.dbRemoveRecentItemsBatch !== 'function') {
return false;
}
try {
await window.electron.dbRemoveRecentItemsBatch(items);
return true;
@@ -2,10 +2,14 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { DashboardDataService } from './dashboard-data.service';
describe('DashboardDataService', () => {
@@ -68,6 +72,10 @@ describe('DashboardDataService', () => {
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const xtreamDataSourceMock = {
getFavorites: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
};
const playlistMock: Playlist = {
_id: 'm3u-1',
title: 'M3U Playlist',
@@ -138,6 +146,10 @@ describe('DashboardDataService', () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
dbServiceMock.removeFromFavorites.mockClear();
dbServiceMock.removeRecentItem.mockClear();
xtreamDataSourceMock.getFavorites.mockClear();
xtreamDataSourceMock.getFavorites.mockResolvedValue([]);
xtreamDataSourceMock.getRecentItems.mockClear();
xtreamDataSourceMock.getRecentItems.mockResolvedValue([]);
storeMock.dispatch.mockClear();
TestBed.configureTestingModule({
@@ -145,6 +157,10 @@ describe('DashboardDataService', () => {
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSourceMock,
},
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
@@ -324,6 +340,44 @@ describe('DashboardDataService', () => {
).toHaveLength(1);
});
it('includes PWA Xtream favorites from the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSourceMock.getFavorites.mockResolvedValue([
{
id: 51,
category_id: 12,
title: 'Action Movie',
rating: '8.0',
added_at: '2026-02-02T10:00:00.000Z',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
},
]);
await service.reloadGlobalFavorites();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 51,
title: 'Action Movie',
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
}),
])
);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
@@ -528,6 +582,45 @@ describe('DashboardDataService', () => {
).toEqual(['xtream-1', 'stalker-1', 'm3u-1']);
});
it('includes PWA Xtream recent items from the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSourceMock.getRecentItems.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
},
]);
await service.reloadGlobalRecentItems();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 91,
title: 'Recent Movie',
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
viewed_at: '2026-04-21T10:00:00.000Z',
}),
])
);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
playlistsSignal.set([
{
@@ -20,6 +20,10 @@ import {
GlobalRecentlyAddedKind,
PlaylistsService,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
import {
buildPlaylistRecentItems,
Channel,
@@ -64,6 +68,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly playlistsService = inject(PlaylistsService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
@@ -298,7 +303,10 @@ export class DashboardDataService {
}
if (!window.electron) {
this.xtreamGlobalRecentItems.set([]);
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
this.ngZone.run(() =>
this.xtreamGlobalRecentItems.set(recentItems)
);
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
return;
@@ -400,7 +408,8 @@ export class DashboardDataService {
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
const favorites = await this.loadPwaXtreamGlobalFavorites();
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
this.finishInitialGlobalFavoritesLoadIfReady();
return;
}
@@ -422,6 +431,87 @@ export class DashboardDataService {
}
}
private async loadPwaXtreamGlobalRecentItems(): Promise<
GlobalRecentItem[]
> {
const results: GlobalRecentItem[] = [];
for (const playlist of this.getXtreamPlaylists()) {
const rows = await this.xtreamDataSource.getRecentItems(
playlist._id
);
results.push(
...rows.map((item) =>
this.mapPwaXtreamRecentItem(item, playlist)
)
);
}
return results;
}
private async loadPwaXtreamGlobalFavorites(): Promise<
DashboardFavoriteItem[]
> {
const results: DashboardFavoriteItem[] = [];
for (const playlist of this.getXtreamPlaylists()) {
const rows = await this.xtreamDataSource.getFavorites(playlist._id);
results.push(
...rows.map((item) =>
this.mapPwaXtreamFavoriteItem(item, playlist)
)
);
}
return results;
}
private getXtreamPlaylists(): PlaylistMeta[] {
return this.playlists().filter((playlist) => !!playlist.serverUrl);
}
private mapPwaXtreamRecentItem(
item: XtreamContentItem,
playlist: PlaylistMeta
): GlobalRecentItem {
return {
id: item.id,
title: item.title,
type: this.normalizeXtreamActivityType(item.type),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Xtream',
viewed_at: item.viewed_at ?? '',
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
private mapPwaXtreamFavoriteItem(
item: XtreamContentItem,
playlist: PlaylistMeta
): DashboardFavoriteItem {
return {
id: item.id,
title: item.title,
type: this.normalizeXtreamActivityType(item.type),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Xtream',
added_at: item.added_at ?? item.added ?? new Date(0).toISOString(),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
private normalizeXtreamActivityType(type: string): PortalActivityType {
if (type === 'live' || type === 'series') {
return type;
}
return 'movie';
}
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
await this.reloadM3uGlobalFavorites();
this.finishInitialGlobalFavoritesLoadIfReady();
@@ -875,8 +965,7 @@ export class DashboardDataService {
const matchedFavoriteId =
channelIdFavoritePosition !== undefined &&
(channelUrlFavoritePosition === undefined ||
channelIdFavoritePosition <=
channelUrlFavoritePosition)
channelIdFavoritePosition <= channelUrlFavoritePosition)
? channelId
: channelUrlFavoritePosition !== undefined
? channelUrl