From a49e56f5dee6a03356bf9a24e9c18a5731aeadd9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 17:02:27 +0200 Subject: [PATCH] fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers The store spreads the selected channel, so the active row is matched by id, url, group and name before widening; G no longer closes the guide from a dialog or menu; guide answers use null-prototype records so a key named __proto__ stays an own property. Co-Authored-By: Claude Fable 5.1 --- .../events/epg-guide-query.service.spec.ts | 30 +++++++++++++++++++ .../src/app/events/epg-guide-query.service.ts | 4 ++- .../src/app/events/epg.events.ts | 2 +- .../m3u-epg-guide-source.service.spec.ts | 10 +++++++ .../epg-guide/m3u-epg-guide-source.service.ts | 25 +++++++++------- .../video-player-guide-gating.spec.ts | 6 ++++ .../video-player/video-player.component.ts | 5 ++-- 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts index c19083b3d..5cbe297e4 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts @@ -549,6 +549,36 @@ describe('EpgGuideQueryService', () => { ).resolves.toEqual({ a: [] }); }); + it('keeps a provider key named __proto__ as an own response property', async () => { + getChannelMetadata.mockResolvedValue({ + __proto__: { id: 'proto.tv', displayName: 'Proto', iconUrl: null }, + }); + getDatabase.mockResolvedValue({ + select: programSelect( + [ + programRow( + 'proto.tv', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'Proto show' + ), + ], + [] + ), + }); + + const result = await service.getProgramsForChannels({ + channelIds: ['__proto__'], + fromMs: FROM, + toMs: TO, + }); + + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe( + true + ); + expect(result['__proto__'][0].title).toBe('Proto show'); + }); + it('drops channel keys cut by the per-request cap from the result entirely', async () => { const ids = Array.from({ length: 101 }, (_, index) => `ch-${index}`); getChannelMetadata.mockResolvedValue({}); diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.ts index 9e21f7617..36badde69 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -215,7 +215,9 @@ export class EpgGuideQueryService { } private emptyResult(channelIds: string[]): Record { - const result: Record = {}; + // Null prototype: a provider key such as `__proto__` must become an + // own property, not hit the legacy setter. + const result: Record = Object.create(null); for (const id of channelIds) { result[id] = []; } diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index a858fe907..2bac72f18 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -293,7 +293,7 @@ export default class EpgEvents { ...args, channelIds: resolvedIds, }); - const answer: Record = {}; + const answer: Record = Object.create(null); for (const id of requested) { const key = this.resolvedGuideKey(mapping, id); if (Object.prototype.hasOwnProperty.call(programs, key)) { diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index 5a1d1e5c2..86b7a6c13 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -180,6 +180,16 @@ describe('M3uEpgGuideSourceService', () => { expect(service.activeChannelId()).toBe('0:dup'); }); + it('marks the copy in the selected group when a stream is listed twice', () => { + const news = makeChannel('dup', { group: 'News' }); + const sports = makeChannel('dup', { group: 'Sports' }); + channels.set([news, sports]); + activeChannel.set({ ...sports }); + expect(service.activeChannelId()).toBe('1:dup'); + activeChannel.set({ ...news }); + expect(service.activeChannelId()).toBe('0:dup'); + }); + it('propagates a coverage failure so the guide keeps coverage unknown', async () => { getProgramCoverage.mockRejectedValue(new Error('bridge down')); await expect( diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index 3e7ae6398..abb5b490a 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -166,17 +166,22 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { if (!active) { return null; } - // Entries can share an id but not a stream: prefer the row whose - // url matches too, so selecting the second copy shows it active. - const rows = this.scopedRows(); - const exact = rows.find( - (row) => - row.channel.id === active.id && row.channel.url === active.url - ); - return ( - (exact ?? rows.find((row) => row.channel.id === active.id)) - ?.rowId ?? null + // The store spreads the selected channel, so identity is gone; the + // same stream can sit in two groups (same id and url), so narrow by + // the metadata that still tells copies apart before widening. + const sameId = this.scopedRows().filter( + (row) => row.channel.id === active.id ); + const match = + sameId.find( + (row) => + row.channel.url === active.url && + row.channel.group?.title === active.group?.title && + row.channel.name === active.name + ) ?? + sameId.find((row) => row.channel.url === active.url) ?? + sameId[0]; + return match?.rowId ?? null; }); bind(inputs: M3uEpgGuideInputs): void { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts index eaba192ea..68e546a0c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts @@ -222,5 +222,11 @@ describe('VideoPlayerComponent — guide gating', () => { overlayContainer.remove(); pressGuideKey(); expect(component.guideOpen()).toBe(true); + + // Once open, a G from inside a dialog must not close it either. + document.body.appendChild(overlayContainer); + pressGuideKey(dialogButton); + expect(component.guideOpen()).toBe(true); + overlayContainer.remove(); }); }); 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 104497558..73ed12cbb 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 @@ -1321,8 +1321,9 @@ export class VideoPlayerComponent !event.ctrlKey && !event.altKey; if (this.guideOpen()) { - // The guide owns the keyboard while open; only G toggles it off. - if (isGuideKey) { + // The guide owns the keyboard while open; only G toggles it off, + // and never from a dialog or menu that owns the keystroke. + if (isGuideKey && !this.isInsideOverlaySurface(event)) { event.preventDefault(); this.closeGuide(); }