mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
d1af5f0510
commit
6303986c72
8 files changed
+142
-73
No files matched your search
+10
-5
@@ -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"
|
||||
|
||||
+4
-2
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
+46
-25
@@ -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
|
||||
*/
|
||||
|
||||
+10
-5
@@ -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"
|
||||
|
||||
+22
-15
@@ -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
|
||||
*/
|
||||
|
||||
+9
-3
@@ -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]="
|
||||
|
||||
+3
-3
@@ -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', () => {
|
||||
|
||||
+38
-15
@@ -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>();
|
||||
|
||||
|
||||
Reference in new issue
Block a user