perf(channel-list): replace per-tick channel cloning with side-car EPG map

The three channel-list views (all-channels, groups, favorites) each had
an enrichedXxx computed signal that, on every progressTick (~30 s),
spread-cloned every channel in scope to attach { epgProgram, logo,
progressPercentage }. On a 90 K-channel M3U playlist, all-channels-view
allocated ~90 K objects per tick — persistent young-gen pressure and
visible GC stutter on large lists.

Replace the pattern with a side-car ChannelEpgMetadata map keyed by EPG
lookup key, holding only entries for channels that actually have EPG
data (typically a small fraction of the playlist). Templates iterate
the raw channel array and look up { epgProgram, progressPercentage }
inline. Logo resolution moves to a per-row method call — under OnPush
+ virtual scroll only ~50 visible rows check at a time.

groups-view's selectedGroupChannels also lost its progressTick
dependency: sorting + filtering only reruns when the selected group or
sort mode changes, no longer every 30 s.

The EnrichedChannel interface is replaced with a smaller
ChannelEpgMetadata. Specs that asserted on the cloned `.logo` field now
call `getLogoForChannel(channel)`.

Inspired by matracey/iptvnator@8d78cc0, @b9e6e82, @296d61e — ported as
a single coherent change. Their version missed our second site
(selectedGroupChannels) and didn't address the per-tick logo cloning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: e400bd558b10
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-02 00:06:16 +02:00
1 parent d1af5f0510
commit 6303986c72
8 files changed
+142 -73

No files matched your search

