fix(workspace): surface PWA Xtream collections

This commit is contained in:
4gray committed 2026-05-15 17:33:20 +02:00
1 parent 162a7902ae
commit fcfdeae9dc
9 files changed
+875 -53

No files matched your search

+10 -1
View File
@@ -6,6 +6,7 @@ import {
import {
ApplicationConfig,
importProvidersFrom,
inject,
provideZoneChangeDetection,
} from '@angular/core';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
@@ -24,9 +25,13 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
XTREAM_COLLECTION_DATA_SOURCE,
} from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import {
provideXtreamDataSource,
XTREAM_DATA_SOURCE,
} from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
@@ -153,6 +158,10 @@ export const appConfig: ApplicationConfig = {
},
...provideWorkspaceShellActions(),
...provideXtreamDataSource(),
{
provide: XTREAM_COLLECTION_DATA_SOURCE,
useFactory: () => inject(XTREAM_DATA_SOURCE),
},
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: { appearance: 'outline', subscriptSizing: 'dynamic' },
@@ -4,3 +4,4 @@ export * from './scope-toggle.service';
export * from './unified-favorites-data.service';
export * from './unified-recent-data.service';
export * from './stream-resolver.service';
export * from './xtream-collection-data-source.token';
@@ -11,12 +11,17 @@ import {
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
import {
XTREAM_COLLECTION_DATA_SOURCE,
XtreamCollectionDataSourceItem,
} from './xtream-collection-data-source.token';
describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let electronApi: {
dbAddFavorite: jest.Mock;
dbGetAllGlobalFavorites: jest.Mock;
dbGetFavorites: jest.Mock;
dbRemoveFavorite: jest.Mock;
};
let databaseService: {
@@ -34,6 +39,11 @@ describe('UnifiedFavoritesDataService', () => {
setFavorites: jest.Mock;
setPortalFavorites: jest.Mock;
};
let xtreamDataSource: {
addFavorite: jest.Mock;
getFavorites: jest.Mock;
removeFavorite: jest.Mock;
};
const m3uChannels: Channel[] = [
{
@@ -93,6 +103,7 @@ describe('UnifiedFavoritesDataService', () => {
electronApi = {
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
dbGetAllGlobalFavorites: jest.fn(),
dbGetFavorites: jest.fn(),
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
};
Object.defineProperty(window, 'electron', {
@@ -126,9 +137,21 @@ describe('UnifiedFavoritesDataService', () => {
macAddress: '00:11:22:33:44:55',
favorites: stalkerFavorites,
},
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
] satisfies PlaylistMeta[])
),
};
xtreamDataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getFavorites: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
@@ -145,6 +168,10 @@ describe('UnifiedFavoritesDataService', () => {
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: XTREAM_COLLECTION_DATA_SOURCE,
useValue: xtreamDataSource,
},
{
provide: TranslateService,
useValue: {
@@ -400,6 +427,80 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('adds Xtream favorites through the PWA data source when Electron favorites APIs are absent', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
await service.addFavorite({
uid: 'xtream::xtream-1::movie:20203',
name: 'PWA Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
posterUrl: 'movie.png',
xtreamId: 20203,
} satisfies UnifiedCollectionItem);
expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled();
expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith(
20203,
'xtream-1',
'movie.png'
);
});
it('maps PWA Xtream favorites from the shared data source without Electron DB APIs', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://xtream.example.com',
} satisfies Partial<PlaylistMeta>,
])
);
xtreamDataSource.getFavorites.mockResolvedValue([
{
id: 20203,
stream_id: 20203,
name: 'PWA Movie',
stream_type: 'movie',
stream_icon: 'movie.png',
category_id: '2',
rating: '8.1',
added_at: '2026-03-26T08:00:00.000Z',
} satisfies XtreamCollectionDataSourceItem,
]);
const items = await service.getFavorites('all');
expect(databaseService.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:20203',
name: 'PWA Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
posterUrl: 'movie.png',
xtreamId: 20203,
contentId: 20203,
rating: '8.1',
}),
]);
});
it('adds Stalker favorites through portal favorites', async () => {
await service.addFavorite({
uid: 'stalker::stalker-1::101',
@@ -27,6 +27,10 @@ import {
xtreamContentType,
XtreamFavoriteRow,
} from './collection-helpers';
import {
XTREAM_COLLECTION_DATA_SOURCE,
XtreamCollectionDataSourceItem,
} from './xtream-collection-data-source.token';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -44,6 +48,7 @@ export class UnifiedFavoritesDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamDataSource = inject(XTREAM_COLLECTION_DATA_SOURCE);
private readonly translate = inject(TranslateService);
async getFavorites(
@@ -88,11 +93,19 @@ export class UnifiedFavoritesDataService {
break;
}
case 'xtream':
if (window.electron && item.contentId != null) {
if (this.hasElectronFavoritesApi() && item.contentId != null) {
await window.electron.dbRemoveFavorite(
item.contentId,
item.playlistId
);
} else {
const contentId = this.getXtreamItemId(item);
if (contentId != null) {
await this.xtreamDataSource.removeFavorite(
contentId,
item.playlistId
);
}
}
break;
case 'stalker': {
@@ -136,31 +149,36 @@ 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
: null);
const contentId = this.hasElectronFavoritesApi()
? (item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
: null))
: this.getXtreamItemId(item);
if (contentId == null) {
return;
}
await window.electron.dbAddFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
if (this.hasElectronFavoritesApi()) {
await window.electron.dbAddFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
} else {
await this.xtreamDataSource.addFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
}
}
private async addStalkerFavorite(
@@ -283,14 +301,14 @@ export class UnifiedFavoritesDataService {
options.playlistId &&
options.portalType === 'xtream'
) {
if (xtreamUpdates.length > 0 && window.electron) {
if (xtreamUpdates.length > 0 && this.hasElectronReorderApi()) {
await window.electron.dbReorderGlobalFavorites(xtreamUpdates);
}
return;
}
await Promise.all([
xtreamUpdates.length > 0 && window.electron
xtreamUpdates.length > 0 && this.hasElectronReorderApi()
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(items.map((item) => item.uid)),
@@ -346,16 +364,35 @@ export class UnifiedFavoritesDataService {
private async clearXtreamFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
if (!window.electron) {
if (this.hasElectronFavoritesApi()) {
await Promise.all(
items
.filter((item) => item.contentId != null)
.map((item) =>
window.electron!.dbRemoveFavorite(
item.contentId!,
item.playlistId
)
)
);
return;
}
await Promise.all(
items
.filter((item) => item.contentId != null)
.map((item) => ({
playlistId: item.playlistId,
contentId: this.getXtreamItemId(item),
}))
.filter(
(
item
): item is { playlistId: string; contentId: number } =>
item.contentId != null
)
.map((item) =>
window.electron!.dbRemoveFavorite(
item.contentId!,
this.xtreamDataSource.removeFavorite(
item.contentId,
item.playlistId
)
)
@@ -484,7 +521,19 @@ export class UnifiedFavoritesDataService {
}
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
if (!window.electron?.dbGetAllGlobalFavorites) return [];
if (!this.hasElectronFavoritesApi()) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((playlist) =>
Boolean(playlist.serverUrl)
)) {
results.push(...(await this.getPwaXtreamFavorites(meta)));
}
return results;
}
try {
const rows =
(await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
@@ -497,7 +546,11 @@ export class UnifiedFavoritesDataService {
private async getXtreamPlaylistFavorites(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
if (!window.electron) return [];
if (!this.hasElectronPlaylistFavoritesApi()) {
const meta = await this.getPlaylistMeta(playlistId);
return meta ? this.getPwaXtreamFavorites(meta) : [];
}
try {
const rows = await this.dbService.getFavorites(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
@@ -511,6 +564,122 @@ export class UnifiedFavoritesDataService {
}
}
private async getPwaXtreamFavorites(
meta: PlaylistMeta
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.xtreamDataSource.getFavorites(meta._id);
return rows.map((row, index) =>
this.mapPwaXtreamFavoriteItem(row, meta, index)
);
} catch {
return [];
}
}
private mapPwaXtreamFavoriteItem(
row: XtreamCollectionDataSourceItem,
meta: PlaylistMeta,
index: number
): UnifiedCollectionItem {
const record = row as unknown as Record<string, unknown>;
const contentType = this.getPwaXtreamContentType(record);
const xtreamId = this.getXtreamNumericValue(record, [
'xtream_id',
'stream_id',
'series_id',
'id',
]);
const contentId = this.getXtreamNumericValue(record, [
'id',
'stream_id',
'series_id',
'xtream_id',
]);
const title =
this.getXtreamString(record['title']) ??
this.getXtreamString(record['name']) ??
this.getXtreamString(record['stream_display_name']) ??
'Unknown';
const image =
this.getXtreamString(record['poster_url']) ??
this.getXtreamString(record['stream_icon']) ??
this.getXtreamString(record['cover']) ??
null;
return {
uid: buildXtreamCollectionUid(meta._id, contentType, xtreamId),
name: title,
contentType,
sourceType: 'xtream',
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'Xtream',
logo: contentType === 'live' ? image : null,
posterUrl: contentType !== 'live' ? image : null,
xtreamId,
categoryId: record['category_id'] as string | number,
tvgId: contentType === 'live' ? String(xtreamId) : undefined,
rating: this.getXtreamString(record['rating']),
addedAt:
normalizeStalkerDate(
this.getXtreamString(record['added_at']) ??
this.getXtreamString(record['added']) ??
''
) || new Date(0).toISOString(),
position: index,
contentId,
};
}
private hasElectronFavoritesApi(): boolean {
return typeof window.electron?.dbGetAllGlobalFavorites === 'function';
}
private hasElectronPlaylistFavoritesApi(): boolean {
return typeof window.electron?.dbGetFavorites === 'function';
}
private hasElectronReorderApi(): boolean {
return typeof window.electron?.dbReorderGlobalFavorites === 'function';
}
private getXtreamItemId(item: UnifiedCollectionItem): number | null {
const value = Number(item.contentId ?? item.xtreamId);
return Number.isFinite(value) && value > 0 ? value : null;
}
private getPwaXtreamContentType(
item: Record<string, unknown>
): UnifiedCollectionItem['contentType'] {
if (item['series_id'] != null) {
return 'series';
}
return xtreamContentType(
String(item['type'] ?? item['stream_type'] ?? 'movie')
);
}
private getXtreamString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
private getXtreamNumericValue(
item: Record<string, unknown>,
keys: string[]
): number {
for (const key of keys) {
const value = Number(item[key]);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
const ct = xtreamContentType(row.type);
return {
@@ -10,6 +10,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedRecentDataService } from './unified-recent-data.service';
import { XTREAM_COLLECTION_DATA_SOURCE } from './xtream-collection-data-source.token';
describe('UnifiedRecentDataService', () => {
let service: UnifiedRecentDataService;
@@ -33,6 +34,12 @@ describe('UnifiedRecentDataService', () => {
addRecentItem: jest.Mock;
getContentByXtreamId: jest.Mock;
};
let xtreamDataSource: {
addRecentItem: jest.Mock;
clearRecentItems: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
};
const playlistMeta = {
_id: 'm3u-1',
@@ -87,6 +94,12 @@ describe('UnifiedRecentDataService', () => {
];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {
dbGetRecentlyViewed: jest.fn(),
} as unknown as Window['electron'],
configurable: true,
});
store = {
select: jest.fn(() => of([playlistMeta])),
dispatch: jest.fn(),
@@ -136,6 +149,12 @@ describe('UnifiedRecentDataService', () => {
addRecentItem: jest.fn().mockResolvedValue(true),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
xtreamDataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearRecentItems: jest.fn().mockResolvedValue(undefined),
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
@@ -143,6 +162,10 @@ describe('UnifiedRecentDataService', () => {
{ provide: Store, useValue: store },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: DatabaseService, useValue: dbService },
{
provide: XTREAM_COLLECTION_DATA_SOURCE,
useValue: xtreamDataSource,
},
],
});
@@ -26,6 +26,10 @@ import {
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import { xtreamContentType } from './collection-helpers';
import {
XTREAM_COLLECTION_DATA_SOURCE,
XtreamCollectionDataSourceItem,
} from './xtream-collection-data-source.token';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
@@ -35,6 +39,7 @@ type PlaylistWithChannels = Playlist & {
export class UnifiedRecentDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly xtreamDataSource = inject(XTREAM_COLLECTION_DATA_SOURCE);
private readonly playlistsService = inject(PlaylistsService);
async getRecentItems(
@@ -55,10 +60,17 @@ export class UnifiedRecentDataService {
return;
}
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
);
if (this.hasElectronRecentApi()) {
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
);
} else {
await this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
);
}
return;
}
@@ -126,7 +138,20 @@ export class UnifiedRecentDataService {
const tasks: Promise<unknown>[] = [];
if (xtreamBatch.length > 0) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
if (this.hasElectronRecentApi()) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
} else {
tasks.push(
Promise.all(
xtreamBatch.map((item) =>
this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
)
)
)
);
}
}
for (const [playlistId, identities] of groupedByPlaylist) {
@@ -153,7 +178,11 @@ export class UnifiedRecentDataService {
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
await this.dbService.clearPlaylistRecentItems(playlistId);
if (this.hasElectronRecentApi()) {
await this.dbService.clearPlaylistRecentItems(playlistId);
} else {
await this.xtreamDataSource.clearRecentItems(playlistId);
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
);
@@ -161,7 +190,18 @@ export class UnifiedRecentDataService {
return;
}
await this.dbService.clearGlobalRecentlyViewed();
if (this.hasElectronRecentApi()) {
await this.dbService.clearGlobalRecentlyViewed();
} else {
const allMeta = await this.getAllMeta();
await Promise.all(
allMeta
.filter((playlist) => Boolean(playlist.serverUrl))
.map((playlist) =>
this.xtreamDataSource.clearRecentItems(playlist._id)
)
);
}
const playlists = (await firstValueFrom(
this.playlistsService.getAllPlaylists()
)) as Playlist[];
@@ -223,17 +263,29 @@ export class UnifiedRecentDataService {
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
? this.hasElectronRecentApi()
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
: item.xtreamId
: null);
if (contentId != null) {
await this.dbService.addRecentItem(contentId, item.playlistId);
if (this.hasElectronRecentApi()) {
await this.dbService.addRecentItem(
contentId,
item.playlistId
);
} else {
await this.xtreamDataSource.addRecentItem(
contentId,
item.playlistId
);
}
}
return {
@@ -309,6 +361,19 @@ export class UnifiedRecentDataService {
}
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
if (!this.hasElectronRecentApi()) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((playlist) =>
Boolean(playlist.serverUrl)
)) {
results.push(...(await this.getPwaXtreamRecent(meta)));
}
return results;
}
try {
const rows = await this.dbService.getGlobalRecentlyViewed();
return (rows || []).map((row) => ({
@@ -339,6 +404,11 @@ export class UnifiedRecentDataService {
private async getXtreamPlaylistRecent(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
if (!this.hasElectronRecentApi()) {
const meta = await this.getPlaylistMeta(playlistId);
return meta ? this.getPwaXtreamRecent(meta) : [];
}
try {
const rows = await this.dbService.getRecentItems(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
@@ -368,6 +438,101 @@ export class UnifiedRecentDataService {
}
}
private async getPwaXtreamRecent(
meta: PlaylistMeta
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.xtreamDataSource.getRecentItems(meta._id);
return rows.map((row) => this.mapPwaXtreamRecentItem(row, meta));
} catch {
return [];
}
}
private mapPwaXtreamRecentItem(
row: XtreamCollectionDataSourceItem,
meta: PlaylistMeta
): UnifiedCollectionItem {
const record = row as unknown as Record<string, unknown>;
const contentType = this.getPwaXtreamContentType(record);
const xtreamId = this.getXtreamNumericValue(record, [
'xtream_id',
'stream_id',
'series_id',
'id',
]);
const contentId = this.getXtreamNumericValue(record, [
'id',
'stream_id',
'series_id',
'xtream_id',
]);
const title =
this.getXtreamString(record['title']) ??
this.getXtreamString(record['name']) ??
this.getXtreamString(record['stream_display_name']) ??
'Unknown';
const image =
this.getXtreamString(record['poster_url']) ??
this.getXtreamString(record['stream_icon']) ??
this.getXtreamString(record['cover']) ??
null;
return {
uid: buildXtreamCollectionUid(meta._id, contentType, xtreamId),
name: title,
contentType,
sourceType: 'xtream',
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'Xtream',
logo: contentType === 'live' ? image : null,
posterUrl: contentType !== 'live' ? image : null,
xtreamId,
categoryId: record['category_id'] as string | number,
tvgId: contentType === 'live' ? String(xtreamId) : undefined,
contentId,
viewedAt: normalizeStalkerDate(
this.getXtreamString(record['viewed_at']) ?? ''
),
};
}
private hasElectronRecentApi(): boolean {
return typeof window.electron?.dbGetRecentlyViewed === 'function';
}
private getPwaXtreamContentType(
item: Record<string, unknown>
): UnifiedCollectionItem['contentType'] {
if (item['series_id'] != null) {
return 'series';
}
return xtreamContentType(
String(item['type'] ?? item['stream_type'] ?? 'movie')
);
}
private getXtreamString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
private getXtreamNumericValue(
item: Record<string, unknown>,
keys: string[]
): number {
for (const key of keys) {
const value = Number(item[key]);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}
private async getM3uGlobalRecent(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
@@ -0,0 +1,61 @@
import { InjectionToken } from '@angular/core';
export interface XtreamCollectionDataSourceItem {
readonly id?: number;
readonly category_id?: number | string;
readonly title?: string;
readonly name?: string;
readonly rating?: string;
readonly added?: string;
readonly added_at?: string;
readonly viewed_at?: string;
readonly poster_url?: string;
readonly backdrop_url?: string | null;
readonly xtream_id?: number;
readonly type?: string;
readonly stream_type?: 'live' | 'movie';
readonly stream_id?: number;
readonly stream_icon?: string;
readonly stream_display_name?: string;
readonly cover?: string;
readonly series_id?: number;
}
export interface XtreamCollectionDataSource {
getFavorites(playlistId: string): Promise<XtreamCollectionDataSourceItem[]>;
addFavorite(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void>;
removeFavorite(contentId: number, playlistId: string): Promise<void>;
getRecentItems(
playlistId: string
): Promise<XtreamCollectionDataSourceItem[]>;
addRecentItem(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void>;
removeRecentItem(contentId: number, playlistId: string): Promise<void>;
clearRecentItems(playlistId: string): Promise<void>;
}
const noopXtreamCollectionDataSource: XtreamCollectionDataSource = {
getFavorites: async () => [],
addFavorite: async () => undefined,
removeFavorite: async () => undefined,
getRecentItems: async () => [],
addRecentItem: async () => undefined,
removeRecentItem: async () => undefined,
clearRecentItems: async () => undefined,
};
export const XTREAM_COLLECTION_DATA_SOURCE =
new InjectionToken<XtreamCollectionDataSource>(
'XtreamCollectionDataSource',
{
providedIn: 'root',
factory: () => noopXtreamCollectionDataSource,
}
);
@@ -2,7 +2,11 @@ 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 { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -68,6 +72,12 @@ describe('DashboardDataService', () => {
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const xtreamDataSourceMock = {
getFavorites: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const playlistMock: Playlist = {
_id: 'm3u-1',
title: 'M3U Playlist',
@@ -114,7 +124,11 @@ describe('DashboardDataService', () => {
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
value: {
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
} as unknown as Window['electron'],
configurable: true,
});
playlistsLoadedSignal.set(true);
@@ -138,6 +152,14 @@ describe('DashboardDataService', () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
dbServiceMock.removeFromFavorites.mockClear();
dbServiceMock.removeRecentItem.mockClear();
xtreamDataSourceMock.getFavorites.mockClear();
xtreamDataSourceMock.getFavorites.mockResolvedValue([]);
xtreamDataSourceMock.getRecentItems.mockClear();
xtreamDataSourceMock.getRecentItems.mockResolvedValue([]);
xtreamDataSourceMock.removeFavorite.mockClear();
xtreamDataSourceMock.removeFavorite.mockResolvedValue(undefined);
xtreamDataSourceMock.removeRecentItem.mockClear();
xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined);
storeMock.dispatch.mockClear();
TestBed.configureTestingModule({
@@ -149,6 +171,10 @@ describe('DashboardDataService', () => {
provide: PlaylistsService,
useValue: playlistsServiceMock,
},
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSourceMock,
},
{
provide: TranslateService,
useValue: {
@@ -324,6 +350,48 @@ describe('DashboardDataService', () => {
).toHaveLength(1);
});
it('includes PWA Xtream favorites from the data source when Electron DB APIs are unavailable', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
xtreamDataSourceMock.getFavorites.mockResolvedValue([
{
id: 20203,
stream_id: 20203,
category_id: 206,
title: 'PWA Movie',
type: 'movie',
poster_url: 'https://example.com/pwa-movie.png',
backdrop_url: 'https://example.com/pwa-movie-backdrop.png',
xtream_id: 20203,
added: '1774298340',
},
]);
await service.reloadGlobalFavorites();
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 20203,
title: 'PWA Movie',
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
poster_url: 'https://example.com/pwa-movie.png',
backdrop_url: 'https://example.com/pwa-movie-backdrop.png',
source: 'xtream',
xtream_id: 20203,
}),
])
);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
@@ -419,6 +487,47 @@ describe('DashboardDataService', () => {
);
});
it('includes PWA Xtream recent items from the data source when Electron DB APIs are unavailable', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
xtreamDataSourceMock.getRecentItems.mockResolvedValue([
{
id: 20203,
stream_id: 20203,
category_id: 206,
title: 'PWA Recent Movie',
type: 'movie',
poster_url: 'https://example.com/pwa-recent.png',
backdrop_url: 'https://example.com/pwa-recent-backdrop.png',
xtream_id: 20203,
viewed_at: '2026-05-15T15:04:54.176Z',
},
]);
await service.reloadGlobalRecentItems();
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 20203,
title: 'PWA Recent Movie',
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
viewed_at: '2026-05-15T15:04:54.176Z',
source: 'xtream',
xtream_id: 20203,
}),
])
);
});
it('prioritizes recently used Xtream sources over newer imported M3U sources', 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,
@@ -38,6 +42,7 @@ import {
mapDbFavoriteToItem,
mapDbRecentlyAddedToItem,
mapDbRecentToItem,
normalizeActivityType,
toDateTimestamp,
toTimestamp,
} from './dashboard-mappers';
@@ -64,6 +69,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);
@@ -297,8 +303,8 @@ export class DashboardDataService {
this.globalRecentLoadingState.set(true);
}
if (!window.electron) {
this.xtreamGlobalRecentItems.set([]);
if (!this.hasElectronGlobalRecentApi()) {
await this.reloadPwaXtreamGlobalRecentItems();
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
return;
@@ -399,8 +405,9 @@ export class DashboardDataService {
}
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
if (!this.hasElectronGlobalFavoritesApi()) {
const favorites = await this.getPwaXtreamGlobalFavorites();
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
this.finishInitialGlobalFavoritesLoadIfReady();
return;
}
@@ -427,6 +434,184 @@ export class DashboardDataService {
this.finishInitialGlobalFavoritesLoadIfReady();
}
private async reloadPwaXtreamGlobalRecentItems(): Promise<void> {
const items = await this.getPwaXtreamGlobalRecentItems();
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(items));
}
private async getPwaXtreamGlobalFavorites(): Promise<
DashboardFavoriteItem[]
> {
const results: DashboardFavoriteItem[] = [];
for (const playlist of this.getXtreamPlaylists()) {
try {
const favorites = await this.xtreamDataSource.getFavorites(
playlist._id
);
results.push(
...favorites.map((item) =>
this.mapXtreamDataSourceFavorite(item, playlist)
)
);
} catch {
// Keep the dashboard usable if one PWA playlist cache is stale.
}
}
return results;
}
private async getPwaXtreamGlobalRecentItems(): Promise<GlobalRecentItem[]> {
const results: GlobalRecentItem[] = [];
for (const playlist of this.getXtreamPlaylists()) {
try {
const recentItems = await this.xtreamDataSource.getRecentItems(
playlist._id
);
results.push(
...recentItems.map((item) =>
this.mapXtreamDataSourceRecent(item, playlist)
)
);
} catch {
// Keep the dashboard usable if one PWA playlist cache is stale.
}
}
return results;
}
private getXtreamPlaylists(): PlaylistMeta[] {
return this.playlists().filter((playlist) => !!playlist.serverUrl);
}
private mapXtreamDataSourceFavorite(
item: XtreamContentItem,
playlist: PlaylistMeta
): DashboardFavoriteItem {
return {
...this.mapXtreamDataSourceActivity(item, playlist),
added_at: this.getXtreamDate(item, 'added_at'),
};
}
private mapXtreamDataSourceRecent(
item: XtreamContentItem,
playlist: PlaylistMeta
): GlobalRecentItem {
return {
...this.mapXtreamDataSourceActivity(item, playlist),
viewed_at: this.getXtreamDate(item, 'viewed_at'),
};
}
private mapXtreamDataSourceActivity(
item: XtreamContentItem,
playlist: PlaylistMeta
): Omit<DashboardFavoriteItem, 'added_at'> {
const record = item as unknown as Record<string, unknown>;
const id = this.getXtreamNumericValue(record, [
'id',
'stream_id',
'series_id',
'xtream_id',
]);
const xtreamId =
this.getXtreamNumericValue(record, [
'xtream_id',
'stream_id',
'series_id',
'id',
]) ?? id;
return {
id: id ?? String(record['stream_id'] ?? record['series_id'] ?? ''),
title: this.getXtreamTitle(record),
type: normalizeActivityType(this.getPwaXtreamActivityType(record)),
playlist_id: playlist._id,
playlist_name:
playlist.title ||
playlist.filename ||
this.translateText('WORKSPACE.DASHBOARD.XTREAM'),
category_id: (record['category_id'] as string | number) ?? '',
xtream_id:
xtreamId ??
String(record['stream_id'] ?? record['series_id'] ?? ''),
poster_url: this.getXtreamImage(record),
backdrop_url: this.getXtreamString(record['backdrop_url']),
source: 'xtream',
};
}
private getPwaXtreamActivityType(item: Record<string, unknown>): string {
if (item['series_id'] != null) {
return 'series';
}
return String(item['type'] ?? item['stream_type'] ?? 'movie');
}
private getXtreamTitle(item: Record<string, unknown>): string {
return (
this.getXtreamString(item['title']) ??
this.getXtreamString(item['name']) ??
this.getXtreamString(item['stream_display_name']) ??
this.translateText('WORKSPACE.DASHBOARD.UNKNOWN_TITLE')
);
}
private getXtreamImage(item: Record<string, unknown>): string {
return (
this.getXtreamString(item['poster_url']) ??
this.getXtreamString(item['stream_icon']) ??
this.getXtreamString(item['cover']) ??
''
);
}
private getXtreamDate(
item: XtreamContentItem,
key: 'added_at' | 'viewed_at'
): string {
const record = item as unknown as Record<string, unknown>;
const value =
record[key] ??
(key === 'added_at' ? record['added'] : undefined) ??
new Date(0).toISOString();
const timestamp = toTimestamp(value as string | number);
return timestamp ? new Date(timestamp).toISOString() : String(value);
}
private getXtreamString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
private getXtreamNumericValue(
item: Record<string, unknown>,
keys: string[]
): number | null {
for (const key of keys) {
const value = Number(item[key]);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
private hasElectronGlobalRecentApi(): boolean {
return typeof window.electron?.dbGetRecentlyViewed === 'function';
}
private hasElectronGlobalFavoritesApi(): boolean {
return typeof window.electron?.dbGetAllGlobalFavorites === 'function';
}
private async reloadM3uGlobalFavorites(): Promise<void> {
const m3uPlaylists = this.playlists().filter(
(playlist) =>
@@ -875,8 +1060,7 @@ export class DashboardDataService {
const matchedFavoriteId =
channelIdFavoritePosition !== undefined &&
(channelUrlFavoritePosition === undefined ||
channelIdFavoritePosition <=
channelUrlFavoritePosition)
channelIdFavoritePosition <= channelUrlFavoritePosition)
? channelId
: channelUrlFavoritePosition !== undefined
? channelUrl