perf(epg): batch current-programs lookup into a single IPC + SQL query

Channel-list rendering called EpgService.getCurrentProgramsForChannels(),
which forkJoined N getChannelPrograms() Observables — each firing its own
IPC round-trip and its own SQL query. For a 500-channel visible window on
first scroll, that was 500 IPC calls and 500 SELECTs hammering the EPG
table.

Add GET_CURRENT_PROGRAMS_BATCH IPC handler that takes the channel-id
array and runs a single SELECT with WHERE channel_id IN (...) AND
start <= now AND stop >= now. The renderer-side cache and TTL behavior
are preserved; only the network of IPC calls collapses to one. A
fallback path keeps the old per-channel behavior if the preload lacks
the new endpoint.

Per-channel display-name fallback (NOCASE id, then NOCASE display name)
is preserved from handleGetChannelPrograms so behavior matches the
existing single-channel handler.

Inspired by matracey/iptvnator@d25a7e8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6719280e397b
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-01 23:45:10 +02:00
1 parent 94a0756424
commit d1af5f0510
5 files changed
+158 -4

No files matched your search

@@ -338,6 +338,8 @@ const electronApi = {
ipcRenderer.invoke('FETCH_EPG', { url: urls }),
getChannelPrograms: (channelId: string) =>
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
getCurrentProgramsBatch: (channelIds: string[]) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds }),
getEpgChannelMetadata: (channelIds: string[]) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
@@ -1,4 +1,4 @@
import { eq, sql } from 'drizzle-orm';
import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
@@ -81,6 +81,14 @@ export default class EpgEvents {
}
);
// Get current programs for many channels in a single batched query
ipcMain.handle(
'GET_CURRENT_PROGRAMS_BATCH',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetCurrentProgramsBatch(args.channelIds);
}
);
// Get all channels from database
ipcMain.handle('EPG_GET_CHANNELS', async () => {
return this.handleGetAllChannels();
@@ -635,6 +643,115 @@ export default class EpgEvents {
}
}
/**
* Batch lookup of "currently playing" programs for many channels in a
* single SQL query. Replaces the N+1 pattern where the channel list
* fired one IPC + query per visible channel.
*/
private static async handleGetCurrentProgramsBatch(
channelIds: string[]
): Promise<Record<string, EpgProgram | null>> {
const result: Record<string, EpgProgram | null> = {};
if (!Array.isArray(channelIds) || channelIds.length === 0) {
return result;
}
const validIds = Array.from(
new Set(
channelIds
.map((id) => id?.trim())
.filter((id): id is string => Boolean(id))
)
);
if (validIds.length === 0) {
return result;
}
try {
const db = await getDatabase();
const now = new Date().toISOString();
const rows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
inArray(schema.epgPrograms.channelId, validIds),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
);
for (const row of rows) {
if (!result[row.channelId]) {
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
result[row.channelId] = program;
}
}
}
// Per-channel fallback for IDs that didn't match by exact channel_id.
// Mirrors handleGetChannelPrograms's NOCASE-id and display-name resolution.
const unmatchedIds = validIds.filter((id) => !(id in result));
for (const channelId of unmatchedIds) {
result[channelId] = null;
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`
)
.limit(1);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${channelId} COLLATE NOCASE`
)
.limit(1);
}
if (channel.length === 0) {
continue;
}
const programRows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channel[0].id),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.limit(1);
if (programRows.length > 0) {
const program = this.transformDbRowToEpgProgram(
programRows[0]
);
if (this.isValidEpgProgram(program)) {
result[channelId] = program;
}
}
}
return result;
} catch (error) {
console.error(
this.loggerLabel,
'Error getting batch current programs:',
error
);
return result;
}
}
/**
* Get all channels from database
*/
+3
View File
@@ -90,6 +90,9 @@ declare global {
skipped?: string[];
}>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
Vendored
+3
View File
@@ -83,6 +83,9 @@ declare global {
urls: string[]
) => Promise<{ success: boolean; message?: string }>;
getChannelPrograms: (channelId: string) => Promise<any>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, any>>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (
skip: number,
+32 -3
View File
@@ -205,16 +205,45 @@ export class EpgService {
return of(resultMap);
}
// Fetch uncached channels with timeout and error handling per request
// Single batched IPC + SQL query when the backend supports it.
// Replaces the legacy N+1 forkJoin where each channel fired its own
// GET_CHANNEL_PROGRAMS round-trip.
const batchApi = window.electron as Window['electron'] & {
getCurrentProgramsBatch?: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
};
if (typeof batchApi?.getCurrentProgramsBatch === 'function') {
return from(batchApi.getCurrentProgramsBatch(channelsToFetch)).pipe(
timeout(5000),
map((batchResult) => {
const cacheTimestamp = Date.now();
channelsToFetch.forEach((channelId) => {
const program = batchResult?.[channelId] ?? null;
resultMap.set(channelId, program);
this.programCache.set(channelId, {
program,
timestamp: cacheTimestamp,
});
});
return resultMap;
}),
catchError((err) => {
console.error('EPG batch current programs error:', err);
return of(resultMap);
})
);
}
// Fallback for older preload bundles without the batch endpoint.
const fetchObservables = channelsToFetch.map((channelId) =>
this.getCurrentProgramForChannel(channelId).pipe(
timeout(5000), // 5 second timeout per request
timeout(5000),
map((program) => ({ channelId, program })),
catchError(() => of({ channelId, program: null }))
)
);
// Combine all fetches using forkJoin
return forkJoin(fetchObservables).pipe(
map((results) => {
results.forEach((result) => {