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

@@ -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