mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
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
|
||||
*/
|
||||
|
||||
Vendored
+3
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user