diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 49f18665b..ddb29cf31 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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', diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts new file mode 100644 index 000000000..a30525e98 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts @@ -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]); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index aff6f8192..5da5eff2d 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -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; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 44e20543d..b6982a124 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -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; let dispatch: jest.Mock; let activePlaylistSignal: ReturnType>; + let favoriteChannelIds$: BehaviorSubject; beforeEach(async () => { const routerEvents$ = new Subject(); dispatch = jest.fn(); + favoriteChannelIds$ = new BehaviorSubject([]); activePlaylistSignal = signal({ _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); + }); }); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 49f145458..f94ee2e75 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -71,6 +71,19 @@ function groupChannelsByTitle(channels: Channel[]): Record { }, {}); } +function mapChannelsByFirstUrl(channels: Channel[]): Map { + const channelsByUrl = new Map(); + + 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() || '', + ] + ) ) ); }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index 78a3cf7d6..aaff1b53c 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -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(); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 5b5820397..bd9dcf168 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -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((acc, group) => { @@ -341,20 +340,30 @@ export class GroupsViewComponent { }); }); + private readonly groupKeyByChannelUrl = computed(() => { + const groupKeys = new Map(); + + 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 { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 9cd36831e..d1cf685de 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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 () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 70c95af7e..d3244d9e7 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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(); + + favorites.forEach((favorite, index) => { + if (!favoritePositions.has(favorite)) { + favoritePositions.set(favorite, index); + } + }); return items.reduce((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;