diff --git a/apps/electron-backend/build-worker.js b/apps/electron-backend/build-worker.js index 5fd6b8413..e23285b81 100644 --- a/apps/electron-backend/build-worker.js +++ b/apps/electron-backend/build-worker.js @@ -12,6 +12,11 @@ const nodeBuiltins = [ 'v8', 'vm', 'worker_threads', 'zlib' ]; +// Native modules that must be externalized (loaded at runtime) +const nativeModules = [ + 'better-sqlite3', +]; + const isProduction = process.env.NODE_ENV === 'production'; async function buildWorker() { @@ -25,12 +30,17 @@ async function buildWorker() { target: 'node18', format: 'cjs', outfile: path.join(__dirname, '../../dist/apps/electron-backend/workers/epg-parser.worker.js'), - external: nodeBuiltins.map(m => `node:${m}`).concat(nodeBuiltins), + external: [ + ...nodeBuiltins.map(m => `node:${m}`), + ...nodeBuiltins, + ...nativeModules, + ], sourcemap: !isProduction, minify: isProduction, // Resolve workspace libraries from tsconfig paths alias: { 'shared-interfaces': path.join(__dirname, '../../libs/shared/interfaces/src/index.ts'), + 'database': path.join(__dirname, '../../libs/shared/database/src/index.ts'), }, }); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index dc1434a28..bf51bc1ee 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -17,6 +17,17 @@ contextBridge.exposeInMainWorld('electron', { ) => { ipcRenderer.on('player-error', (_event, data) => callback(data)); }, + // EPG progress listener + onEpgProgress: ( + callback: (data: { + url: string; + status: 'loading' | 'complete' | 'error'; + stats?: { totalChannels: number; totalPrograms: number }; + error?: string; + }) => void + ) => { + ipcRenderer.on('EPG_PROGRESS_UPDATE', (_event, data) => callback(data)); + }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), platform: process.platform, fetchPlaylistByUrl: (url: string, title?: string) => @@ -72,6 +83,11 @@ contextBridge.exposeInMainWorld('electron', { getEpgChannelsByRange: (skip: number, limit: number) => ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }), forceFetchEpg: (url: string) => ipcRenderer.invoke('EPG_FORCE_FETCH', url), + clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), + checkEpgFreshness: (urls: string[], maxAgeHours?: number) => + ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }), + searchEpgPrograms: (searchTerm: string, limit?: number) => + ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit), setMpvPlayerPath: (mpvPlayerPath: string) => ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath), setVlcPlayerPath: (vlcPlayerPath: string) => diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index 752ad0784..cd9c38421 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -10,6 +10,8 @@ export { content, recentlyViewed, favorites, + epgChannels, + epgPrograms, // Types type Playlist, type NewPlaylist, @@ -21,5 +23,9 @@ export { type NewRecentlyViewed, type Favorite, type NewFavorite, + type EpgChannel, + type NewEpgChannel, + type EpgProgramDb, + type NewEpgProgramDb, } from 'database'; diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index fce5dadf2..cbe25b94e 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -7,6 +7,7 @@ import { ipcMain } from 'electron'; import './database/category.events'; import './database/content.events'; +import './database/epg-db.events'; import './database/favorites.events'; import './database/playlist.events'; import './database/recently-viewed.events'; 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 new file mode 100644 index 000000000..2dfedb00e --- /dev/null +++ b/apps/electron-backend/src/app/events/database/epg-db.events.ts @@ -0,0 +1,391 @@ +/** + * EPG Database IPC event handlers + * Operations for storing and querying EPG data in SQLite with FTS5 support + */ + +import { and, eq, gte, lte, 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(); + const now = new Date().toISOString(); + + let query = db + .select() + .from(schema.epgPrograms) + .where(eq(schema.epgPrograms.channelId, channelId)); + + // 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 { + // Default: from now onwards + query = db + .select() + .from(schema.epgPrograms) + .where( + and( + eq(schema.epgPrograms.channelId, channelId), + gte(schema.epgPrograms.stop, now) + ) + ); + } + + 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 + * Includes channel display name via JOIN + */ +ipcMain.handle( + 'EPG_DB_SEARCH_PROGRAMS', + async (_event, searchTerm: string, limit = 50) => { + try { + const db = await getDatabase(); + const now = new Date().toISOString(); + const trimmedTerm = searchTerm.trim(); + + if (!trimmedTerm) { + return []; + } + + // Use LIKE for substring matching (works better with Cyrillic) + // This is more intuitive for users expecting exact substring matches + const likePattern = `%${trimmedTerm}%`; + + // JOIN with epg_channels to get channel display name + const results = await db.all(sql` + SELECT + p.*, + c.display_name as channel_name + FROM epg_programs p + LEFT JOIN epg_channels c ON p.channel_id = c.id + WHERE ( + p.title LIKE ${likePattern} + OR p.description LIKE ${likePattern} + OR p.category LIKE ${likePattern} + ) + AND p.stop >= ${now} + ORDER BY p.start + LIMIT ${limit} + `); + + return results; + } catch (error) { + console.error(loggerLabel, 'Error searching EPG programs:', error); + throw error; + } + } +); + +/** + * 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; + } +}); + +/** + * Cleanup expired programs (older than specified hours) + */ +ipcMain.handle('EPG_DB_CLEANUP_EXPIRED', async (_event, hoursToKeep = 24) => { + try { + const db = await getDatabase(); + const cutoff = new Date( + Date.now() - hoursToKeep * 60 * 60 * 1000 + ).toISOString(); + + const result = await db + .delete(schema.epgPrograms) + .where(lte(schema.epgPrograms.stop, cutoff)); + + console.log( + loggerLabel, + `Cleaned up expired programs (older than ${hoursToKeep}h)` + ); + return { success: true }; + } catch (error) { + console.error(loggerLabel, 'Error cleaning up EPG programs:', 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(); + + // Deleting channels will cascade delete programs due to foreign key + await db + .delete(schema.epgChannels) + .where(eq(schema.epgChannels.sourceUrl, sourceUrl)); + + 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; + } +}); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 79d738389..01523102d 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,96 +1,220 @@ -import { app, ipcMain } from 'electron'; +import { and, eq, gte, sql } from 'drizzle-orm'; +import { app, BrowserWindow, ipcMain } from 'electron'; import * as path from 'path'; -import { EpgChannelWithPrograms, EpgData, EpgProgram } from 'shared-interfaces'; import { Worker } from 'worker_threads'; import { pathToFileURL } from 'url'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { EpgProgram } from 'shared-interfaces'; /** * EPG Events Handler - * Manages EPG data fetching and querying using worker threads + * Manages EPG data fetching and querying using worker threads. + * Database operations are performed in the worker thread to avoid blocking the main thread. */ export default class EpgEvents { - private static epgData: EpgData = { - channels: [], - programs: [], - }; - - private static epgDataMerged: Map = - new Map(); private static fetchedUrls: Set = new Set(); private static workers: Map = new Map(); private static readonly loggerLabel = '[EPG Events]'; + /** + * Send EPG progress to all renderer windows + */ + private static sendProgressToRenderer( + url: string, + status: 'queued' | 'loading' | 'complete' | 'error', + stats?: { totalChannels: number; totalPrograms: number }, + error?: string, + queuePosition?: number + ): void { + const windows = BrowserWindow.getAllWindows(); + windows.forEach((win) => { + win.webContents.send('EPG_PROGRESS_UPDATE', { + url, + status, + stats, + error, + queuePosition, + }); + }); + } + /** * Bootstrap EPG events */ static bootstrapEpgEvents(): Electron.IpcMain { // Fetch EPG from URLs - ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => { + ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => { return await this.handleFetchEpg(args.url); }); // Get programs for a specific channel ipcMain.handle( 'GET_CHANNEL_PROGRAMS', - async (event, args: { channelId: string }) => { + async (_event, args: { channelId: string }) => { return this.handleGetChannelPrograms(args.channelId); } ); - // Get all channels + // Get all channels from database ipcMain.handle('EPG_GET_CHANNELS', async () => { - return this.epgData; + return this.handleGetAllChannels(); }); // Get channels by range (pagination) ipcMain.handle( 'EPG_GET_CHANNELS_BY_RANGE', - async (event, args: { skip: number; limit: number }) => { + async (_event, args: { skip: number; limit: number }) => { return this.handleGetChannelsByRange(args.skip, args.limit); } ); // Force fetch (ignore cache) - ipcMain.handle('EPG_FORCE_FETCH', async (event, url: string) => { + ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => { this.fetchedUrls.delete(url); return await this.handleFetchEpg([url]); }); + // Cleanup expired programs (uses worker thread) + ipcMain.handle( + 'EPG_CLEANUP_EXPIRED', + async (_event, hoursToKeep?: number) => { + return this.runCleanupInWorker(hoursToKeep ?? 24); + } + ); + + // Clear all EPG data + ipcMain.handle('EPG_CLEAR_ALL', async () => { + await this.clearEpgData(); + return { success: true }; + }); + + // Check if EPG data for URLs is fresh (not stale) + ipcMain.handle( + 'EPG_CHECK_FRESHNESS', + async ( + _event, + args: { urls: string[]; maxAgeHours?: number } + ): Promise<{ staleUrls: string[]; freshUrls: string[] }> => { + return this.checkEpgFreshness(args.urls, args.maxAgeHours ?? 12); + } + ); + return ipcMain; } + /** + * Check which EPG URLs have fresh data vs stale/missing data + * @param urls - EPG source URLs to check + * @param maxAgeHours - Maximum age in hours before data is considered stale + */ + private static async checkEpgFreshness( + urls: string[], + maxAgeHours: number + ): Promise<{ staleUrls: string[]; freshUrls: string[] }> { + const staleUrls: string[] = []; + const freshUrls: string[] = []; + const cutoffTime = new Date( + Date.now() - maxAgeHours * 60 * 60 * 1000 + ).toISOString(); + + try { + const db = await getDatabase(); + + for (const url of urls) { + if (!url?.trim()) continue; + + const result = await db + .select({ updatedAt: schema.epgChannels.updatedAt }) + .from(schema.epgChannels) + .where(eq(schema.epgChannels.sourceUrl, url)) + .limit(1); + + const isFresh = + result.length > 0 && + result[0].updatedAt && + result[0].updatedAt >= cutoffTime; + + if (isFresh) { + freshUrls.push(url); + this.fetchedUrls.add(url); + } else { + staleUrls.push(url); + } + } + } catch (error) { + console.error(this.loggerLabel, 'Error checking EPG freshness:', error); + return { staleUrls: urls, freshUrls: [] }; + } + + // Log summary + if (freshUrls.length > 0) { + console.log(this.loggerLabel, `EPG fresh (skipping): ${freshUrls.length} source(s)`); + } + if (staleUrls.length > 0) { + console.log(this.loggerLabel, `EPG stale (will fetch): ${staleUrls.length} source(s)`); + } + + return { staleUrls, freshUrls }; + } + /** * Handle EPG fetch from URLs + * Automatically skips URLs with fresh data (less than 12 hours old) + * Processes URLs sequentially to avoid SQLite database locking issues */ private static async handleFetchEpg( urls: string[] - ): Promise<{ success: boolean; message?: string }> { + ): Promise<{ success: boolean; message?: string; skipped?: string[] }> { const validUrls = urls.filter((url) => url?.trim()); if (validUrls.length === 0) { return { success: false, message: 'No valid URLs provided' }; } - const promises = validUrls.map((url) => this.fetchEpgFromUrl(url)); + // Check which URLs have fresh data and can be skipped + const { staleUrls, freshUrls } = await this.checkEpgFreshness(validUrls, 12); - try { - await Promise.all(promises); - return { success: true }; - } catch (error) { - console.error(this.loggerLabel, 'Error fetching EPG:', error); + if (staleUrls.length === 0) { + return { success: true, message: 'All EPG data is fresh', skipped: freshUrls }; + } + + // Send queued status for all stale URLs first + staleUrls.forEach((url, index) => { + this.sendProgressToRenderer(url, 'queued', undefined, undefined, index + 1); + }); + + // Process only stale URLs sequentially to avoid database locking + const errors: string[] = []; + for (let i = 0; i < staleUrls.length; i++) { + const url = staleUrls[i]; + try { + await this.fetchEpgFromUrl(url); + } catch (error) { + console.error(this.loggerLabel, `Error fetching EPG from ${url}:`, error); + errors.push(error instanceof Error ? error.message : String(error)); + } + } + + if (errors.length > 0) { return { - success: false, - message: error instanceof Error ? error.message : String(error), + success: errors.length < staleUrls.length, // Partial success if some worked + message: errors.join('; '), + skipped: freshUrls, }; } + + return { success: true, skipped: freshUrls }; } /** * Fetch EPG from a single URL using worker thread + * The worker handles both parsing AND database operations */ private static async fetchEpgFromUrl(url: string): Promise { - // Skip if already fetched + // Skip if already fetched this session if (this.fetchedUrls.has(url)) { + console.log(this.loggerLabel, `Skipping already fetched URL: ${url}`); return; } @@ -98,7 +222,6 @@ export default class EpgEvents { let workerPath: string; if (app.isPackaged) { - // In packaged app: Resources/dist/apps/electron-backend/workers/epg-parser.worker.js const resourcesPath = path.dirname(app.getAppPath()); workerPath = path.join( resourcesPath, @@ -109,21 +232,20 @@ export default class EpgEvents { 'epg-parser.worker.js' ); } else { - // In development: dist/apps/electron-backend/workers/epg-parser.worker.js workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js'); } let worker: Worker; try { - // Worker threads require file:// URLs wrapped in URL object for packaged apps const workerURL = pathToFileURL(workerPath); - worker = new Worker(workerURL); + worker = new Worker(workerURL, { + resourceLimits: { + maxOldGenerationSizeMb: 4096, + maxYoungGenerationSizeMb: 512, + }, + }); } catch (error) { - console.error( - this.loggerLabel, - 'Failed to create worker:', - error - ); + console.error(this.loggerLabel, 'Failed to create worker:', error); reject(error); return; } @@ -132,31 +254,70 @@ export default class EpgEvents { worker.on( 'message', - (message: { + async (message: { type: string; - data?: EpgData; error?: string; url?: string; + stats?: { totalChannels: number; totalPrograms: number }; }) => { - if (message.type === 'READY') { - worker.postMessage({ type: 'FETCH_EPG', url }); - } else if (message.type === 'EPG_PARSED') { - if (message.data) { - this.mergeEpgData(message.data); - this.fetchedUrls.add(url); + try { + switch (message.type) { + case 'READY': + // Notify renderer that loading started + this.sendProgressToRenderer(url, 'loading', { totalChannels: 0, totalPrograms: 0 }); + worker.postMessage({ type: 'FETCH_EPG', url }); + break; + + case 'EPG_PROGRESS': + if (message.stats) { + console.log( + this.loggerLabel, + `Progress: ${message.stats.totalChannels} channels, ${message.stats.totalPrograms} programs` + ); + // Forward progress to renderer + this.sendProgressToRenderer(url, 'loading', message.stats); + } + break; + + case 'EPG_COMPLETE': + console.log( + this.loggerLabel, + `EPG parsing complete for ${url}:`, + message.stats + ); + // Notify renderer of completion + this.sendProgressToRenderer(url, 'complete', message.stats); + this.fetchedUrls.add(url); + // Trigger cleanup in worker thread (non-blocking) + worker.postMessage({ type: 'CLEANUP_EXPIRED', hoursToKeep: 24 }); + break; + + case 'CLEANUP_COMPLETE': + console.log( + this.loggerLabel, + 'Cleanup complete, terminating worker' + ); + worker.terminate(); + this.workers.delete(url); + resolve(); + break; + + case 'EPG_ERROR': + console.error( + this.loggerLabel, + 'Worker error:', + message.error + ); + // Notify renderer of error + this.sendProgressToRenderer(url, 'error', undefined, message.error); + worker.terminate(); + this.workers.delete(url); + reject(new Error(message.error || 'Unknown error')); + break; } - worker.terminate(); - this.workers.delete(url); - resolve(); - } else if (message.type === 'EPG_ERROR') { - console.error( - this.loggerLabel, - 'Worker error:', - message.error - ); - worker.terminate(); - this.workers.delete(url); - reject(new Error(message.error || 'Unknown error')); + } catch (err) { + console.error(this.loggerLabel, 'Error handling message:', err); + reject(err); } } ); @@ -180,115 +341,263 @@ export default class EpgEvents { } /** - * Merge EPG data (optimized for large datasets) + * Transform database row to flat EpgProgram interface */ - private static mergeEpgData(newData: EpgData): void { - // Merge channels (avoid duplicates) - const existingChannelIds = new Set( - this.epgData.channels.map((c) => c.id) - ); - const newChannels = newData.channels.filter( - (c) => !existingChannelIds.has(c.id) - ); - - // Use concat instead of spread for large arrays - this.epgData.channels = this.epgData.channels.concat(newChannels); - - // Merge programs in chunks to avoid stack overflow - const chunkSize = 10000; - for (let i = 0; i < newData.programs.length; i += chunkSize) { - const chunk = newData.programs.slice(i, i + chunkSize); - this.epgData.programs = this.epgData.programs.concat(chunk); - } - - // Rebuild merged data structure - this.rebuildMergedData(); + private static transformDbRowToEpgProgram(row: { + id: number; + channelId: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + iconUrl: string | null; + rating: string | null; + episodeNum: string | null; + }) { + return { + start: row.start, + stop: row.stop, + channel: row.channelId, + title: row.title, + desc: row.description, + category: row.category, + iconUrl: row.iconUrl, + rating: row.rating, + episodeNum: row.episodeNum, + }; } /** - * Rebuild merged data structure (channels with programs) - * Optimized for large datasets + * Get programs for a specific channel from database */ - private static rebuildMergedData(): void { - this.epgDataMerged.clear(); + private static async handleGetChannelPrograms(channelId: string): Promise { + try { + const db = await getDatabase(); + const now = new Date().toISOString(); - // Create channel lookup map for O(1) access - const channelMap = new Map(this.epgData.channels.map((c) => [c.id, c])); - - // Group programs by channel in a single pass - const programsByChannel = new Map(); - - for (const program of this.epgData.programs) { - const channelId = program.channel; - if (!programsByChannel.has(channelId)) { - programsByChannel.set(channelId, []); - } - programsByChannel.get(channelId)!.push(program); - } - - // Build merged structure - for (const [channelId, programs] of programsByChannel.entries()) { - const channel = channelMap.get(channelId); - if (channel) { - this.epgDataMerged.set(channelId, { - ...channel, - programs, - }); - } - } - } - - /** - * Get programs for a specific channel - */ - private static handleGetChannelPrograms(channelId: string): EpgProgram[] { - // First try exact ID match - let channelData = this.epgDataMerged.get(channelId); - - // If not found, try to find by display name - if (!channelData) { - for (const [id, channel] of this.epgDataMerged.entries()) { - const displayNames = channel.displayName.map((d) => - d.value.toLowerCase() - ); - if ( - displayNames.some( - (name) => - name === channelId.toLowerCase() || - name.includes(channelId.toLowerCase()) || - channelId.toLowerCase().includes(name) + // Try exact channel ID match first + let results = await db + .select() + .from(schema.epgPrograms) + .where( + and( + eq(schema.epgPrograms.channelId, channelId), + gte(schema.epgPrograms.stop, now) ) - ) { - channelData = channel; - break; - } - } - } + ) + .orderBy(schema.epgPrograms.start) + .limit(100); - return channelData?.programs || []; + if (results.length > 0) { + return results.map(this.transformDbRowToEpgProgram); + } + + // Try to find channel by display name + const channel = await db + .select() + .from(schema.epgChannels) + .where( + sql`LOWER(${schema.epgChannels.displayName}) LIKE LOWER(${'%' + channelId + '%'})` + ) + .limit(1); + + if (channel.length > 0) { + results = await db + .select() + .from(schema.epgPrograms) + .where( + and( + eq(schema.epgPrograms.channelId, channel[0].id), + gte(schema.epgPrograms.stop, now) + ) + ) + .orderBy(schema.epgPrograms.start) + .limit(100); + + return results.map(this.transformDbRowToEpgProgram); + } + + return []; + } catch (error) { + console.error(this.loggerLabel, 'Error getting channel programs:', error); + return []; + } } /** - * Get channels by range (for pagination) + * Get all channels from database */ - private static handleGetChannelsByRange( + private static async handleGetAllChannels(): Promise<{ + channels: Array<{ id: string; displayName: string }>; + programs: never[]; + }> { + try { + const db = await getDatabase(); + const channels = await db + .select({ + id: schema.epgChannels.id, + displayName: schema.epgChannels.displayName, + }) + .from(schema.epgChannels) + .orderBy(schema.epgChannels.displayName); + + return { channels, programs: [] }; + } catch (error) { + console.error(this.loggerLabel, 'Error getting all channels:', error); + return { channels: [], programs: [] }; + } + } + + /** + * Get channels by range (for pagination) with their programs + */ + private static async handleGetChannelsByRange( skip: number, limit: number - ): EpgChannelWithPrograms[] { - const channels = Array.from(this.epgDataMerged.values()); - return channels.slice(skip, skip + limit); + ): Promise> { + try { + const db = await getDatabase(); + const channels = await db + .select({ + id: schema.epgChannels.id, + displayName: schema.epgChannels.displayName, + iconUrl: schema.epgChannels.iconUrl, + }) + .from(schema.epgChannels) + .orderBy(schema.epgChannels.displayName) + .offset(skip) + .limit(limit); + + // Fetch programs for each channel + const channelsWithPrograms = await Promise.all( + channels.map(async (channel) => { + const programs = await db + .select() + .from(schema.epgPrograms) + .where(eq(schema.epgPrograms.channelId, channel.id)) + .orderBy(schema.epgPrograms.start); + + return { + ...channel, + programs: programs.map(this.transformDbRowToEpgProgram), + }; + }) + ); + + return channelsWithPrograms; + } catch (error) { + console.error(this.loggerLabel, 'Error getting channels by range:', error); + return []; + } } /** - * Clear all EPG data + * Run cleanup in worker thread to avoid blocking main thread */ - static clearEpgData(): void { - this.epgData = { channels: [], programs: [] }; - this.epgDataMerged.clear(); - this.fetchedUrls.clear(); + private static async runCleanupInWorker(hoursToKeep: number): Promise<{ success: boolean }> { + return new Promise((resolve, reject) => { + let workerPath: string; - // Terminate all workers - this.workers.forEach((worker) => worker.terminate()); - this.workers.clear(); + if (app.isPackaged) { + const resourcesPath = path.dirname(app.getAppPath()); + workerPath = path.join( + resourcesPath, + 'dist', + 'apps', + 'electron-backend', + 'workers', + 'epg-parser.worker.js' + ); + } else { + workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js'); + } + + let worker: Worker; + try { + const workerURL = pathToFileURL(workerPath); + worker = new Worker(workerURL); + } catch (error) { + console.error(this.loggerLabel, 'Failed to create worker for cleanup:', error); + reject(error); + return; + } + + worker.on('message', (message: { type: string; error?: string }) => { + if (message.type === 'READY') { + worker.postMessage({ type: 'CLEANUP_EXPIRED', hoursToKeep }); + } else if (message.type === 'CLEANUP_COMPLETE') { + worker.terminate(); + resolve({ success: true }); + } else if (message.type === 'EPG_ERROR') { + console.error(this.loggerLabel, 'Worker cleanup error:', message.error); + worker.terminate(); + reject(new Error(message.error || 'Cleanup failed')); + } + }); + + worker.on('error', (error) => { + console.error(this.loggerLabel, 'Worker error during cleanup:', error); + worker.terminate(); + reject(error); + }); + }); + } + + /** + * Clear all EPG data using worker thread to avoid blocking main thread + */ + static async clearEpgData(): Promise { + return new Promise((resolve, reject) => { + let workerPath: string; + + if (app.isPackaged) { + const resourcesPath = path.dirname(app.getAppPath()); + workerPath = path.join( + resourcesPath, + 'dist', + 'apps', + 'electron-backend', + 'workers', + 'epg-parser.worker.js' + ); + } else { + workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js'); + } + + let worker: Worker; + try { + const workerURL = pathToFileURL(workerPath); + worker = new Worker(workerURL); + } catch (error) { + console.error(this.loggerLabel, 'Failed to create worker for clear:', error); + reject(error); + return; + } + + worker.on('message', (message: { type: string; error?: string }) => { + if (message.type === 'READY') { + worker.postMessage({ type: 'CLEAR_EPG' }); + } else if (message.type === 'CLEAR_COMPLETE') { + console.log(this.loggerLabel, 'EPG data cleared via worker'); + this.fetchedUrls.clear(); + // Terminate any running fetch workers + this.workers.forEach((w) => w.terminate()); + this.workers.clear(); + worker.terminate(); + resolve(); + } else if (message.type === 'EPG_ERROR') { + console.error(this.loggerLabel, 'Worker clear error:', message.error); + worker.terminate(); + reject(new Error(message.error || 'Clear failed')); + } + }); + + worker.on('error', (error) => { + console.error(this.loggerLabel, 'Worker error during clear:', error); + worker.terminate(); + reject(error); + }); + }); } } diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index cbf56a20e..b0af79482 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,100 +1,721 @@ -import { parse as parseEpg } from 'epg-parser'; -import { EpgData, EpgProgram } from 'shared-interfaces'; import { parentPort } from 'worker_threads'; -import { gunzipSync } from 'zlib'; +import { createGunzip } from 'zlib'; +import { SaxesParser, SaxesTagPlain } from 'saxes'; +import { Readable } from 'stream'; +import Database from 'better-sqlite3'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; /** - * EPG Parser Worker - * Runs in a separate thread to avoid blocking the main process - * Compatible with epg-parser v0.4.0 + * Internal parsing types with arrays for XML parsing + * These are different from the flat EpgProgram interface used by the frontend + */ +interface ParsedTextValue { + lang: string; + value: string; +} + +interface ParsedIcon { + src: string; + width?: number; + height?: number; +} + +interface ParsedRating { + system: string; + value: string; +} + +interface ParsedEpisodeNum { + system: string; + value: string; +} + +interface ParsedChannel { + id: string; + displayName: ParsedTextValue[]; + icon: ParsedIcon[]; + url: string[]; +} + +interface ParsedProgram { + start: string; + stop: string; + channel: string; + title: ParsedTextValue[]; + desc: ParsedTextValue[]; + category: ParsedTextValue[]; + date: string; + episodeNum: ParsedEpisodeNum[]; + icon: ParsedIcon[]; + rating: ParsedRating[]; +} + +/** + * Streaming EPG Parser Worker + * Uses SAX parsing to process XML incrementally without loading entire file into memory. + * Supports both regular and gzipped EPG files. + * Performs database operations directly to avoid blocking the main thread. */ interface WorkerMessage { - type: 'FETCH_EPG' | 'FORCE_FETCH'; - url: string; + type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG' | 'CLEANUP_EXPIRED'; + url?: string; + hoursToKeep?: number; } interface WorkerResponse { - type: 'EPG_PARSED' | 'EPG_ERROR' | 'EPG_PROGRESS' | 'READY'; - data?: EpgData; + type: + | 'EPG_COMPLETE' + | 'EPG_ERROR' + | 'EPG_PROGRESS' + | 'CLEAR_COMPLETE' + | 'CLEANUP_COMPLETE' + | 'READY'; error?: string; url?: string; + stats?: { + totalChannels: number; + totalPrograms: number; + }; } const loggerLabel = '[EPG Worker]'; -/** - * Fetches and parses EPG data from URL - */ -async function fetchAndParseEpg(url: string): Promise { - try { - const isGzipped = url.endsWith('.gz'); +// Batch size for database inserts +const CHANNEL_BATCH_SIZE = 100; +const PROGRAM_BATCH_SIZE = 1000; + +// Skip programs that ended more than this many hours ago +const SKIP_PROGRAMS_OLDER_THAN_HOURS = 2; + +/** + * Calculate the cutoff time for filtering old programs + * Programs that ended before this time will be skipped + */ +function getProgramCutoffTime(): Date { + return new Date(Date.now() - SKIP_PROGRAMS_OLDER_THAN_HOURS * 60 * 60 * 1000); +} + +/** + * Check if a program has already ended (is expired) + * Handles timezone-aware ISO date strings + */ +function isProgramExpired(stopTime: string, cutoff: Date): boolean { + if (!stopTime) return true; // Skip programs without stop time + + try { + const stopDate = new Date(stopTime); + // Check if the date is valid + if (isNaN(stopDate.getTime())) return false; // Don't skip if we can't parse + return stopDate < cutoff; + } catch { + return false; // Don't skip if parsing fails + } +} + +/** + * Get database file path (same as main app) + */ +function getDatabasePath(): string { + const dbDir = join(homedir(), '.iptvnator', 'databases'); + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + return join(dbDir, 'iptvnator.db'); +} + +/** + * Database helper class for EPG operations + * Creates its own connection to avoid blocking main thread + */ +class EpgDatabase { + private db: Database.Database; + private knownChannelIds: Set = new Set(); + + // Prepared statements for better performance + private insertChannelStmt: Database.Statement; + private insertProgramStmt: Database.Statement; + private deleteChannelsStmt: Database.Statement; + + constructor() { + const dbPath = getDatabasePath(); + this.db = new Database(dbPath); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('journal_mode = WAL'); // Better concurrent write performance + + // Prepare statements + // Use INSERT OR REPLACE to update existing channels and refresh updated_at + // Use strftime with ISO format for consistent date comparison + this.insertChannelStmt = this.db.prepare(` + INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at) + VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + icon_url = excluded.icon_url, + url = excluded.url, + source_url = excluded.source_url, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + `); + + this.insertProgramStmt = this.db.prepare(` + INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + this.deleteChannelsStmt = this.db.prepare(` + DELETE FROM epg_channels WHERE source_url = ? + `); + } + + /** + * Clear existing EPG data for a source URL + */ + clearSourceData(sourceUrl: string): void { + this.deleteChannelsStmt.run(sourceUrl); + this.knownChannelIds.clear(); + } + + /** + * Insert a batch of channels + */ + insertChannels(channels: ParsedChannel[], sourceUrl: string): void { + const insertMany = this.db.transaction((channels: ParsedChannel[]) => { + for (const channel of channels) { + const displayName = channel.displayName?.[0]?.value || channel.id; + const iconUrl = channel.icon?.[0]?.src || null; + const url = channel.url?.[0] || null; + + this.insertChannelStmt.run( + channel.id, + displayName, + iconUrl, + url, + sourceUrl + ); + this.knownChannelIds.add(channel.id); + } + }); + + insertMany(channels); + } + + /** + * Insert a batch of programs + * Only inserts programs for known channels to avoid FK constraint failures + */ + insertPrograms(programs: ParsedProgram[]): number { + let insertedCount = 0; + + const insertMany = this.db.transaction((programs: ParsedProgram[]) => { + for (const prog of programs) { + // Skip if channel not known + if (!this.knownChannelIds.has(prog.channel)) continue; + + const title = prog.title?.[0]?.value || 'Unknown'; + const description = prog.desc?.[0]?.value || null; + const category = prog.category?.[0]?.value || null; + const iconUrl = prog.icon?.[0]?.src || null; + const rating = prog.rating?.[0]?.value || null; + const episodeNum = prog.episodeNum?.[0]?.value || null; + + try { + this.insertProgramStmt.run( + prog.channel, + prog.start, + prog.stop, + title, + description, + category, + iconUrl, + rating, + episodeNum + ); + insertedCount++; + } catch (err) { + // Skip individual failures (e.g., FK constraint) + } + } + }); + + insertMany(programs); + return insertedCount; + } + + /** + * Close the database connection + */ + close(): void { + this.db.close(); + } +} + +/** + * Parse XMLTV datetime format to ISO string + * Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss + */ +function parseXmltvDate(dateStr: string): string { + if (!dateStr) return ''; + + const match = dateStr.match( + /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/ + ); + + if (!match) return dateStr; + + const [, year, month, day, hour, minute, second, tz] = match; + + let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; + + if (tz) { + isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`; + } else { + isoString += 'Z'; + } + + return isoString; +} + +/** + * Streaming EPG parser using SAX + */ +class StreamingEpgParser { + private parser: SaxesParser; + private channels: ParsedChannel[] = []; + private programs: ParsedProgram[] = []; + private totalChannels = 0; + private totalPrograms = 0; + private skippedPrograms = 0; + + // Cutoff time for filtering old programs (calculated once at start) + private readonly cutoffTime: Date; + + // Current element being parsed + private currentChannel: Partial | null = null; + private currentProgram: Partial | null = null; + private currentTextContent = ''; + private currentLang = ''; + + // For nested elements + private elementStack: string[] = []; + + constructor( + private onChannelsBatch: (channels: ParsedChannel[]) => void, + private onProgramsBatch: (programs: ParsedProgram[]) => void, + private onProgress: (channels: number, programs: number) => void + ) { + this.parser = new SaxesParser(); + this.cutoffTime = getProgramCutoffTime(); + this.setupParser(); + } + + private setupParser(): void { + this.parser.on('opentag', (tag: SaxesTagPlain) => { + this.elementStack.push(tag.name); + this.currentTextContent = ''; + + switch (tag.name) { + case 'channel': + this.currentChannel = { + id: (tag.attributes['id'] as string) || '', + displayName: [], + icon: [], + url: [], + }; + break; + + case 'programme': + this.currentProgram = { + start: parseXmltvDate( + (tag.attributes['start'] as string) || '' + ), + stop: parseXmltvDate( + (tag.attributes['stop'] as string) || '' + ), + channel: (tag.attributes['channel'] as string) || '', + title: [], + desc: [], + category: [], + date: '', + episodeNum: [], + icon: [], + rating: [], + }; + break; + + case 'icon': + if (this.currentChannel) { + this.currentChannel.icon!.push({ + src: (tag.attributes['src'] as string) || '', + width: tag.attributes['width'] + ? parseInt(tag.attributes['width'] as string) + : undefined, + height: tag.attributes['height'] + ? parseInt(tag.attributes['height'] as string) + : undefined, + }); + } else if (this.currentProgram) { + this.currentProgram.icon!.push({ + src: (tag.attributes['src'] as string) || '', + width: tag.attributes['width'] + ? parseInt(tag.attributes['width'] as string) + : undefined, + height: tag.attributes['height'] + ? parseInt(tag.attributes['height'] as string) + : undefined, + }); + } + break; + + case 'display-name': + case 'title': + case 'desc': + case 'category': + this.currentLang = (tag.attributes['lang'] as string) || ''; + break; + + case 'rating': + if (this.currentProgram) { + const system = + (tag.attributes['system'] as string) || ''; + this.currentProgram.rating!.push({ system, value: '' }); + } + break; + + case 'episode-num': + if (this.currentProgram) { + const system = + (tag.attributes['system'] as string) || ''; + this.currentProgram.episodeNum!.push({ + system, + value: '', + }); + } + break; + } + }); + + this.parser.on('text', (text: string) => { + this.currentTextContent += text; + }); + + this.parser.on('closetag', (tag: SaxesTagPlain) => { + const text = this.currentTextContent.trim(); + + if (this.currentChannel) { + switch (tag.name) { + case 'display-name': + this.currentChannel.displayName!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'url': + if (text) this.currentChannel.url!.push(text); + break; + case 'channel': + this.channels.push(this.currentChannel as ParsedChannel); + this.totalChannels++; + this.currentChannel = null; + + if (this.channels.length >= CHANNEL_BATCH_SIZE) { + this.flushChannels(); + } + break; + } + } + + if (this.currentProgram) { + switch (tag.name) { + case 'title': + this.currentProgram.title!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'desc': + this.currentProgram.desc!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'category': + this.currentProgram.category!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'date': + this.currentProgram.date = text; + break; + case 'value': + if ( + this.elementStack.includes('rating') && + this.currentProgram.rating!.length > 0 + ) { + this.currentProgram.rating![ + this.currentProgram.rating!.length - 1 + ].value = text; + } + break; + case 'episode-num': + if (this.currentProgram.episodeNum!.length > 0) { + this.currentProgram.episodeNum![ + this.currentProgram.episodeNum!.length - 1 + ].value = text; + } + break; + case 'programme': + // Skip programs that have already ended + if (isProgramExpired(this.currentProgram.stop!, this.cutoffTime)) { + this.skippedPrograms++; + } else { + this.programs.push(this.currentProgram as ParsedProgram); + this.totalPrograms++; + + if (this.programs.length >= PROGRAM_BATCH_SIZE) { + this.flushPrograms(); + } + } + this.currentProgram = null; + break; + } + } + + this.elementStack.pop(); + this.currentTextContent = ''; + }); + + this.parser.on('error', (err: Error) => { + console.error(loggerLabel, 'Parser error:', err.message); + }); + } + + private flushChannels(): void { + if (this.channels.length > 0) { + this.onChannelsBatch([...this.channels]); + this.channels = []; + this.onProgress(this.totalChannels, this.totalPrograms); + } + } + + private flushPrograms(): void { + if (this.programs.length > 0) { + this.onProgramsBatch([...this.programs]); + this.programs = []; + this.onProgress(this.totalChannels, this.totalPrograms); + } + } + + write(chunk: string): void { + this.parser.write(chunk); + } + + finish(): { totalChannels: number; totalPrograms: number; skippedPrograms: number } { + this.parser.close(); + this.flushChannels(); + this.flushPrograms(); + + if (this.skippedPrograms > 0) { + console.log( + loggerLabel, + `Skipped ${this.skippedPrograms} expired programs (ended more than ${SKIP_PROGRAMS_OLDER_THAN_HOURS}h ago)` + ); + } + + return { + totalChannels: this.totalChannels, + totalPrograms: this.totalPrograms, + skippedPrograms: this.skippedPrograms, + }; + } +} + +/** + * Fetches and parses EPG data from URL using streaming + * Inserts directly into SQLite to avoid blocking main thread + */ +async function fetchAndParseEpgStreaming(url: string): Promise { + const isGzipped = url.endsWith('.gz'); + + console.log( + loggerLabel, + `Fetching EPG from ${url} (gzipped: ${isGzipped})` + ); + + // Create database connection in worker + const epgDb = new EpgDatabase(); + + try { + // Clear existing data for this source + console.log(loggerLabel, `Clearing existing data for ${url}`); + epgDb.clearSourceData(url); - // Use native fetch (available in Node.js 18+) const response = await fetch(url.trim()); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - let xmlString: string; - - if (isGzipped) { - // For gzipped files, get as ArrayBuffer - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - const decompressed = gunzipSync(buffer); - xmlString = decompressed.toString('utf-8'); - } else { - // For regular files, get as text - xmlString = await response.text(); + if (!response.body) { + throw new Error('Response body is null'); } - const parsed = parseEpg(xmlString); - - // Normalize data to match our interface - const normalized: EpgData = { - channels: parsed.channels.map( - (channel: Record) => ({ - id: channel.id as string, - displayName: - (channel.displayName as { - lang: string; - value: string; - }[]) || [], - icon: - (channel.icon as { - src: string; - width?: number; - height?: number; - }[]) || [], - url: (channel.url as string[]) || [], - }) - ), - programs: parsed.programs.map( - (program: Record) => ({ - ...program, - start: program.start as string, // Already ISO string in v0.4.0 - stop: program.stop as string, // Already ISO string in v0.4.0 - date: - typeof program.date === 'string' - ? program.date - : (program.date as string[])?.[0] || '', - }) - ) as EpgProgram[], - }; - - return normalized; - } catch (error) { - console.error( - loggerLabel, - 'Error fetching EPG:', - error instanceof Error ? error.message : String(error) + const parser = new StreamingEpgParser( + (channels) => { + // Insert channels directly into database + epgDb.insertChannels(channels, url); + }, + (programs) => { + // Insert programs directly into database + epgDb.insertPrograms(programs); + }, + (totalChannels, totalPrograms) => { + // Send progress to main thread (lightweight) + const response: WorkerResponse = { + type: 'EPG_PROGRESS', + stats: { totalChannels, totalPrograms }, + }; + parentPort?.postMessage(response); + } ); + + // Convert web stream to Node.js stream + const nodeStream = Readable.fromWeb(response.body as any); + + return new Promise((resolve, reject) => { + let dataStream: Readable = nodeStream; + + if (isGzipped) { + const gunzip = createGunzip(); + dataStream = nodeStream.pipe(gunzip); + + gunzip.on('error', (err) => { + console.error(loggerLabel, 'Gunzip error:', err); + epgDb.close(); + reject(err); + }); + } + + dataStream.on('data', (chunk: Buffer) => { + try { + parser.write(chunk.toString('utf-8')); + } catch (err) { + console.error(loggerLabel, 'Parse error:', err); + epgDb.close(); + reject(err); + } + }); + + dataStream.on('end', () => { + try { + const stats = parser.finish(); + console.log( + loggerLabel, + `Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs` + + (stats.skippedPrograms > 0 + ? ` (skipped ${stats.skippedPrograms} expired)` + : '') + ); + + // Close database connection + epgDb.close(); + + const response: WorkerResponse = { + type: 'EPG_COMPLETE', + url, + stats: { + totalChannels: stats.totalChannels, + totalPrograms: stats.totalPrograms, + }, + }; + parentPort?.postMessage(response); + resolve(); + } catch (err) { + epgDb.close(); + reject(err); + } + }); + + dataStream.on('error', (err) => { + console.error(loggerLabel, 'Stream error:', err); + epgDb.close(); + reject(err); + }); + }); + } catch (error) { + epgDb.close(); throw error; } } +/** + * Clears all EPG data from the database + * Runs in worker thread to avoid blocking main thread + */ +function clearAllEpgData(): void { + const dbPath = getDatabasePath(); + const db = new Database(dbPath); + + try { + console.log(loggerLabel, 'Clearing all EPG data...'); + + // Delete programs first (foreign key constraint) + db.exec('DELETE FROM epg_programs'); + // Then delete channels + db.exec('DELETE FROM epg_channels'); + + console.log(loggerLabel, 'All EPG data cleared'); + + const response: WorkerResponse = { type: 'CLEAR_COMPLETE' }; + parentPort?.postMessage(response); + } catch (error) { + console.error(loggerLabel, 'Error clearing EPG data:', error); + const errorResponse: WorkerResponse = { + type: 'EPG_ERROR', + error: error instanceof Error ? error.message : String(error), + }; + parentPort?.postMessage(errorResponse); + } finally { + db.close(); + } +} + +/** + * Cleans up expired EPG programs from the database + * Runs in worker thread to avoid blocking main thread + */ +function cleanupExpiredPrograms(hoursToKeep: number): void { + const dbPath = getDatabasePath(); + const db = new Database(dbPath); + + try { + const cutoff = new Date( + Date.now() - hoursToKeep * 60 * 60 * 1000 + ).toISOString(); + + console.log(loggerLabel, `Cleaning up programs older than ${hoursToKeep}h (before ${cutoff})...`); + + const stmt = db.prepare('DELETE FROM epg_programs WHERE stop <= ?'); + const result = stmt.run(cutoff); + + console.log(loggerLabel, `Cleaned up ${result.changes} expired programs`); + + const response: WorkerResponse = { type: 'CLEANUP_COMPLETE' }; + parentPort?.postMessage(response); + } catch (error) { + console.error(loggerLabel, 'Error cleaning up expired programs:', error); + const errorResponse: WorkerResponse = { + type: 'EPG_ERROR', + error: error instanceof Error ? error.message : String(error), + }; + parentPort?.postMessage(errorResponse); + } finally { + db.close(); + } +} + /** * Worker message handler */ @@ -105,15 +726,11 @@ if (parentPort) { message.type === 'FETCH_EPG' || message.type === 'FORCE_FETCH' ) { - const parsedData = await fetchAndParseEpg(message.url); - - const response: WorkerResponse = { - type: 'EPG_PARSED', - data: parsedData, - url: message.url, - }; - - parentPort?.postMessage(response); + await fetchAndParseEpgStreaming(message.url!); + } else if (message.type === 'CLEAR_EPG') { + clearAllEpgData(); + } else if (message.type === 'CLEANUP_EXPIRED') { + cleanupExpiredPrograms(message.hoursToKeep ?? 24); } } catch (error) { console.error(loggerLabel, 'Worker error:', error); @@ -122,7 +739,6 @@ if (parentPort) { error: error instanceof Error ? error.message : String(error), url: message.url, }; - parentPort?.postMessage(errorResponse); } }); diff --git a/apps/web/project.json b/apps/web/project.json index 17bd28c6b..07bdb1a66 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -39,8 +39,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kb", - "maximumError": "10kb" + "maximumWarning": "10kb", + "maximumError": "15kb" } ], "outputHashing": "all", @@ -61,8 +61,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kb", - "maximumError": "10kb" + "maximumWarning": "10kb", + "maximumError": "15kb" } ], "outputHashing": "all", diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index e48bb3113..39b78770f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -100,13 +100,13 @@ export class AppComponent implements OnInit { this.translate.use(settings.language ?? this.DEFAULT_LANG); - // Fetch EPG if URLs are configured + // Fetch EPG if URLs are configured (only fetch stale data) if ( window.electron && settings.epgUrl?.length > 0 && settings.epgUrl?.some((u) => u !== '') ) { - this.epgService.fetchEpg(settings.epgUrl); + this.fetchStaleEpgData(settings.epgUrl); } if (settings.theme) { @@ -165,6 +165,33 @@ export class AppComponent implements OnInit { }); } + /** + * Fetches EPG data only for URLs that have stale or missing data. + * Data is considered fresh if updated within the last 12 hours. + */ + private async fetchStaleEpgData(urls: string[]): Promise { + try { + const result = await window.electron.checkEpgFreshness(urls, 12); + + if (result.freshUrls.length > 0) { + console.log( + `EPG: ${result.freshUrls.length} source(s) already fresh, skipping fetch` + ); + } + + if (result.staleUrls.length > 0) { + console.log( + `EPG: Fetching ${result.staleUrls.length} stale source(s)` + ); + this.epgService.fetchEpg(result.staleUrls); + } + } catch (error) { + console.error('Error checking EPG freshness, fetching all:', error); + // Fallback: fetch all URLs if freshness check fails + this.epgService.fetchEpg(urls); + } + } + /** * Triggers auto-update for playlists that have autoRefresh enabled */ diff --git a/apps/web/src/app/services/epg-progress.service.ts b/apps/web/src/app/services/epg-progress.service.ts new file mode 100644 index 000000000..c4bd1f7f2 --- /dev/null +++ b/apps/web/src/app/services/epg-progress.service.ts @@ -0,0 +1,92 @@ +import { Injectable, signal, computed } from '@angular/core'; + +export interface EpgImportProgress { + url: string; + status: 'queued' | 'loading' | 'complete' | 'error'; + stats?: { totalChannels: number; totalPrograms: number }; + error?: string; + queuePosition?: number; +} + +/** + * Service to track EPG import progress across multiple sources. + * Uses signals for reactive state management. + */ +@Injectable({ providedIn: 'root' }) +export class EpgProgressService { + private _imports = signal>(new Map()); + private _initialized = false; + + /** All current import progress entries */ + readonly imports = computed(() => Array.from(this._imports().values())); + + /** Whether any imports are currently loading */ + readonly hasActiveImports = computed(() => + this.imports().some((i) => i.status === 'loading') + ); + + /** Count of active imports */ + readonly activeCount = computed(() => + this.imports().filter((i) => i.status === 'loading').length + ); + + /** Queued imports waiting to be processed */ + readonly queuedImports = computed(() => + this.imports() + .filter((i) => i.status === 'queued') + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)) + ); + + /** Count of queued imports */ + readonly queuedCount = computed(() => this.queuedImports().length); + + /** Whether the panel should be visible */ + readonly isVisible = computed(() => this.imports().length > 0); + + constructor() { + this.initializeListener(); + } + + private initializeListener(): void { + if (this._initialized) return; + this._initialized = true; + + // Listen for EPG progress updates from Electron + if (window.electron?.onEpgProgress) { + window.electron.onEpgProgress((data) => { + this.updateProgress(data); + }); + } + } + + private updateProgress(progress: EpgImportProgress): void { + this._imports.update((current) => { + const updated = new Map(current); + updated.set(progress.url, progress); + return updated; + }); + + // Auto-remove completed/error entries after delay + if (progress.status === 'complete' || progress.status === 'error') { + setTimeout(() => this.removeImport(progress.url), 5000); + } + } + + private removeImport(url: string): void { + this._imports.update((current) => { + const updated = new Map(current); + updated.delete(url); + return updated; + }); + } + + /** Manually dismiss an import notification */ + dismiss(url: string): void { + this.removeImport(url); + } + + /** Dismiss all notifications */ + dismissAll(): void { + this._imports.set(new Map()); + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index d184d28f2..e5d0cb27c 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -76,6 +76,14 @@ > {{ 'SETTINGS.ADD_EPG_SOURCE' | translate }} + diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index f12c0062e..11b7b51d1 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -386,6 +386,26 @@ export class SettingsComponent implements OnInit { this.settingsForm.markAsDirty(); } + /** + * Clears all EPG data from database + */ + clearEpgData(): void { + this.dialogService.openConfirmDialog({ + title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'), + message: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.MESSAGE'), + onConfirm: async (): Promise => { + if (window.electron?.clearEpgData) { + await window.electron.clearEpgData(); + this.snackBar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEARED'), + null, + { duration: 2000, horizontalPosition: 'start' } + ); + } + }, + }); + } + exportData() { this.playlistsService .getAllData() diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html new file mode 100644 index 000000000..888106b0d --- /dev/null +++ b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html @@ -0,0 +1,95 @@ +@if (isVisible()) { +
+
+ + live_tv + {{ 'EPG.IMPORT_PROGRESS' | translate }} + + @if (imports().length > 1) { + + } +
+ +
+ + @for (item of activeImports; track item.url) { +
+
+
+ + {{ getStatusIcon(item.status) }} + +
+ + {{ getDisplayUrl(item.url) }} + + +
+ + @if (item.status === 'loading') { + + } + +
+ @if (item.stats) { + + tv + {{ item.stats.totalChannels }} + + + event + {{ item.stats.totalPrograms }} + + } @else if (item.error) { + {{ item.error }} + } @else if (item.status === 'loading') { + {{ 'EPG.LOADING' | translate }}... + } +
+
+ } + + + @if (queuedImports.length > 0) { +
+
+ queue + {{ 'EPG.QUEUED' | translate }} ({{ queuedImports.length }}) +
+
+ @for (item of queuedImports; track item.url; let i = $index) { +
+ {{ i + 1 }} + + {{ getDisplayUrl(item.url) }} + + +
+ } +
+
+ } +
+
+} diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss new file mode 100644 index 000000000..ac6f8b72d --- /dev/null +++ b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss @@ -0,0 +1,512 @@ +// EPG Progress Panel - Broadcast Control Room Aesthetic +// Sleek, modern design with glassmorphism and subtle glow effects + +// CSS Custom Properties for theming +:host { + --epg-panel-bg: rgba(18, 22, 28, 0.92); + --epg-panel-bg-solid: #12161c; + --epg-panel-border: rgba(56, 189, 248, 0.15); + --epg-panel-border-glow: rgba(56, 189, 248, 0.25); + --epg-header-bg: rgba(56, 189, 248, 0.08); + --epg-header-border: rgba(56, 189, 248, 0.12); + --epg-text-primary: rgba(255, 255, 255, 0.95); + --epg-text-secondary: rgba(255, 255, 255, 0.6); + --epg-text-tertiary: rgba(255, 255, 255, 0.4); + --epg-accent: #38bdf8; + --epg-accent-glow: rgba(56, 189, 248, 0.4); + --epg-success: #22c55e; + --epg-success-glow: rgba(34, 197, 94, 0.3); + --epg-error: #ef4444; + --epg-error-glow: rgba(239, 68, 68, 0.3); + --epg-queued: #a78bfa; + --epg-queued-glow: rgba(167, 139, 250, 0.3); + --epg-item-border: rgba(255, 255, 255, 0.06); + --epg-item-hover: rgba(56, 189, 248, 0.05); + --epg-progress-track: rgba(255, 255, 255, 0.08); + --epg-scrollbar-thumb: rgba(56, 189, 248, 0.3); + --epg-scrollbar-track: rgba(255, 255, 255, 0.05); + --epg-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + --epg-icon-bg: rgba(56, 189, 248, 0.12); + --epg-queue-bg: rgba(167, 139, 250, 0.06); + --epg-queue-border: rgba(167, 139, 250, 0.15); +} + +// Light theme overrides +:host-context(.light-theme), +:host-context([data-theme='light']), +:host-context(.mat-app-background.light) { + --epg-panel-bg: rgba(255, 255, 255, 0.95); + --epg-panel-bg-solid: #ffffff; + --epg-panel-border: rgba(15, 118, 110, 0.2); + --epg-panel-border-glow: rgba(15, 118, 110, 0.15); + --epg-header-bg: rgba(15, 118, 110, 0.06); + --epg-header-border: rgba(15, 118, 110, 0.1); + --epg-text-primary: rgba(15, 23, 42, 0.95); + --epg-text-secondary: rgba(15, 23, 42, 0.6); + --epg-text-tertiary: rgba(15, 23, 42, 0.4); + --epg-accent: #0f766e; + --epg-accent-glow: rgba(15, 118, 110, 0.25); + --epg-success: #16a34a; + --epg-success-glow: rgba(22, 163, 74, 0.2); + --epg-error: #dc2626; + --epg-error-glow: rgba(220, 38, 38, 0.2); + --epg-queued: #7c3aed; + --epg-queued-glow: rgba(124, 58, 237, 0.2); + --epg-item-border: rgba(15, 23, 42, 0.08); + --epg-item-hover: rgba(15, 118, 110, 0.04); + --epg-progress-track: rgba(15, 23, 42, 0.08); + --epg-scrollbar-thumb: rgba(15, 118, 110, 0.3); + --epg-scrollbar-track: rgba(15, 23, 42, 0.05); + --epg-shadow: + 0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08); + --epg-icon-bg: rgba(15, 118, 110, 0.1); + --epg-queue-bg: rgba(124, 58, 237, 0.05); + --epg-queue-border: rgba(124, 58, 237, 0.12); +} + +.epg-progress-panel { + position: fixed; + bottom: 20px; + left: 20px; + width: 340px; + max-width: calc(100vw - 40px); + background: var(--epg-panel-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-radius: 16px; + box-shadow: var(--epg-shadow); + z-index: 1000; + overflow: hidden; + border: 1px solid var(--epg-panel-border); + + // Subtle glow effect on top edge + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--epg-panel-border-glow) 20%, + var(--epg-accent-glow) 50%, + var(--epg-panel-border-glow) 80%, + transparent 100% + ); + } + + // Entry animation + animation: slideInFromLeft 0.35s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideInFromLeft { + from { + opacity: 0; + transform: translateX(-24px) scale(0.96); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--epg-header-bg); + border-bottom: 1px solid var(--epg-header-border); + position: relative; + + .header-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 13px; + letter-spacing: 0.02em; + color: var(--epg-text-primary); + text-transform: uppercase; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--epg-accent); + filter: drop-shadow(0 0 6px var(--epg-accent-glow)); + animation: pulse-glow 2s ease-in-out infinite; + } + } + + .dismiss-all-btn { + width: 28px; + height: 28px; + line-height: 28px; + border-radius: 8px; + transition: all 0.2s ease; + color: var(--epg-text-tertiary); + + &:hover { + background: var(--epg-item-hover); + color: var(--epg-text-primary); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +@keyframes pulse-glow { + 0%, + 100% { + filter: drop-shadow(0 0 4px var(--epg-accent-glow)); + } + 50% { + filter: drop-shadow(0 0 10px var(--epg-accent-glow)); + } +} + +.imports-list { + max-height: 360px; + overflow-y: auto; + padding: 4px 0; + + // Custom scrollbar + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--epg-scrollbar-track); + border-radius: 3px; + margin: 4px 0; + } + + &::-webkit-scrollbar-thumb { + background: var(--epg-scrollbar-thumb); + border-radius: 3px; + + &:hover { + background: var(--epg-accent); + } + } +} + +.import-item { + padding: 14px 16px; + margin: 0 8px; + border-radius: 10px; + transition: all 0.2s ease; + position: relative; + + &:not(:last-child) { + margin-bottom: 2px; + } + + &:hover { + background: var(--epg-item-hover); + } + + .item-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + + .status-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--epg-icon-bg); + flex-shrink: 0; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + transition: all 0.3s ease; + } + + .item-url { + flex: 1; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--epg-text-secondary); + font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace; + letter-spacing: -0.02em; + } + + .dismiss-btn { + width: 26px; + height: 26px; + line-height: 26px; + border-radius: 6px; + opacity: 0; + transition: all 0.2s ease; + color: var(--epg-text-tertiary); + + &:hover { + background: var(--epg-error-glow); + color: var(--epg-error); + } + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + } + + &:hover .dismiss-btn { + opacity: 1; + } + + mat-progress-bar { + margin-bottom: 10px; + border-radius: 2px; + height: 4px; + + ::ng-deep { + .mdc-linear-progress__buffer { + background-color: var(--epg-progress-track); + } + + .mdc-linear-progress__bar-inner { + border-color: var(--epg-accent); + } + + .mdc-linear-progress__buffer-bar { + background-color: var(--epg-progress-track); + } + } + } + + .item-stats { + font-size: 11px; + color: var(--epg-text-tertiary); + display: flex; + align-items: center; + gap: 8px; + + .stat-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: var(--epg-icon-bg); + border-radius: 6px; + font-weight: 600; + font-variant-numeric: tabular-nums; + font-size: 12px; + color: var(--epg-text-secondary); + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + opacity: 0.7; + } + } + } + + // Status variations + &.status-loading { + .status-icon-wrapper { + background: var(--epg-icon-bg); + } + + .item-header mat-icon { + color: var(--epg-accent); + } + + .item-stats { + color: var(--epg-text-tertiary); + } + } + + &.status-complete { + .status-icon-wrapper { + background: var(--epg-success-glow); + } + + .item-header mat-icon { + color: var(--epg-success); + filter: drop-shadow(0 0 6px var(--epg-success-glow)); + } + + .item-stats { + color: var(--epg-success); + + .stat-badge { + background: var(--epg-success-glow); + color: var(--epg-success); + } + } + } + + &.status-error { + .status-icon-wrapper { + background: var(--epg-error-glow); + } + + .item-header mat-icon { + color: var(--epg-error); + filter: drop-shadow(0 0 6px var(--epg-error-glow)); + } + + .error-text { + color: var(--epg-error); + font-size: 11px; + font-weight: 500; + } + } +} + +// Queue section styles +.queue-section { + margin: 8px; + padding: 12px; + border-radius: 10px; + animation: fadeIn 0.3s ease; + + .queue-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--epg-queue-border); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--epg-queued); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + + .queue-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .queue-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(167, 139, 250, 0.04); + border-radius: 6px; + transition: all 0.2s ease; + + &:hover { + background: rgba(167, 139, 250, 0.08); + + .dismiss-btn { + opacity: 1; + } + } + + .queue-position { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + background: var(--epg-queued-glow); + color: var(--epg-queued); + font-size: 11px; + font-weight: 700; + flex-shrink: 0; + } + + .queue-url { + flex: 1; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--epg-text-secondary); + font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace; + letter-spacing: -0.02em; + } + + .dismiss-btn { + width: 22px; + height: 22px; + line-height: 22px; + border-radius: 4px; + opacity: 0; + transition: all 0.2s ease; + color: var(--epg-text-tertiary); + flex-shrink: 0; + + &:hover { + background: var(--epg-error-glow); + color: var(--epg-error); + } + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.spinning { + animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +// Responsive adjustments +@media (max-width: 400px) { + .epg-progress-panel { + left: 12px; + right: 12px; + bottom: 12px; + width: auto; + max-width: none; + } +} diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts new file mode 100644 index 000000000..8775a29d2 --- /dev/null +++ b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts @@ -0,0 +1,78 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateModule } from '@ngx-translate/core'; +import { + EpgProgressService, + EpgImportProgress, +} from '../../services/epg-progress.service'; + +@Component({ + selector: 'app-epg-progress-panel', + standalone: true, + imports: [ + CommonModule, + MatProgressBarModule, + MatIconModule, + MatButtonModule, + TranslateModule, + ], + templateUrl: './epg-progress-panel.component.html', + styleUrl: './epg-progress-panel.component.scss', +}) +export class EpgProgressPanelComponent { + private epgProgress = inject(EpgProgressService); + + readonly imports = this.epgProgress.imports; + readonly isVisible = this.epgProgress.isVisible; + readonly queuedCount = this.epgProgress.queuedCount; + readonly activeCount = this.epgProgress.activeCount; + + /** Active and completed imports (not queued) */ + get activeImports() { + return this.imports().filter((i) => i.status !== 'queued'); + } + + /** Queued imports waiting to be processed */ + get queuedImports() { + return this.imports() + .filter((i) => i.status === 'queued') + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); + } + + getStatusIcon(status: EpgImportProgress['status']): string { + switch (status) { + case 'queued': + return 'schedule'; + case 'loading': + return 'sync'; + case 'complete': + return 'check_circle'; + case 'error': + return 'error'; + } + } + + getStatusClass(status: EpgImportProgress['status']): string { + return `status-${status}`; + } + + getDisplayUrl(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname + urlObj.pathname.split('/').pop(); + } catch { + return url.length > 40 ? url.substring(0, 40) + '...' : url; + } + } + + dismiss(url: string): void { + this.epgProgress.dismiss(url); + } + + dismissAll(): void { + this.epgProgress.dismissAll(); + } +} diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 747ed03ac..914f0e1b8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -150,6 +150,12 @@ "THEME": "Visual theme", "SHOW_CAPTIONS": "Show subtitles", "ADD_EPG_SOURCE": "Add EPG source", + "CLEAR_EPG_DATA": "Clear EPG data", + "CLEAR_EPG_DIALOG": { + "TITLE": "Clear EPG data", + "MESSAGE": "Are you sure you want to remove all EPG (Electronic Program Guide) data from the database? This will delete all cached program schedules." + }, + "EPG_DATA_CLEARED": "EPG data has been cleared", "EPG_URL_ERROR": "The value is not a valid URL", "VIDEO_PLAYER_DESCRIPTION": "Select default video player", "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", @@ -238,12 +244,19 @@ "PROGRAM_DETAILS": "Details about the TV program", "TITLE": "Title", "CATEGORY": "Category", - "PARENTAL_RATING_SYSTEM": "Television content rating system", + "PARENTAL_RATING_SYSTEM": "Rating", "DESCRIPTION": "Description", "LANGUAGE": "Language", - "SHOW_PROGRAM_DETAILS": "Show details about this program" + "SHOW_PROGRAM_DETAILS": "Show details about this program", + "EPISODE": "Episode", + "DURATION": "Duration" }, - "FETCH_SUCCESS": "EPG was fetched successfully" + "FETCH_SUCCESS": "EPG was fetched successfully", + "IMPORT_PROGRESS": "EPG Import", + "CHANNELS": "channels", + "PROGRAMS": "programs", + "LOADING": "Loading", + "QUEUED": "Queued" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/global.d.ts b/global.d.ts index f84587d6d..4f3adfed2 100644 --- a/global.d.ts +++ b/global.d.ts @@ -57,6 +57,15 @@ declare global { forceFetchEpg: ( url: string ) => Promise<{ success: boolean; message?: string }>; + clearEpgData: () => Promise<{ success: boolean }>; + checkEpgFreshness: ( + urls: string[], + maxAgeHours?: number + ) => Promise<{ staleUrls: string[]; freshUrls: string[] }>; + searchEpgPrograms: ( + searchTerm: string, + limit?: number + ) => Promise; updateSettings: (settings: any) => Promise; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; setVlcPlayerPath: (vlcPlayerPath: string) => Promise; @@ -173,6 +182,16 @@ declare global { }) => void ) => void; getLocalIpAddresses: () => Promise; + // EPG progress listener + onEpgProgress?: ( + callback: (data: { + url: string; + status: 'loading' | 'complete' | 'error'; + stats?: { totalChannels: number; totalPrograms: number }; + error?: string; + }) => void + ) => void; + dbDeleteAllPlaylists: () => Promise<{ success: boolean }>; }; process: NodeJS.Process; require: NodeRequire; diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 5aabfd38a..b25863ca6 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -105,6 +105,57 @@ const CREATE_TABLE_STATEMENTS = [ `CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id)`, `CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id)`, `CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at)`, + // EPG tables + `CREATE TABLE IF NOT EXISTS epg_channels ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + icon_url TEXT, + url TEXT, + source_url TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS epg_programs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id TEXT NOT NULL, + start TEXT NOT NULL, + stop TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + category TEXT, + icon_url TEXT, + rating TEXT, + episode_num TEXT, + FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE + )`, + // EPG indexes + `CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url)`, + `CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`, + // FTS5 virtual table for full-text search on EPG programs + `CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5( + title, + description, + category, + content='epg_programs', + content_rowid='id' + )`, + // Triggers to keep FTS index in sync with epg_programs table + `CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN + INSERT INTO epg_programs_fts(rowid, title, description, category) + VALUES (new.id, new.title, new.description, new.category); + END`, + `CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN + INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) + VALUES ('delete', old.id, old.title, old.description, old.category); + END`, + `CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN + INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) + VALUES ('delete', old.id, old.title, old.description, old.category); + INSERT INTO epg_programs_fts(rowid, title, description, category) + VALUES (new.id, new.title, new.description, new.category); + END`, ]; /** diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 968c96893..3e21f0167 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -123,6 +123,51 @@ export const favorites = sqliteTable( }) ); +// EPG Channels table +export const epgChannels = sqliteTable( + 'epg_channels', + { + id: text('id').primaryKey(), // Channel ID from EPG source + displayName: text('display_name').notNull(), + iconUrl: text('icon_url'), + url: text('url'), + sourceUrl: text('source_url').notNull(), // Which EPG URL this came from + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + sourceIdx: index('idx_epg_channels_source').on(table.sourceUrl), + nameIdx: index('idx_epg_channels_name').on(table.displayName), + }) +); + +// EPG Programs table +export const epgPrograms = sqliteTable( + 'epg_programs', + { + id: integer('id').primaryKey({ autoIncrement: true }), + channelId: text('channel_id') + .notNull() + .references(() => epgChannels.id, { onDelete: 'cascade' }), + start: text('start').notNull(), // ISO datetime + stop: text('stop').notNull(), // ISO datetime + title: text('title').notNull(), + description: text('description'), + category: text('category'), + iconUrl: text('icon_url'), + rating: text('rating'), + episodeNum: text('episode_num'), + }, + (table) => ({ + channelIdx: index('idx_epg_programs_channel').on(table.channelId), + startIdx: index('idx_epg_programs_start').on(table.start), + timeRangeIdx: index('idx_epg_programs_time_range').on( + table.channelId, + table.start, + table.stop + ), + }) +); + // Type exports for TypeScript export type Playlist = typeof playlists.$inferSelect; export type NewPlaylist = typeof playlists.$inferInsert; @@ -138,3 +183,9 @@ export type NewRecentlyViewed = typeof recentlyViewed.$inferInsert; export type Favorite = typeof favorites.$inferSelect; export type NewFavorite = typeof favorites.$inferInsert; + +export type EpgChannel = typeof epgChannels.$inferSelect; +export type NewEpgChannel = typeof epgChannels.$inferInsert; + +export type EpgProgramDb = typeof epgPrograms.$inferSelect; +export type NewEpgProgramDb = typeof epgPrograms.$inferInsert; diff --git a/libs/shared/interfaces/src/lib/epg-program.model.ts b/libs/shared/interfaces/src/lib/epg-program.model.ts index d80d2dbaf..3a41c0b84 100644 --- a/libs/shared/interfaces/src/lib/epg-program.model.ts +++ b/libs/shared/interfaces/src/lib/epg-program.model.ts @@ -1,20 +1,15 @@ /** - * EPG Program interface compatible with epg-parser v0.4.0 - * Note: start/stop are ISO strings, date is string instead of array + * EPG Program interface - flat structure matching database storage */ export interface EpgProgram { start: string; // ISO string stop: string; // ISO string channel: string; - title: { lang: string; value: string }[]; - desc: { lang: string; value: string }[]; - category: { lang: string; value: string }[]; - date: string; // Changed from array to string in v0.4.0 - episodeNum: any[]; - previouslyShown: any[]; - subtitles: any[]; - icon: { src: string; width?: number; height?: number }[]; - rating: { system: string; value: string }[]; - credits: any[]; - audio: any[]; + title: string; + desc: string | null; + category: string | null; + date?: string; + episodeNum?: string | null; + iconUrl?: string | null; + rating?: string | null; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index f9b43119e..86c6d4338 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -27,7 +27,7 @@
@if (epgProgram) {
- {{ epgProgram.title?.[0]?.value }} + {{ epgProgram.title }}
- {{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }} - - - @if (epgProgram?.title?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }} +
+ +
+
+
+ {{ formatTime(epgProgram.start) }} + — + {{ formatTime(epgProgram.stop) }} +
+
{{ formatDate(epgProgram.start) }}
-

