perf: optimize playlist favorite lookups

Use Map and Set lookups for M3U favorite aggregation, dashboard global favorite mapping, and channel-list favorite/group resolution.

Remove the duplicate NgxIndexedDB provider registration and add regression coverage for duplicate URL and earliest-match behavior.

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-05-01 20:44:30 +02:00
1 parent 6ef9e8c4b0
commit 1af287205f
9 files changed
+332 -55

No files matched your search

-3
View File
@@ -78,9 +78,6 @@ export const appConfig: ApplicationConfig = {
registrationStrategy: 'registerWhenStable:30000',
}),
importProvidersFrom(
AppConfig.environment === 'WEB'
? NgxIndexedDBModule.forRoot(dbConfig)
: [],
NgxIndexedDBModule.forRoot(dbConfig),
NgxSkeletonLoaderModule.forRoot({
animation: 'pulse',
@@ -0,0 +1,78 @@
import { Channel, Playlist } from 'shared-interfaces';
import { aggregateFavoriteChannels } from './playlist.utils';
function createChannel(id: string, url: string, name = id): Channel {
return {
group: { title: 'Group' },
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id,
name,
radio: 'false',
tvg: {
id,
logo: '',
name,
rec: '',
url: '',
},
url,
};
}
function createPlaylist(
id: string,
channels: Channel[],
favorites: Playlist['favorites']
): Playlist {
return {
_id: id,
autoRefresh: false,
count: channels.length,
favorites,
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
playlist: {
items: channels,
},
title: id,
};
}
describe('playlist utils', () => {
it('aggregates M3U favorite channels with constant-time id and URL lookups', () => {
const first = createChannel(
'channel-1',
'https://example.com/stream-1.m3u8',
'Channel One'
);
const second = createChannel(
'channel-2',
'https://example.com/stream-2.m3u8',
'Channel Two'
);
const third = createChannel(
'channel-3',
'https://example.com/stream-3.m3u8',
'Channel Three'
);
const result = aggregateFavoriteChannels([
createPlaylist(
'playlist-1',
[first, second],
['channel-1', 'missing-channel']
),
createPlaylist(
'playlist-2',
[third],
['https://example.com/stream-3.m3u8']
),
]);
expect(result).toEqual([first, third]);
});
});
+18 -12
View File
@@ -13,19 +13,25 @@ import { v4 as uuidv4 } from 'uuid';
*/
export function aggregateFavoriteChannels(playlists: Playlist[]): Channel[] {
const favorites: Channel[] = [];
playlists.forEach((playlist) => {
if (playlist.favorites?.length && playlist.favorites.length > 0) {
playlist?.playlist?.items.forEach((channel: Channel) => {
if (
playlist.favorites &&
(playlist.favorites.includes(channel.id) ||
playlist.favorites.includes(channel.url))
) {
favorites.push(channel);
}
});
for (const playlist of playlists) {
const favoriteIds = new Set(
(playlist.favorites ?? []).filter(
(favorite): favorite is string => typeof favorite === 'string'
)
);
if (favoriteIds.size === 0) {
continue;
}
});
for (const channel of playlist.playlist?.items ?? []) {
if (favoriteIds.has(channel.id) || favoriteIds.has(channel.url)) {
favorites.push(channel);
}
}
}
return favorites;
}
@@ -1,24 +1,72 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import {
convertToParamMap,
ActivatedRoute,
NavigationEnd,
Router,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of, Subject } from 'rxjs';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { ChannelActions, PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { Channel, PlaylistMeta } from 'shared-interfaces';
import { ChannelListContainerComponent } from './channel-list-container.component';
function createChannel(id: string, url: string): Channel {
return {
id,
url,
name: id,
group: { title: 'Group' },
tvg: {
id,
name: id,
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
};
}
function createTrackedUrlChannel(
id: string,
url: string,
onUrlRead: () => void
): Channel {
const channel = createChannel(id, url);
Object.defineProperty(channel, 'url', {
configurable: true,
enumerable: true,
get: () => {
onUrlRead();
return url;
},
});
return channel;
}
describe('ChannelListContainerComponent', () => {
let fixture: ComponentFixture<ChannelListContainerComponent>;
let dispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let favoriteChannelIds$: BehaviorSubject<string[]>;
beforeEach(async () => {
const routerEvents$ = new Subject<NavigationEnd>();
dispatch = jest.fn();
favoriteChannelIds$ = new BehaviorSubject<string[]>([]);
activePlaylistSignal = signal<PlaylistMeta | null>({
_id: 'playlist-1',
title: 'Playlist One',
@@ -77,7 +125,11 @@ describe('ChannelListContainerComponent', () => {
provide: Store,
useValue: {
dispatch,
select: jest.fn().mockReturnValue(of([])),
select: jest
.fn()
.mockReturnValue(
favoriteChannelIds$.asObservable()
),
selectSignal: jest.fn(() => signal(undefined)),
},
},
@@ -138,4 +190,46 @@ describe('ChannelListContainerComponent', () => {
})
);
});
it('maps favorite URLs with one channel lookup pass while preserving order and first duplicate match', async () => {
let urlReadCount = 0;
const firstDuplicate = createTrackedUrlChannel(
'first-duplicate',
'duplicate-url',
() => {
urlReadCount += 1;
}
);
const sports = createTrackedUrlChannel('sports', 'sports-url', () => {
urlReadCount += 1;
});
const secondDuplicate = createTrackedUrlChannel(
'second-duplicate',
'duplicate-url',
() => {
urlReadCount += 1;
}
);
fixture.componentInstance.channelList = [
firstDuplicate,
sports,
secondDuplicate,
];
favoriteChannelIds$.next([
'sports-url',
'missing-url',
'duplicate-url',
]);
urlReadCount = 0;
const favorites = await firstValueFrom(
fixture.componentInstance.favorites$
);
expect(favorites).toHaveLength(2);
expect(favorites[0]).toBe(sports);
expect(favorites[1]).toBe(firstDuplicate);
expect(urlReadCount).toBe(3);
});
});
@@ -71,6 +71,19 @@ function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
}, {});
}
function mapChannelsByFirstUrl(channels: Channel[]): Map<string, Channel> {
const channelsByUrl = new Map<string, Channel>();
for (const channel of channels) {
const channelUrl = channel.url;
if (!channelsByUrl.has(channelUrl)) {
channelsByUrl.set(channelUrl, channel);
}
}
return channelsByUrl;
}
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
@@ -174,7 +187,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
}
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId = this.playlistContext.resolvedPlaylistId;
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
readonly hiddenGroupTitles = computed(() => {
@@ -256,11 +270,11 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
this.channelList$,
]).pipe(
map(([favoriteChannelIds, channelList]) => {
const channelsByUrl = mapChannelsByFirstUrl(channelList);
return favoriteChannelIds
.map((favoriteChannelId) =>
channelList.find(
(channel) => channel.url === favoriteChannelId
)
channelsByUrl.get(favoriteChannelId)
)
.filter((channel): channel is Channel => channel !== undefined);
})
@@ -336,10 +350,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
this.channelEpgMap.set(epgMap);
this.channelIconMap.set(
new Map(
Array.from(metadataMap.entries(), ([channelId, metadata]) => [
channelId,
metadata?.iconUrl?.trim() || '',
])
Array.from(
metadataMap.entries(),
([channelId, metadata]) => [
channelId,
metadata?.iconUrl?.trim() || '',
]
)
)
);
});
@@ -314,6 +314,32 @@ describe('GroupsViewComponent', () => {
expect(component.selectedGroup()?.key).toBe('News');
});
it('keeps the first grouped-channel match for duplicate active channel URLs', () => {
const movieMirror = createChannel(
'mirror-1',
'Movie Mirror',
'http://example.com/shared-stream.m3u8',
'Movies'
);
const sportsMirror = createChannel(
'mirror-2',
'Sports Mirror',
'http://example.com/shared-stream.m3u8',
'Sports'
);
setInputs({
activeChannelUrl: 'http://example.com/shared-stream.m3u8',
groupedChannels: {
Movies: [movieMirror],
Sports: [sportsMirror],
},
});
expect(component.activeChannelGroupKey()).toBe('Movies');
expect(component.selectedGroupKey()).toBe('Movies');
});
it('retains a visible manual selection and falls back to the first visible group', () => {
component.selectGroup('Movies');
fixture.detectChanges();
@@ -261,13 +261,12 @@ export class GroupsViewComponent {
const groups = this.visibleGroups();
if (!term) {
return groups
.map((group) => ({
channels: group.channels,
count: group.count,
key: group.key,
titleMatches: false,
}));
return groups.map((group) => ({
channels: group.channels,
count: group.count,
key: group.key,
titleMatches: false,
}));
}
return groups.reduce<FilteredGroupView[]>((acc, group) => {
@@ -341,20 +340,30 @@ export class GroupsViewComponent {
});
});
private readonly groupKeyByChannelUrl = computed(() => {
const groupKeys = new Map<string, string>();
for (const [groupKey, channels] of Object.entries(
this.groupedChannels()
)) {
for (const channel of channels) {
const channelUrl = channel.url;
if (!groupKeys.has(channelUrl)) {
groupKeys.set(channelUrl, groupKey);
}
}
}
return groupKeys;
});
readonly activeChannelGroupKey = computed(() => {
const activeChannelUrl = this.activeChannelUrl();
if (!activeChannelUrl) {
return null;
}
const grouped = this.groupedChannels();
for (const [groupKey, channels] of Object.entries(grouped)) {
if (channels.some((channel) => channel.url === activeChannelUrl)) {
return groupKey;
}
}
return null;
return this.groupKeyByChannelUrl().get(activeChannelUrl) ?? null;
});
selectGroup(groupKey: string): void {
@@ -233,6 +233,39 @@ describe('DashboardDataService', () => {
);
});
it('keeps the earliest matching M3U favorite id when channel id and URL both match', async () => {
const channel = playlistMock.playlist.items[0];
const channelUrl = channel.url;
const channelId = channel.id;
playlistsSignal.set([
{
...createDefaultPlaylists()[0],
favorites: [channelUrl, channelId],
},
]);
playlistsServiceMock.getPlaylistById.mockReturnValue(
of({
...playlistMock,
favorites: [channelUrl, channelId],
playlist: {
items: [channel],
},
} as Playlist)
);
await service.reloadGlobalFavorites();
expect(
service.globalFavoriteItems().find((item) => item.source === 'm3u')
).toEqual(
expect.objectContaining({
id: channelUrl,
title: 'Channel One',
})
);
});
it('includes Xtream movies and series in global favorite items', async () => {
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
@@ -423,9 +456,9 @@ describe('DashboardDataService', () => {
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['xtream-1', 'm3u-fresh']
);
expect(
service.recentPlaylists().map((playlist) => playlist._id)
).toEqual(['xtream-1', 'm3u-fresh']);
});
it('includes M3U, Xtream, and Stalker sources when all have recent activity', async () => {
@@ -490,9 +523,9 @@ describe('DashboardDataService', () => {
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['xtream-1', 'stalker-1', 'm3u-1']
);
expect(
service.recentPlaylists().map((playlist) => playlist._id)
).toEqual(['xtream-1', 'stalker-1', 'm3u-1']);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
@@ -529,9 +562,9 @@ describe('DashboardDataService', () => {
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['m3u-1', 'stalker-1', 'xtream-1']
);
expect(
service.recentPlaylists().map((playlist) => playlist._id)
).toEqual(['m3u-1', 'stalker-1', 'xtream-1']);
});
it('maps recently added Xtream rows for the widget', async () => {
@@ -95,7 +95,9 @@ export class DashboardDataService {
readonly xtreamPlaylistCount = computed(
() => this.playlists().filter((playlist) => !!playlist.serverUrl).length
);
readonly hasXtreamPlaylists = computed(() => this.xtreamPlaylistCount() > 0);
readonly hasXtreamPlaylists = computed(
() => this.xtreamPlaylistCount() > 0
);
readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly();
readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly();
readonly globalFavoritesLoading =
@@ -775,13 +777,28 @@ export class DashboardDataService {
const fallbackTimestamp =
this.getM3uFavoriteTimestamp(playlistMeta) ??
new Date(0).toISOString();
const favoritePositions = new Map<string, number>();
favorites.forEach((favorite, index) => {
if (!favoritePositions.has(favorite)) {
favoritePositions.set(favorite, index);
}
});
return items.reduce<DashboardFavoriteItem[]>((acc, channel) => {
const channelId = String(channel.id ?? '').trim();
const channelUrl = String(channel.url ?? '').trim();
const matchedFavoriteId = favorites.find(
(favorite) => favorite === channelId || favorite === channelUrl
);
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) {
return acc;