Merge origin/master into PR 955

This commit is contained in:
4gray committed 2026-05-22 01:34:56 +03:00
commit 2a769ad44e
89 files changed
+6476 -465

No files matched your search

@@ -2,7 +2,10 @@ 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 {
@@ -11,12 +14,31 @@ import {
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { DashboardDataService } from './dashboard-data.service';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DashboardDataService,
DashboardFavoriteItem,
GlobalRecentItem,
} from './dashboard-data.service';
describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsLoadedSignal = signal(true);
const createPendingItems = <T = never>() => {
let resolvePending: (items: T[]) => void = () => {
throw new Error('Pending item promise resolved before init');
};
const promise = new Promise<T[]>((resolve) => {
resolvePending = resolve;
});
return {
promise,
resolve: resolvePending,
};
};
const createDefaultPlaylists = (): PlaylistMeta[] => [
{
_id: 'm3u-1',
@@ -73,6 +95,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',
@@ -153,6 +181,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();
playbackPositionsMock.getAllPlaybackPositions.mockClear();
@@ -163,6 +199,10 @@ describe('DashboardDataService', () => {
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSourceMock,
},
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
@@ -348,6 +388,139 @@ 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('loads PWA Xtream favorites for multiple playlists in parallel', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://one.example.com',
},
{
_id: 'xtream-2',
title: 'Xtream Two',
serverUrl: 'https://two.example.com',
},
]);
const firstFavorites = createPendingItems();
xtreamDataSourceMock.getFavorites.mockImplementation((playlistId) =>
playlistId === 'xtream-1'
? firstFavorites.promise
: Promise.resolve([])
);
const reload = service.reloadGlobalFavorites();
await Promise.resolve();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-2'
);
firstFavorites.resolve([]);
await reload;
});
it('uses the epoch fallback for PWA Xtream favorites with empty added dates', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSourceMock.getFavorites.mockResolvedValue([
{
id: 52,
category_id: 12,
title: 'Undated Movie',
rating: '8.0',
added: '',
poster_url: 'https://example.com/movie.png',
xtream_id: 5002,
type: 'movie',
},
]);
await service.reloadGlobalFavorites();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 52,
added_at: new Date(0).toISOString(),
}),
])
);
});
it('does not load Stalker playlists through the PWA Xtream favorites path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
]);
await service.reloadGlobalFavorites();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1);
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
@@ -428,6 +601,35 @@ describe('DashboardDataService', () => {
);
});
it('removes PWA Xtream favorites through the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
const item = {
id: 51,
category_id: 12,
title: 'Action Movie',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
} satisfies DashboardFavoriteItem;
await service.removeGlobalFavorite(item);
expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith(
51,
'xtream-1'
);
expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
});
it('includes M3U recently viewed items in global recent items', () => {
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
@@ -910,6 +1112,110 @@ 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('loads PWA Xtream recent items for multiple playlists in parallel', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://one.example.com',
},
{
_id: 'xtream-2',
title: 'Xtream Two',
serverUrl: 'https://two.example.com',
},
]);
const firstRecentItems = createPendingItems();
xtreamDataSourceMock.getRecentItems.mockImplementation((playlistId) =>
playlistId === 'xtream-1'
? firstRecentItems.promise
: Promise.resolve([])
);
const reload = service.reloadGlobalRecentItems();
await Promise.resolve();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-2'
);
firstRecentItems.resolve([]);
await reload;
});
it('does not load Stalker playlists through the PWA Xtream recent path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
]);
await service.reloadGlobalRecentItems();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1);
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
playlistsSignal.set([
{
@@ -1118,4 +1424,34 @@ describe('DashboardDataService', () => {
playlistsServiceMock.removeFromM3uRecentlyViewed
).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8');
});
it('removes PWA Xtream recently viewed through the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
const item = {
id: 91,
category_id: 18,
title: 'Recent Movie',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
} satisfies GlobalRecentItem;
await service.removeGlobalRecentItem(item);
expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith(
91,
'xtream-1'
);
expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
});
@@ -20,6 +20,10 @@ import {
GlobalRecentlyAddedKind,
PlaylistsService,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
import {
buildPlaylistRecentItems,
Channel,
@@ -99,6 +103,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);
@@ -475,7 +480,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;
@@ -577,7 +585,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;
}
@@ -599,6 +608,95 @@ export class DashboardDataService {
}
}
private async loadPwaXtreamGlobalRecentItems(): Promise<
GlobalRecentItem[]
> {
const nested = await Promise.all(
this.getXtreamPlaylists().map(async (playlist) => {
const rows = await this.xtreamDataSource.getRecentItems(
playlist._id
);
return rows.map((item) =>
this.mapPwaXtreamRecentItem(item, playlist)
);
})
);
return nested.reduce<GlobalRecentItem[]>(
(items, playlistItems) => items.concat(playlistItems),
[]
);
}
private async loadPwaXtreamGlobalFavorites(): Promise<
DashboardFavoriteItem[]
> {
const nested = await Promise.all(
this.getXtreamPlaylists().map(async (playlist) => {
const rows = await this.xtreamDataSource.getFavorites(
playlist._id
);
return rows.map((item) =>
this.mapPwaXtreamFavoriteItem(item, playlist)
);
})
);
return nested.reduce<DashboardFavoriteItem[]>(
(items, playlistItems) => items.concat(playlistItems),
[]
);
}
private getXtreamPlaylists(): PlaylistMeta[] {
return this.playlists().filter(
(playlist) => !!playlist.serverUrl && !playlist.macAddress
);
}
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();
@@ -762,10 +860,17 @@ export class DashboardDataService {
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
if (window.electron) {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
} else {
await this.xtreamDataSource.removeRecentItem(
item.id as number,
item.playlist_id
);
}
await this.reloadGlobalRecentItems();
return;
}
@@ -916,10 +1021,17 @@ export class DashboardDataService {
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
if (window.electron) {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
} else {
await this.xtreamDataSource.removeFavorite(
item.id as number,
item.playlist_id
);
}
await this.reloadGlobalFavorites();
return;
}
@@ -1052,8 +1164,7 @@ export class DashboardDataService {
const matchedFavoriteId =
channelIdFavoritePosition !== undefined &&
(channelUrlFavoritePosition === undefined ||
channelIdFavoritePosition <=
channelUrlFavoritePosition)
channelIdFavoritePosition <= channelUrlFavoritePosition)
? channelId
: channelUrlFavoritePosition !== undefined
? channelUrl