mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
fix(workspace): surface PWA Xtream collections
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user