import { Injectable, NgZone, computed, effect, inject, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from 'm3u-state'; import { firstValueFrom, startWith } from 'rxjs'; import { DatabaseService, GlobalRecentlyAddedKind, PlaylistsService, } from 'services'; import { buildPlaylistRecentItems, Channel, Playlist, PortalAddedItem, PortalActivityItem, PlaylistMeta, PortalActivityType, PortalFavoriteItem, PortalRecentItem, stalkerItemMatchesId, } from 'shared-interfaces'; import { buildStalkerFavoriteItems, getActivityTypeLabelKey, mapDbFavoriteToItem, mapDbRecentlyAddedToItem, mapDbRecentToItem, toDateTimestamp, toTimestamp, } from './dashboard-mappers'; import { buildStalkerDetailNavigationTarget, buildStalkerStateItem, buildXtreamNavigationTarget, getGlobalFavoriteNavigation, getRecentItemNavigation, WorkspaceNavigationTarget, } from '@iptvnator/portal/shared/util'; export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series'; /** @deprecated Use {@link PortalRecentItem} from `shared-interfaces` instead. */ export type GlobalRecentItem = PortalRecentItem; /** @deprecated Use {@link PortalFavoriteItem} from `shared-interfaces` instead. */ export type DashboardFavoriteItem = PortalFavoriteItem; /** @deprecated Use {@link PortalAddedItem} from `shared-interfaces` instead. */ export type DashboardRecentlyAddedItem = PortalAddedItem; export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind; @Injectable({ providedIn: 'root' }) 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 favoritesAutoRefreshEnabled = false; private readonly languageTick = toSignal( this.translate.onLangChange.pipe(startWith(null)), { initialValue: null } ); private readonly xtreamGlobalRecentItems = signal([]); private readonly xtreamRecentlyAddedItemsState = signal< DashboardRecentlyAddedItem[] >([]); private readonly xtreamGlobalFavorites = signal( [] ); private readonly m3uGlobalFavorites = signal([]); private readonly globalRecentLoadingState = signal(true); private readonly globalRecentLoadedState = signal(false); private readonly globalRecentDbLoadedState = signal(!window.electron); private readonly globalFavoritesLoadingState = signal(true); private readonly globalFavoritesLoadedState = signal(false); private readonly xtreamRecentlyAddedLoadingState = signal(true); private readonly xtreamRecentlyAddedLoadedState = signal(false); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); readonly playlistsLoaded = this.store.selectSignal( selectPlaylistsLoadingFlag ); readonly xtreamPlaylistCount = computed( () => this.playlists().filter((playlist) => !!playlist.serverUrl).length ); readonly hasXtreamPlaylists = computed(() => this.xtreamPlaylistCount() > 0); readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly(); readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly(); readonly globalFavoritesLoading = this.globalFavoritesLoadingState.asReadonly(); readonly globalFavoritesLoaded = this.globalFavoritesLoadedState.asReadonly(); readonly xtreamRecentlyAddedLoading = this.xtreamRecentlyAddedLoadingState.asReadonly(); readonly xtreamRecentlyAddedLoaded = this.xtreamRecentlyAddedLoadedState.asReadonly(); readonly xtreamRecentlyAddedItems = this.xtreamRecentlyAddedItemsState.asReadonly(); readonly dashboardReady = computed( () => this.playlistsLoaded() && this.globalRecentLoaded() && this.globalFavoritesLoaded() && (!this.hasXtreamPlaylists() || this.xtreamRecentlyAddedLoaded()) ); private readonly playlistFavoritesReloadKey = computed(() => { if (!this.playlistsLoaded()) { return null; } return this.playlists() .map((playlist) => [ playlist._id, playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u', JSON.stringify(playlist.favorites ?? []), ].join('::') ) .join('|'); }); readonly playlistBackedGlobalRecentItems = computed( () => { this.languageTick(); return buildPlaylistRecentItems(this.playlists(), { stalker: this.translateText( 'WORKSPACE.DASHBOARD.STALKER_PORTAL' ), m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'), }); } ); readonly globalRecentItems = computed(() => [ ...this.xtreamGlobalRecentItems(), ...this.playlistBackedGlobalRecentItems(), ] .sort( (a, b) => toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at) ) .slice(0, 200) ); readonly stalkerGlobalFavorites = computed(() => { this.languageTick(); return buildStalkerFavoriteItems( this.playlists(), this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL') ); }); readonly globalFavoriteItems = computed(() => [ ...this.xtreamGlobalFavorites(), ...this.m3uGlobalFavorites(), ...this.stalkerGlobalFavorites(), ] .sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at)) .slice(0, 200) ); private readonly recentPlaylistActivityTimestamps = computed(() => { const timestamps = new Map(); for (const item of this.globalRecentItems()) { const viewedAt = toDateTimestamp(item.viewed_at); const current = timestamps.get(item.playlist_id) ?? 0; if (viewedAt > current) { timestamps.set(item.playlist_id, viewedAt); } } return timestamps; }); constructor() { effect(() => { this.playlistFavoritesReloadKey(); if (!this.playlistsLoaded() || !this.favoritesAutoRefreshEnabled) { return; } void this.refreshPlaylistBackedGlobalFavorites(); }); effect(() => { this.playlistsLoaded(); this.finishInitialGlobalRecentLoadIfReady(); this.finishInitialGlobalFavoritesLoadIfReady(); }); effect(() => { if (this.hasXtreamPlaylists()) { return; } this.ngZone.run(() => { this.xtreamRecentlyAddedItemsState.set([]); this.xtreamRecentlyAddedLoadingState.set(false); this.xtreamRecentlyAddedLoadedState.set(true); }); }); } readonly stats = computed(() => { const items = this.playlists(); return { total: items.length, xtream: items.filter((item) => !!item.serverUrl).length, stalker: items.filter((item) => !!item.macAddress).length, m3u: items.filter((item) => !item.serverUrl && !item.macAddress) .length, }; }); readonly recentPlaylists = computed(() => [...this.playlists()] .sort((a, b) => { const activityDelta = this.getPlaylistActivityTimestamp(b) - this.getPlaylistActivityTimestamp(a); if (activityDelta !== 0) { return activityDelta; } const metadataDelta = this.getRecentTimestamp(b) - this.getRecentTimestamp(a); if (metadataDelta !== 0) { return metadataDelta; } return a._id.localeCompare(b._id); }) .slice(0, 12) ); readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4)); async reloadGlobalRecentItems(): Promise { if (!this.globalRecentLoaded()) { this.globalRecentLoadingState.set(true); } if (!window.electron) { this.xtreamGlobalRecentItems.set([]); this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); return; } try { const recentItems = await this.dbService.getGlobalRecentlyViewed(); const normalized = recentItems.map((item) => mapDbRecentToItem(item) ); this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized)); } catch (err) { console.warn( '[DashboardData] Failed to reload global recent items', err ); this.ngZone.run(() => this.xtreamGlobalRecentItems.set([])); } finally { this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); } } async reloadGlobalFavorites(): Promise { if (!this.globalFavoritesLoaded()) { this.globalFavoritesLoadingState.set(true); } const xtreamReload = this.reloadXtreamGlobalFavorites(); const m3uReload = this.refreshPlaylistBackedGlobalFavorites(); await Promise.all([xtreamReload, m3uReload]); this.favoritesAutoRefreshEnabled = true; } async getGlobalRecentlyAddedItems( kind: DashboardRecentlyAddedFilterKind, limit = 200 ): Promise { if (!window.electron) { return []; } const items = await this.dbService.getGlobalRecentlyAdded(kind, limit); return items .map((item) => mapDbRecentlyAddedToItem(item)) .sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at)); } async getXtreamRecentlyAddedItems( limit = 20 ): Promise { if (!window.electron) { return []; } const items = await this.dbService.getGlobalRecentlyAdded( 'all', limit, 'xtream' ); return items .map((item) => mapDbRecentlyAddedToItem(item)) .sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at)); } async reloadXtreamRecentlyAddedItems(limit = 20): Promise { if (!this.xtreamRecentlyAddedLoaded()) { this.xtreamRecentlyAddedLoadingState.set(true); } if (!window.electron) { this.ngZone.run(() => { this.xtreamRecentlyAddedItemsState.set([]); this.xtreamRecentlyAddedLoadingState.set(false); this.xtreamRecentlyAddedLoadedState.set(true); }); return; } try { const items = await this.getXtreamRecentlyAddedItems(limit); this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set(items) ); } catch (err) { console.warn( '[DashboardData] Failed to reload Xtream recently added items', err ); this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([])); } finally { this.ngZone.run(() => { this.xtreamRecentlyAddedLoadingState.set(false); this.xtreamRecentlyAddedLoadedState.set(true); }); } } private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { this.xtreamGlobalFavorites.set([]); this.finishInitialGlobalFavoritesLoadIfReady(); return; } try { const favorites = await this.dbService.getAllGlobalFavorites(); const normalized = favorites.map((item) => mapDbFavoriteToItem(item) ); this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized)); } catch (err) { console.warn( '[DashboardData] Failed to reload global favorites', err ); this.ngZone.run(() => this.xtreamGlobalFavorites.set([])); } finally { this.finishInitialGlobalFavoritesLoadIfReady(); } } private async refreshPlaylistBackedGlobalFavorites(): Promise { await this.reloadM3uGlobalFavorites(); this.finishInitialGlobalFavoritesLoadIfReady(); } private async reloadM3uGlobalFavorites(): Promise { 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)); } private finishInitialGlobalFavoritesLoadIfReady(): void { if (!this.playlistsLoaded()) { return; } this.ngZone.run(() => { this.globalFavoritesLoadedState.set(true); this.globalFavoritesLoadingState.set(false); }); } private finishInitialGlobalRecentLoadIfReady(): void { if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) { return; } this.ngZone.run(() => { this.globalRecentLoadedState.set(true); this.globalRecentLoadingState.set(false); }); } isTypeInKind( type: PortalActivityType, kind: DashboardContentKind ): boolean { if (kind === 'all') { return true; } if (kind === 'channels') { return type === 'live'; } if (kind === 'vod') { return type === 'movie'; } return type === 'series'; } getPlaylistLink(playlist: PlaylistMeta): string[] { if (playlist.serverUrl) { return ['/workspace', 'xtreams', playlist._id, 'vod']; } if (playlist.macAddress) { return ['/workspace', 'stalker', playlist._id, 'vod']; } return ['/workspace', 'playlists', playlist._id]; } getPlaylistProvider(playlist: PlaylistMeta): string { this.languageTick(); if (playlist.serverUrl) { return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } if (playlist.macAddress) { return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } return this.translateText('WORKSPACE.DASHBOARD.M3U'); } getRecentItemProviderLabel(item: GlobalRecentItem): string { this.languageTick(); if (item.source === 'stalker') { return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } 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'); } getRecentItemTypeLabel(item: GlobalRecentItem): string { this.languageTick(); return this.translateText(getActivityTypeLabelKey(item.type)); } getRecentItemLink(item: GlobalRecentItem): string[] { return getRecentItemNavigation(item).link; } getRecentItemNavigationState( item: GlobalRecentItem ): WorkspaceNavigationTarget['state'] { return getRecentItemNavigation(item).state; } async removeGlobalRecentItem(item: GlobalRecentItem): Promise { if (item.source === 'xtream') { await this.dbService.removeRecentItem( item.id as number, item.playlist_id ); await this.reloadGlobalRecentItems(); return; } if (item.source === 'stalker') { const updatedPlaylist = await firstValueFrom( this.playlistsService.removeFromPortalRecentlyViewed( item.playlist_id, item.id ) ); this.store.dispatch( PlaylistActions.updatePlaylistMeta({ playlist: { _id: item.playlist_id, 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 ); } } getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string { return this.getActivityItemProviderLabel(item); } getRecentlyAddedItemProviderLabel( item: DashboardRecentlyAddedItem ): string { return this.getActivityItemProviderLabel(item); } private getActivityItemProviderLabel( item: Pick ): string { this.languageTick(); if (item.source === 'stalker') { return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } 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'); } getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string { return this.getActivityItemTypeLabel(item); } getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string { return this.getActivityItemTypeLabel(item); } private getActivityItemTypeLabel( item: Pick ): string { this.languageTick(); return this.translateText(getActivityTypeLabelKey(item.type)); } getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] { return getGlobalFavoriteNavigation(item).link; } getGlobalFavoriteNavigationState( item: DashboardFavoriteItem ): WorkspaceNavigationTarget['state'] { return getGlobalFavoriteNavigation(item).state; } getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] { if (item.source === 'stalker' && item.type !== 'live') { return buildStalkerDetailNavigationTarget({ playlistId: item.playlist_id, type: item.type, categoryId: item.category_id, item: buildStalkerStateItem(item.stalker_item, { id: item.id, title: item.title, type: item.type, category_id: item.category_id, poster_url: item.poster_url, }), }).link; } return buildXtreamNavigationTarget({ playlistId: item.playlist_id, type: item.type, categoryId: item.category_id, itemId: item.xtream_id, title: item.title, imageUrl: item.poster_url, }).link; } getRecentlyAddedNavigationState( item: DashboardRecentlyAddedItem ): WorkspaceNavigationTarget['state'] { if (item.source === 'stalker' && item.type !== 'live') { return buildStalkerDetailNavigationTarget({ playlistId: item.playlist_id, type: item.type, categoryId: item.category_id, item: buildStalkerStateItem(item.stalker_item, { id: item.id, title: item.title, type: item.type, category_id: item.category_id, poster_url: item.poster_url, }), }).state; } return buildXtreamNavigationTarget({ playlistId: item.playlist_id, type: item.type, categoryId: item.category_id, itemId: item.xtream_id, title: item.title, imageUrl: item.poster_url, }).state; } async removeGlobalFavorite(item: DashboardFavoriteItem): Promise { if (item.source === 'xtream') { await this.dbService.removeFromFavorites( item.id as number, item.playlist_id ); await this.reloadGlobalFavorites(); return; } if (item.source === 'stalker') { const playlist = this.playlists().find( (p) => p._id === item.playlist_id ); if (!playlist) return; const currentFavorites = Array.isArray(playlist.favorites) ? [...playlist.favorites] : []; const itemMatchStr = String(item.id); const filteredFavorites = currentFavorites.filter( (raw, index) => !stalkerItemMatchesId( raw, itemMatchStr, playlist._id, index ) ); this.store.dispatch( PlaylistActions.updatePlaylistMeta({ playlist: { _id: item.playlist_id, favorites: filteredFavorites, } as unknown as PlaylistMeta, }) 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 { this.languageTick(); const timestamp = toTimestamp(value); if (!timestamp) { return this.translateText('WORKSPACE.DASHBOARD.NOT_YET_SYNCED'); } return new Date(timestamp).toLocaleString(this.getLocale()); } private async loadM3uPlaylistFavorites( playlistMeta: PlaylistMeta ): Promise { 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((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 getPlaylistActivityTimestamp(item: PlaylistMeta): number { return this.recentPlaylistActivityTimestamps().get(item._id) ?? 0; } private getRecentTimestamp(item: PlaylistMeta): number { return toTimestamp(item.updateDate) || toTimestamp(item.importDate); } private getLocale(): string | undefined { return ( this.translate.currentLang || this.translate.defaultLang || undefined ); } private translateText( key: string, params?: Record ): string { return this.translate.instant(key, params); } }