diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index d3244d9e7..06e197b79 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -80,7 +80,41 @@ export class DashboardDataService { private readonly xtreamGlobalFavorites = signal( [] ); - private readonly m3uGlobalFavorites = signal([]); + /** + * Per-M3U-playlist favorites contributions, keyed by playlist ID. + * Each playlist's contribution lands in the map as soon as ITS load + * resolves, so the favorites rail flips on a playlist at a time + * instead of waiting for the slowest of a Promise.all. + */ + private readonly m3uPlaylistFavoritesMap = signal< + Map + >(new Map()); + + /** + * Memoized parsed favorites per playlist. The fingerprint includes the + * favorites JSON and the playlist's update timestamp so a cache hit + * returns instantly and a refresh / favorites change naturally + * invalidates without explicit busting. Avoids re-parsing the entire + * (potentially 90K-channel) playlist payload on repeated dashboard + * mounts. + */ + private readonly m3uFavoritesCache = new Map< + string, + { fingerprint: string; items: DashboardFavoriteItem[] } + >(); + + private readonly m3uGlobalFavorites = computed( + () => { + const map = this.m3uPlaylistFavoritesMap(); + const all: DashboardFavoriteItem[] = []; + map.forEach((items) => { + for (const item of items) { + all.push(item); + } + }); + return all; + } + ); private readonly globalRecentLoadingState = signal(true); private readonly globalRecentLoadedState = signal(false); private readonly globalRecentDbLoadedState = signal(!window.electron); @@ -406,24 +440,53 @@ export class DashboardDataService { ) ); + const validIds = new Set(m3uPlaylists.map((p) => p._id)); + + // Drop entries for M3U playlists no longer in the eligible set + // (removed playlists or playlists whose favorites were cleared). + this.ngZone.run(() => { + this.m3uPlaylistFavoritesMap.update((prev) => { + let next: Map | null = null; + for (const id of prev.keys()) { + if (!validIds.has(id)) { + next ??= new Map(prev); + next.delete(id); + } + } + return next ?? prev; + }); + }); + for (const id of Array.from(this.m3uFavoritesCache.keys())) { + if (!validIds.has(id)) { + this.m3uFavoritesCache.delete(id); + } + } + 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[] - ); + // Stream per-playlist results into the map as each load resolves. + // The favorites rail re-renders incrementally — a slow playlist no + // longer pins the rest behind a Promise.all on the slowest one. + await Promise.all( + m3uPlaylists.map(async (playlist) => { + let items: DashboardFavoriteItem[]; + try { + items = await this.loadM3uPlaylistFavorites(playlist); + } catch { + items = []; + } - this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites)); + this.ngZone.run(() => { + this.m3uPlaylistFavoritesMap.update((prev) => { + const next = new Map(prev); + next.set(playlist._id, items); + return next; + }); + }); + }) + ); } private finishInitialGlobalFavoritesLoadIfReady(): void { @@ -753,6 +816,18 @@ export class DashboardDataService { private async loadM3uPlaylistFavorites( playlistMeta: PlaylistMeta ): Promise { + // Cache fingerprint covers what affects the result: the favorites + // list itself and the playlist's update timestamp (changes mean + // channels may have been added/removed by a refresh). A cache hit + // skips the heavyweight getPlaylistById() call — for large M3U + // playlists that's a serialized payload of 90K+ channels avoided + // entirely on repeat dashboard mounts. + const fingerprint = this.buildM3uFavoritesFingerprint(playlistMeta); + const cached = this.m3uFavoritesCache.get(playlistMeta._id); + if (cached && cached.fingerprint === fingerprint) { + return cached.items; + } + const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(playlistMeta._id) )) as Playlist & { @@ -768,6 +843,10 @@ export class DashboardDataService { : []; if (favorites.length === 0) { + this.m3uFavoritesCache.set(playlistMeta._id, { + fingerprint, + items: [], + }); return []; } @@ -785,40 +864,63 @@ export class DashboardDataService { } }); - return items.reduce((acc, channel) => { - const channelId = String(channel.id ?? '').trim(); - const channelUrl = String(channel.url ?? '').trim(); - const channelIdFavoritePosition = favoritePositions.get(channelId); - const channelUrlFavoritePosition = - favoritePositions.get(channelUrl); - const matchedFavoriteId = - channelIdFavoritePosition !== undefined && - (channelUrlFavoritePosition === undefined || - channelIdFavoritePosition <= channelUrlFavoritePosition) - ? channelId - : channelUrlFavoritePosition !== undefined - ? channelUrl - : null; + const computedItems = items.reduce( + (acc, channel) => { + const channelId = String(channel.id ?? '').trim(); + const channelUrl = String(channel.url ?? '').trim(); + const channelIdFavoritePosition = + favoritePositions.get(channelId); + const channelUrlFavoritePosition = + favoritePositions.get(channelUrl); + const matchedFavoriteId = + channelIdFavoritePosition !== undefined && + (channelUrlFavoritePosition === undefined || + channelIdFavoritePosition <= + channelUrlFavoritePosition) + ? channelId + : channelUrlFavoritePosition !== undefined + ? channelUrl + : null; - if (!matchedFavoriteId) { + 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; - } + }, + [] + ); - 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; - }, []); + this.m3uFavoritesCache.set(playlistMeta._id, { + fingerprint, + items: computedItems, + }); + return computedItems; + } + + private buildM3uFavoritesFingerprint(playlist: PlaylistMeta): string { + // updateDate changes on refresh (channel list may have changed); + // favorites JSON changes on add/remove. Together they cover every + // way the result could differ. + const favoritesPart = JSON.stringify(playlist.favorites ?? []); + const updatePart = String( + playlist.updateDate ?? playlist.importDate ?? '' + ); + return `${updatePart}::${favoritesPart}`; } private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null {