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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 17:02:27 +02:00
1 parent 378e57add7
commit a49e56f5de
7 files changed
+68 -14

No files matched your search

@@ -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({});
@@ -215,7 +215,9 @@ export class EpgGuideQueryService {
}
private emptyResult(channelIds: string[]): Record<string, EpgProgram[]> {
const result: Record<string, EpgProgram[]> = {};
// Null prototype: a provider key such as `__proto__` must become an
// own property, not hit the legacy setter.
const result: Record<string, EpgProgram[]> = Object.create(null);
for (const id of channelIds) {
result[id] = [];
}
@@ -293,7 +293,7 @@ export default class EpgEvents {
...args,
channelIds: resolvedIds,
});
const answer: Record<string, EpgProgram[]> = {};
const answer: Record<string, EpgProgram[]> = Object.create(null);
for (const id of requested) {
const key = this.resolvedGuideKey(mapping, id);
if (Object.prototype.hasOwnProperty.call(programs, key)) {
@@ -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(
@@ -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 {
@@ -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();
});
});
@@ -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();
}