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

@@ -32,10 +32,12 @@ import {
FavoritesActions,
PlaylistActions,
selectActive,
selectActivePlaylistId,
selectChannels,
selectCurrentEpgProgram,
} from 'm3u-state';
import {
firstValueFrom,
Observable,
Subscription,
combineLatest,
@@ -64,7 +66,9 @@ import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
EpgProgram,
M3uRecentlyViewedItem,
PLAYLIST_PARSE_BY_URL,
PlaylistMeta,
STORE_KEY,
Settings,
SidebarView,
@@ -106,6 +110,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
/** Active selected channel */
readonly activeChannel = this.store.selectSignal(selectActive);
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
readonly channels = this.store.selectSignal(selectChannels);
/** Channels list */
readonly channels$: Observable<Channel[]> = this.store.select(
@@ -115,7 +121,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
/** Current epg program */
readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram);
/** Active M3U view (all, groups, favorites) */
/** Active M3U view (all, groups, favorites, recent) */
readonly activeView = toSignal(
this.activatedRoute.params.pipe(
map((params) => {
@@ -153,6 +159,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private statusSubscription?: Subscription;
private routeSubscription?: Subscription;
private lastKnownVolume = 1;
private lastRecordedRecentKey = '';
/** Info overlay component reference for manual triggering */
readonly infoOverlay = viewChild(InfoOverlayComponent);
@@ -178,6 +185,62 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
showCaptions: this.settingsStore.showCaptions(),
};
});
effect(() => {
const playlistId = this.activePlaylistId();
const activeChannel = this.activeChannel();
const currentUrl = this.router.url;
if (
!playlistId ||
!activeChannel?.url ||
currentUrl.includes('/workspace/global-favorites')
) {
return;
}
const nextKey = `${playlistId}::${activeChannel.url}`;
if (this.lastRecordedRecentKey === nextKey) {
return;
}
this.lastRecordedRecentKey = nextKey;
void this.persistRecentlyViewedChannel(playlistId, activeChannel);
});
effect(() => {
const currentView = this.activeView();
const channels = this.channels();
const activeChannel = this.activeChannel();
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const targetUrl =
typeof state?.openRecentChannelUrl === 'string'
? state.openRecentChannelUrl.trim()
: '';
if (currentView !== 'recent' || !targetUrl || channels.length === 0) {
return;
}
if (activeChannel?.url === targetUrl) {
this.clearConsumedRecentChannelState();
return;
}
const matchedChannel = channels.find(
(channel) => channel.url === targetUrl
);
if (!matchedChannel) {
return;
}
this.store.dispatch(
ChannelActions.setActiveChannel({ channel: matchedChannel })
);
this.clearConsumedRecentChannelState();
});
}
/**
@@ -410,6 +473,59 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
});
}
private async persistRecentlyViewedChannel(
playlistId: string,
channel: Channel
): Promise<void> {
const recentlyViewedItem: M3uRecentlyViewedItem = {
source: 'm3u',
id: channel.url,
url: channel.url,
title: channel.name?.trim() || channel.tvg?.name || channel.url,
channel_id: channel.id,
poster_url: channel.tvg?.logo || undefined,
tvg_id: channel.tvg?.id || undefined,
tvg_name: channel.tvg?.name || undefined,
group_title: channel.group?.title || undefined,
category_id: 'live',
added_at: new Date().toISOString(),
};
const updatedPlaylist = await firstValueFrom(
this.playlistsService.addM3uRecentlyViewed(
playlistId,
recentlyViewedItem
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
private clearConsumedRecentChannelState(): void {
const historyState = (window.history.state ?? {}) as Record<
string,
unknown
>;
if (!historyState['openRecentChannelUrl']) {
return;
}
try {
const nextState = { ...historyState };
delete nextState['openRecentChannelUrl'];
window.history.replaceState(nextState, document.title);
} catch {
// no-op
}
}
/**
* Opens the overlay with multi EPG view
*/
@@ -32,7 +32,7 @@ import { PortalStatus, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
type PlaylistRouteProvider = 'playlists' | 'xtreams' | 'stalker';
type PlaylistSectionProvider = Exclude<PlaylistRouteProvider, 'playlists'>;
type PlaylistSectionProvider = PlaylistRouteProvider;
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
interface PlaylistRouteContext {
@@ -83,6 +83,7 @@ const STALKER_SECTIONS = [
'search',
'downloads',
] as const;
const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const;
@Component({
selector: 'app-playlist-switcher',
@@ -292,11 +293,7 @@ export class PlaylistSwitcherComponent {
private rememberSectionFromRoute(url: string): void {
const routeContext = this.getRouteContext(url);
if (
!routeContext.provider ||
routeContext.provider === 'playlists' ||
!routeContext.section
) {
if (!routeContext.provider || !routeContext.section) {
return;
}
@@ -502,7 +499,12 @@ export class PlaylistSwitcherComponent {
}
if (provider === 'playlists') {
return [...prefix, 'playlists', playlistId];
const section = this.resolveTargetSection(
provider,
routeContext.section,
playlistId
);
return [...prefix, 'playlists', playlistId, section];
}
if (!this.supportsSectionNavigation(provider)) {
@@ -551,6 +553,10 @@ export class PlaylistSwitcherComponent {
return normalizedProviderSection;
}
if (provider === 'playlists') {
return 'all';
}
return 'vod';
}
@@ -582,6 +588,14 @@ export class PlaylistSwitcherComponent {
: null;
}
if (provider === 'playlists') {
return M3U_SECTIONS.includes(
section as (typeof M3U_SECTIONS)[number]
)
? section
: null;
}
if (section === 'live') {
return 'itv';
}
@@ -620,6 +634,9 @@ export class PlaylistSwitcherComponent {
parsedProviders !== null
) {
const candidate = parsedProviders as Record<string, unknown>;
if (typeof candidate['playlists'] === 'string') {
providers.playlists = candidate['playlists'];
}
if (typeof candidate['xtreams'] === 'string') {
providers.xtreams = candidate['xtreams'];
}
@@ -628,8 +645,11 @@ export class PlaylistSwitcherComponent {
}
} else {
// Backward compatibility with previous format:
// { xtreams: "...", stalker: "..." }
// { playlists: "...", xtreams: "...", stalker: "..." }
const legacy = parsed as Record<string, unknown>;
if (typeof legacy['playlists'] === 'string') {
providers.playlists = legacy['playlists'];
}
if (typeof legacy['xtreams'] === 'string') {
providers.xtreams = legacy['xtreams'];
}
@@ -657,7 +677,9 @@ export class PlaylistSwitcherComponent {
const section = row['section'];
const updatedAt = row['updatedAt'];
const isProviderValid =
provider === 'xtreams' || provider === 'stalker';
provider === 'playlists' ||
provider === 'xtreams' ||
provider === 'stalker';
if (!isProviderValid || typeof section !== 'string') {
return;
}
@@ -31,7 +31,7 @@ export interface FavoriteLayoutItem {
readonly playlist_id?: string;
readonly playlist_name?: string;
readonly poster_url?: string;
readonly source?: 'xtream' | 'stalker';
readonly source?: 'xtream' | 'stalker' | 'm3u';
readonly stalker_item?: StalkerPortalItem;
readonly stream_icon?: string;
readonly title?: string;
@@ -94,6 +94,13 @@ describe('buildPortalRailLinks', () => {
exact: true,
section: 'groups',
},
{
icon: 'history',
tooltip: 'Recently viewed (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'recent'],
exact: true,
section: 'recent',
},
{
icon: 'star',
tooltip: 'Favorites (this playlist)',
@@ -187,6 +187,13 @@ export function buildPortalRailLinks(
exact: true,
section: 'groups',
},
{
icon: 'history',
tooltip: 'Recently viewed (this playlist)',
path: [...root, 'recent'],
exact: true,
section: 'recent',
},
{
icon: 'star',
tooltip: 'Favorites (this playlist)',
@@ -9,11 +9,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { firstValueFrom, pipe, switchMap, tap } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
buildPlaylistRecentItems,
Playlist,
PortalRecentItem,
} from 'shared-interfaces';
@@ -148,61 +144,14 @@ export const withRecentItems = function () {
const playlists = (await firstValueFrom(
playlistsService.getAllPlaylists()
)) as Playlist[];
const stalkerItems: RecentlyViewedItem[] = playlists
.filter((playlist) => Boolean(playlist.macAddress))
.reduce(
(acc: RecentlyViewedItem[], playlist) => {
const recent = Array.isArray(
playlist.recentlyViewed
)
? playlist.recentlyViewed
: [];
const mapped = recent.map(
(rawItem, index: number) => {
const item = (rawItem ??
{}) as Record<string, unknown>;
const id = extractStalkerItemId(
item,
String(playlist._id),
index
);
return {
id,
title: extractStalkerItemTitle(
item
),
type: extractStalkerItemType(
item
),
poster_url:
extractStalkerItemPoster(
item
),
content_id: id,
playlist_id: String(
playlist._id
),
playlist_name:
playlist.title ||
'Stalker Portal',
viewed_at: normalizeStalkerDate(
item['added_at']
),
xtream_id: id,
category_id: String(
item['category_id'] ?? ''
),
source: 'stalker' as const,
stalker_item: rawItem,
};
}
);
acc.push(...mapped);
return acc;
},
[]
);
const playlistBackedItems =
buildPlaylistRecentItems(playlists, {
stalker: 'Stalker Portal',
m3u: 'M3U',
}).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
const normalizedXtream: RecentlyViewedItem[] = (
xtreamItems || []
@@ -222,7 +171,7 @@ export const withRecentItems = function () {
const items = [
...normalizedXtream,
...stalkerItems,
...playlistBackedItems,
].sort(
(a, b) =>
new Date(b.viewed_at).getTime() -
@@ -247,12 +196,14 @@ export const withRecentItems = function () {
)) as Playlist[];
await Promise.all(
playlists
.filter((playlist) =>
Boolean(playlist.macAddress)
.filter(
(playlist) =>
Boolean(playlist.macAddress) ||
!playlist.serverUrl
)
.map((playlist) =>
firstValueFrom(
playlistsService.clearPortalRecentlyViewed(
playlistsService.clearPlaylistRecentlyViewed(
playlist._id
)
)
@@ -32,7 +32,7 @@
<div class="global-recent-subheader-left">
<div class="global-recent-note">
<mat-icon>info_outline</mat-icon>
<span>Includes Xtream and Stalker items</span>
<span>Includes Xtream, Stalker, and M3U items</span>
</div>
<div class="global-recent-filters">
<mat-checkbox
@@ -414,6 +414,21 @@ export class RecentlyViewedComponent {
return;
}
if (source === 'm3u' && this.isGlobal) {
this.dialogRef?.close();
this.router.navigate(
['/workspace', 'playlists', item.playlist_id, 'recent'],
{
state: {
openRecentChannelUrl: String(
item.xtream_id ?? item.id ?? ''
),
},
}
);
return;
}
const type = item.type === 'movie' ? 'vod' : item.type;
this.xtreamStore.setSelectedContentType(type);
@@ -464,6 +479,13 @@ export class RecentlyViewedComponent {
item.id
)
);
} else if (item.source === 'm3u') {
await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
item.playlist_id,
String(item.xtream_id ?? item.id)
)
);
} else {
await this.dbService.removeRecentItem(
Number(item.id),
+167 -102
View File
@@ -21,10 +21,15 @@ import {
import {
Channel,
DbStores,
extractStalkerItemId,
isM3uRecentlyViewedItem,
M3uRecentlyViewedItem,
Playlist,
PlaylistMeta,
PlaylistRecentlyViewedItem,
PlaylistUpdateState,
StalkerPortalItem,
normalizeStalkerDate,
} from 'shared-interfaces';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
@@ -35,11 +40,6 @@ type PortalFavoriteItem = StalkerPortalItem & {
[key: string]: unknown;
};
type PortalRecentlyViewedItem = StalkerPortalItem & {
id?: string | number;
title?: string;
};
type PlaylistRawItem = {
raw?: string;
};
@@ -654,73 +654,102 @@ export class PlaylistsService {
.pipe(map(() => undefined));
}
getPortalRecentlyViewed(portalId: string) {
if (!portalId) {
throw new Error('Portal ID is required');
private normalizePortalRecentIdentity(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) {
return '';
}
return this.getPlaylistById(portalId).pipe(
return raw.includes(':') ? raw.split(':')[0] : raw;
}
private getPlaylistRecentIdentity(item: PlaylistRecentlyViewedItem): string {
if (isM3uRecentlyViewedItem(item)) {
return String(item.url ?? item.id ?? '').trim();
}
return this.normalizePortalRecentIdentity(
extractStalkerItemId(item ?? {})
);
}
private matchesPlaylistRecentIdentity(
item: PlaylistRecentlyViewedItem,
expectedIdentity: string | number
): boolean {
const expectedRaw = String(expectedIdentity ?? '').trim();
if (!expectedRaw) {
return false;
}
if (isM3uRecentlyViewedItem(item)) {
return this.getPlaylistRecentIdentity(item) === expectedRaw;
}
return (
this.getPlaylistRecentIdentity(item) ===
this.normalizePortalRecentIdentity(expectedRaw)
);
}
private sortPlaylistRecentItems(
items: PlaylistRecentlyViewedItem[]
): PlaylistRecentlyViewedItem[] {
return [...items].sort(
(a, b) =>
new Date(normalizeStalkerDate(b.added_at ?? '')).getTime() -
new Date(normalizeStalkerDate(a.added_at ?? '')).getTime()
);
}
getPlaylistRecentlyViewed(playlistId: string) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
map((item) => {
if (!item || !item.recentlyViewed) return [];
return item.recentlyViewed as PortalRecentlyViewedItem[];
if (!item || !Array.isArray(item.recentlyViewed)) {
return [];
}
return item.recentlyViewed as PlaylistRecentlyViewedItem[];
}),
map((items) =>
items.sort(
(a, b) =>
new Date(b.added_at ?? '').getTime() -
new Date(a.added_at ?? '').getTime()
)
)
map((items) => this.sortPlaylistRecentItems(items))
);
}
addPortalRecentlyViewed(
portalId: string,
item: PortalRecentlyViewedItem & { id: string | number; title: string }
addPlaylistRecentlyViewed(
playlistId: string,
item: PlaylistRecentlyViewedItem
) {
if (!portalId) {
throw new Error('Portal ID is required');
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nowIso = new Date().toISOString();
const recentItems =
(portal.recentlyViewed as PortalRecentlyViewedItem[]) ?? [];
const existingIndex = recentItems.findIndex(
(recentItem) => String(recentItem?.id) === String(item.id)
const recentItems = Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
const existingIndex = recentItems.findIndex((recentItem) =>
this.matchesPlaylistRecentIdentity(
recentItem,
this.getPlaylistRecentIdentity(item)
)
);
const existingItem =
existingIndex >= 0 ? recentItems[existingIndex] : null;
const nextItem: PlaylistRecentlyViewedItem = {
...(existingItem ?? {}),
...item,
added_at: nowIso,
};
const remainingItems = recentItems.filter(
(_, index) => index !== existingIndex
);
if (existingIndex >= 0) {
const updatedRecentItems = [...recentItems];
updatedRecentItems[existingIndex] = {
...updatedRecentItems[existingIndex],
...item,
added_at: nowIso,
};
const nextPlaylist: Playlist = {
...portal,
recentlyViewed: updatedRecentItems,
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(
DbStores.Playlists,
nextPlaylist
);
}
const nextPlaylist: Playlist = {
...portal,
recentlyViewed: [
...recentItems,
{
...item,
added_at: item.added_at ?? nowIso,
},
],
...playlist,
recentlyViewed: [nextItem, ...remainingItems],
};
if (this.isElectronStorageAvailable) {
@@ -732,49 +761,47 @@ export class PlaylistsService {
);
}
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
const normalizePortalItemId = (value: unknown): string => {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
};
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const expectedId = String(id);
const expectedNormalized = normalizePortalItemId(id);
const nextPlaylist: Playlist = {
...portal,
recentlyViewed: (
portal.recentlyViewed as PortalRecentlyViewedItem[]
)?.filter((item) => {
const itemId = String(item?.id ?? '');
const itemNormalized = normalizePortalItemId(itemId);
return (
itemId !== expectedId &&
itemNormalized !== expectedNormalized
);
}),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
clearPortalRecentlyViewed(portalId: string) {
if (!portalId) {
throw new Error('Portal ID is required');
removeFromPlaylistRecentlyViewed(
playlistId: string,
identity: string | number
) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...portal,
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!this.matchesPlaylistRecentIdentity(
item,
identity
)
),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
clearPlaylistRecentlyViewed(playlistId: string) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: [],
};
@@ -786,4 +813,42 @@ export class PlaylistsService {
})
);
}
getPortalRecentlyViewed(portalId: string) {
return this.getPlaylistRecentlyViewed(portalId).pipe(
map((items) =>
items.filter(
(item): item is StalkerPortalItem =>
!isM3uRecentlyViewedItem(item)
)
)
);
}
addPortalRecentlyViewed(
portalId: string,
item: StalkerPortalItem & { id: string | number; title: string }
) {
return this.addPlaylistRecentlyViewed(portalId, item);
}
addM3uRecentlyViewed(playlistId: string, item: M3uRecentlyViewedItem) {
return this.addPlaylistRecentlyViewed(playlistId, item);
}
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
return this.removeFromPlaylistRecentlyViewed(portalId, id);
}
removeFromM3uRecentlyViewed(playlistId: string, channelUrl: string) {
return this.removeFromPlaylistRecentlyViewed(playlistId, channelUrl);
}
clearPortalRecentlyViewed(portalId: string) {
return this.clearPlaylistRecentlyViewed(portalId);
}
clearM3uRecentlyViewed(playlistId: string) {
return this.clearPlaylistRecentlyViewed(playlistId);
}
}
+2
View File
@@ -12,6 +12,8 @@ export * from './lib/language.enum';
export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-meta.type';
export * from './lib/playlist-recently-viewed.interface';
export * from './lib/playlist-recently-viewed.utils';
export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
@@ -0,0 +1,34 @@
import { StalkerPortalItem } from './stalker-portal-item.interface';
export interface M3uRecentlyViewedItem {
source: 'm3u';
id: string;
url: string;
title: string;
channel_id?: string;
poster_url?: string;
tvg_id?: string;
tvg_name?: string;
group_title?: string;
category_id: 'live';
added_at: string | number;
}
export type PlaylistRecentlyViewedItem =
| M3uRecentlyViewedItem
| StalkerPortalItem;
export function isM3uRecentlyViewedItem(
item: unknown
): item is M3uRecentlyViewedItem {
if (!item || typeof item !== 'object') {
return false;
}
const candidate = item as Record<string, unknown>;
return (
candidate['source'] === 'm3u' &&
typeof candidate['url'] === 'string' &&
candidate['url'].trim().length > 0
);
}
@@ -0,0 +1,117 @@
import { PlaylistMeta } from './playlist-meta.type';
import { PortalRecentItem } from './portal-activity-item.interface';
import {
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
PlaylistRecentlyViewedItem,
isM3uRecentlyViewedItem,
} from './playlist-recently-viewed.interface';
interface PlaylistRecentLabels {
stalker: string;
m3u: string;
}
function getPlaylistRecentItems(
playlist: PlaylistMeta
): PlaylistRecentlyViewedItem[] {
return Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
}
function mapStalkerPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem, index) => {
if (isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const item = (rawItem ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(item, playlist._id, index);
acc.push({
id,
title: extractStalkerItemTitle(item),
type: extractStalkerItemType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(item['added_at']),
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
source: 'stalker',
stalker_item: rawItem,
});
return acc;
},
[]
);
}
function mapM3uPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem) => {
if (!isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const channelUrl = rawItem.url.trim();
if (!channelUrl) {
return acc;
}
acc.push({
id: rawItem.id || channelUrl,
title:
rawItem.title?.trim() ||
rawItem.tvg_name?.trim() ||
rawItem.channel_id ||
channelUrl,
type: 'live',
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(rawItem.added_at),
category_id: rawItem.category_id || 'live',
xtream_id: channelUrl,
poster_url: rawItem.poster_url || undefined,
source: 'm3u',
});
return acc;
},
[]
);
}
export function buildPlaylistRecentItems(
playlists: PlaylistMeta[],
labels: PlaylistRecentLabels
): PortalRecentItem[] {
return playlists.reduce<PortalRecentItem[]>((acc, playlist) => {
if (playlist.macAddress) {
acc.push(
...mapStalkerPlaylistRecentItems(playlist, labels.stalker)
);
return acc;
}
if (!playlist.serverUrl) {
acc.push(...mapM3uPlaylistRecentItems(playlist, labels.m3u));
}
return acc;
}, []);
}
@@ -1,3 +1,4 @@
import { PlaylistRecentlyViewedItem } from './playlist-recently-viewed.interface';
import { StalkerPortalItem } from './stalker-portal-item.interface';
/**
@@ -42,7 +43,7 @@ export interface Playlist {
password?: string;
macAddress?: string;
portalUrl?: string;
recentlyViewed?: any[];
recentlyViewed?: PlaylistRecentlyViewedItem[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Session token for full stalker portal authentication - persisted for session */
@@ -16,7 +16,7 @@ export interface PortalActivityItem {
category_id: string | number;
xtream_id: string | number;
poster_url?: string;
source?: 'xtream' | 'stalker';
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
}
@@ -3,6 +3,14 @@
<header class="context-header">
<div class="context-header__top">
<h2>{{ viewTitle() | translate }}</h2>
@if (
activeView() === 'recent' &&
recentChannelItems().length > 0
) {
<button mat-button (click)="clearRecentChannels()">
{{ 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' | translate }}
</button>
}
</div>
</header>
<div class="context-divider"></div>
@@ -49,6 +57,17 @@
/>
}
}
@case ('recent') {
<app-recent-view
[recentItems]="recentChannelItems()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
(channelSelected)="onChannelSelected($event)"
(removeRecent)="removeRecentChannel($event)"
/>
}
}
} @else {
<div class="loading-container">
@@ -11,6 +11,7 @@ import {
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -20,15 +21,37 @@ import { EpgService } from '@iptvnator/epg/data-access';
import {
ChannelActions,
FavoritesActions,
PlaylistActions,
selectActive,
selectActivePlaylistId,
selectFavorites,
} from 'm3u-state';
import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs';
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
skipWhile,
} from 'rxjs';
import { PlaylistsService } from 'services';
import {
Channel,
EpgProgram,
isM3uRecentlyViewedItem,
normalizeStalkerDate,
PlaylistMeta,
PlaylistRecentlyViewedItem,
Settings,
STORE_KEY,
} from 'shared-interfaces';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
import {
RecentViewComponent,
RecentViewItem,
} from './recent-view/recent-view.component';
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
@@ -51,12 +74,15 @@ function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
CommonModule,
FavoritesViewComponent,
GroupsViewComponent,
MatButtonModule,
MatIconModule,
RecentViewComponent,
TranslatePipe,
],
})
export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
@@ -79,8 +105,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Item size for virtual scroll - compact when no EPG */
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
/** Active view (all, groups, favorites) */
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly currentUrl = toSignal(
this.router.events.pipe(
@@ -95,6 +122,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
const url = this.currentUrl();
if (view === 'all') return 'CHANNELS.ALL_CHANNELS';
if (view === 'groups') return 'CHANNELS.GROUPS';
if (view === 'recent') return 'PORTALS.SIDEBAR.RECENT';
if (view === 'favorites') {
return url.includes('/workspace/global-favorites')
? 'HOME.PLAYLISTS.GLOBAL_FAVORITES'
@@ -136,6 +164,49 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
groupChannelsByTitle(this.displayedChannels())
);
readonly recentChannelItems = computed<RecentViewItem[]>(() => {
const channels = this.channelListSignal();
const recentItems = this.recentItems();
const channelsByUrl = new Map(
channels.map((channel) => [channel.url, channel] as const)
);
const channelsById = new Map(
channels.map((channel) => [channel.id, channel] as const)
);
const seenUrls = new Set<string>();
return [...recentItems]
.filter(isM3uRecentlyViewedItem)
.sort(
(a, b) =>
new Date(normalizeStalkerDate(b.added_at)).getTime() -
new Date(normalizeStalkerDate(a.added_at)).getTime()
)
.reduce<RecentViewItem[]>((acc, item) => {
const channelUrl = item.url?.trim();
if (!channelUrl || seenUrls.has(channelUrl)) {
return acc;
}
const channel =
channelsByUrl.get(channelUrl) ||
(item.channel_id
? channelsById.get(item.channel_id)
: undefined);
if (!channel) {
return acc;
}
seenUrls.add(channel.url);
acc.push({
channel,
viewedAt: normalizeStalkerDate(item.added_at),
});
return acc;
}, []);
});
/** Selected channel */
readonly activeChannel = this.store.selectSignal(selectActive);
@@ -263,4 +334,47 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
onFavoritesReordered(channelIds: string[]): void {
this.store.dispatch(FavoritesActions.setFavorites({ channelIds }));
}
async removeRecentChannel(channelUrl: string): Promise<void> {
const playlistId = this.activePlaylistIdSignal();
if (!playlistId) {
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
playlistId,
channelUrl
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
async clearRecentChannels(): Promise<void> {
const playlistId = this.activePlaylistIdSignal();
if (!playlistId) {
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearM3uRecentlyViewed(playlistId)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
}
@@ -58,7 +58,8 @@
</div>
@if (
(showProgramInfoButton() && showEpg() && epgProgram()) ||
showFavoriteButton()
showFavoriteButton() ||
showAuxActionButton()
) {
<div class="action-buttons">
@if (showProgramInfoButton() && showEpg() && epgProgram(); as program) {
@@ -90,6 +91,16 @@
}}</mat-icon>
</button>
}
@if (showAuxActionButton()) {
<button
mat-icon-button
class="favorite-button"
[matTooltip]="auxActionTooltip()"
(click)="onAuxActionClick($event)"
>
<mat-icon>{{ auxActionIcon() }}</mat-icon>
</button>
}
</div>
}
</div>
@@ -39,6 +39,7 @@ export class ChannelListItemComponent {
readonly logo = input<string | null | undefined>('');
readonly name = input('');
readonly showFavoriteButton = input(false);
readonly showAuxActionButton = input(false);
readonly showProgramInfoButton = input(true);
readonly isFavorite = input(false);
readonly selected = input(false);
@@ -46,9 +47,12 @@ export class ChannelListItemComponent {
readonly epgProgram = input<EpgProgram | null | undefined>();
/** Progress percentage pre-computed by parent for performance */
readonly progressPercentage = input(0);
readonly auxActionIcon = input('delete');
readonly auxActionTooltip = input('');
readonly clicked = output<void>();
readonly favoriteToggled = output<MouseEvent>();
readonly auxActionClicked = output<MouseEvent>();
constructor() {
effect(() => {
@@ -86,6 +90,11 @@ export class ChannelListItemComponent {
this.favoriteToggled.emit(event);
}
onAuxActionClick(event: MouseEvent): void {
event.stopPropagation();
this.auxActionClicked.emit(event);
}
showLogoFallback(): boolean {
return !this.logo() || this.logoFailed();
}
@@ -0,0 +1,37 @@
<div class="channel-list" id="recent-list">
@if (enrichedRecentItems().length > 0) {
@for (
item of enrichedRecentItems();
track item.channel.url;
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(item.channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="item.channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="item.epgProgram"
[progressPercentage]="item.progressPercentage"
[selected]="activeChannelUrl() === item.channel?.url"
[showAuxActionButton]="true"
[auxActionIcon]="'delete'"
[auxActionTooltip]="
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(clicked)="onChannelClick(item.channel)"
(auxActionClicked)="onRemoveRecent(item.channel, $event)"
/>
}
} @else {
<div class="empty-recent">
<strong>{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}</strong>
</div>
<div class="empty-recent-hint">
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
</div>
}
</div>
@@ -0,0 +1,33 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
#recent-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.channel-list {
overflow-x: hidden;
}
.empty-recent,
.empty-recent-hint {
padding: 16px 24px;
text-align: center;
}
.empty-recent {
margin-top: 40px;
font-size: 1rem;
}
.empty-recent-hint {
opacity: 0.7;
font-size: 0.9rem;
}
@@ -0,0 +1,80 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
export interface RecentViewItem {
channel: Channel;
viewedAt: string;
}
@Component({
selector: 'app-recent-view',
templateUrl: './recent-view.component.html',
styleUrls: ['./recent-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChannelListItemComponent, TranslatePipe],
})
export class RecentViewComponent {
readonly recentItems = input.required<RecentViewItem[]>();
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
readonly activeChannelUrl = input<string | undefined>();
readonly channelSelected = output<Channel>();
readonly removeRecent = output<string>();
readonly enrichedRecentItems = computed(() => {
const recentItems = this.recentItems();
const epgMap = this.channelEpgMap();
this.progressTick();
return recentItems.map(({ channel, viewedAt }) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
channel,
viewedAt,
epgProgram,
progressPercentage: this.calculateProgress(epgProgram),
};
});
});
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = new Date().getTime();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
trackByFn(_: number, item: RecentViewItem): string {
return item.channel?.url;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onRemoveRecent(channel: Channel, event: MouseEvent): void {
event.stopPropagation();
this.removeRecent.emit(channel.url);
}
}
@@ -20,4 +20,5 @@
<app-channel-list-container
[channelList]="channels()"
[activeView]="activeView()"
[recentItems]="activePlaylist()?.recentlyViewed ?? []"
/>
@@ -6,7 +6,7 @@ import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { selectPlaylistTitle } from 'm3u-state';
import { selectActivePlaylist, selectPlaylistTitle } from 'm3u-state';
import { Channel } from 'shared-interfaces';
import { ChannelListContainerComponent } from 'components';
@@ -33,6 +33,7 @@ export class SidebarComponent {
private readonly translate = inject(TranslateService);
readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle);
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
readonly subtitle = computed(() => {
const count = this.channels()?.length ?? 0;
@@ -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,
@@ -36,6 +36,7 @@ import {
PlaylistType,
} from '@iptvnator/playlist/shared/ui';
import {
PlaylistActions,
selectActivePlaylist,
selectAllPlaylistsMeta,
selectPlaylistTitle,
@@ -55,6 +56,7 @@ import {
PlaylistsService,
SettingsStore,
} from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import {
SettingsContextService,
WorkspaceAccountInfoData,
@@ -481,6 +483,21 @@ export class WorkspaceShellComponent {
};
}
if (context.provider === 'playlists' && section === 'recent') {
return {
icon: 'delete_sweep',
tooltip: this.translateText(
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'
),
ariaLabel: this.translateText(
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA'
),
disabled:
(this.activePlaylist()?.recentlyViewed?.length ?? 0) ===
0,
};
}
return null;
}
);
@@ -707,11 +724,37 @@ export class WorkspaceShellComponent {
}
if (context.provider === 'stalker' && section === 'recent') {
await firstValueFrom(
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPortalRecentlyViewed(
context.playlistId
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: context.playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
this.bumpRefreshQueryParam();
return;
}
if (context.provider === 'playlists' && section === 'recent') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearM3uRecentlyViewed(
context.playlistId
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: context.playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
this.bumpRefreshQueryParam();
}
}