From fe8e907e9f90543e7b58877b647d1950bd1225d6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 26 Dec 2025 01:32:51 +0100 Subject: [PATCH 1/6] refactor(epg): implement streaming SAX-based EPG parser and batching Replace full-file parsing with a streaming SAX parser to avoid loading entire XMLTV payloads into memory. Add support for gzipped feeds by using streaming gunzip (createGunzip) and piping into a SaxesParser, allowing incremental processing of channels and programmes. Introduce: - StreamingEpgParser class that incrementally builds channel and program objects and emits batches. - parseXmltvDate helper to normalize XMLTV datetimes to ISO strings. - batching constants and response types for channel/program batches and progress reporting. - use of Readable/Saxes for streaming parsing and reduced memory use. This change improves performance and memory usage for large EPG files, enables progress reporting and batch emission to the main thread, and prepares for handling very large or gzipped EPG sources. --- .../src/app/events/epg.events.ts | 122 +++-- .../src/app/workers/epg-parser.worker.ts | 455 +++++++++++++++--- 2 files changed, 459 insertions(+), 118 deletions(-) diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 79d738389..d00313d55 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,6 +1,11 @@ import { app, ipcMain } from 'electron'; import * as path from 'path'; -import { EpgChannelWithPrograms, EpgData, EpgProgram } from 'shared-interfaces'; +import { + EpgChannel, + EpgChannelWithPrograms, + EpgData, + EpgProgram, +} from 'shared-interfaces'; import { Worker } from 'worker_threads'; import { pathToFileURL } from 'url'; @@ -117,7 +122,13 @@ export default class EpgEvents { 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, { + // Increase memory limits for large EPG files + resourceLimits: { + maxOldGenerationSizeMb: 4096, // 4GB for parsed data + maxYoungGenerationSizeMb: 512, // 512MB for temporary allocations + }, + }); } catch (error) { console.error( this.loggerLabel, @@ -134,29 +145,64 @@ export default class EpgEvents { 'message', (message: { type: string; - data?: EpgData; + channels?: EpgChannel[]; + programs?: EpgProgram[]; 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); + switch (message.type) { + case 'READY': + worker.postMessage({ type: 'FETCH_EPG', url }); + break; + + case 'EPG_CHANNELS_BATCH': + // Add channels incrementally + if (message.channels) { + this.addChannelsBatch(message.channels); + } + break; + + case 'EPG_PROGRAMS_BATCH': + // Add programs incrementally + if (message.programs) { + this.addProgramsBatch(message.programs); + } + break; + + case 'EPG_PROGRESS': + // Log progress for large files + if (message.stats) { + console.log( + this.loggerLabel, + `Progress: ${message.stats.totalChannels} channels, ${message.stats.totalPrograms} programs` + ); + } + break; + + case 'EPG_COMPLETE': + console.log( + this.loggerLabel, + `EPG parsing complete for ${url}:`, + message.stats + ); + this.rebuildMergedData(); this.fetchedUrls.add(url); - } - 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')); + worker.terminate(); + this.workers.delete(url); + resolve(); + break; + + case 'EPG_ERROR': + console.error( + this.loggerLabel, + 'Worker error:', + message.error + ); + worker.terminate(); + this.workers.delete(url); + reject(new Error(message.error || 'Unknown error')); + break; } } ); @@ -180,29 +226,21 @@ export default class EpgEvents { } /** - * Merge EPG data (optimized for large datasets) + * Add a batch of channels (streaming mode) */ - 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 + private static addChannelsBatch(channels: EpgChannel[]): void { + // Use Set for O(1) duplicate checking + const existingIds = new Set(this.epgData.channels.map((c) => c.id)); + const newChannels = channels.filter((c) => !existingIds.has(c.id)); 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(); + /** + * Add a batch of programs (streaming mode) + */ + private static addProgramsBatch(programs: EpgProgram[]): void { + // Simply append programs - no duplicate check needed as each program is unique + this.epgData.programs = this.epgData.programs.concat(programs); } /** @@ -247,7 +285,7 @@ export default class EpgEvents { // If not found, try to find by display name if (!channelData) { - for (const [id, channel] of this.epgDataMerged.entries()) { + for (const [, channel] of this.epgDataMerged.entries()) { const displayNames = channel.displayName.map((d) => d.value.toLowerCase() ); 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..2943c24b8 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,12 +1,13 @@ -import { parse as parseEpg } from 'epg-parser'; -import { EpgData, EpgProgram } from 'shared-interfaces'; +import { EpgChannel, 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'; /** - * EPG Parser Worker - * Runs in a separate thread to avoid blocking the main process - * Compatible with epg-parser v0.4.0 + * Streaming EPG Parser Worker + * Uses SAX parsing to process XML incrementally without loading entire file into memory. + * Supports both regular and gzipped EPG files. */ interface WorkerMessage { @@ -15,84 +16,395 @@ interface WorkerMessage { } interface WorkerResponse { - type: 'EPG_PARSED' | 'EPG_ERROR' | 'EPG_PROGRESS' | 'READY'; - data?: EpgData; + type: + | 'EPG_CHANNELS_BATCH' + | 'EPG_PROGRAMS_BATCH' + | 'EPG_COMPLETE' + | 'EPG_ERROR' + | 'EPG_PROGRESS' + | 'READY'; + channels?: EpgChannel[]; + programs?: EpgProgram[]; error?: string; url?: string; + stats?: { + totalChannels: number; + totalPrograms: number; + }; } const loggerLabel = '[EPG Worker]'; +// Batch size for sending data back to main thread +const CHANNEL_BATCH_SIZE = 100; +const PROGRAM_BATCH_SIZE = 1000; + /** - * Fetches and parses EPG data from URL + * Parse XMLTV datetime format to ISO string + * Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss */ -async function fetchAndParseEpg(url: string): Promise { - try { - const isGzipped = url.endsWith('.gz'); +function parseXmltvDate(dateStr: string): string { + if (!dateStr) return ''; - // Use native fetch (available in Node.js 18+) - const response = await fetch(url.trim()); + // Extract date parts: 20231225060000 +0000 + const match = dateStr.match( + /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/ + ); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (!match) return dateStr; + + const [, year, month, day, hour, minute, second, tz] = match; + + // Build ISO string + let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; + + if (tz) { + // Convert +0000 to +00:00 + 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: EpgChannel[] = []; + private programs: EpgProgram[] = []; + private totalChannels = 0; + private totalPrograms = 0; + + // 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: EpgChannel[]) => void, private onProgramsBatch: (programs: EpgProgram[]) => void, private onProgress: (channels: number, programs: number) => void) { + this.parser = new SaxesParser(); + 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: [], + previouslyShown: [], + subtitles: [], + icon: [], + rating: [], + credits: [], + audio: [], + }; + 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) { + // Rating has system attribute, value comes as child element + const system = + (tag.attributes['system'] as string) || ''; + // We'll complete this when we see the value element + 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': + // Channel complete, add to batch + this.channels.push(this.currentChannel as EpgChannel); + 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': + // This is for rating 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': + // Program complete, add to batch + this.programs.push(this.currentProgram as EpgProgram); + this.totalPrograms++; + this.currentProgram = null; + + if (this.programs.length >= PROGRAM_BATCH_SIZE) { + this.flushPrograms(); + } + 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); } + } - let xmlString: string; + 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 } { + this.parser.close(); + // Flush any remaining data + this.flushChannels(); + this.flushPrograms(); + return { + totalChannels: this.totalChannels, + totalPrograms: this.totalPrograms, + }; + } +} + +/** + * Fetches and parses EPG data from URL using streaming + */ +async function fetchAndParseEpgStreaming(url: string): Promise { + const isGzipped = url.endsWith('.gz'); + + console.log( + loggerLabel, + `Fetching EPG from ${url} (gzipped: ${isGzipped})` + ); + + const response = await fetch(url.trim()); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + const parser = new StreamingEpgParser( + (channels) => { + const response: WorkerResponse = { + type: 'EPG_CHANNELS_BATCH', + channels, + }; + parentPort?.postMessage(response); + }, + (programs) => { + const response: WorkerResponse = { + type: 'EPG_PROGRAMS_BATCH', + programs, + }; + parentPort?.postMessage(response); + }, + (totalChannels, totalPrograms) => { + 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) { - // 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(); + const gunzip = createGunzip(); + dataStream = nodeStream.pipe(gunzip); + + gunzip.on('error', (err) => { + console.error(loggerLabel, 'Gunzip error:', err); + reject(err); + }); } - const parsed = parseEpg(xmlString); + dataStream.on('data', (chunk: Buffer) => { + try { + parser.write(chunk.toString('utf-8')); + } catch (err) { + console.error(loggerLabel, 'Parse error:', err); + reject(err); + } + }); - // 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[], - }; + dataStream.on('end', () => { + try { + const stats = parser.finish(); + console.log( + loggerLabel, + `Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs` + ); - return normalized; - } catch (error) { - console.error( - loggerLabel, - 'Error fetching EPG:', - error instanceof Error ? error.message : String(error) - ); - throw error; - } + const response: WorkerResponse = { + type: 'EPG_COMPLETE', + url, + stats, + }; + parentPort?.postMessage(response); + resolve(); + } catch (err) { + reject(err); + } + }); + + dataStream.on('error', (err) => { + console.error(loggerLabel, 'Stream error:', err); + reject(err); + }); + }); } /** @@ -105,15 +417,7 @@ 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); } } catch (error) { console.error(loggerLabel, 'Worker error:', error); @@ -122,7 +426,6 @@ if (parentPort) { error: error instanceof Error ? error.message : String(error), url: message.url, }; - parentPort?.postMessage(errorResponse); } }); From a6ad3fce140684e527009760a7a16e5fa8a91cb4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 26 Dec 2025 01:48:15 +0100 Subject: [PATCH 2/6] feat(epg): add indexing and cleanup for faster lookups and memory Introduce a lowercase display name index (Map) to allow O(1) resolution from display name to channel id. Rebuild the index during merged-data rebuild and use it in channel lookups to avoid expensive full scans. Add a fallback partial-match pass only when the index lookup fails. Add automatic and manual cleanup of expired programs: - Add cleanupExpiredPrograms(hoursToKeep) to remove programs older than the specified number of hours from epgDataMerged. - Invoke cleanup after worker fetches (auto-cleanup with 24 hours). - Expose an IPC handler EPG_CLEANUP_EXPIRED to trigger cleanup on demand. Other changes: - Clear epgData.programs after merging to free memory. - Log merged data rebuild and cleanup counts for observability. These changes improve performance of channel resolution, reduce memory usage by pruning stale program entries, and provide a manual hook for on-demand maintenance. --- .../src/app/events/epg.events.ts | 90 +++++++++++++++---- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index d00313d55..9d6d33824 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -25,6 +25,9 @@ export default class EpgEvents { private static workers: Map = new Map(); private static readonly loggerLabel = '[EPG Events]'; + // Display name index for O(1) lookups (lowercase name -> channel id) + private static displayNameIndex: Map = new Map(); + /** * Bootstrap EPG events */ @@ -61,6 +64,14 @@ export default class EpgEvents { return await this.handleFetchEpg([url]); }); + // Cleanup expired programs + ipcMain.handle( + 'EPG_CLEANUP_EXPIRED', + async (event, hoursToKeep?: number) => { + return this.cleanupExpiredPrograms(hoursToKeep); + } + ); + return ipcMain; } @@ -187,6 +198,8 @@ export default class EpgEvents { message.stats ); this.rebuildMergedData(); + // Auto-cleanup programs older than 24 hours + this.cleanupExpiredPrograms(24); this.fetchedUrls.add(url); worker.terminate(); this.workers.delete(url); @@ -249,10 +262,23 @@ export default class EpgEvents { */ private static rebuildMergedData(): void { this.epgDataMerged.clear(); + this.displayNameIndex.clear(); // Create channel lookup map for O(1) access const channelMap = new Map(this.epgData.channels.map((c) => [c.id, c])); + // Build display name index for fast lookups + for (const channel of this.epgData.channels) { + for (const name of channel.displayName) { + if (name.value) { + this.displayNameIndex.set( + name.value.toLowerCase(), + channel.id + ); + } + } + } + // Group programs by channel in a single pass const programsByChannel = new Map(); @@ -274,6 +300,14 @@ export default class EpgEvents { }); } } + + // Free memory - programs are now stored in epgDataMerged + this.epgData.programs = []; + + console.log( + this.loggerLabel, + `Merged data rebuilt: ${this.epgDataMerged.size} channels, ${this.displayNameIndex.size} display names indexed` + ); } /** @@ -283,22 +317,21 @@ export default class EpgEvents { // First try exact ID match let channelData = this.epgDataMerged.get(channelId); - // If not found, try to find by display name + // If not found, try display name index (O(1) lookup) if (!channelData) { - for (const [, 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) - ) - ) { - channelData = channel; - break; + const indexedId = this.displayNameIndex.get(channelId.toLowerCase()); + if (indexedId) { + channelData = this.epgDataMerged.get(indexedId); + } + } + + // Fallback: partial match (only if index lookup failed) + if (!channelData) { + const searchTerm = channelId.toLowerCase(); + for (const [name, id] of this.displayNameIndex.entries()) { + if (name.includes(searchTerm) || searchTerm.includes(name)) { + channelData = this.epgDataMerged.get(id); + if (channelData) break; } } } @@ -317,12 +350,39 @@ export default class EpgEvents { return channels.slice(skip, skip + limit); } + /** + * Clean up expired programs (older than specified hours) + * Call this periodically to free memory from stale EPG data + */ + static cleanupExpiredPrograms(hoursToKeep = 24): number { + const cutoff = new Date( + Date.now() - hoursToKeep * 60 * 60 * 1000 + ).toISOString(); + let removedCount = 0; + + for (const channel of this.epgDataMerged.values()) { + const originalLength = channel.programs.length; + channel.programs = channel.programs.filter((p) => p.stop > cutoff); + removedCount += originalLength - channel.programs.length; + } + + if (removedCount > 0) { + console.log( + this.loggerLabel, + `Cleaned up ${removedCount} expired programs (older than ${hoursToKeep}h)` + ); + } + + return removedCount; + } + /** * Clear all EPG data */ static clearEpgData(): void { this.epgData = { channels: [], programs: [] }; this.epgDataMerged.clear(); + this.displayNameIndex.clear(); this.fetchedUrls.clear(); // Terminate all workers From 87bf0f617898be29f7ab9feda4b93de071ec69f1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 2 Jan 2026 01:02:20 +0100 Subject: [PATCH 3/6] feat(ui): add EPG import progress panel component Introduce a standalone EpgProgressPanelComponent with template and bindings to show active, completed and queued EPG imports. The new component: - provides a header with title and optional "dismiss all" button when multiple imports exist. - lists active/completed imports with status icon, URL display, dismiss button, and conditional progress bar for loading state. - shows stats (channels/programs) or error text when available. - renders a queued section with positions and dismiss controls. - wires to EpgProgressService observables (imports, visibility and counts) and implements helper getters (activeImports). This adds UI for import progress feedback and controls so users can monitor and manage EPG import operations. Files added: - epg-progress-panel.component.ts - epg-progress-panel.component.html --- .../epg-progress-panel.component.html | 95 ++++ .../epg-progress-panel.component.scss | 512 ++++++++++++++++++ .../epg-progress-panel.component.ts | 78 +++ 3 files changed, 685 insertions(+) create mode 100644 apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html create mode 100644 apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss create mode 100644 apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts 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(); + } +} From 895a269200361d90d5aedb0d41b7672892e22aaf Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 2 Jan 2026 17:22:09 +0100 Subject: [PATCH 4/6] feat(db): add EPG schema and IPC handlers for EPG storage Add tables, indexes, FTS5 virtual table, and triggers to support EPG (electronic program guide) data in the shared SQLite connection. The changes introduce epg_channels and epg_programs tables, multiple indexes for efficient lookups (by source, name, channel, start, and time range), and an epg_programs_fts virtual table with triggers to keep the full-text index synchronized with epg_programs. Register EPG database event handlers in the Electron backend and add an IPC handler for bulk-saving channels. The handler deletes existing channels for a source and inserts new channels in chunks to avoid large single inserts, returning the inserted count. These changes enable storing, querying, and full-text searching EPG data and prepare backend IPC endpoints for importing program guide data. --- apps/electron-backend/build-worker.js | 12 +- .../src/app/api/main.preload.ts | 16 + .../src/app/database/schema.ts | 6 + .../src/app/events/database.events.ts | 1 + .../src/app/events/database/epg-db.events.ts | 391 +++++++++++ .../src/app/events/epg.events.ts | 651 ++++++++++++------ .../src/app/workers/epg-parser.worker.ts | 525 +++++++++++--- global.d.ts | 19 + libs/shared/database/src/lib/connection.ts | 51 ++ libs/shared/database/src/lib/schema.ts | 51 ++ 10 files changed, 1396 insertions(+), 327 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/epg-db.events.ts 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 9d6d33824..01523102d 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,112 +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 { - EpgChannel, - 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]'; - // Display name index for O(1) lookups (lowercase name -> channel id) - private static displayNameIndex: Map = new Map(); + /** + * 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 + // Cleanup expired programs (uses worker thread) ipcMain.handle( 'EPG_CLEANUP_EXPIRED', - async (event, hoursToKeep?: number) => { - return this.cleanupExpiredPrograms(hoursToKeep); + 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; } @@ -114,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, @@ -125,27 +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, { - // Increase memory limits for large EPG files resourceLimits: { - maxOldGenerationSizeMb: 4096, // 4GB for parsed data - maxYoungGenerationSizeMb: 512, // 512MB for temporary allocations + 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; } @@ -154,68 +254,70 @@ export default class EpgEvents { worker.on( 'message', - (message: { + async (message: { type: string; - channels?: EpgChannel[]; - programs?: EpgProgram[]; error?: string; url?: string; stats?: { totalChannels: number; totalPrograms: number }; }) => { - switch (message.type) { - case 'READY': - worker.postMessage({ type: 'FETCH_EPG', url }); - break; + 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_CHANNELS_BATCH': - // Add channels incrementally - if (message.channels) { - this.addChannelsBatch(message.channels); - } - 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_PROGRAMS_BATCH': - // Add programs incrementally - if (message.programs) { - this.addProgramsBatch(message.programs); - } - break; - - case 'EPG_PROGRESS': - // Log progress for large files - if (message.stats) { + case 'EPG_COMPLETE': console.log( this.loggerLabel, - `Progress: ${message.stats.totalChannels} channels, ${message.stats.totalPrograms} programs` + `EPG parsing complete for ${url}:`, + message.stats ); - } - break; + // 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 'EPG_COMPLETE': - console.log( - this.loggerLabel, - `EPG parsing complete for ${url}:`, - message.stats - ); - this.rebuildMergedData(); - // Auto-cleanup programs older than 24 hours - this.cleanupExpiredPrograms(24); - this.fetchedUrls.add(url); - worker.terminate(); - this.workers.delete(url); - resolve(); - 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 - ); - worker.terminate(); - this.workers.delete(url); - reject(new Error(message.error || 'Unknown error')); - 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; + } + } catch (err) { + console.error(this.loggerLabel, 'Error handling message:', err); + reject(err); } } ); @@ -239,154 +341,263 @@ export default class EpgEvents { } /** - * Add a batch of channels (streaming mode) + * Transform database row to flat EpgProgram interface */ - private static addChannelsBatch(channels: EpgChannel[]): void { - // Use Set for O(1) duplicate checking - const existingIds = new Set(this.epgData.channels.map((c) => c.id)); - const newChannels = channels.filter((c) => !existingIds.has(c.id)); - this.epgData.channels = this.epgData.channels.concat(newChannels); + 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, + }; } /** - * Add a batch of programs (streaming mode) + * Get programs for a specific channel from database */ - private static addProgramsBatch(programs: EpgProgram[]): void { - // Simply append programs - no duplicate check needed as each program is unique - this.epgData.programs = this.epgData.programs.concat(programs); + private static async handleGetChannelPrograms(channelId: string): Promise { + try { + const db = await getDatabase(); + const now = new Date().toISOString(); + + // 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) + ) + ) + .orderBy(schema.epgPrograms.start) + .limit(100); + + 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 []; + } } /** - * Rebuild merged data structure (channels with programs) - * Optimized for large datasets + * Get all channels from database */ - private static rebuildMergedData(): void { - this.epgDataMerged.clear(); - this.displayNameIndex.clear(); + 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); - // Create channel lookup map for O(1) access - const channelMap = new Map(this.epgData.channels.map((c) => [c.id, c])); - - // Build display name index for fast lookups - for (const channel of this.epgData.channels) { - for (const name of channel.displayName) { - if (name.value) { - this.displayNameIndex.set( - name.value.toLowerCase(), - channel.id - ); - } - } + return { channels, programs: [] }; + } catch (error) { + console.error(this.loggerLabel, 'Error getting all channels:', error); + return { channels: [], programs: [] }; } - - // 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, - }); - } - } - - // Free memory - programs are now stored in epgDataMerged - this.epgData.programs = []; - - console.log( - this.loggerLabel, - `Merged data rebuilt: ${this.epgDataMerged.size} channels, ${this.displayNameIndex.size} display names indexed` - ); } /** - * Get programs for a specific channel + * Get channels by range (for pagination) with their programs */ - private static handleGetChannelPrograms(channelId: string): EpgProgram[] { - // First try exact ID match - let channelData = this.epgDataMerged.get(channelId); - - // If not found, try display name index (O(1) lookup) - if (!channelData) { - const indexedId = this.displayNameIndex.get(channelId.toLowerCase()); - if (indexedId) { - channelData = this.epgDataMerged.get(indexedId); - } - } - - // Fallback: partial match (only if index lookup failed) - if (!channelData) { - const searchTerm = channelId.toLowerCase(); - for (const [name, id] of this.displayNameIndex.entries()) { - if (name.includes(searchTerm) || searchTerm.includes(name)) { - channelData = this.epgDataMerged.get(id); - if (channelData) break; - } - } - } - - return channelData?.programs || []; - } - - /** - * Get channels by range (for pagination) - */ - private static handleGetChannelsByRange( + 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); - /** - * Clean up expired programs (older than specified hours) - * Call this periodically to free memory from stale EPG data - */ - static cleanupExpiredPrograms(hoursToKeep = 24): number { - const cutoff = new Date( - Date.now() - hoursToKeep * 60 * 60 * 1000 - ).toISOString(); - let removedCount = 0; + // 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); - for (const channel of this.epgDataMerged.values()) { - const originalLength = channel.programs.length; - channel.programs = channel.programs.filter((p) => p.stop > cutoff); - removedCount += originalLength - channel.programs.length; - } - - if (removedCount > 0) { - console.log( - this.loggerLabel, - `Cleaned up ${removedCount} expired programs (older than ${hoursToKeep}h)` + return { + ...channel, + programs: programs.map(this.transformDbRowToEpgProgram), + }; + }) ); - } - return removedCount; + 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.displayNameIndex.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 2943c24b8..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,30 +1,78 @@ -import { EpgChannel, EpgProgram } from 'shared-interfaces'; import { parentPort } from 'worker_threads'; 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'; + +/** + * 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_CHANNELS_BATCH' - | 'EPG_PROGRAMS_BATCH' | 'EPG_COMPLETE' | 'EPG_ERROR' | 'EPG_PROGRESS' + | 'CLEAR_COMPLETE' + | 'CLEANUP_COMPLETE' | 'READY'; - channels?: EpgChannel[]; - programs?: EpgProgram[]; error?: string; url?: string; stats?: { @@ -35,10 +83,174 @@ interface WorkerResponse { const loggerLabel = '[EPG Worker]'; -// Batch size for sending data back to main thread +// 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 @@ -46,7 +258,6 @@ const PROGRAM_BATCH_SIZE = 1000; function parseXmltvDate(dateStr: string): string { if (!dateStr) return ''; - // Extract date parts: 20231225060000 +0000 const match = dateStr.match( /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/ ); @@ -55,11 +266,9 @@ function parseXmltvDate(dateStr: string): string { const [, year, month, day, hour, minute, second, tz] = match; - // Build ISO string let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; if (tz) { - // Convert +0000 to +00:00 isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`; } else { isoString += 'Z'; @@ -73,22 +282,31 @@ function parseXmltvDate(dateStr: string): string { */ class StreamingEpgParser { private parser: SaxesParser; - private channels: EpgChannel[] = []; - private programs: EpgProgram[] = []; + 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 currentChannel: Partial | null = null; + private currentProgram: Partial | null = null; private currentTextContent = ''; private currentLang = ''; // For nested elements private elementStack: string[] = []; - constructor(private onChannelsBatch: (channels: EpgChannel[]) => void, private onProgramsBatch: (programs: EpgProgram[]) => void, private onProgress: (channels: number, programs: number) => void) { + 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(); } @@ -121,12 +339,8 @@ class StreamingEpgParser { category: [], date: '', episodeNum: [], - previouslyShown: [], - subtitles: [], icon: [], rating: [], - credits: [], - audio: [], }; break; @@ -163,10 +377,8 @@ class StreamingEpgParser { case 'rating': if (this.currentProgram) { - // Rating has system attribute, value comes as child element const system = (tag.attributes['system'] as string) || ''; - // We'll complete this when we see the value element this.currentProgram.rating!.push({ system, value: '' }); } break; @@ -203,8 +415,7 @@ class StreamingEpgParser { if (text) this.currentChannel.url!.push(text); break; case 'channel': - // Channel complete, add to batch - this.channels.push(this.currentChannel as EpgChannel); + this.channels.push(this.currentChannel as ParsedChannel); this.totalChannels++; this.currentChannel = null; @@ -239,7 +450,6 @@ class StreamingEpgParser { this.currentProgram.date = text; break; case 'value': - // This is for rating value if ( this.elementStack.includes('rating') && this.currentProgram.rating!.length > 0 @@ -257,14 +467,18 @@ class StreamingEpgParser { } break; case 'programme': - // Program complete, add to batch - this.programs.push(this.currentProgram as EpgProgram); - this.totalPrograms++; - this.currentProgram = null; + // 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(); + if (this.programs.length >= PROGRAM_BATCH_SIZE) { + this.flushPrograms(); + } } + this.currentProgram = null; break; } } @@ -298,20 +512,29 @@ class StreamingEpgParser { this.parser.write(chunk); } - finish(): { totalChannels: number; totalPrograms: number } { + finish(): { totalChannels: number; totalPrograms: number; skippedPrograms: number } { this.parser.close(); - // Flush any remaining data 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'); @@ -321,90 +544,176 @@ async function fetchAndParseEpgStreaming(url: string): Promise { `Fetching EPG from ${url} (gzipped: ${isGzipped})` ); - const response = await fetch(url.trim()); + // Create database connection in worker + const epgDb = new EpgDatabase(); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + try { + // Clear existing data for this source + console.log(loggerLabel, `Clearing existing data for ${url}`); + epgDb.clearSourceData(url); - if (!response.body) { - throw new Error('Response body is null'); - } + const response = await fetch(url.trim()); - const parser = new StreamingEpgParser( - (channels) => { - const response: WorkerResponse = { - type: 'EPG_CHANNELS_BATCH', - channels, - }; - parentPort?.postMessage(response); - }, - (programs) => { - const response: WorkerResponse = { - type: 'EPG_PROGRAMS_BATCH', - programs, - }; - parentPort?.postMessage(response); - }, - (totalChannels, totalPrograms) => { - 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); - reject(err); - }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } - dataStream.on('data', (chunk: Buffer) => { - try { - parser.write(chunk.toString('utf-8')); - } catch (err) { - console.error(loggerLabel, 'Parse error:', err); - reject(err); - } - }); - - dataStream.on('end', () => { - try { - const stats = parser.finish(); - console.log( - loggerLabel, - `Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs` - ); + if (!response.body) { + throw new Error('Response body is null'); + } + 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_COMPLETE', - url, - stats, + type: 'EPG_PROGRESS', + stats: { totalChannels, totalPrograms }, }; parentPort?.postMessage(response); - resolve(); - } catch (err) { - reject(err); } - }); + ); - dataStream.on('error', (err) => { - console.error(loggerLabel, 'Stream error:', err); - reject(err); + // 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(); + } } /** @@ -417,7 +726,11 @@ if (parentPort) { message.type === 'FETCH_EPG' || message.type === 'FORCE_FETCH' ) { - await fetchAndParseEpgStreaming(message.url); + 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); 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; From 99b5ad161fc55406595c6911e2475b9d82d84554 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 2 Jan 2026 01:04:38 +0100 Subject: [PATCH 5/6] feat(ui): improve info overlay and add EPG header search styles Update info-overlay template to read simplified EPG fields and avoid indexing into arrays for title, icon and description so bindings are resilient and clearer: - Use epgProgram?.iconUrl instead of epgProgram?.icon?.[0]?.src - Use epgProgram?.title instead of epgProgram?.title?.[0]?.value - Use epgProgram?.desc instead of epgProgram?.desc?.[0]?.value - Guard description rendering with epgProgram?.desc Adjust multi-epg header layout and add search UI styles to support desktop window chrome and draggable regions: - Add left padding to #epg-navigation for macOS traffic lights, enable backdrop drag via -webkit-app-region: drag and set z-index to keep header above content. - Exclude interactive elements (buttons, search inputs) from drag by applying -webkit-app-region: no-drag to maintain native interactions. - Add extensive styles for a channel search container (search input, icons, clear button, filter count, animations) and scaffold a program search container. These changes improve robustness of EPG data rendering and add styled search controls while ensuring proper window dragging behavior on desktop builds. --- apps/web/project.json | 8 +- apps/web/src/app/app.component.ts | 31 +- .../src/app/services/epg-progress.service.ts | 92 +++ .../src/app/settings/settings.component.html | 8 + .../src/app/settings/settings.component.ts | 20 + apps/web/src/assets/i18n/en.json | 19 +- .../interfaces/src/lib/epg-program.model.ts | 21 +- .../channel-list-item.component.html | 2 +- .../epg-item-description.component.html | 97 +-- .../epg-item-description.component.scss | 307 ++++++++- .../epg-item-description.component.ts | 51 +- .../epg-list-item.component.html | 2 +- .../src/lib/epg-list/epg-list.component.html | 7 +- .../info-overlay/info-overlay.component.html | 8 +- .../multi-epg-container.component.html | 131 +++- .../multi-epg-container.component.scss | 392 ++++++++++- .../multi-epg-container.component.ts | 242 +++++-- package-lock.json | 634 ++++++------------ 18 files changed, 1479 insertions(+), 593 deletions(-) create mode 100644 apps/web/src/app/services/epg-progress.service.ts 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/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/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 @@