feat(global-widgets): add M3U recently-viewed and favs support

- Introduced M3uRecentlyViewedItem interface to define structure for recently viewed items.
- Implemented utility functions to handle mapping and building of recently viewed items from playlists.
- Updated Playlist interface to use PlaylistRecentlyViewedItem type for recently viewed items.
- Enhanced PortalActivityItem interface to include 'm3u' as a possible source.
- Added UI components for displaying recently viewed channels, including clear functionality.
- Integrated recent view functionality into the channel list container and sidebar components.
- Updated dashboard data service to manage M3U favorites and recently viewed items.
- Added tests to ensure correct behavior of recently viewed items in the dashboard data service.
This commit is contained in:
4gray committed 2026-03-11 23:57:13 +01:00
1 parent 188ebb6fd9
commit 1e9f5d9d05
27 files changed
+1286 -227

No files matched your search

@@ -4,11 +4,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { of } from 'rxjs';
import { DatabaseService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { DatabaseService, PlaylistsService } from 'services';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
import { DashboardDataService } from './dashboard-data.service';
describe('DashboardDataService.matchesScope', () => {
describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsSignal = signal<PlaylistMeta[]>([
@@ -18,6 +18,22 @@ describe('DashboardDataService.matchesScope', () => {
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
recentlyViewed: [
{
source: 'm3u',
id: 'https://example.com/stream-1.m3u8',
url: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
channel_id: 'channel-1',
poster_url: 'https://example.com/logo-1.png',
tvg_id: 'tvg-1',
tvg_name: 'Channel One',
group_title: 'News',
category_id: 'live',
added_at: '2026-02-01T12:00:00.000Z',
},
],
},
{
_id: 'xtream-1',
@@ -36,19 +52,90 @@ describe('DashboardDataService.matchesScope', () => {
}
return signal(null);
}),
dispatch: jest.fn(),
};
const dbServiceMock = {
getGlobalRecentlyViewed: jest.fn(),
getGlobalFavorites: jest.fn(),
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
getGlobalFavorites: jest.fn().mockResolvedValue([]),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const playlistMock: Playlist = {
_id: 'm3u-1',
title: 'M3U Playlist',
count: 2,
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
playlist: {
items: [
{
id: 'channel-1',
name: 'Channel One',
url: 'https://example.com/stream-1.m3u8',
tvg: {
logo: 'https://example.com/logo-1.png',
id: 'tvg-1',
name: 'Channel One',
},
},
{
id: 'channel-2',
name: 'Channel Two',
url: 'https://example.com/stream-2.m3u8',
tvg: {
logo: 'https://example.com/logo-2.png',
id: 'tvg-2',
name: 'Channel Two',
},
},
],
},
} as Playlist;
const playlistsServiceMock = {
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
setFavorites: jest.fn().mockReturnValue(of(undefined)),
removeFromM3uRecentlyViewed: jest
.fn()
.mockReturnValue(
of({
...playlistMock,
recentlyViewed: [],
})
),
};
beforeEach(() => {
playlistsServiceMock.getPlaylistById.mockClear();
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
playlistsServiceMock.setFavorites.mockClear();
playlistsServiceMock.setFavorites.mockReturnValue(of(undefined));
playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear();
playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue(
of({
...playlistMock,
recentlyViewed: [],
})
);
dbServiceMock.getGlobalFavorites.mockClear();
dbServiceMock.getGlobalFavorites.mockResolvedValue([]);
dbServiceMock.getGlobalRecentlyViewed.mockClear();
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
dbServiceMock.removeFromFavorites.mockClear();
dbServiceMock.removeRecentItem.mockClear();
storeMock.dispatch.mockClear();
TestBed.configureTestingModule({
providers: [
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
},
{
provide: TranslateService,
useValue: {
@@ -102,4 +189,118 @@ describe('DashboardDataService.matchesScope', () => {
expect(result).toBe(true);
});
it('includes M3U favorites in global favorite items', async () => {
await service.reloadGlobalFavorites();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
source: 'm3u',
poster_url: 'https://example.com/logo-1.png',
}),
expect.objectContaining({
id: 'https://example.com/stream-2.m3u8',
title: 'Channel Two',
type: 'live',
playlist_id: 'm3u-1',
source: 'm3u',
poster_url: 'https://example.com/logo-2.png',
}),
])
);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
.globalFavoriteItems()
.find((item) => item.source === 'm3u');
expect(m3uItem).toBeDefined();
expect(service.getGlobalFavoriteLink(m3uItem!)).toEqual([
'/workspace',
'playlists',
'm3u-1',
'favorites',
]);
});
it('removes M3U favorites via PlaylistsService', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
.globalFavoriteItems()
.find((item) => item.id === 'channel-1');
expect(m3uItem).toBeDefined();
await service.removeGlobalFavorite(m3uItem!);
expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith(
'm3u-1',
['https://example.com/stream-2.m3u8']
);
});
it('matches M3U provider scope', () => {
const result = service.matchesScope('m3u-1', 'm3u', {
providers: ['m3u'],
playlistIds: [],
});
expect(result).toBe(true);
});
it('includes M3U recently viewed items in global recent items', () => {
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
source: 'm3u',
xtream_id: 'https://example.com/stream-1.m3u8',
}),
])
);
});
it('builds the M3U recent route with navigation state', () => {
const m3uItem = service
.globalRecentItems()
.find((item) => item.source === 'm3u');
expect(m3uItem).toBeDefined();
expect(service.getRecentItemLink(m3uItem!)).toEqual([
'/workspace',
'playlists',
'm3u-1',
'recent',
]);
expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({
openRecentChannelUrl: 'https://example.com/stream-1.m3u8',
});
});
it('removes M3U recently viewed via PlaylistsService', async () => {
const m3uItem = service
.globalRecentItems()
.find((item) => item.source === 'm3u');
expect(m3uItem).toBeDefined();
await service.removeGlobalRecentItem(m3uItem!);
expect(
playlistsServiceMock.removeFromM3uRecentlyViewed
).toHaveBeenCalledWith(
'm3u-1',
'https://example.com/stream-1.m3u8'
);
});
});
@@ -7,9 +7,12 @@ import {
selectActivePlaylist,
selectAllPlaylistsMeta,
} from 'm3u-state';
import { startWith } from 'rxjs';
import { DatabaseService } from 'services';
import { firstValueFrom, startWith } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
buildPlaylistRecentItems,
Channel,
Playlist,
PlaylistMeta,
PortalActivityType,
PortalFavoriteItem,
@@ -18,7 +21,6 @@ import {
} from 'shared-interfaces';
import {
buildStalkerFavoriteItems,
buildStalkerRecentItems,
getActivityTypeLabelKey,
mapDbFavoriteToItem,
mapDbRecentToItem,
@@ -46,6 +48,7 @@ export type DashboardFavoriteItem = PortalFavoriteItem;
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
@@ -57,19 +60,27 @@ export class DashboardDataService {
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
[]
);
private readonly m3uGlobalFavorites = signal<DashboardFavoriteItem[]>([]);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
readonly stalkerGlobalRecentItems = computed<GlobalRecentItem[]>(() => {
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
() => {
this.languageTick();
return buildStalkerRecentItems(
this.playlists(),
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
);
});
return buildPlaylistRecentItems(this.playlists(), {
stalker: this.translateText(
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
),
m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'),
});
}
);
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
[...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()]
[
...this.xtreamGlobalRecentItems(),
...this.playlistBackedGlobalRecentItems(),
]
.sort(
(a, b) =>
toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at)
@@ -86,15 +97,19 @@ export class DashboardDataService {
});
readonly globalFavoriteItems = computed(() =>
[...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()]
[
...this.xtreamGlobalFavorites(),
...this.m3uGlobalFavorites(),
...this.stalkerGlobalFavorites(),
]
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at))
.slice(0, 200)
);
constructor() {
void this.reloadGlobalFavorites();
if (window.electron) {
void this.reloadGlobalRecentItems();
void this.reloadGlobalFavorites();
}
}
@@ -142,6 +157,13 @@ export class DashboardDataService {
}
async reloadGlobalFavorites(): Promise<void> {
const xtreamReload = this.reloadXtreamGlobalFavorites();
const m3uReload = this.reloadM3uGlobalFavorites();
await Promise.all([xtreamReload, m3uReload]);
}
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
return;
@@ -162,6 +184,38 @@ export class DashboardDataService {
}
}
private async reloadM3uGlobalFavorites(): Promise<void> {
const m3uPlaylists = this.playlists().filter(
(playlist) =>
!playlist.serverUrl &&
!playlist.macAddress &&
Array.isArray(playlist.favorites) &&
playlist.favorites.some(
(favorite): favorite is string =>
typeof favorite === 'string' && favorite.trim().length > 0
)
);
if (m3uPlaylists.length === 0) {
this.ngZone.run(() => this.m3uGlobalFavorites.set([]));
return;
}
const favoriteItems = await Promise.all(
m3uPlaylists.map((playlist) =>
this.loadM3uPlaylistFavorites(playlist).catch(
(): DashboardFavoriteItem[] => []
)
)
);
const flattenedFavorites = favoriteItems.reduce(
(acc, items) => [...acc, ...items],
[] as DashboardFavoriteItem[]
);
this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites));
}
isTypeInKind(
type: PortalActivityType,
kind: DashboardContentKind
@@ -256,6 +310,9 @@ export class DashboardDataService {
if (item.source === 'xtream') {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (item.source === 'm3u') {
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
}
@@ -285,36 +342,37 @@ export class DashboardDataService {
}
if (item.source === 'stalker') {
const playlist = this.playlists().find(
(p) => p._id === item.playlist_id
);
if (!playlist) return;
const currentRecent = Array.isArray(
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
)
? [
...((playlist as { recentlyViewed?: unknown[] })
.recentlyViewed ?? []),
]
: [];
const itemMatchStr = String(item.id);
const filteredRecent = currentRecent.filter(
(raw, index) =>
!stalkerItemMatchesId(
raw,
itemMatchStr,
playlist._id,
index
)
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromPortalRecentlyViewed(
item.playlist_id,
item.id
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
recentlyViewed: filteredRecent,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as unknown as PlaylistMeta,
}) as any
);
return;
}
if (item.source === 'm3u') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
item.playlist_id,
String(item.xtream_id ?? item.id)
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as unknown as PlaylistMeta,
}) as any
);
@@ -330,6 +388,9 @@ export class DashboardDataService {
if (item.source === 'xtream') {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (item.source === 'm3u') {
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
}
@@ -388,6 +449,29 @@ export class DashboardDataService {
}) as any
);
}
if (item.source === 'm3u') {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlist_id)
);
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string'
)
: [];
const filteredFavorites = currentFavorites.filter(
(favorite) => favorite !== String(item.id)
);
await firstValueFrom(
this.playlistsService.setFavorites(
item.playlist_id,
filteredFavorites
)
);
await this.reloadGlobalFavorites();
}
}
formatTimestamp(value?: string | number): string {
@@ -422,6 +506,73 @@ export class DashboardDataService {
return fallback;
}
private async loadM3uPlaylistFavorites(
playlistMeta: PlaylistMeta
): Promise<DashboardFavoriteItem[]> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistMeta._id)
)) as Playlist & {
playlist?: {
items?: Channel[];
};
};
const favorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string' && favorite.trim().length > 0
)
: [];
if (favorites.length === 0) {
return [];
}
const items: Channel[] = Array.isArray(playlist?.playlist?.items)
? (playlist.playlist.items as Channel[])
: [];
const fallbackTimestamp =
this.getM3uFavoriteTimestamp(playlistMeta) ??
new Date(0).toISOString();
return items.reduce<DashboardFavoriteItem[]>((acc, channel) => {
const channelId = String(channel.id ?? '').trim();
const channelUrl = String(channel.url ?? '').trim();
const matchedFavoriteId = favorites.find(
(favorite) =>
favorite === channelId || favorite === channelUrl
);
if (!matchedFavoriteId) {
return acc;
}
acc.push({
id: matchedFavoriteId,
title: channel.name?.trim() || channel.tvg?.name || channelId,
type: 'live',
playlist_id: playlistMeta._id,
playlist_name:
playlistMeta.title || playlistMeta.filename || 'M3U',
added_at: fallbackTimestamp,
category_id: 'live',
xtream_id: matchedFavoriteId,
poster_url: channel.tvg?.logo || undefined,
source: 'm3u',
});
return acc;
}, []);
}
private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null {
if (playlist.updateDate) {
return new Date(playlist.updateDate).toISOString();
}
return typeof playlist.importDate === 'string'
? playlist.importDate
: null;
}
private getRecentTimestamp(item: PlaylistMeta): number {
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
}
@@ -25,6 +25,15 @@ export function getRecentItemNavigation(
};
}
if (item.source === 'm3u') {
return {
link: ['/workspace', 'playlists', item.playlist_id, 'recent'],
state: {
openRecentChannelUrl: String(item.xtream_id ?? item.id ?? ''),
},
};
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
@@ -52,6 +61,12 @@ export function getGlobalFavoriteNavigation(
};
}
if (item.source === 'm3u') {
return {
link: ['/workspace', 'playlists', item.playlist_id, 'favorites'],
};
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,