feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 11:48:13 +02:00
1 parent 9a455048b6
commit f6ac75b8db
13 files changed
+192 -88

No files matched your search

+2 -2
View File
@@ -718,7 +718,7 @@ test.describe('Electron EPG', () => {
async function getEpgChannelCount(page: Page): Promise<number> {
return page.evaluate(async () => {
const channels = await window.electron?.getEpgChannelsByRange?.(0, 20);
return Array.isArray(channels) ? channels.length : 0;
const result = await window.electron?.getEpgChannels?.();
return Array.isArray(result?.channels) ? result.channels.length : 0;
});
}
@@ -514,10 +514,16 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
forwardedArgs: [],
},
{
method: 'getEpgChannelsByRange',
args: [10, 20],
channel: 'EPG_GET_CHANNELS_BY_RANGE',
forwardedArgs: [{ skip: 10, limit: 20 }],
method: 'getEpgProgramsForChannels',
args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
channel: 'EPG_GET_PROGRAMS_FOR_CHANNELS',
forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
},
{
method: 'getEpgProgramCoverage',
args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
channel: 'EPG_GET_PROGRAM_COVERAGE',
forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
},
{
method: 'forceFetchEpg',
@@ -37,6 +37,7 @@ import type {
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
ElectronBridgeCurrentProgramsOptions,
ElectronBridgeEpgGuideWindow,
ElectronBridgeEpgLookupOptions,
ElectronBridgeEpgProgress,
ElectronBridgePlaybackPositionInput,
@@ -672,8 +673,10 @@ const electronApi: ElectronBridgeApi = {
) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds, options }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
getEpgChannelsByRange: (skip: number, limit: number) =>
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
getEpgProgramsForChannels: (window: ElectronBridgeEpgGuideWindow) =>
ipcRenderer.invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', window),
getEpgProgramCoverage: (window: ElectronBridgeEpgGuideWindow) =>
ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window),
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
@@ -2,11 +2,13 @@ import { reconcileEpgSources } from './epg-source-settings.service';
import { ipcMain } from 'electron';
import {
ElectronBridgeCurrentProgramsOptions,
ElectronBridgeEpgGuideWindow,
ElectronBridgeTrustOptions,
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { epgQueryService } from './epg-query.service';
import { epgGuideQueryService } from './epg-guide-query.service';
import { epgWorkerService } from './epg-worker.service';
import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service';
import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service';
@@ -17,6 +19,7 @@ import {
handleSearchEpgChannels,
handleSetEpgMapping,
queryByResolvedChannelIds,
resolveChannelIds,
} from './epg-mapping.service';
/**
@@ -98,9 +101,16 @@ export default class EpgEvents {
);
ipcMain.handle(
'EPG_GET_CHANNELS_BY_RANGE',
async (_event, args: { skip: number; limit: number }) => {
return this.handleGetChannelsByRange(args.skip, args.limit);
'EPG_GET_PROGRAMS_FOR_CHANNELS',
async (_event, args: ElectronBridgeEpgGuideWindow) => {
return this.handleGetGuidePrograms(args);
}
);
ipcMain.handle(
'EPG_GET_PROGRAM_COVERAGE',
async (_event, args: ElectronBridgeEpgGuideWindow) => {
return this.handleGetGuideCoverage(args);
}
);
@@ -249,18 +259,39 @@ export default class EpgEvents {
);
}
private static async handleGetChannelsByRange(
skip: number,
limit: number
): Promise<
Array<{
id: string;
displayName: string;
iconUrl: string | null;
programs: EpgProgram[];
}>
> {
return epgQueryService.getChannelsByRange(skip, limit);
/**
* 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.
*/
private static async handleGetGuidePrograms(
args: ElectronBridgeEpgGuideWindow
): Promise<Record<string, EpgProgram[]>> {
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] ?? []])
);
}
private static async handleGetGuideCoverage(
args: ElectronBridgeEpgGuideWindow
): Promise<string[]> {
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(
await epgGuideQueryService.getProgramCoverage({
...args,
channelIds: resolvedIds,
})
);
return requested.filter((id) => covered.has(mapping.get(id) ?? id));
}
static async clearEpgData(): Promise<void> {
@@ -240,7 +240,8 @@ export function createElectronStub(): typeof window.electron {
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
getChannelPrograms: jest.fn().mockResolvedValue([]),
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
getEpgProgramsForChannels: jest.fn().mockResolvedValue({}),
getEpgProgramCoverage: jest.fn().mockResolvedValue([]),
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
checkForAppUpdate: jest
.fn()
@@ -16,7 +16,7 @@ describe('EpgRuntimeBridgeService', () => {
supportsEpgChannelMetadata: false,
supportsEpgSourceFreshness: false,
supportsEpgDataManagement: false,
supportsEpgChannelBrowser: false,
supportsEpgGuide: false,
supportsEpgProgramSearch: false,
};
@@ -122,7 +122,6 @@ describe('EpgRuntimeBridgeService', () => {
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
});
const getEpgChannelsByRange = jest.fn().mockResolvedValue([]);
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
@@ -130,14 +129,12 @@ describe('EpgRuntimeBridgeService', () => {
getCurrentProgramsBatch,
getEpgChannelMetadata,
checkEpgFreshness,
getEpgChannelsByRange,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgProgramLookup = true;
runtimeCapabilities.supportsEpgCurrentProgramBatch = true;
runtimeCapabilities.supportsEpgChannelMetadata = true;
runtimeCapabilities.supportsEpgSourceFreshness = true;
runtimeCapabilities.supportsEpgChannelBrowser = true;
runtimeCapabilities.supportsEpgProgramSearch = true;
await service.getChannelPrograms('channel-1');
@@ -148,7 +145,6 @@ describe('EpgRuntimeBridgeService', () => {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.checkFreshness(['https://example.com/epg.xml'], 12);
await service.getChannelsByRange(0, 20);
await service.searchPrograms('news', 20);
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
@@ -162,10 +158,47 @@ describe('EpgRuntimeBridgeService', () => {
['https://example.com/epg.xml'],
12
);
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
});
it('forwards guide reads when the runtime supports them', async () => {
const getEpgProgramsForChannels = jest
.fn()
.mockResolvedValue({ a: [] });
const getEpgProgramCoverage = jest.fn().mockResolvedValue(['a']);
window.electron = {
...window.electron,
getEpgProgramsForChannels,
getEpgProgramCoverage,
} 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([
'a',
]);
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(window_);
expect(getEpgProgramCoverage).toHaveBeenCalledWith(window_);
});
it('answers null for guide reads without runtime support or channels', async () => {
runtimeCapabilities.supportsEpgGuide = false;
await expect(
service.getProgramsForChannels({
channelIds: ['a'],
fromMs: 1,
toMs: 2,
})
).resolves.toBeNull();
runtimeCapabilities.supportsEpgGuide = true;
await expect(
service.getProgramCoverage({ channelIds: [], fromMs: 1, toMs: 2 })
).resolves.toBeNull();
});
it('subscribes to EPG progress only when progress events are supported', () => {
const onEpgProgress = jest.fn();
window.electron = {
@@ -9,7 +9,7 @@ import {
ElectronBridgeEpgProgressStats,
ElectronBridgeEpgProgressStatus,
ElectronBridgeEpgSearchResult,
ElectronBridgeEpgChannelWithPrograms,
ElectronBridgeEpgGuideWindow,
ElectronBridgeEpgFreshnessResult,
ElectronBridgeEpgLookupOptions,
ElectronBridgeResult,
@@ -39,7 +39,8 @@ type EpgElectronBridge = Pick<
| 'getChannelPrograms'
| 'getCurrentProgramsBatch'
| 'getEpgChannelMetadata'
| 'getEpgChannelsByRange'
| 'getEpgProgramsForChannels'
| 'getEpgProgramCoverage'
| 'onEpgProgress'
| 'searchEpgPrograms'
| 'getEpgMapping'
@@ -81,8 +82,8 @@ export class EpgRuntimeBridgeService {
return this.runtime.supportsEpgDataManagement;
}
get supportsChannelBrowser(): boolean {
return this.runtime.supportsEpgChannelBrowser;
get supportsGuide(): boolean {
return this.runtime.supportsEpgGuide;
}
get supportsProgramSearch(): boolean {
@@ -206,16 +207,26 @@ export class EpgRuntimeBridgeService {
);
}
getChannelsByRange(
skip: number,
limit: number
): Promise<ElectronBridgeEpgChannelWithPrograms[] | null> {
if (!this.supportsChannelBrowser) {
getProgramsForChannels(
window: ElectronBridgeEpgGuideWindow
): Promise<Record<string, EpgProgram[]> | null> {
if (!this.supportsGuide || window.channelIds.length === 0) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgChannelsByRange?.(skip, limit) ??
this.bridge?.getEpgProgramsForChannels?.(window) ??
Promise.resolve(null)
);
}
getProgramCoverage(
window: ElectronBridgeEpgGuideWindow
): Promise<string[] | null> {
if (!this.supportsGuide || window.channelIds.length === 0) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgProgramCoverage?.(window) ??
Promise.resolve(null)
);
}
@@ -53,7 +53,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgGuide).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
@@ -145,7 +145,8 @@ describe('RuntimeCapabilitiesService', () => {
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
getEpgProgramsForChannels: jest.fn(),
getEpgProgramCoverage: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -179,7 +180,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgChannelMetadata).toBe(true);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgChannelBrowser).toBe(true);
expect(service.supportsEpgGuide).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
});
@@ -216,7 +217,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgGuide).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
@@ -343,7 +344,8 @@ describe('RuntimeCapabilitiesService', () => {
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
getEpgProgramsForChannels: jest.fn(),
getEpgProgramCoverage: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -359,7 +361,8 @@ describe('RuntimeCapabilitiesService', () => {
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
getEpgProgramsForChannels: jest.fn(),
getEpgProgramCoverage: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -373,7 +376,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgChannelBrowser).toBe(true);
expect(service.supportsEpgGuide).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
testWindow.electron = {
@@ -385,10 +388,20 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgGuide).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('does not report guide support with only one of the two guide reads', () => {
testWindow.electron = {
getEpgProgramsForChannels: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.supportsEpgGuide).toBe(false);
});
it('requires the complete downloads preload surface', () => {
testWindow.electron = {
downloadsGetList: jest.fn(),
@@ -97,7 +97,7 @@ export class RuntimeCapabilitiesService {
this.supportsEpgProgramLookup &&
this.supportsEpgSourceFreshness &&
this.supportsEpgDataManagement &&
this.supportsEpgChannelBrowser &&
this.supportsEpgGuide &&
this.supportsEpgProgramSearch
);
}
@@ -134,8 +134,12 @@ export class RuntimeCapabilitiesService {
);
}
get supportsEpgChannelBrowser(): boolean {
return this.hasElectronMethod('getEpgChannelsByRange');
/** Both guide reads exist: programmes for a channel batch and coverage. */
get supportsEpgGuide(): boolean {
return (
this.hasElectronMethod('getEpgProgramsForChannels') &&
this.hasElectronMethod('getEpgProgramCoverage')
);
}
get supportsEpgProgramSearch(): boolean {
@@ -379,6 +379,15 @@ export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgL
nowMs?: number;
}
export interface ElectronBridgeEpgGuideWindow
extends ElectronBridgeEpgLookupOptions {
/** Playlist channel lookup keys (tvg-id, else name), ≤100 per call. */
channelIds: string[];
/** Provider-clock window bounds in epoch ms. */
fromMs: number;
toMs: number;
}
export interface ElectronBridgeEpgProgressStats {
totalChannels: number;
totalPrograms: number;
@@ -406,11 +415,6 @@ export interface ElectronBridgeEpgChannelListResult {
programs: [];
}
export interface ElectronBridgeEpgChannelWithPrograms extends ElectronBridgeEpgChannelSummary {
iconUrl: string | null;
programs: EpgProgram[];
}
export interface ElectronBridgeDbOperationEvent {
operationId?: string;
operation: string;
@@ -803,10 +807,12 @@ export interface ElectronBridgeApi {
options?: ElectronBridgeEpgLookupOptions
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannels: () => Promise<ElectronBridgeEpgChannelListResult>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<ElectronBridgeEpgChannelWithPrograms[]>;
getEpgProgramsForChannels: (
window: ElectronBridgeEpgGuideWindow
) => Promise<Record<string, EpgProgram[]>>;
getEpgProgramCoverage: (
window: ElectronBridgeEpgGuideWindow
) => Promise<string[]>;
forceFetchEpg: (
url: string,
options?: ElectronBridgeTrustOptions
@@ -4,9 +4,6 @@ export const EPG_FETCH_DONE = 'EPG:FETCH_DONE';
export const EPG_ERROR = 'EPG:ERROR';
export const EPG_GET_CHANNELS = 'EPG:GET_CHANNELS';
export const EPG_GET_CHANNELS_DONE = 'EPG:GET_CHANNELS_DONE';
export const EPG_GET_CHANNELS_BY_RANGE = 'EPG:EPG_GET_CHANNELS_BY_RANGE';
export const EPG_GET_CHANNELS_BY_RANGE_RESPONSE =
'EPG:EPG_GET_CHANNELS_BY_RANGE_RESPONSE';
export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH';
// Playlist related commands
@@ -6,10 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '@iptvnator/services';
import {
ElectronBridgeEpgChannelWithPrograms,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
MultiEpgContainerComponent,
isSelectedEpgDayToday,
@@ -46,9 +43,8 @@ describe('MultiEpgContainerComponent runtime gates', () => {
beforeEach(async () => {
epgOffsetMinutes.set(0);
epgBridge = {
getChannelsByRange: jest.fn().mockResolvedValue([]),
searchPrograms: jest.fn().mockResolvedValue([]),
supportsChannelBrowser: false,
supportsGuide: false,
supportsProgramSearch: false,
};
@@ -110,7 +106,9 @@ describe('MultiEpgContainerComponent runtime gates', () => {
programAt(morning, 60, 'Morning'),
programAt(late, 60, 'Late'),
],
} as unknown as ElectronBridgeEpgChannelWithPrograms,
} as unknown as ReturnType<
typeof component.originalEpgData
>[number],
]);
epgOffsetMinutes.set(90);
@@ -128,23 +126,18 @@ describe('MultiEpgContainerComponent runtime gates', () => {
await component.requestPrograms();
expect(epgBridge.getChannelsByRange).not.toHaveBeenCalled();
expect(component.isLoading()).toBe(false);
});
it('requests EPG channel ranges through the EPG runtime bridge', async () => {
epgBridge.getChannelsByRange = jest.fn().mockResolvedValue([
{
channel_id: 'channel-1',
display_name: 'Channel One',
programs: [],
},
]);
epgBridge.supportsChannelBrowser = true;
// This legacy grid's `EPG_GET_CHANNELS_BY_RANGE` read was removed in
// Task 2 (it never carried real data past this point regardless — see
// the temporary `Promise.resolve([])` in `requestPrograms()`); the whole
// component is deleted in Task 10.
it.skip('requests EPG channel ranges through the EPG runtime bridge', async () => {
epgBridge.supportsGuide = true;
await component.requestPrograms();
expect(epgBridge.getChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(component.isLoading()).toBe(false);
});
@@ -25,7 +25,6 @@ import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { Observable, Subscription, startWith } from 'rxjs';
import {
Channel,
ElectronBridgeEpgChannelWithPrograms,
EpgChannel,
epgDisplayTimeMs,
EpgProgram,
@@ -36,7 +35,15 @@ import { getProgramTimeMs } from '../epg-program.utils';
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
type MultiEpgChannel = ElectronBridgeEpgChannelWithPrograms;
// TODO(Task 10): this legacy multi-EPG view is being replaced by the
// host-agnostic `app-epg-guide` grid; `EPG_GET_CHANNELS_BY_RANGE` is gone,
// so this component is temporarily patched to keep typecheck green.
interface MultiEpgChannel {
id: string;
displayName: string;
iconUrl: string | null;
programs: EpgProgram[];
}
interface EnrichedProgram extends EpgProgram {
startDate: Date;
@@ -271,7 +278,7 @@ export class MultiEpgContainerComponent
}
async requestPrograms(): Promise<void> {
if (!this.epgBridge.supportsChannelBrowser) {
if (!this.epgBridge.supportsGuide) {
console.warn('Multi-EPG not available in this runtime');
return;
}
@@ -283,9 +290,8 @@ export class MultiEpgContainerComponent
this.isLoading.set(true);
try {
const response = await this.epgBridge.getChannelsByRange(
this.channelsLowerRange,
this.visibleChannels
const response = await Promise.resolve(
[] as MultiEpgChannel[]
);
if (response && Array.isArray(response)) {