From a84090026b4cce2b99be379a75050ace1263dac1 Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Sun, 28 Jun 2026 12:37:42 +0200
Subject: [PATCH] fix(epg): avoid duplicate track keys for archive rows (#1096)
* fix(epg): avoid duplicate track keys for archive rows
* refactor(epg): extract epg list helpers
---
.../src/lib/epg-list/epg-list.component.html | 2 +-
.../lib/epg-list/epg-list.component.spec.ts | 55 ++++++++++++++
.../src/lib/epg-list/epg-list.component.ts | 65 +++--------------
.../ui/epg/src/lib/epg-list/epg-list.utils.ts | 73 +++++++++++++++++++
4 files changed, 138 insertions(+), 57 deletions(-)
create mode 100644 libs/ui/epg/src/lib/epg-list/epg-list.utils.ts
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html
index 1e11b461c..6f56ef7eb 100644
--- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html
+++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html
@@ -35,7 +35,7 @@
@if (items.length > 0) {
- @for (program of items; track program.start) {
+ @for (program of items; track trackProgram($index, program)) {
@if (!canActivateProgram(program)) {
{
expect(emitted).toEqual([{ program: programs[0], type: 'timeshift' }]);
});
+ it('renders duplicate-start programs without duplicate track-key errors and keeps clicks bound to each row', () => {
+ const programs = [
+ buildProgram(
+ 'duplicate-a',
+ 'Duplicate Start A',
+ '2026-04-05T09:00:00.000Z',
+ '2026-04-05T09:30:00.000Z'
+ ),
+ buildProgram(
+ 'duplicate-b',
+ 'Duplicate Start B',
+ '2026-04-05T09:00:00.000Z',
+ '2026-04-05T10:00:00.000Z'
+ ),
+ buildProgram(
+ 'current',
+ 'Current Show',
+ '2026-04-05T11:30:00.000Z',
+ '2026-04-05T12:30:00.000Z'
+ ),
+ ];
+ const emitted: unknown[] = [];
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ component.programActivated.subscribe((event) => emitted.push(event));
+ fixture.componentRef.setInput('controlledPrograms', programs);
+ fixture.componentRef.setInput('archivePlaybackAvailable', true);
+
+ try {
+ expect(component.trackProgram(0, programs[0])).not.toBe(
+ component.trackProgram(1, programs[1])
+ );
+
+ fixture.detectChanges();
+
+ const duplicateTrackKeyError = consoleErrorSpy.mock.calls.some(
+ (args) => args.some((arg) => String(arg).includes('NG0955'))
+ );
+ const rows =
+ fixture.nativeElement.querySelectorAll(
+ '.program-item.clickable'
+ );
+ rows[1].click();
+
+ expect(duplicateTrackKeyError).toBe(false);
+ expect(rows).toHaveLength(3);
+ expect(emitted).toEqual([
+ { program: programs[1], type: 'timeshift' },
+ ]);
+ } finally {
+ consoleErrorSpy.mockRestore();
+ }
+ });
+
it('updates the selected-day header when the app language changes', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
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 6a886a8ec..890819a69 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
@@ -20,16 +20,22 @@ import { normalizeDateLocale } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgActions, selectActive } from '@iptvnator/m3u-state';
-import { format, subDays } from 'date-fns';
+import { subDays } from 'date-fns';
import { startWith } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
- EPG_DATE_KEY_FORMAT,
EpgDateNavigationDirection,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '../epg-date';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
+import {
+ areProgramsSame,
+ buildScrollContextKey,
+ getProgramDateKey,
+ getProgramTimeMs,
+ trackProgram,
+} from './epg-list.utils';
export interface EpgProgramActivationEvent {
program: EpgProgram;
@@ -62,6 +68,7 @@ export class EpgListComponent {
readonly showDateNavigator = input(true);
readonly programActivated = output();
readonly selectedDateChange = output();
+ readonly trackProgram = trackProgram;
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
@@ -371,57 +378,3 @@ export class EpgListComponent {
}
}
}
-
-function buildScrollContextKey(
- channel: Channel | null,
- programs: EpgProgram[]
-): string | null {
- if (!channel && programs.length === 0) {
- return null;
- }
-
- const channelKey =
- channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel';
- const programKey = programs
- .map(
- (program) =>
- `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}`
- )
- .join('|');
-
- return `${channelKey}:${programKey}`;
-}
-
-function getProgramTimeMs(
- isoValue: string,
- timestampValue?: number | null
-): number {
- if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) {
- return Number(timestampValue) * 1000;
- }
-
- return Date.parse(isoValue);
-}
-
-function getProgramDateKey(
- isoValue: string,
- timestampValue?: number | null
-): string {
- const programTimeMs = getProgramTimeMs(isoValue, timestampValue);
-
- if (!Number.isFinite(programTimeMs)) {
- return '';
- }
-
- return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
-}
-
-function areProgramsSame(left: EpgProgram, right: EpgProgram): boolean {
- return (
- (left.channel ?? '') === (right.channel ?? '') &&
- getProgramTimeMs(left.start, left.startTimestamp) ===
- getProgramTimeMs(right.start, right.startTimestamp) &&
- getProgramTimeMs(left.stop, left.stopTimestamp) ===
- getProgramTimeMs(right.stop, right.stopTimestamp)
- );
-}
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
new file mode 100644
index 000000000..a901f6d30
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts
@@ -0,0 +1,73 @@
+import { format } from 'date-fns';
+import type { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
+import { EPG_DATE_KEY_FORMAT } from '../epg-date';
+
+export function trackProgram(index: number, program: EpgProgram): string {
+ const start = getProgramTimeMs(program.start, program.startTimestamp);
+ const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
+
+ return [
+ program.channel ?? '',
+ Number.isFinite(start) ? start : program.start,
+ Number.isFinite(stop) ? stop : program.stop,
+ program.title ?? '',
+ index,
+ ].join('|');
+}
+
+export function buildScrollContextKey(
+ channel: Channel | null,
+ programs: EpgProgram[]
+): string | null {
+ if (!channel && programs.length === 0) {
+ return null;
+ }
+
+ const channelKey =
+ channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel';
+ const programKey = programs
+ .map(
+ (program) =>
+ `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}`
+ )
+ .join('|');
+
+ return `${channelKey}:${programKey}`;
+}
+
+export function getProgramTimeMs(
+ isoValue: string,
+ timestampValue?: number | null
+): number {
+ if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) {
+ return Number(timestampValue) * 1000;
+ }
+
+ return Date.parse(isoValue);
+}
+
+export function getProgramDateKey(
+ isoValue: string,
+ timestampValue?: number | null
+): string {
+ const programTimeMs = getProgramTimeMs(isoValue, timestampValue);
+
+ if (!Number.isFinite(programTimeMs)) {
+ return '';
+ }
+
+ return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
+}
+
+export function areProgramsSame(
+ left: EpgProgram,
+ right: EpgProgram
+): boolean {
+ return (
+ (left.channel ?? '') === (right.channel ?? '') &&
+ getProgramTimeMs(left.start, left.startTimestamp) ===
+ getProgramTimeMs(right.start, right.startTimestamp) &&
+ getProgramTimeMs(left.stop, left.stopTimestamp) ===
+ getProgramTimeMs(right.stop, right.stopTimestamp)
+ );
+}