mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(epg): key guide IPC answers by trimmed, present keys only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
59f27d1549
commit
53df760cd8
5 files changed
+251
-27
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import type EpgEventsType from './epg.events';
|
||||
|
||||
const getProgramsForChannels = jest.fn();
|
||||
const getProgramCoverage = jest.fn();
|
||||
const resolveChannelIds = jest.fn();
|
||||
const ipcHandlers = new Map<
|
||||
string,
|
||||
(event: unknown, args: unknown) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [],
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./epg-guide-query.service', () => ({
|
||||
epgGuideQueryService: {
|
||||
getProgramsForChannels: (...args: unknown[]) =>
|
||||
getProgramsForChannels(...args),
|
||||
getProgramCoverage: (...args: unknown[]) =>
|
||||
getProgramCoverage(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./epg-mapping.service', () => ({
|
||||
resolveChannelIds: (...args: unknown[]) => resolveChannelIds(...args),
|
||||
queryByResolvedChannelIds: jest.fn(),
|
||||
handleGetEpgMapping: jest.fn(),
|
||||
handleGetEpgMappingsBatch: jest.fn(),
|
||||
handleSetEpgMapping: jest.fn(),
|
||||
handleDeleteEpgMapping: jest.fn(),
|
||||
handleSearchEpgChannels: jest.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` apply manual
|
||||
* mappings (`resolveChannelIds`) before delegating to `epgGuideQueryService`,
|
||||
* then key the answer back by the verbatim requested id. The service keys
|
||||
* its own answer by the TRIMMED, deduplicated form of the ids it was given
|
||||
* (`normalizeGuideWindow`), so the handler must resolve the same trimmed key
|
||||
* to find that answer — these tests pin the trim mismatch, the "absent
|
||||
* means not queried" contract, and the coverage de-duplication.
|
||||
*/
|
||||
describe('EPG guide IPC handlers', () => {
|
||||
let EpgEvents: typeof EpgEventsType;
|
||||
|
||||
const program = {
|
||||
channelId: 'xmltv.id',
|
||||
title: 'News',
|
||||
start: '2026-01-01T00:00:00.000Z',
|
||||
stop: '2026-01-01T01:00:00.000Z',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
ipcHandlers.clear();
|
||||
[getProgramsForChannels, getProgramCoverage, resolveChannelIds].forEach(
|
||||
(mock) => mock.mockReset()
|
||||
);
|
||||
resolveChannelIds.mockResolvedValue(new Map());
|
||||
|
||||
({ default: EpgEvents } = await import('./epg.events'));
|
||||
|
||||
const { ipcMain } = jest.requireMock('electron');
|
||||
(ipcMain.handle as jest.Mock).mockReset();
|
||||
(ipcMain.handle as jest.Mock).mockImplementation(
|
||||
(
|
||||
channel: string,
|
||||
handler: (event: unknown, args: unknown) => Promise<unknown>
|
||||
) => {
|
||||
ipcHandlers.set(channel, handler);
|
||||
}
|
||||
);
|
||||
|
||||
EpgEvents.bootstrapEpgEvents();
|
||||
});
|
||||
|
||||
function invoke(channel: string, args: unknown): Promise<unknown> {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`No IPC handler registered for "${channel}"`);
|
||||
}
|
||||
return handler({}, args);
|
||||
}
|
||||
|
||||
it('applies a manual mapping before querying and keys the answer back by the requested key', async () => {
|
||||
resolveChannelIds.mockResolvedValue(
|
||||
new Map([['m3u-key', 'xmltv.id']])
|
||||
);
|
||||
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['m3u-key'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(getProgramsForChannels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelIds: ['xmltv.id'] })
|
||||
);
|
||||
expect(result).toEqual({ 'm3u-key': [program] });
|
||||
});
|
||||
|
||||
it('gives two requested keys resolving to one target their own array reference', async () => {
|
||||
resolveChannelIds.mockResolvedValue(
|
||||
new Map([
|
||||
['a', 'xmltv.id'],
|
||||
['b', 'xmltv.id'],
|
||||
])
|
||||
);
|
||||
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
|
||||
|
||||
const result = (await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['a', 'b'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
})) as Record<string, unknown[]>;
|
||||
|
||||
expect(result['a']).toEqual([program]);
|
||||
expect(result['b']).toEqual([program]);
|
||||
expect(result['a']).not.toBe(result['b']);
|
||||
});
|
||||
|
||||
it('trims a padded requested id to find the service answer keyed by the trimmed form', async () => {
|
||||
getProgramsForChannels.mockResolvedValue({ CNN: [program] });
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: [' CNN '],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(getProgramsForChannels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelIds: [' CNN '] })
|
||||
);
|
||||
expect(result).toEqual({ ' CNN ': [program] });
|
||||
});
|
||||
|
||||
it('omits a key the service did not answer for instead of filling it with []', async () => {
|
||||
getProgramsForChannels.mockResolvedValue({});
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['dropped'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('de-duplicates requested coverage keys and keeps only the covered ones', async () => {
|
||||
getProgramCoverage.mockResolvedValue(['a']);
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAM_COVERAGE', {
|
||||
channelIds: ['a', 'a', 'b'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -259,30 +259,58 @@ export default class EpgEvents {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The guide query service keys its answer by the TRIMMED, deduplicated,
|
||||
* cap-respecting form of the channel ids it was given
|
||||
* (`normalizeGuideWindow`). Handlers must resolve the same trimmed key
|
||||
* to look up that answer, or a padded request id (`" CNN "`) would never
|
||||
* find its entry even though the service queried it successfully.
|
||||
*/
|
||||
private static resolvedGuideKey(
|
||||
mapping: Map<string, string>,
|
||||
id: unknown
|
||||
): string {
|
||||
return typeof id === 'string' ? (mapping.get(id) ?? id).trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guide reads take playlist channel keys. Manual mappings are applied
|
||||
* here, before the query, and the answer is keyed back by the requested
|
||||
* key so the renderer never sees a mapped id.
|
||||
* key so the renderer never sees a mapped id. A key the service did not
|
||||
* answer for (cut by its per-request cap) stays absent from the result
|
||||
* rather than being filled with `[]`, so callers can tell "queried,
|
||||
* nothing found" apart from "not queried at all".
|
||||
*/
|
||||
private static async handleGetGuidePrograms(
|
||||
args: ElectronBridgeEpgGuideWindow
|
||||
): Promise<Record<string, EpgProgram[]>> {
|
||||
const requested = Array.isArray(args?.channelIds) ? args.channelIds : [];
|
||||
const requested = Array.isArray(args?.channelIds)
|
||||
? args.channelIds
|
||||
: [];
|
||||
const mapping = await resolveChannelIds(requested);
|
||||
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
|
||||
const programs = await epgGuideQueryService.getProgramsForChannels({
|
||||
...args,
|
||||
channelIds: resolvedIds,
|
||||
});
|
||||
return Object.fromEntries(
|
||||
requested.map((id) => [id, programs[mapping.get(id) ?? id] ?? []])
|
||||
);
|
||||
const answer: Record<string, EpgProgram[]> = {};
|
||||
for (const id of requested) {
|
||||
const key = this.resolvedGuideKey(mapping, id);
|
||||
if (Object.prototype.hasOwnProperty.call(programs, key)) {
|
||||
// Copy so two requested ids resolving to one target never
|
||||
// share an array reference.
|
||||
answer[String(id)] = [...programs[key]];
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
private static async handleGetGuideCoverage(
|
||||
args: ElectronBridgeEpgGuideWindow
|
||||
): Promise<string[]> {
|
||||
const requested = Array.isArray(args?.channelIds) ? args.channelIds : [];
|
||||
const requested = Array.isArray(args?.channelIds)
|
||||
? args.channelIds
|
||||
: [];
|
||||
const mapping = await resolveChannelIds(requested);
|
||||
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
|
||||
const covered = new Set(
|
||||
@@ -291,7 +319,18 @@ export default class EpgEvents {
|
||||
channelIds: resolvedIds,
|
||||
})
|
||||
);
|
||||
return requested.filter((id) => covered.has(mapping.get(id) ?? id));
|
||||
const seen = new Set<string>();
|
||||
const answer: string[] = [];
|
||||
for (const id of requested) {
|
||||
if (seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
if (covered.has(this.resolvedGuideKey(mapping, id))) {
|
||||
answer.push(id);
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
static async clearEpgData(): Promise<void> {
|
||||
|
||||
@@ -173,15 +173,15 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
} as unknown as typeof window.electron;
|
||||
runtimeCapabilities.supportsEpgGuide = true;
|
||||
|
||||
const window_ = { channelIds: ['a'], fromMs: 1, toMs: 2 };
|
||||
await expect(service.getProgramsForChannels(window_)).resolves.toEqual(
|
||||
{ a: [] }
|
||||
);
|
||||
await expect(service.getProgramCoverage(window_)).resolves.toEqual([
|
||||
const guideWindow = { channelIds: ['a'], fromMs: 1, toMs: 2 };
|
||||
await expect(
|
||||
service.getProgramsForChannels(guideWindow)
|
||||
).resolves.toEqual({ a: [] });
|
||||
await expect(service.getProgramCoverage(guideWindow)).resolves.toEqual([
|
||||
'a',
|
||||
]);
|
||||
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(window_);
|
||||
expect(getEpgProgramCoverage).toHaveBeenCalledWith(window_);
|
||||
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(guideWindow);
|
||||
expect(getEpgProgramCoverage).toHaveBeenCalledWith(guideWindow);
|
||||
});
|
||||
|
||||
it('answers null for guide reads without runtime support or channels', async () => {
|
||||
|
||||
@@ -208,25 +208,25 @@ export class EpgRuntimeBridgeService {
|
||||
}
|
||||
|
||||
getProgramsForChannels(
|
||||
window: ElectronBridgeEpgGuideWindow
|
||||
guideWindow: ElectronBridgeEpgGuideWindow
|
||||
): Promise<Record<string, EpgProgram[]> | null> {
|
||||
if (!this.supportsGuide || window.channelIds.length === 0) {
|
||||
if (!this.supportsGuide || guideWindow.channelIds.length === 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return (
|
||||
this.bridge?.getEpgProgramsForChannels?.(window) ??
|
||||
this.bridge?.getEpgProgramsForChannels?.(guideWindow) ??
|
||||
Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
getProgramCoverage(
|
||||
window: ElectronBridgeEpgGuideWindow
|
||||
guideWindow: ElectronBridgeEpgGuideWindow
|
||||
): Promise<string[] | null> {
|
||||
if (!this.supportsGuide || window.channelIds.length === 0) {
|
||||
if (!this.supportsGuide || guideWindow.channelIds.length === 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return (
|
||||
this.bridge?.getEpgProgramCoverage?.(window) ??
|
||||
this.bridge?.getEpgProgramCoverage?.(guideWindow) ??
|
||||
Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
@@ -252,7 +252,9 @@ export class EpgRuntimeBridgeService {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null);
|
||||
return (
|
||||
this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
getEpgMappingsBatch(
|
||||
@@ -278,8 +280,11 @@ export class EpgRuntimeBridgeService {
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ??
|
||||
Promise.resolve(null)
|
||||
this.bridge?.setEpgMapping?.(
|
||||
channelKey,
|
||||
epgChannelId,
|
||||
playlistId
|
||||
) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,7 +293,9 @@ export class EpgRuntimeBridgeService {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null);
|
||||
return (
|
||||
this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
searchEpgChannels(
|
||||
|
||||
@@ -379,9 +379,12 @@ export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgL
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgGuideWindow
|
||||
extends ElectronBridgeEpgLookupOptions {
|
||||
/** Playlist channel lookup keys (tvg-id, else name), ≤100 per call. */
|
||||
export interface ElectronBridgeEpgGuideWindow extends ElectronBridgeEpgLookupOptions {
|
||||
/**
|
||||
* Playlist channel lookup keys (tvg-id, else name), ≤100 keys for
|
||||
* programmes, ≤2000 for coverage; larger batches are truncated and the
|
||||
* cut keys are absent from the answer.
|
||||
*/
|
||||
channelIds: string[];
|
||||
/** Provider-clock window bounds in epoch ms. */
|
||||
fromMs: number;
|
||||
|
||||
Reference in new issue
Block a user