{{ epgProgram.title[0]?.value }}

- @if (epgProgram.title[0].lang) { -
- {{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }} + @if (channelName) { +
+ 📺 + {{ channelName }}
} -

{{ epgProgram.title[0].lang }}

- } - @if (epgProgram?.category?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }} +
+ + +
+

{{ epgProgram.title }}

+ @if (epgProgram.category) { + {{ epgProgram.category }} + } +
+ + +
+ @if (epgProgram.desc) { +

{{ epgProgram.desc }}

+ } + + +
+ @if (epgProgram.episodeNum) { +
+ {{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }} + {{ epgProgram.episodeNum }} +
+ } + @if (epgProgram.rating) { +
+ {{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }} + {{ epgProgram.rating }} +
+ } + @if (duration) { +
+ {{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }} + {{ duration }} +
+ }
-

{{ epgProgram.category[0]?.value }}

- } - @if (epgProgram?.desc?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }} -
-

{{ epgProgram.desc[0]?.value }}

- } - @if (epgProgram?.rating?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }} -
-

- {{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}] -

- } - - - - +
+ + +
+ +
+
diff --git a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss index fff588eb8..2cf554704 100644 --- a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss @@ -1,13 +1,300 @@ -.subheading-2 { - color: #666; - text-decoration: underline; - text-transform: uppercase; - font-weight: 900; - font-size: 0.9em; -} +// Broadcast control room palette +$bg-deep: #0a0a0f; +$bg-surface: #12121a; +$bg-elevated: #1a1a24; +$accent-cyan: #00d4aa; +$accent-purple: #8b5cf6; +$text-primary: #f8fafc; +$text-secondary: rgba(248, 250, 252, 0.7); +$text-muted: rgba(248, 250, 252, 0.4); +$border-subtle: rgba(255, 255, 255, 0.08); -::ng-deep { - .dark-theme .subheading-2 { - color: #bdbdbd; +.program-dialog { + background: linear-gradient(180deg, $bg-surface 0%, $bg-deep 100%); + color: $text-primary; + min-width: 380px; + max-width: 480px; + border-radius: 16px; + overflow: hidden; + position: relative; + + // Ambient glow effect + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 120px; + background: radial-gradient( + ellipse at top center, + rgba($accent-cyan, 0.12) 0%, + transparent 70% + ); + pointer-events: none; + } +} + +// Header with time and channel +.program-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 24px 24px 16px; + position: relative; + z-index: 1; +} + +.time-block { + display: flex; + flex-direction: column; + gap: 4px; +} + +.time-display { + display: flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.02em; + + .time-start { + color: $accent-cyan; + text-shadow: 0 0 20px rgba($accent-cyan, 0.4); + } + + .time-separator { + color: $text-muted; + font-weight: 300; + } + + .time-end { + color: $text-secondary; + } +} + +.date-display { + font-size: 13px; + color: $text-muted; + letter-spacing: 0.02em; +} + +.channel-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: rgba($accent-purple, 0.15); + border: 1px solid rgba($accent-purple, 0.3); + border-radius: 20px; + font-size: 13px; + font-weight: 500; + color: $text-primary; + + .channel-icon { + font-size: 14px; + } + + .channel-name { + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// Title section +.program-title-section { + padding: 0 24px 20px; + border-bottom: 1px solid $border-subtle; +} + +.program-title { + margin: 0 0 12px; + font-size: 22px; + font-weight: 600; + line-height: 1.3; + color: $text-primary; + letter-spacing: -0.01em; +} + +.category-tag { + display: inline-block; + padding: 6px 12px; + background: rgba($accent-cyan, 0.1); + border: 1px solid rgba($accent-cyan, 0.25); + border-radius: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $accent-cyan; +} + +// Content section +.program-content { + padding: 20px 24px; +} + +.program-description { + margin: 0 0 24px; + font-size: 14px; + line-height: 1.7; + color: $text-secondary; +} + +// Meta grid +.meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; +} + +.meta-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + border: 1px solid $border-subtle; +} + +.meta-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: $text-muted; +} + +.meta-value { + font-size: 14px; + font-weight: 500; + color: $text-primary; + + &.rating { + color: $accent-purple; + } +} + +// Footer +.program-footer { + padding: 16px 24px 24px; + display: flex; + justify-content: flex-end; +} + +.close-button { + padding: 10px 24px; + background: transparent; + border: 1px solid rgba($accent-cyan, 0.4); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: $accent-cyan; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba($accent-cyan, 0.1); + border-color: $accent-cyan; + box-shadow: 0 0 20px rgba($accent-cyan, 0.2); + } +} + +// Override Material dialog styling +::ng-deep .mat-mdc-dialog-container { + --mdc-dialog-container-color: transparent; + --mdc-dialog-container-shape: 16px; + + .mdc-dialog__surface { + background: transparent !important; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), + 0 0 0 1px rgba(255, 255, 255, 0.05) !important; + } +} + +// Light theme adjustments +::ng-deep .light-theme { + .program-dialog { + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + color: #1e293b; + + &::before { + background: radial-gradient( + ellipse at top center, + rgba(#059669, 0.08) 0%, + transparent 70% + ); + } + } + + .time-display { + .time-start { + color: #059669; + text-shadow: none; + } + .time-end { + color: #64748b; + } + } + + .date-display { + color: #94a3b8; + } + + .channel-badge { + background: rgba(#7c3aed, 0.08); + border-color: rgba(#7c3aed, 0.2); + color: #1e293b; + } + + .program-title { + color: #0f172a; + } + + .program-title-section { + border-bottom-color: rgba(0, 0, 0, 0.06); + } + + .category-tag { + background: rgba(#059669, 0.08); + border-color: rgba(#059669, 0.2); + color: #059669; + } + + .program-description { + color: #475569; + } + + .meta-item { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.06); + } + + .meta-label { + color: #94a3b8; + } + + .meta-value { + color: #1e293b; + + &.rating { + color: #7c3aed; + } + } + + .close-button { + border-color: rgba(#059669, 0.4); + color: #059669; + + &:hover { + background: rgba(#059669, 0.08); + border-color: #059669; + box-shadow: none; + } } } diff --git a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts index 3d3369bd8..db0702169 100644 --- a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts @@ -3,6 +3,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgProgram } from 'shared-interfaces'; +import { format, differenceInMinutes } from 'date-fns'; @Component({ selector: 'app-epg-item-description', @@ -11,16 +12,54 @@ import { EpgProgram } from 'shared-interfaces'; imports: [MatButtonModule, MatDialogModule, TranslatePipe], }) export class EpgItemDescriptionComponent { - dialogData = inject(MAT_DIALOG_DATA); + dialogData = inject(MAT_DIALOG_DATA); - /** EPG program object */ epgProgram: EpgProgram; + channelName: string | null = null; + duration: string | null = null; - /** - * Creates an instance of the component and injects the program of the clicked epg program - * @param epgProgram epg program - */ constructor() { this.epgProgram = this.dialogData; + // Check multiple possible field names for channel name + this.channelName = this.dialogData.channelName + || this.dialogData.channel_name + || this.dialogData.display_name + || null; + this.duration = this.calculateDuration(); + } + + formatTime(dateStr: string): string { + if (!dateStr) return ''; + try { + return format(new Date(dateStr), 'HH:mm'); + } catch { + return ''; + } + } + + formatDate(dateStr: string): string { + if (!dateStr) return ''; + try { + return format(new Date(dateStr), 'EEEE, MMMM d'); + } catch { + return ''; + } + } + + private calculateDuration(): string | null { + if (!this.epgProgram.start || !this.epgProgram.stop) return null; + try { + const start = new Date(this.epgProgram.start); + const stop = new Date(this.epgProgram.stop); + const mins = differenceInMinutes(stop, start); + if (mins < 60) { + return `${mins} min`; + } + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`; + } catch { + return null; + } } } diff --git a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html index 3fc5902dc..c91b6b63b 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html +++ b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html @@ -17,7 +17,7 @@ > } -@if (item?.desc?.length > 0) { +@if (item?.desc) { {{ playingNow - ? (playingNow.title?.[0]?.value || 'EPG.LIVE_STREAM' - | translate) + ? (playingNow.title || 'EPG.LIVE_STREAM' | translate) : ('EPG.LIVE_STREAM' | translate) }}
@@ -64,7 +63,7 @@ />
- {{ program?.title?.[0]?.value }} + {{ program?.title }}
@if (isProgramPlaying(program)) {
@@ -101,7 +100,7 @@
@if (isProgramPlaying(program)) {
diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.html b/libs/ui/components/src/lib/info-overlay/info-overlay.component.html index ee49fb95c..0066246c1 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.html +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.html @@ -9,7 +9,7 @@