mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
perf(dashboard): memoize M3U favorite parses + stream per-playlist updates
Two compounding wins for the dashboard's M3U favorites path. #3 — Memoize loadM3uPlaylistFavorites The function calls getPlaylistById() which deserializes the entire M3U playlist payload (potentially 90K channels) just to read the favorites field and the matching items. On every dashboard mount, it re-paid that cost for every M3U playlist with favorites — even though nothing relevant had changed. Add a per-playlist cache keyed by playlist ID with a fingerprint of {updateDate, favorites JSON}. A cache hit skips the heavyweight playlist read entirely; a refresh (updateDate change) or favorites add/remove naturally invalidates without explicit busting. First mount still pays the parse; subsequent dashboard visits and re-renders are effectively free. #4 — Stream per-playlist favorites into the rail reloadM3uGlobalFavorites used Promise.all over loadM3uPlaylistFavorites and wrote one flat array at the end, so a single slow M3U playlist pinned the entire M3U favorites contribution behind it. Replace the writable m3uGlobalFavorites signal-of-array with a WritableSignal<Map<playlistId, items[]>> + computed flatten. Each playlist's contribution lands in the map as soon as ITS load resolves (usually instant via the new cache), so the favorites rail re-renders incrementally — Xtream favorites first, then each M3U playlist's favorites appear as it streams in. Cache + map entries for playlists that no longer have favorites are pruned at the start of each reload, so removed playlists don't leave stale state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: fb28be89e785
This commit is contained in:
1 parent
d67150d38f
commit
13acbe7e3d
1 file changed
+147
-45
@@ -80,7 +80,41 @@ export class DashboardDataService {
|
||||
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
||||
[]
|
||||
);
|
||||
private readonly m3uGlobalFavorites = signal<DashboardFavoriteItem[]>([]);
|
||||
/**
|
||||
* 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<string, DashboardFavoriteItem[]>
|
||||
>(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<DashboardFavoriteItem[]>(
|
||||
() => {
|
||||
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<string, DashboardFavoriteItem[]> | 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<DashboardFavoriteItem[]> {
|
||||
// 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<DashboardFavoriteItem[]>((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<DashboardFavoriteItem[]>(
|
||||
(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 {
|
||||
|
||||
Reference in new issue
Block a user