From 3cb228d931d95d5ada920a07246764983ed5fbe1 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:34:51 +0200 Subject: [PATCH] fix(m3u): highlight active archive epg program (#1097) fix(m3u): highlight active archive epg program * Store the active archive EPG program with the resolved playback URL so the clicked M3U catch-up row stays highlighted during archive playback. * Show archive playback state and return-to-live controls in the inline player panel. * Hide duplicate EPG program rows that share the same time slot while keeping the richer provider entry. --- libs/m3u-state/src/lib/actions.ts | 5 +- libs/m3u-state/src/lib/effects.ts | 5 +- .../src/lib/reducers/channel.reducers.spec.ts | 27 ++++++- .../src/lib/reducers/channel.reducers.ts | 2 + .../src/lib/reducers/epg.reducers.spec.ts | 60 ++++++++++++++ .../src/lib/reducers/epg.reducers.ts | 14 +++- libs/m3u-state/src/lib/reducers/index.ts | 2 + libs/m3u-state/src/lib/selectors.spec.ts | 23 +++++- libs/m3u-state/src/lib/selectors.ts | 5 ++ libs/m3u-state/src/lib/state.ts | 2 + .../video-player/video-player.component.html | 5 ++ .../video-player.component.spec.ts | 79 +++++++++++++++++++ .../video-player/video-player.component.ts | 26 +++++- .../lib/epg-list/epg-list.component.spec.ts | 60 +++++++++++++- .../src/lib/epg-list/epg-list.component.ts | 25 +++--- .../ui/epg/src/lib/epg-list/epg-list.utils.ts | 52 ++++++++++++ 16 files changed, 372 insertions(+), 20 deletions(-) diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 2d70f0708..d2ea2baee 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -55,7 +55,10 @@ export const EpgActions = createActionGroup({ source: 'EPG', events: { 'Set Active Epg Program': props<{ program: EpgProgram }>(), - 'Set Active Playback Url': props<{ playbackUrl: string }>(), + 'Set Active Playback Url': props<{ + playbackUrl: string; + program?: EpgProgram; + }>(), 'Set Current Epg Program': props<{ program: EpgProgram }>(), 'Reset Active Epg Program': emptyProps(), 'Return To Live Playback': emptyProps(), diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 4f673e056..20f9765d5 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -118,7 +118,10 @@ export class PlaylistEffects { : null; return playbackUrl - ? EpgActions.setActivePlaybackUrl({ playbackUrl }) + ? EpgActions.setActivePlaybackUrl({ + playbackUrl, + program: action.program, + }) : EpgActions.resetActiveEpgProgram(); }) ); diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts index 32ae4778e..2eadc7e2f 100644 --- a/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts @@ -1,5 +1,5 @@ import { createReducer } from '@ngrx/store'; -import { Channel } from '@iptvnator/shared/interfaces'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; import { ChannelActions } from '../actions'; import { initialState } from '../state'; import { channelReducers } from './channel.reducers'; @@ -27,6 +27,17 @@ describe('channelReducers', () => { url: 'https://example.com/live.m3u8', } as Channel; + const archivedProgram: EpgProgram = { + channel: 'sample-tvg-id', + start: '2026-06-28T09:00:00.000Z', + stop: '2026-06-28T10:00:00.000Z', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1782637200, + stopTimestamp: 1782640800, + }; + it('tracks explicit channel loading state', () => { const nextState = reducer( initialState, @@ -50,4 +61,18 @@ describe('channelReducers', () => { expect(nextState.channels).toEqual([sampleChannel]); expect(nextState.channelsLoading).toBe(false); }); + + it('clears archive playback state when a new channel becomes active', () => { + const nextState = reducer( + { + ...initialState, + activePlaybackUrl: 'https://archive.example.com/catchup.m3u8', + activeEpgProgram: archivedProgram, + }, + ChannelActions.setActiveChannelSuccess({ channel: sampleChannel }) + ); + + expect(nextState.activePlaybackUrl).toBeNull(); + expect(nextState.activeEpgProgram).toBeUndefined(); + }); }); diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.ts index 52554d5fd..fef980442 100644 --- a/libs/m3u-state/src/lib/reducers/channel.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.ts @@ -18,6 +18,7 @@ export const channelReducers = [ ...state, active: { ...channel, epgParams: '' } as Channel, activePlaybackUrl: null, + activeEpgProgram: undefined, }; } ), @@ -26,6 +27,7 @@ export const channelReducers = [ ...state, active: undefined, activePlaybackUrl: null, + activeEpgProgram: undefined, }; }), on(ChannelActions.setChannels, (state, action): PlaylistState => { diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts index d7164f018..8061c3022 100644 --- a/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts @@ -1,4 +1,5 @@ import { createReducer } from '@ngrx/store'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgActions } from '../actions'; import { initialState } from '../state'; import { epgReducers } from './epg.reducers'; @@ -6,6 +7,17 @@ import { epgReducers } from './epg.reducers'; const reducer = createReducer(initialState, ...epgReducers); describe('epgReducers', () => { + const archivedProgram: EpgProgram = { + channel: 'sample-tv', + start: '2026-06-28T09:00:00.000Z', + stop: '2026-06-28T10:00:00.000Z', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1782637200, + stopTimestamp: 1782640800, + }; + it('does not recreate the active channel when epg params are already empty', () => { const state = { ...initialState, @@ -108,4 +120,52 @@ describe('epgReducers', () => { ); expect(nextState.active?.url).toBe('https://example.com/live.m3u8'); }); + + it('stores the active archive program with the resolved playback url', () => { + const nextState = reducer( + initialState, + EpgActions.setActivePlaybackUrl({ + playbackUrl: 'https://archive.example.com/catchup.m3u8', + program: archivedProgram, + }) + ); + + expect(nextState.activePlaybackUrl).toBe( + 'https://archive.example.com/catchup.m3u8' + ); + expect(nextState.activeEpgProgram).toEqual(archivedProgram); + }); + + it('clears the active archive program when returning to live playback', () => { + const state = { + ...initialState, + active: { + id: 'channel-1', + name: 'ARD-alpha', + url: 'https://example.com/live.m3u8', + tvg: { + id: '', + name: 'ARD-alpha', + url: '', + logo: '', + rec: '', + }, + group: { title: '' }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: 'false', + epgParams: '', + }, + activePlaybackUrl: 'https://archive.example.com/catchup.m3u8', + activeEpgProgram: archivedProgram, + }; + + const nextState = reducer(state, EpgActions.returnToLivePlayback()); + + expect(nextState.activePlaybackUrl).toBeNull(); + expect(nextState.activeEpgProgram).toBeUndefined(); + }); }); diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.ts index 946a8694a..74ebc4d38 100644 --- a/libs/m3u-state/src/lib/reducers/epg.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/epg.reducers.ts @@ -7,22 +7,32 @@ export const epgReducers = [ on(EpgActions.setActivePlaybackUrl, (state, action): PlaylistState => ({ ...state, activePlaybackUrl: action.playbackUrl, + activeEpgProgram: action.program, })), on( EpgActions.resetActiveEpgProgram, EpgActions.returnToLivePlayback, (state): PlaylistState => { - if (!state.active && !state.activePlaybackUrl) { + if ( + !state.active && + !state.activePlaybackUrl && + !state.activeEpgProgram + ) { return state; } - if (!state.activePlaybackUrl && !state.active?.epgParams) { + if ( + !state.activePlaybackUrl && + !state.activeEpgProgram && + !state.active?.epgParams + ) { return state; } return { ...state, activePlaybackUrl: null, + activeEpgProgram: undefined, active: state.active ? ({ ...state.active, epgParams: '' } as Channel) : undefined, diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts index 177a008f3..cac5d90ac 100644 --- a/libs/m3u-state/src/lib/reducers/index.ts +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -25,6 +25,8 @@ export const selectIsEpgAvailableReducer = (state: PlaylistState) => export const selectActiveReducer = (state: PlaylistState) => state.active; export const selectActivePlaybackUrlReducer = (state: PlaylistState) => state.activePlaybackUrl; +export const selectActiveEpgProgramReducer = (state: PlaylistState) => + state.activeEpgProgram; export const selectCurrentEpgProgramReducer = (state: PlaylistState) => state.currentEpgProgram; export const selectChannelsLoadingReducer = (state: PlaylistState) => diff --git a/libs/m3u-state/src/lib/selectors.spec.ts b/libs/m3u-state/src/lib/selectors.spec.ts index 6aa66a5f9..1da02a06d 100644 --- a/libs/m3u-state/src/lib/selectors.spec.ts +++ b/libs/m3u-state/src/lib/selectors.spec.ts @@ -1,6 +1,10 @@ import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { PlaylistState } from './state'; -import { selectActivePlaylist, selectChannelsLoading } from './selectors'; +import { + selectActiveEpgProgram, + selectActivePlaylist, + selectChannelsLoading, +} from './selectors'; describe('selectors', () => { const playlistOne = { @@ -42,4 +46,21 @@ describe('selectors', () => { ).toBe(false); }); }); + + describe('selectActiveEpgProgram', () => { + it('returns the active archive EPG program', () => { + const program = { + channel: 'sample-tvg-id', + start: '2026-06-28T09:00:00.000Z', + stop: '2026-06-28T10:00:00.000Z', + title: 'Archived Show', + }; + + expect( + selectActiveEpgProgram.projector({ + activeEpgProgram: program, + } as PlaylistState) + ).toBe(program); + }); + }); }); diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts index 6edea8183..0aad2b321 100644 --- a/libs/m3u-state/src/lib/selectors.ts +++ b/libs/m3u-state/src/lib/selectors.ts @@ -22,6 +22,11 @@ export const selectActivePlaybackUrl = createSelector( fromPlaylistState.selectActivePlaybackUrlReducer ); +export const selectActiveEpgProgram = createSelector( + selectPlaylistState, + fromPlaylistState.selectActiveEpgProgramReducer +); + export const selectCurrentEpgProgram = createSelector( selectPlaylistState, fromPlaylistState.selectCurrentEpgProgramReducer diff --git a/libs/m3u-state/src/lib/state.ts b/libs/m3u-state/src/lib/state.ts index 7daf9217d..ad2bd3b4a 100644 --- a/libs/m3u-state/src/lib/state.ts +++ b/libs/m3u-state/src/lib/state.ts @@ -6,6 +6,7 @@ import { EpgProgram } from '@iptvnator/shared/interfaces'; export interface PlaylistState { active: Channel | undefined; activePlaybackUrl: string | null; + activeEpgProgram: EpgProgram | undefined; currentEpgProgram: EpgProgram | undefined; epgAvailable: boolean; channelsLoading: boolean; @@ -16,6 +17,7 @@ export interface PlaylistState { export const initialState: PlaylistState = { active: undefined, activePlaybackUrl: null, + activeEpgProgram: undefined, currentEpgProgram: undefined, epgAvailable: false, channelsLoading: false, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index a5d66acc1..fbc1bec44 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -101,15 +101,19 @@
(null); readonly loading = input(false); + readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); readonly showDateNavigator = input(false); readonly selectedDate = input(null); + readonly showReturnToLive = input(false); readonly collapsedChange = output(); readonly dateNavigation = output<'next' | 'prev'>(); + readonly returnToLive = output(); } @Component({ @@ -141,6 +146,7 @@ class StubWebPlayerViewComponent { template: '', }) class StubEpgListComponent { + readonly activeProgram = input(null); readonly selectedDate = input(null); readonly showDateNavigator = input(false); readonly archivePlaybackAvailable = input(false); @@ -172,6 +178,7 @@ describe('VideoPlayerComponent', () => { const channels = signal([]); const channelsLoading = signal(false); const currentEpgProgram = signal(null); + const activeEpgProgram = signal(null); const channels$ = new BehaviorSubject([]); const activeChannel$ = new BehaviorSubject(null); @@ -211,6 +218,8 @@ describe('VideoPlayerComponent', () => { return channelsLoading; case selectCurrentEpgProgram: return currentEpgProgram; + case selectActiveEpgProgram: + return activeEpgProgram; default: return signal(null); } @@ -302,6 +311,7 @@ describe('VideoPlayerComponent', () => { activePlaybackUrl.set(null); channelsLoading.set(false); currentEpgProgram.set(null); + activeEpgProgram.set(null); currentEpgProgram$.next(null); overlayMock.create.mockClear(); overlayRef.attach.mockClear(); @@ -664,11 +674,80 @@ describe('VideoPlayerComponent', () => { expect(component.playbackChannel()?.url).toBe( 'http://localhost/archive.m3u8?utc=123&lutc=456' ); + expect(component.embeddedPlayback()?.isLive).toBe(false); activePlaybackUrl.set(null); fixture.detectChanges(); expect(component.playbackChannel()?.url).toBe(sampleChannel.url); + expect(component.embeddedPlayback()?.isLive).toBe(true); + }); + + it('passes the active archive EPG program to the EPG list for highlighting', () => { + const archivedProgram: EpgProgram = { + channel: 'sample-tvg-id', + start: '2026-06-28T09:00:00.000Z', + stop: '2026-06-28T10:00:00.000Z', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1782637200, + stopTimestamp: 1782640800, + }; + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456'); + activeEpgProgram.set(archivedProgram); + + fixture.detectChanges(); + + const epgList = fixture.debugElement.query( + By.directive(StubEpgListComponent) + ); + + expect(epgList.componentInstance.activeProgram()).toEqual( + archivedProgram + ); + }); + + it('shows the active archive EPG program in the inline panel summary', () => { + const archivedProgram = buildProgram('Archived Show'); + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456'); + activeEpgProgram.set(archivedProgram); + + fixture.detectChanges(); + + const panel = fixture.debugElement.query( + By.directive(StubLiveEpgPanelComponent) + ); + + expect(panel.componentInstance.summary()).toEqual( + expect.objectContaining({ title: 'Archived Show' }) + ); + expect(panel.componentInstance.summaryLabelKey()).toBe( + 'EPG.ARCHIVE_PLAYBACK' + ); + expect(panel.componentInstance.showReturnToLive()).toBe(true); + }); + + it('dispatches return-to-live from the inline EPG panel', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456'); + activeEpgProgram.set(buildProgram('Archived Show')); + + fixture.detectChanges(); + + const panel = fixture.debugElement.query( + By.directive(StubLiveEpgPanelComponent) + ); + panel.componentInstance.returnToLive.emit(); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + EpgActions.returnToLivePlayback() + ); }); it('updates the outer sidebar width while grouped view requests a larger total width', () => { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index f8f7ebfdd..dbf067684 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -33,9 +33,11 @@ import { } from '@iptvnator/ui/epg'; import { ChannelActions, + EpgActions, PlaylistActions, buildExternalPlayerPayload, selectActive, + selectActiveEpgProgram, selectActivePlaybackUrl, selectChannels, selectChannelsLoading, @@ -150,6 +152,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly activePlaybackUrl = this.store.selectSignal( selectActivePlaybackUrl ); + readonly activeEpgProgram = this.store.selectSignal( + selectActiveEpgProgram + ); + readonly activeEpgProgramOrNull = computed( + () => this.activeEpgProgram() ?? null + ); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; readonly channels = this.store.selectSignal(selectChannels); readonly channelsLoading = this.store.selectSignal(selectChannelsLoading); @@ -200,7 +208,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { activeChannel.tvg?.name || playbackTarget.url, thumbnail: activeChannel.tvg?.logo ?? null, - isLive: true, + isLive: !this.activePlaybackUrl(), headers: Object.keys(headers).length > 0 ? headers : undefined, userAgent: http['user-agent'] || undefined, referer: http.referrer || undefined, @@ -237,7 +245,17 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Current epg program */ readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram); readonly liveEpgPanelSummary = computed(() => - this.toLiveEpgPanelSummary(this.epgProgram()) + this.toLiveEpgPanelSummary( + this.activeEpgProgramOrNull() ?? this.epgProgram() + ) + ); + readonly liveEpgPanelSummaryLabelKey = computed(() => + this.activeEpgProgramOrNull() + ? 'EPG.ARCHIVE_PLAYBACK' + : 'EPG.CURRENT_PROGRAM' + ); + readonly showReturnToLive = computed( + () => this.activeEpgProgramOrNull() !== null ); /** Active M3U view (all, groups, favorites, recent) */ @@ -539,6 +557,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.selectedLiveEpgDate.set(selectedDate); } + returnToLivePlayback(): void { + this.store.dispatch(EpgActions.returnToLivePlayback()); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts index 67084cd3b..9db0c2721 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts @@ -321,6 +321,62 @@ describe('EpgListComponent', () => { } }); + it('shows one row per duplicated time slot and keeps the richer program entry', () => { + const sparseProgram = buildProgram( + 'duplicate-a', + 'World Cup Breakfast', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z', + { + channel: 'channel-101', + desc: null, + } + ); + const richProgram = buildProgram( + 'duplicate-b', + 'World Cup Breakfast', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z', + { + channel: 'channel-101', + desc: 'A preview of the day ahead.', + category: 'Sports', + } + ); + const nextProgram = buildProgram( + 'next', + 'World Cup Matchday', + '2026-04-05T10:00:00.000Z', + '2026-04-05T11:00:00.000Z', + { + channel: 'channel-101', + } + ); + const emitted: unknown[] = []; + component.programActivated.subscribe((event) => emitted.push(event)); + fixture.componentRef.setInput('controlledPrograms', [ + sparseProgram, + richProgram, + nextProgram, + ]); + fixture.componentRef.setInput('archivePlaybackAvailable', true); + + fixture.detectChanges(); + + const rows = + fixture.nativeElement.querySelectorAll( + '.program-item' + ); + rows[0].click(); + + expect(component.filteredItems()).toEqual([ + richProgram, + nextProgram, + ]); + expect(rows).toHaveLength(2); + expect(emitted).toEqual([{ program: richProgram, type: 'timeshift' }]); + }); + it('updates the selected-day header when the app language changes', () => { fixture.componentRef.setInput('controlledPrograms', buildPrograms()); fixture.detectChanges(); @@ -389,7 +445,8 @@ function buildProgram( channelSuffix: string, title: string, start: string, - stop: string + stop: string, + overrides: Partial = {} ): EpgProgram { return { start, @@ -400,6 +457,7 @@ function buildProgram( category: null, startTimestamp: Math.floor(Date.parse(start) / 1000), stopTimestamp: Math.floor(Date.parse(stop) / 1000), + ...overrides, }; } diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index 890819a69..739093f0f 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -32,6 +32,7 @@ import { EpgListItemComponent } from './epg-list-item/epg-list-item.component'; import { areProgramsSame, buildScrollContextKey, + deduplicateProgramsByTimeSlot, getProgramDateKey, getProgramTimeMs, trackProgram, @@ -144,17 +145,19 @@ export class EpgListComponent { () => this.archivePlaybackAvailable() ?? this.archiveDays() > 0 ); readonly filteredItems = computed(() => - [...this.items()] - .filter( - (item) => - getProgramDateKey(item.start, item.startTimestamp) === - this.selectedDateKey() - ) - .sort( - (left, right) => - getProgramTimeMs(left.start, left.startTimestamp) - - getProgramTimeMs(right.start, right.startTimestamp) - ) + deduplicateProgramsByTimeSlot( + [...this.items()] + .filter( + (item) => + getProgramDateKey(item.start, item.startTimestamp) === + this.selectedDateKey() + ) + .sort( + (left, right) => + getProgramTimeMs(left.start, left.startTimestamp) - + getProgramTimeMs(right.start, right.startTimestamp) + ) + ) ); private scrollScheduled = false; diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts index a901f6d30..463181137 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts @@ -15,6 +15,26 @@ export function trackProgram(index: number, program: EpgProgram): string { ].join('|'); } +export function deduplicateProgramsByTimeSlot( + programs: EpgProgram[] +): EpgProgram[] { + const programsByTimeSlot = new Map(); + + for (const program of programs) { + const timeSlotKey = buildProgramTimeSlotKey(program); + const existingProgram = programsByTimeSlot.get(timeSlotKey); + + programsByTimeSlot.set( + timeSlotKey, + existingProgram + ? selectMoreInformativeProgram(existingProgram, program) + : program + ); + } + + return Array.from(programsByTimeSlot.values()); +} + export function buildScrollContextKey( channel: Channel | null, programs: EpgProgram[] @@ -71,3 +91,35 @@ export function areProgramsSame( getProgramTimeMs(right.stop, right.stopTimestamp) ); } + +function buildProgramTimeSlotKey(program: EpgProgram): string { + return [ + getProgramTimeMs(program.start, program.startTimestamp), + getProgramTimeMs(program.stop, program.stopTimestamp), + ].join('|'); +} + +function selectMoreInformativeProgram( + existingProgram: EpgProgram, + candidateProgram: EpgProgram +): EpgProgram { + return getProgramMetadataScore(candidateProgram) > + getProgramMetadataScore(existingProgram) + ? candidateProgram + : existingProgram; +} + +function getProgramMetadataScore(program: EpgProgram): number { + return ( + getTextScore(program.desc) * 8 + + getTextScore(program.category) * 4 + + getTextScore(program.iconUrl) * 2 + + getTextScore(program.rating) * 2 + + getTextScore(program.episodeNum) * 2 + + getTextScore(program.title) + ); +} + +function getTextScore(value: string | null | undefined): number { + return value?.trim() ? 1 : 0; +}