diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts index 5eaa802b6..3e5d07be5 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts @@ -10,6 +10,17 @@ jest.mock('../../database/connection', () => ({ getDatabase: (...args: unknown[]) => getDatabase(...args), })); +function getRegisteredChannels(): string[] { + const { ipcMain } = jest.requireMock('electron') as { + ipcMain: { handle: jest.Mock }; + }; + return ( + ipcMain.handle.mock.calls as Array< + [string, (...args: unknown[]) => unknown] + > + ).map(([registeredChannel]) => registeredChannel); +} + function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown { const { ipcMain } = jest.requireMock('electron') as { ipcMain: { handle: jest.Mock }; @@ -29,7 +40,6 @@ function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown { } describe('epg-db.events', () => { - let consoleLogSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; beforeEach(async () => { @@ -39,45 +49,47 @@ describe('epg-db.events', () => { ipcMain: { handle: jest.Mock }; }; ipcMain.handle.mockClear(); - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); await import('./epg-db.events'); }); afterEach(() => { - consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); - it('clears a source in a single database transaction', async () => { - const runOrder: string[] = []; - const txDelete = jest - .fn() - .mockReturnValueOnce({ - where: jest.fn(() => ({ - run: jest.fn(() => runOrder.push('programs')), - })), - }) - .mockReturnValueOnce({ - where: jest.fn(() => ({ - run: jest.fn(() => runOrder.push('channels')), - })), - }); - const transaction = jest.fn((callback: (tx: unknown) => void) => - callback({ delete: txDelete }) - ); - getDatabase.mockResolvedValue({ transaction }); + it('registers only the programme-search channel', () => { + // Every other EPG persistence/lookup path lives in the EPG worker and + // epg-query.service.ts; the removed EPG_DB_* handlers must not return. + expect(getRegisteredChannels()).toEqual(['EPG_DB_SEARCH_PROGRAMS']); + }); + + it('returns an empty result for a blank search term without querying', async () => { + const all = jest.fn(); + getDatabase.mockResolvedValue({ all }); await expect( - getIpcMainHandler('EPG_DB_CLEAR_SOURCE')( - {}, - 'https://playlist.example.com/guide.xml' - ) - ).resolves.toEqual({ success: true }); + getIpcMainHandler('EPG_DB_SEARCH_PROGRAMS')({}, ' ') + ).resolves.toEqual([]); - expect(transaction).toHaveBeenCalledTimes(1); - expect(txDelete).toHaveBeenCalledTimes(2); - expect(runOrder).toEqual(['programs', 'channels']); + expect(all).not.toHaveBeenCalled(); + }); + + it('searches programmes with a LIKE pattern built from the trimmed term', async () => { + const rows = [{ title: 'News', channel_name: 'NHK' }]; + const all = jest.fn().mockResolvedValue(rows); + getDatabase.mockResolvedValue({ all }); + + await expect( + getIpcMainHandler('EPG_DB_SEARCH_PROGRAMS')({}, ' news ', 25) + ).resolves.toEqual(rows); + + expect(all).toHaveBeenCalledTimes(1); + const query = all.mock.calls[0][0] as { + queryChunks?: unknown[]; + }; + const boundParams = JSON.stringify(query); + expect(boundParams).toContain('%news%'); + expect(boundParams).toContain('25'); }); }); diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.ts b/apps/electron-backend/src/app/events/database/epg-db.events.ts index 4cc2bd80c..7d96345f1 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.ts @@ -1,199 +1,18 @@ /** * EPG Database IPC event handlers - * Operations for storing and querying EPG data in SQLite with FTS5 support + * + * Only the full-text programme search is exposed to the renderer + * (`window.electron.searchEpgPrograms`). All other EPG persistence and + * lookups run through the EPG worker (`workers/epg-database.ts`) and + * `epg-query.service.ts` handlers registered in `epg.events.ts`. */ -import { and, eq, gte, lte, sql } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; import { ipcMain } from 'electron'; import { getDatabase } from '../../database/connection'; -import * as schema from '../../database/schema'; const loggerLabel = '[EPG DB]'; -/** - * Save EPG channels in bulk (upsert) - */ -ipcMain.handle( - 'EPG_DB_SAVE_CHANNELS', - async ( - _event, - sourceUrl: string, - channels: Array<{ - id: string; - displayName: string; - iconUrl?: string; - url?: string; - }> - ) => { - try { - const db = await getDatabase(); - - // Delete existing channels from this source first - await db - .delete(schema.epgChannels) - .where(eq(schema.epgChannels.sourceUrl, sourceUrl)); - - if (channels.length === 0) return { success: true, count: 0 }; - - // Insert in chunks - const CHUNK_SIZE = 500; - let totalInserted = 0; - - for (let i = 0; i < channels.length; i += CHUNK_SIZE) { - const chunk = channels.slice(i, i + CHUNK_SIZE); - const values = chunk.map((ch) => ({ - id: ch.id, - displayName: ch.displayName, - iconUrl: ch.iconUrl || null, - url: ch.url || null, - sourceUrl, - })); - - await db.insert(schema.epgChannels).values(values); - totalInserted += chunk.length; - } - - console.log( - loggerLabel, - `Saved ${totalInserted} channels from ${sourceUrl}` - ); - return { success: true, count: totalInserted }; - } catch (error) { - console.error(loggerLabel, 'Error saving EPG channels:', error); - throw error; - } - } -); - -/** - * Save EPG programs in bulk - */ -ipcMain.handle( - 'EPG_DB_SAVE_PROGRAMS', - async ( - _event, - programs: Array<{ - channelId: string; - start: string; - stop: string; - title: string; - description?: string; - category?: string; - iconUrl?: string; - rating?: string; - episodeNum?: string; - }> - ) => { - try { - const db = await getDatabase(); - - if (programs.length === 0) return { success: true, count: 0 }; - - // Insert in chunks for better performance - const CHUNK_SIZE = 500; - let totalInserted = 0; - - for (let i = 0; i < programs.length; i += CHUNK_SIZE) { - const chunk = programs.slice(i, i + CHUNK_SIZE); - const values = chunk.map((prog) => ({ - channelId: prog.channelId, - start: prog.start, - stop: prog.stop, - title: prog.title, - description: prog.description || null, - category: prog.category || null, - iconUrl: prog.iconUrl || null, - rating: prog.rating || null, - episodeNum: prog.episodeNum || null, - })); - - await db.insert(schema.epgPrograms).values(values); - totalInserted += chunk.length; - } - - console.log(loggerLabel, `Saved ${totalInserted} programs`); - return { success: true, count: totalInserted }; - } catch (error) { - console.error(loggerLabel, 'Error saving EPG programs:', error); - throw error; - } - } -); - -/** - * Get programs for a specific channel - */ -ipcMain.handle( - 'EPG_DB_GET_CHANNEL_PROGRAMS', - async (_event, channelId: string, fromTime?: string, toTime?: string) => { - try { - const db = await getDatabase(); - - let query; - - // Apply time filters if provided - if (fromTime && toTime) { - query = db - .select() - .from(schema.epgPrograms) - .where( - and( - eq(schema.epgPrograms.channelId, channelId), - gte(schema.epgPrograms.stop, fromTime), - lte(schema.epgPrograms.start, toTime) - ) - ); - } else { - // Return all programs for this channel - query = db - .select() - .from(schema.epgPrograms) - .where(eq(schema.epgPrograms.channelId, channelId)); - } - - const results = await query.orderBy(schema.epgPrograms.start); - return results; - } catch (error) { - console.error( - loggerLabel, - 'Error getting channel programs:', - error - ); - throw error; - } - } -); - -/** - * Get current program for a channel (what's on now) - */ -ipcMain.handle( - 'EPG_DB_GET_CURRENT_PROGRAM', - async (_event, channelId: string) => { - try { - const db = await getDatabase(); - const now = new Date().toISOString(); - - const result = await db - .select() - .from(schema.epgPrograms) - .where( - and( - eq(schema.epgPrograms.channelId, channelId), - lte(schema.epgPrograms.start, now), - gte(schema.epgPrograms.stop, now) - ) - ) - .limit(1); - - return result[0] || null; - } catch (error) { - console.error(loggerLabel, 'Error getting current program:', error); - throw error; - } - } -); - /** * Full-text search EPG programs using FTS5 with LIKE fallback * Handles Cyrillic and other Unicode text properly @@ -238,136 +57,3 @@ ipcMain.handle( } } ); - -/** - * Get all EPG channels - */ -ipcMain.handle('EPG_DB_GET_CHANNELS', async () => { - try { - const db = await getDatabase(); - const results = await db - .select() - .from(schema.epgChannels) - .orderBy(schema.epgChannels.displayName); - return results; - } catch (error) { - console.error(loggerLabel, 'Error getting EPG channels:', error); - throw error; - } -}); - -/** - * Get channel by ID or display name - */ -ipcMain.handle( - 'EPG_DB_GET_CHANNEL', - async (_event, channelIdOrName: string) => { - try { - const db = await getDatabase(); - - // Try exact ID match first - let result = await db - .select() - .from(schema.epgChannels) - .where(eq(schema.epgChannels.id, channelIdOrName)) - .limit(1); - - if (result.length > 0) return result[0]; - - // Try display name match (case-insensitive) - result = await db - .select() - .from(schema.epgChannels) - .where( - sql`LOWER(${schema.epgChannels.displayName}) = LOWER(${channelIdOrName})` - ) - .limit(1); - - return result[0] || null; - } catch (error) { - console.error(loggerLabel, 'Error getting EPG channel:', error); - throw error; - } - } -); - -/** - * Clear all EPG data for a specific source URL - */ -ipcMain.handle('EPG_DB_CLEAR_SOURCE', async (_event, sourceUrl: string) => { - try { - const db = await getDatabase(); - - await db.transaction((tx) => { - tx.delete(schema.epgPrograms) - .where(eq(schema.epgPrograms.sourceUrl, sourceUrl)) - .run(); - tx.delete(schema.epgChannels) - .where(sql` - ${schema.epgChannels.sourceUrl} = ${sourceUrl} - AND NOT EXISTS ( - SELECT 1 - FROM ${schema.epgPrograms} - WHERE ${schema.epgPrograms.channelId} = ${schema.epgChannels.id} - ) - `) - .run(); - }); - - console.log(loggerLabel, `Cleared EPG data for source: ${sourceUrl}`); - return { success: true }; - } catch (error) { - console.error(loggerLabel, 'Error clearing EPG source:', error); - throw error; - } -}); - -/** - * Clear all EPG data - */ -ipcMain.handle('EPG_DB_CLEAR_ALL', async () => { - try { - const db = await getDatabase(); - - await db.delete(schema.epgPrograms); - await db.delete(schema.epgChannels); - - console.log(loggerLabel, 'Cleared all EPG data'); - return { success: true }; - } catch (error) { - console.error(loggerLabel, 'Error clearing all EPG data:', error); - throw error; - } -}); - -/** - * Get EPG statistics - */ -ipcMain.handle('EPG_DB_GET_STATS', async () => { - try { - const db = await getDatabase(); - - const channelCount = await db - .select({ count: sql`count(*)` }) - .from(schema.epgChannels); - - const programCount = await db - .select({ count: sql`count(*)` }) - .from(schema.epgPrograms); - - const now = new Date().toISOString(); - const futureProgramCount = await db - .select({ count: sql`count(*)` }) - .from(schema.epgPrograms) - .where(gte(schema.epgPrograms.stop, now)); - - return { - channels: channelCount[0].count, - programs: programCount[0].count, - futurePrograms: futureProgramCount[0].count, - }; - } catch (error) { - console.error(loggerLabel, 'Error getting EPG stats:', error); - throw error; - } -});