mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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)) {
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+15
-10
@@ -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 {
|
||||
|
||||
+6
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user