mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(pwa): restore xtream user collections
This commit is contained in:
1 parent
362595525d
commit
1d4d793aac
17 files changed
+1339
-205
No files matched your search
+94
@@ -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',
|
||||
|
||||
+112
-14
@@ -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[] = [];
|
||||
|
||||
+108
@@ -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
|
||||
|
||||
Reference in new issue
Block a user