From d1af5f05104a1f3d95f43e44ecfe1122633ba00e Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 1 May 2026 23:45:10 +0200 Subject: [PATCH] perf(epg): batch current-programs lookup into a single IPC + SQL query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Entire-Checkpoint: 6719280e397b --- .../src/app/api/main.preload.ts | 2 + .../src/app/events/epg.events.ts | 119 +++++++++++++++++- apps/web/src/typings.d.ts | 3 + global.d.ts | 3 + libs/epg/data-access/src/lib/epg.service.ts | 35 +++++- 5 files changed, 158 insertions(+), 4 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 20113f933..144557fd0 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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'), diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index ee8000e6d..4a7e31c91 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -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> { + const result: Record = {}; + 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 */ diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index d4d328e96..5a9d49089 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -90,6 +90,9 @@ declare global { skipped?: string[]; }>; getChannelPrograms: (channelId: string) => Promise; + getCurrentProgramsBatch: ( + channelIds: string[] + ) => Promise>; getEpgChannelMetadata: ( channelIds: string[] ) => Promise>; diff --git a/global.d.ts b/global.d.ts index 635339785..0a30c3241 100644 --- a/global.d.ts +++ b/global.d.ts @@ -83,6 +83,9 @@ declare global { urls: string[] ) => Promise<{ success: boolean; message?: string }>; getChannelPrograms: (channelId: string) => Promise; + getCurrentProgramsBatch: ( + channelIds: string[] + ) => Promise>; getEpgChannels: () => Promise; getEpgChannelsByRange: ( skip: number, diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index dc9552d99..b60f6a1d1 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -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>; + }; + 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) => {