mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
1 parent
6ef9e8c4b0
commit
1af287205f
9 files changed
+332
-55
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+98
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
-8
@@ -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() || '',
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
+26
@@ -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();
|
||||
|
||||
+24
-15
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user