@@ -1,5 +1,5 @@
<div id="all-channels" class="channel-list">
@if (enrichedChannels().length === 0 && searchTerm()) {
@if (filteredChannels().length === 0 && searchTerm()) {
<div class="no-results">
<mat-icon class="no-results-icon">search_off</mat-icon>
<div class="no-results-title">
@@ -16,7 +16,7 @@
>
<app-channel-list-item
*cdkVirtualFor="
let channel of enrichedChannels();
let channel of filteredChannels();
index as i;
trackBy: trackByFn
"
@@ -26,12 +26,17 @@
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel.logo"
[logo]="getLogoForChannel(channel)"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
[epgProgram]="
epgMetadataMap().get(getChannelEpgKey(channel))?.epgProgram
"
[progressPercentage]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.progressPercentage ?? 0
"
(clicked)="onChannelClick(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
@@ -129,7 +129,9 @@ describe('AllChannelsViewComponent', () => {
);
fixture.detectChanges();
expect(component.enrichedChannels()[0].logo).toBe(playlistLogo);
expect(component.getLogoForChannel(component.filteredChannels()[0])).toBe(
playlistLogo
);
});
it('falls back to the EPG icon using tvg-name when tvg-id and playlist logo are missing', () => {
@@ -150,7 +152,7 @@ describe('AllChannelsViewComponent', () => {
);
fixture.detectChanges();
expect(component.enrichedChannels()[0].logo).toBe(
expect(component.getLogoForChannel(component.filteredChannels()[0])).toBe(
'https://example.com/guide-news.png'
);
});
@@ -20,10 +20,13 @@ import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
/** Enriched channel with pre-computed EPG and progress data */
export interface EnrichedChannel extends Channel {
/**
* Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key.
* Replaces the older EnrichedChannel pattern that spread-cloned every channel
* on every progressTick (~30 s).
*/
export interface ChannelEpgMetadata {
epgProgram: EpgProgram | null | undefined;
logo: string;
progressPercentage: number;
}
@@ -86,38 +89,56 @@ export class AllChannelsViewComponent {
});
/**
* Computed signal for filtered and enriched channels.
* Filtered channels — just a subset reference, no cloning.
* Recomputes only when the source list or the search term changes.
*/
readonly enrichedChannels = computed(() => {
readonly filteredChannels = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
if (!term) {
return channels;
}
return channels.filter((ch) =>
ch.name?.toLowerCase().includes(term)
);
});
/**
* Side-car EPG metadata keyed by channel EPG lookup key.
* Rebuilt every progressTick (~30 s) but only contains entries for channels
* that actually have EPG data — typically a small fraction of the playlist.
* Replaces the previous spread-clone-every-channel pattern that allocated
* ~90K objects per tick on large M3U playlists.
*/
readonly epgMetadataMap = computed(() => {
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
// Read progressTick to create dependency for progress refresh
this.progressTick();
let result = channels;
// Filter if search term exists
if (term) {
result = channels.filter((ch) =>
ch.name?.toLowerCase().includes(term)
);
}
// Enrich with EPG data and pre-calculate progress
return result.map((channel) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
const result = new Map<string, ChannelEpgMetadata>();
epgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: this.calculateProgress(program),
});
});
return result;
});
/** Resolves the EPG lookup key the side-car map is keyed by. */
getChannelEpgKey(channel: Channel): string {
return resolveChannelEpgLookupKey(channel) ?? '';
}
/**
* Resolves the channel logo. Called per visible row from the template; under
* OnPush + virtual scroll only ~50 rows check at a time so direct calls are
* cheaper than rebuilding a separate logo map per channels/iconMap change.
*/
getLogoForChannel(channel: Channel): string {
return resolveChannelLogo(channel, this.channelIconMap());
}
/**
* Calculates progress percentage for an EPG program
*/
@@ -5,9 +5,9 @@
(cdkDropListDropped)="onDrop($event)"
id="favorites-list"
>
@if (enrichedFavorites().length > 0) {
@if (filteredFavorites().length > 0) {
@for (
channel of enrichedFavorites();
channel of filteredFavorites();
track trackByFn($index, channel);
let i = $index
) {
@@ -19,11 +19,16 @@
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[isDraggable]="!hasSearchTerm()"
[logo]="channel.logo"
[logo]="getLogoForChannel(channel)"
[showEpg]="shouldShowEpg()"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
[epgProgram]="
epgMetadataMap().get(getChannelEpgKey(channel))?.epgProgram
"
[progressPercentage]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.progressPercentage ?? 0
"
(clicked)="onChannelClick(channel)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
@@ -13,7 +13,7 @@ import {
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -71,27 +71,34 @@ export class FavoritesViewComponent {
});
/**
* Computed signal for enriched favorites with EPG data
* Side-car EPG metadata keyed by channel EPG lookup key. Rebuilt every
* progressTick (~30 s) but only contains entries for channels with EPG
* data. Replaces the previous spread-clone-every-channel pattern.
*/
readonly enrichedFavorites = computed(() => {
const favorites = this.filteredFavorites();
readonly epgMetadataMap = computed(() => {
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
// Read progressTick to trigger recalculation
this.progressTick();
return favorites.map((channel) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
const result = new Map<string, ChannelEpgMetadata>();
epgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: this.calculateProgress(program),
});
});
return result;
});
/** Resolves the EPG lookup key the side-car map is keyed by. */
getChannelEpgKey(channel: Channel): string {
return resolveChannelEpgLookupKey(channel) ?? '';
}
/** Resolves the channel logo. Called per visible row from the template. */
getLogoForChannel(channel: Channel): string {
return resolveChannelLogo(channel, this.channelIconMap());
}
/**
* Calculates progress percentage for an EPG program
*/
@@ -235,12 +235,18 @@
(channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate))
"
[logo]="channel.logo"
[logo]="getLogoForChannel(channel)"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
[epgProgram]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.epgProgram
"
[progressPercentage]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.progressPercentage ?? 0
"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="
@@ -250,9 +250,9 @@ describe('GroupsViewComponent', () => {
);
fixture.detectChanges();
expect(component.selectedGroupChannels()[0].logo).toBe(
'https://example.com/sports.png'
);
expect(
component.getLogoForChannel(component.selectedGroupChannels()[0])
).toBe('https://example.com/sports.png');
});
it('persists sort mode changes', () => {
@@ -28,7 +28,7 @@ import {
restorePortalChannelSortMode,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -313,12 +313,14 @@ export class GroupsViewComponent {
);
});
readonly selectedGroupChannels = computed<EnrichedChannel[]>(() => {
/**
* Channels for the currently selected group, sorted but NOT cloned.
* Recomputes only when the selected group or sort mode changes — no longer
* tied to progressTick, so we don't re-sort/re-allocate every 30 s.
*/
readonly selectedGroupChannels = computed<readonly Channel[]>(() => {
const group = this.selectedGroup();
const sortMode = this.groupChannelSortMode();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
this.progressTick();
if (!group) {
return [];
@@ -328,18 +330,39 @@ export class GroupsViewComponent {
group.channels,
sortMode,
(channel) => channel?.name
).map((channel) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
);
});
/**
* Side-car EPG metadata keyed by channel EPG lookup key. Rebuilt every
* progressTick (~30 s) but only contains entries for channels with EPG
* data — typically a small fraction of the playlist. Replaces the previous
* spread-clone-every-channel pattern.
*/
readonly epgMetadataMap = computed(() => {
const epgMap = this.channelEpgMap();
this.progressTick();
const result = new Map<string, ChannelEpgMetadata>();
epgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: this.calculateProgress(program),
});
});
return result;
});
/** Resolves the EPG lookup key the side-car map is keyed by. */
getChannelEpgKey(channel: Channel): string {
return resolveChannelEpgLookupKey(channel) ?? '';
}
/** Resolves the channel logo. Called per visible row from the template. */
getLogoForChannel(channel: Channel): string {
return resolveChannelLogo(channel, this.channelIconMap());
}
private readonly groupKeyByChannelUrl = computed(() => {
const groupKeys = new Map<string, string>();