Merge pull request #706 from 4gray/refactor/streaming-sax-epg-parser

refactor/streaming-sax-epg-parser
This commit is contained in:
4gray authored and GitHub committed 2026-01-03 00:28:45 +01:00
commit 2e2bf7b84e
34 files changed
+5027 -1956

No files matched your search

+11 -1
View File
@@ -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'),
},
});
@@ -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) =>
@@ -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';
@@ -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';
@@ -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<number>`count(*)` })
.from(schema.epgChannels);
const programCount = await db
.select({ count: sql<number>`count(*)` })
.from(schema.epgPrograms);
const now = new Date().toISOString();
const futureProgramCount = await db
.select({ count: sql<number>`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;
}
});
+455 -146
View File
@@ -1,96 +1,220 @@
import { app, ipcMain } from 'electron';
import { and, eq, gte, sql } from 'drizzle-orm';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { EpgChannelWithPrograms, EpgData, EpgProgram } from 'shared-interfaces';
import { Worker } from 'worker_threads';
import { pathToFileURL } from 'url';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { EpgProgram } from 'shared-interfaces';
/**
* EPG Events Handler
* Manages EPG data fetching and querying using worker threads
* Manages EPG data fetching and querying using worker threads.
* Database operations are performed in the worker thread to avoid blocking the main thread.
*/
export default class EpgEvents {
private static epgData: EpgData = {
channels: [],
programs: [],
};
private static epgDataMerged: Map<string, EpgChannelWithPrograms> =
new Map();
private static fetchedUrls: Set<string> = new Set();
private static workers: Map<string, Worker> = new Map();
private static readonly loggerLabel = '[EPG Events]';
/**
* Send EPG progress to all renderer windows
*/
private static sendProgressToRenderer(
url: string,
status: 'queued' | 'loading' | 'complete' | 'error',
stats?: { totalChannels: number; totalPrograms: number },
error?: string,
queuePosition?: number
): void {
const windows = BrowserWindow.getAllWindows();
windows.forEach((win) => {
win.webContents.send('EPG_PROGRESS_UPDATE', {
url,
status,
stats,
error,
queuePosition,
});
});
}
/**
* Bootstrap EPG events
*/
static bootstrapEpgEvents(): Electron.IpcMain {
// Fetch EPG from URLs
ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => {
ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => {
return await this.handleFetchEpg(args.url);
});
// Get programs for a specific channel
ipcMain.handle(
'GET_CHANNEL_PROGRAMS',
async (event, args: { channelId: string }) => {
async (_event, args: { channelId: string }) => {
return this.handleGetChannelPrograms(args.channelId);
}
);
// Get all channels
// Get all channels from database
ipcMain.handle('EPG_GET_CHANNELS', async () => {
return this.epgData;
return this.handleGetAllChannels();
});
// Get channels by range (pagination)
ipcMain.handle(
'EPG_GET_CHANNELS_BY_RANGE',
async (event, args: { skip: number; limit: number }) => {
async (_event, args: { skip: number; limit: number }) => {
return this.handleGetChannelsByRange(args.skip, args.limit);
}
);
// Force fetch (ignore cache)
ipcMain.handle('EPG_FORCE_FETCH', async (event, url: string) => {
ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => {
this.fetchedUrls.delete(url);
return await this.handleFetchEpg([url]);
});
// Cleanup expired programs (uses worker thread)
ipcMain.handle(
'EPG_CLEANUP_EXPIRED',
async (_event, hoursToKeep?: number) => {
return this.runCleanupInWorker(hoursToKeep ?? 24);
}
);
// Clear all EPG data
ipcMain.handle('EPG_CLEAR_ALL', async () => {
await this.clearEpgData();
return { success: true };
});
// Check if EPG data for URLs is fresh (not stale)
ipcMain.handle(
'EPG_CHECK_FRESHNESS',
async (
_event,
args: { urls: string[]; maxAgeHours?: number }
): Promise<{ staleUrls: string[]; freshUrls: string[] }> => {
return this.checkEpgFreshness(args.urls, args.maxAgeHours ?? 12);
}
);
return ipcMain;
}
/**
* Check which EPG URLs have fresh data vs stale/missing data
* @param urls - EPG source URLs to check
* @param maxAgeHours - Maximum age in hours before data is considered stale
*/
private static async checkEpgFreshness(
urls: string[],
maxAgeHours: number
): Promise<{ staleUrls: string[]; freshUrls: string[] }> {
const staleUrls: string[] = [];
const freshUrls: string[] = [];
const cutoffTime = new Date(
Date.now() - maxAgeHours * 60 * 60 * 1000
).toISOString();
try {
const db = await getDatabase();
for (const url of urls) {
if (!url?.trim()) continue;
const result = await db
.select({ updatedAt: schema.epgChannels.updatedAt })
.from(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, url))
.limit(1);
const isFresh =
result.length > 0 &&
result[0].updatedAt &&
result[0].updatedAt >= cutoffTime;
if (isFresh) {
freshUrls.push(url);
this.fetchedUrls.add(url);
} else {
staleUrls.push(url);
}
}
} catch (error) {
console.error(this.loggerLabel, 'Error checking EPG freshness:', error);
return { staleUrls: urls, freshUrls: [] };
}
// Log summary
if (freshUrls.length > 0) {
console.log(this.loggerLabel, `EPG fresh (skipping): ${freshUrls.length} source(s)`);
}
if (staleUrls.length > 0) {
console.log(this.loggerLabel, `EPG stale (will fetch): ${staleUrls.length} source(s)`);
}
return { staleUrls, freshUrls };
}
/**
* Handle EPG fetch from URLs
* Automatically skips URLs with fresh data (less than 12 hours old)
* Processes URLs sequentially to avoid SQLite database locking issues
*/
private static async handleFetchEpg(
urls: string[]
): Promise<{ success: boolean; message?: string }> {
): Promise<{ success: boolean; message?: string; skipped?: string[] }> {
const validUrls = urls.filter((url) => url?.trim());
if (validUrls.length === 0) {
return { success: false, message: 'No valid URLs provided' };
}
const promises = validUrls.map((url) => this.fetchEpgFromUrl(url));
// Check which URLs have fresh data and can be skipped
const { staleUrls, freshUrls } = await this.checkEpgFreshness(validUrls, 12);
try {
await Promise.all(promises);
return { success: true };
} catch (error) {
console.error(this.loggerLabel, 'Error fetching EPG:', error);
if (staleUrls.length === 0) {
return { success: true, message: 'All EPG data is fresh', skipped: freshUrls };
}
// Send queued status for all stale URLs first
staleUrls.forEach((url, index) => {
this.sendProgressToRenderer(url, 'queued', undefined, undefined, index + 1);
});
// Process only stale URLs sequentially to avoid database locking
const errors: string[] = [];
for (let i = 0; i < staleUrls.length; i++) {
const url = staleUrls[i];
try {
await this.fetchEpgFromUrl(url);
} catch (error) {
console.error(this.loggerLabel, `Error fetching EPG from ${url}:`, error);
errors.push(error instanceof Error ? error.message : String(error));
}
}
if (errors.length > 0) {
return {
success: false,
message: error instanceof Error ? error.message : String(error),
success: errors.length < staleUrls.length, // Partial success if some worked
message: errors.join('; '),
skipped: freshUrls,
};
}
return { success: true, skipped: freshUrls };
}
/**
* Fetch EPG from a single URL using worker thread
* The worker handles both parsing AND database operations
*/
private static async fetchEpgFromUrl(url: string): Promise<void> {
// Skip if already fetched
// Skip if already fetched this session
if (this.fetchedUrls.has(url)) {
console.log(this.loggerLabel, `Skipping already fetched URL: ${url}`);
return;
}
@@ -98,7 +222,6 @@ export default class EpgEvents {
let workerPath: string;
if (app.isPackaged) {
// In packaged app: Resources/dist/apps/electron-backend/workers/epg-parser.worker.js
const resourcesPath = path.dirname(app.getAppPath());
workerPath = path.join(
resourcesPath,
@@ -109,21 +232,20 @@ export default class EpgEvents {
'epg-parser.worker.js'
);
} else {
// In development: dist/apps/electron-backend/workers/epg-parser.worker.js
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
}
let worker: Worker;
try {
// Worker threads require file:// URLs wrapped in URL object for packaged apps
const workerURL = pathToFileURL(workerPath);
worker = new Worker(workerURL);
worker = new Worker(workerURL, {
resourceLimits: {
maxOldGenerationSizeMb: 4096,
maxYoungGenerationSizeMb: 512,
},
});
} catch (error) {
console.error(
this.loggerLabel,
'Failed to create worker:',
error
);
console.error(this.loggerLabel, 'Failed to create worker:', error);
reject(error);
return;
}
@@ -132,31 +254,70 @@ export default class EpgEvents {
worker.on(
'message',
(message: {
async (message: {
type: string;
data?: EpgData;
error?: string;
url?: string;
stats?: { totalChannels: number; totalPrograms: number };
}) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'FETCH_EPG', url });
} else if (message.type === 'EPG_PARSED') {
if (message.data) {
this.mergeEpgData(message.data);
this.fetchedUrls.add(url);
try {
switch (message.type) {
case 'READY':
// Notify renderer that loading started
this.sendProgressToRenderer(url, 'loading', { totalChannels: 0, totalPrograms: 0 });
worker.postMessage({ type: 'FETCH_EPG', url });
break;
case 'EPG_PROGRESS':
if (message.stats) {
console.log(
this.loggerLabel,
`Progress: ${message.stats.totalChannels} channels, ${message.stats.totalPrograms} programs`
);
// Forward progress to renderer
this.sendProgressToRenderer(url, 'loading', message.stats);
}
break;
case 'EPG_COMPLETE':
console.log(
this.loggerLabel,
`EPG parsing complete for ${url}:`,
message.stats
);
// Notify renderer of completion
this.sendProgressToRenderer(url, 'complete', message.stats);
this.fetchedUrls.add(url);
// Trigger cleanup in worker thread (non-blocking)
worker.postMessage({ type: 'CLEANUP_EXPIRED', hoursToKeep: 24 });
break;
case 'CLEANUP_COMPLETE':
console.log(
this.loggerLabel,
'Cleanup complete, terminating worker'
);
worker.terminate();
this.workers.delete(url);
resolve();
break;
case 'EPG_ERROR':
console.error(
this.loggerLabel,
'Worker error:',
message.error
);
// Notify renderer of error
this.sendProgressToRenderer(url, 'error', undefined, message.error);
worker.terminate();
this.workers.delete(url);
reject(new Error(message.error || 'Unknown error'));
break;
}
worker.terminate();
this.workers.delete(url);
resolve();
} else if (message.type === 'EPG_ERROR') {
console.error(
this.loggerLabel,
'Worker error:',
message.error
);
worker.terminate();
this.workers.delete(url);
reject(new Error(message.error || 'Unknown error'));
} catch (err) {
console.error(this.loggerLabel, 'Error handling message:', err);
reject(err);
}
}
);
@@ -180,115 +341,263 @@ export default class EpgEvents {
}
/**
* Merge EPG data (optimized for large datasets)
* Transform database row to flat EpgProgram interface
*/
private static mergeEpgData(newData: EpgData): void {
// Merge channels (avoid duplicates)
const existingChannelIds = new Set(
this.epgData.channels.map((c) => c.id)
);
const newChannels = newData.channels.filter(
(c) => !existingChannelIds.has(c.id)
);
// Use concat instead of spread for large arrays
this.epgData.channels = this.epgData.channels.concat(newChannels);
// Merge programs in chunks to avoid stack overflow
const chunkSize = 10000;
for (let i = 0; i < newData.programs.length; i += chunkSize) {
const chunk = newData.programs.slice(i, i + chunkSize);
this.epgData.programs = this.epgData.programs.concat(chunk);
}
// Rebuild merged data structure
this.rebuildMergedData();
private static transformDbRowToEpgProgram(row: {
id: number;
channelId: string;
start: string;
stop: string;
title: string;
description: string | null;
category: string | null;
iconUrl: string | null;
rating: string | null;
episodeNum: string | null;
}) {
return {
start: row.start,
stop: row.stop,
channel: row.channelId,
title: row.title,
desc: row.description,
category: row.category,
iconUrl: row.iconUrl,
rating: row.rating,
episodeNum: row.episodeNum,
};
}
/**
* Rebuild merged data structure (channels with programs)
* Optimized for large datasets
* Get programs for a specific channel from database
*/
private static rebuildMergedData(): void {
this.epgDataMerged.clear();
private static async handleGetChannelPrograms(channelId: string): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const now = new Date().toISOString();
// Create channel lookup map for O(1) access
const channelMap = new Map(this.epgData.channels.map((c) => [c.id, c]));
// Group programs by channel in a single pass
const programsByChannel = new Map<string, EpgProgram[]>();
for (const program of this.epgData.programs) {
const channelId = program.channel;
if (!programsByChannel.has(channelId)) {
programsByChannel.set(channelId, []);
}
programsByChannel.get(channelId)!.push(program);
}
// Build merged structure
for (const [channelId, programs] of programsByChannel.entries()) {
const channel = channelMap.get(channelId);
if (channel) {
this.epgDataMerged.set(channelId, {
...channel,
programs,
});
}
}
}
/**
* Get programs for a specific channel
*/
private static handleGetChannelPrograms(channelId: string): EpgProgram[] {
// First try exact ID match
let channelData = this.epgDataMerged.get(channelId);
// If not found, try to find by display name
if (!channelData) {
for (const [id, channel] of this.epgDataMerged.entries()) {
const displayNames = channel.displayName.map((d) =>
d.value.toLowerCase()
);
if (
displayNames.some(
(name) =>
name === channelId.toLowerCase() ||
name.includes(channelId.toLowerCase()) ||
channelId.toLowerCase().includes(name)
// Try exact channel ID match first
let results = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channelId),
gte(schema.epgPrograms.stop, now)
)
) {
channelData = channel;
break;
}
}
}
)
.orderBy(schema.epgPrograms.start)
.limit(100);
return channelData?.programs || [];
if (results.length > 0) {
return results.map(this.transformDbRowToEpgProgram);
}
// Try to find channel by display name
const channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`LOWER(${schema.epgChannels.displayName}) LIKE LOWER(${'%' + channelId + '%'})`
)
.limit(1);
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channel[0].id),
gte(schema.epgPrograms.stop, now)
)
)
.orderBy(schema.epgPrograms.start)
.limit(100);
return results.map(this.transformDbRowToEpgProgram);
}
return [];
} catch (error) {
console.error(this.loggerLabel, 'Error getting channel programs:', error);
return [];
}
}
/**
* Get channels by range (for pagination)
* Get all channels from database
*/
private static handleGetChannelsByRange(
private static async handleGetAllChannels(): Promise<{
channels: Array<{ id: string; displayName: string }>;
programs: never[];
}> {
try {
const db = await getDatabase();
const channels = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
})
.from(schema.epgChannels)
.orderBy(schema.epgChannels.displayName);
return { channels, programs: [] };
} catch (error) {
console.error(this.loggerLabel, 'Error getting all channels:', error);
return { channels: [], programs: [] };
}
}
/**
* Get channels by range (for pagination) with their programs
*/
private static async handleGetChannelsByRange(
skip: number,
limit: number
): EpgChannelWithPrograms[] {
const channels = Array.from(this.epgDataMerged.values());
return channels.slice(skip, skip + limit);
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null; programs: EpgProgram[] }>> {
try {
const db = await getDatabase();
const channels = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.orderBy(schema.epgChannels.displayName)
.offset(skip)
.limit(limit);
// Fetch programs for each channel
const channelsWithPrograms = await Promise.all(
channels.map(async (channel) => {
const programs = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel.id))
.orderBy(schema.epgPrograms.start);
return {
...channel,
programs: programs.map(this.transformDbRowToEpgProgram),
};
})
);
return channelsWithPrograms;
} catch (error) {
console.error(this.loggerLabel, 'Error getting channels by range:', error);
return [];
}
}
/**
* Clear all EPG data
* Run cleanup in worker thread to avoid blocking main thread
*/
static clearEpgData(): void {
this.epgData = { channels: [], programs: [] };
this.epgDataMerged.clear();
this.fetchedUrls.clear();
private static async runCleanupInWorker(hoursToKeep: number): Promise<{ success: boolean }> {
return new Promise((resolve, reject) => {
let workerPath: string;
// Terminate all workers
this.workers.forEach((worker) => worker.terminate());
this.workers.clear();
if (app.isPackaged) {
const resourcesPath = path.dirname(app.getAppPath());
workerPath = path.join(
resourcesPath,
'dist',
'apps',
'electron-backend',
'workers',
'epg-parser.worker.js'
);
} else {
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
}
let worker: Worker;
try {
const workerURL = pathToFileURL(workerPath);
worker = new Worker(workerURL);
} catch (error) {
console.error(this.loggerLabel, 'Failed to create worker for cleanup:', error);
reject(error);
return;
}
worker.on('message', (message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEANUP_EXPIRED', hoursToKeep });
} else if (message.type === 'CLEANUP_COMPLETE') {
worker.terminate();
resolve({ success: true });
} else if (message.type === 'EPG_ERROR') {
console.error(this.loggerLabel, 'Worker cleanup error:', message.error);
worker.terminate();
reject(new Error(message.error || 'Cleanup failed'));
}
});
worker.on('error', (error) => {
console.error(this.loggerLabel, 'Worker error during cleanup:', error);
worker.terminate();
reject(error);
});
});
}
/**
* Clear all EPG data using worker thread to avoid blocking main thread
*/
static async clearEpgData(): Promise<void> {
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);
});
});
}
}
@@ -1,100 +1,721 @@
import { parse as parseEpg } from 'epg-parser';
import { EpgData, EpgProgram } from 'shared-interfaces';
import { parentPort } from 'worker_threads';
import { gunzipSync } from 'zlib';
import { createGunzip } from 'zlib';
import { SaxesParser, SaxesTagPlain } from 'saxes';
import { Readable } from 'stream';
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
/**
* EPG Parser Worker
* Runs in a separate thread to avoid blocking the main process
* Compatible with epg-parser v0.4.0
* Internal parsing types with arrays for XML parsing
* These are different from the flat EpgProgram interface used by the frontend
*/
interface ParsedTextValue {
lang: string;
value: string;
}
interface ParsedIcon {
src: string;
width?: number;
height?: number;
}
interface ParsedRating {
system: string;
value: string;
}
interface ParsedEpisodeNum {
system: string;
value: string;
}
interface ParsedChannel {
id: string;
displayName: ParsedTextValue[];
icon: ParsedIcon[];
url: string[];
}
interface ParsedProgram {
start: string;
stop: string;
channel: string;
title: ParsedTextValue[];
desc: ParsedTextValue[];
category: ParsedTextValue[];
date: string;
episodeNum: ParsedEpisodeNum[];
icon: ParsedIcon[];
rating: ParsedRating[];
}
/**
* Streaming EPG Parser Worker
* Uses SAX parsing to process XML incrementally without loading entire file into memory.
* Supports both regular and gzipped EPG files.
* Performs database operations directly to avoid blocking the main thread.
*/
interface WorkerMessage {
type: 'FETCH_EPG' | 'FORCE_FETCH';
url: string;
type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG' | 'CLEANUP_EXPIRED';
url?: string;
hoursToKeep?: number;
}
interface WorkerResponse {
type: 'EPG_PARSED' | 'EPG_ERROR' | 'EPG_PROGRESS' | 'READY';
data?: EpgData;
type:
| 'EPG_COMPLETE'
| 'EPG_ERROR'
| 'EPG_PROGRESS'
| 'CLEAR_COMPLETE'
| 'CLEANUP_COMPLETE'
| 'READY';
error?: string;
url?: string;
stats?: {
totalChannels: number;
totalPrograms: number;
};
}
const loggerLabel = '[EPG Worker]';
/**
* Fetches and parses EPG data from URL
*/
async function fetchAndParseEpg(url: string): Promise<EpgData> {
try {
const isGzipped = url.endsWith('.gz');
// Batch size for database inserts
const CHANNEL_BATCH_SIZE = 100;
const PROGRAM_BATCH_SIZE = 1000;
// Skip programs that ended more than this many hours ago
const SKIP_PROGRAMS_OLDER_THAN_HOURS = 2;
/**
* Calculate the cutoff time for filtering old programs
* Programs that ended before this time will be skipped
*/
function getProgramCutoffTime(): Date {
return new Date(Date.now() - SKIP_PROGRAMS_OLDER_THAN_HOURS * 60 * 60 * 1000);
}
/**
* Check if a program has already ended (is expired)
* Handles timezone-aware ISO date strings
*/
function isProgramExpired(stopTime: string, cutoff: Date): boolean {
if (!stopTime) return true; // Skip programs without stop time
try {
const stopDate = new Date(stopTime);
// Check if the date is valid
if (isNaN(stopDate.getTime())) return false; // Don't skip if we can't parse
return stopDate < cutoff;
} catch {
return false; // Don't skip if parsing fails
}
}
/**
* Get database file path (same as main app)
*/
function getDatabasePath(): string {
const dbDir = join(homedir(), '.iptvnator', 'databases');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'iptvnator.db');
}
/**
* Database helper class for EPG operations
* Creates its own connection to avoid blocking main thread
*/
class EpgDatabase {
private db: Database.Database;
private knownChannelIds: Set<string> = new Set();
// Prepared statements for better performance
private insertChannelStmt: Database.Statement;
private insertProgramStmt: Database.Statement;
private deleteChannelsStmt: Database.Statement;
constructor() {
const dbPath = getDatabasePath();
this.db = new Database(dbPath);
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL'); // Better concurrent write performance
// Prepare statements
// Use INSERT OR REPLACE to update existing channels and refresh updated_at
// Use strftime with ISO format for consistent date comparison
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
`);
}
/**
* Clear existing EPG data for a source URL
*/
clearSourceData(sourceUrl: string): void {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
/**
* Insert a batch of channels
*/
insertChannels(channels: ParsedChannel[], sourceUrl: string): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
for (const channel of channels) {
const displayName = channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert a batch of programs
* Only inserts programs for known channels to avoid FK constraint failures
*/
insertPrograms(programs: ParsedProgram[]): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const prog of programs) {
// Skip if channel not known
if (!this.knownChannelIds.has(prog.channel)) continue;
const title = prog.title?.[0]?.value || 'Unknown';
const description = prog.desc?.[0]?.value || null;
const category = prog.category?.[0]?.value || null;
const iconUrl = prog.icon?.[0]?.src || null;
const rating = prog.rating?.[0]?.value || null;
const episodeNum = prog.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
prog.channel,
prog.start,
prog.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum
);
insertedCount++;
} catch (err) {
// Skip individual failures (e.g., FK constraint)
}
}
});
insertMany(programs);
return insertedCount;
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Parse XMLTV datetime format to ISO string
* Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss
*/
function parseXmltvDate(dateStr: string): string {
if (!dateStr) return '';
const match = dateStr.match(
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/
);
if (!match) return dateStr;
const [, year, month, day, hour, minute, second, tz] = match;
let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
if (tz) {
isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`;
} else {
isoString += 'Z';
}
return isoString;
}
/**
* Streaming EPG parser using SAX
*/
class StreamingEpgParser {
private parser: SaxesParser;
private channels: ParsedChannel[] = [];
private programs: ParsedProgram[] = [];
private totalChannels = 0;
private totalPrograms = 0;
private skippedPrograms = 0;
// Cutoff time for filtering old programs (calculated once at start)
private readonly cutoffTime: Date;
// Current element being parsed
private currentChannel: Partial<ParsedChannel> | null = null;
private currentProgram: Partial<ParsedProgram> | null = null;
private currentTextContent = '';
private currentLang = '';
// For nested elements
private elementStack: string[] = [];
constructor(
private onChannelsBatch: (channels: ParsedChannel[]) => void,
private onProgramsBatch: (programs: ParsedProgram[]) => void,
private onProgress: (channels: number, programs: number) => void
) {
this.parser = new SaxesParser();
this.cutoffTime = getProgramCutoffTime();
this.setupParser();
}
private setupParser(): void {
this.parser.on('opentag', (tag: SaxesTagPlain) => {
this.elementStack.push(tag.name);
this.currentTextContent = '';
switch (tag.name) {
case 'channel':
this.currentChannel = {
id: (tag.attributes['id'] as string) || '',
displayName: [],
icon: [],
url: [],
};
break;
case 'programme':
this.currentProgram = {
start: parseXmltvDate(
(tag.attributes['start'] as string) || ''
),
stop: parseXmltvDate(
(tag.attributes['stop'] as string) || ''
),
channel: (tag.attributes['channel'] as string) || '',
title: [],
desc: [],
category: [],
date: '',
episodeNum: [],
icon: [],
rating: [],
};
break;
case 'icon':
if (this.currentChannel) {
this.currentChannel.icon!.push({
src: (tag.attributes['src'] as string) || '',
width: tag.attributes['width']
? parseInt(tag.attributes['width'] as string)
: undefined,
height: tag.attributes['height']
? parseInt(tag.attributes['height'] as string)
: undefined,
});
} else if (this.currentProgram) {
this.currentProgram.icon!.push({
src: (tag.attributes['src'] as string) || '',
width: tag.attributes['width']
? parseInt(tag.attributes['width'] as string)
: undefined,
height: tag.attributes['height']
? parseInt(tag.attributes['height'] as string)
: undefined,
});
}
break;
case 'display-name':
case 'title':
case 'desc':
case 'category':
this.currentLang = (tag.attributes['lang'] as string) || '';
break;
case 'rating':
if (this.currentProgram) {
const system =
(tag.attributes['system'] as string) || '';
this.currentProgram.rating!.push({ system, value: '' });
}
break;
case 'episode-num':
if (this.currentProgram) {
const system =
(tag.attributes['system'] as string) || '';
this.currentProgram.episodeNum!.push({
system,
value: '',
});
}
break;
}
});
this.parser.on('text', (text: string) => {
this.currentTextContent += text;
});
this.parser.on('closetag', (tag: SaxesTagPlain) => {
const text = this.currentTextContent.trim();
if (this.currentChannel) {
switch (tag.name) {
case 'display-name':
this.currentChannel.displayName!.push({
lang: this.currentLang,
value: text,
});
break;
case 'url':
if (text) this.currentChannel.url!.push(text);
break;
case 'channel':
this.channels.push(this.currentChannel as ParsedChannel);
this.totalChannels++;
this.currentChannel = null;
if (this.channels.length >= CHANNEL_BATCH_SIZE) {
this.flushChannels();
}
break;
}
}
if (this.currentProgram) {
switch (tag.name) {
case 'title':
this.currentProgram.title!.push({
lang: this.currentLang,
value: text,
});
break;
case 'desc':
this.currentProgram.desc!.push({
lang: this.currentLang,
value: text,
});
break;
case 'category':
this.currentProgram.category!.push({
lang: this.currentLang,
value: text,
});
break;
case 'date':
this.currentProgram.date = text;
break;
case 'value':
if (
this.elementStack.includes('rating') &&
this.currentProgram.rating!.length > 0
) {
this.currentProgram.rating![
this.currentProgram.rating!.length - 1
].value = text;
}
break;
case 'episode-num':
if (this.currentProgram.episodeNum!.length > 0) {
this.currentProgram.episodeNum![
this.currentProgram.episodeNum!.length - 1
].value = text;
}
break;
case 'programme':
// Skip programs that have already ended
if (isProgramExpired(this.currentProgram.stop!, this.cutoffTime)) {
this.skippedPrograms++;
} else {
this.programs.push(this.currentProgram as ParsedProgram);
this.totalPrograms++;
if (this.programs.length >= PROGRAM_BATCH_SIZE) {
this.flushPrograms();
}
}
this.currentProgram = null;
break;
}
}
this.elementStack.pop();
this.currentTextContent = '';
});
this.parser.on('error', (err: Error) => {
console.error(loggerLabel, 'Parser error:', err.message);
});
}
private flushChannels(): void {
if (this.channels.length > 0) {
this.onChannelsBatch([...this.channels]);
this.channels = [];
this.onProgress(this.totalChannels, this.totalPrograms);
}
}
private flushPrograms(): void {
if (this.programs.length > 0) {
this.onProgramsBatch([...this.programs]);
this.programs = [];
this.onProgress(this.totalChannels, this.totalPrograms);
}
}
write(chunk: string): void {
this.parser.write(chunk);
}
finish(): { totalChannels: number; totalPrograms: number; skippedPrograms: number } {
this.parser.close();
this.flushChannels();
this.flushPrograms();
if (this.skippedPrograms > 0) {
console.log(
loggerLabel,
`Skipped ${this.skippedPrograms} expired programs (ended more than ${SKIP_PROGRAMS_OLDER_THAN_HOURS}h ago)`
);
}
return {
totalChannels: this.totalChannels,
totalPrograms: this.totalPrograms,
skippedPrograms: this.skippedPrograms,
};
}
}
/**
* Fetches and parses EPG data from URL using streaming
* Inserts directly into SQLite to avoid blocking main thread
*/
async function fetchAndParseEpgStreaming(url: string): Promise<void> {
const isGzipped = url.endsWith('.gz');
console.log(
loggerLabel,
`Fetching EPG from ${url} (gzipped: ${isGzipped})`
);
// Create database connection in worker
const epgDb = new EpgDatabase();
try {
// Clear existing data for this source
console.log(loggerLabel, `Clearing existing data for ${url}`);
epgDb.clearSourceData(url);
// Use native fetch (available in Node.js 18+)
const response = await fetch(url.trim());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
let xmlString: string;
if (isGzipped) {
// For gzipped files, get as ArrayBuffer
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const decompressed = gunzipSync(buffer);
xmlString = decompressed.toString('utf-8');
} else {
// For regular files, get as text
xmlString = await response.text();
if (!response.body) {
throw new Error('Response body is null');
}
const parsed = parseEpg(xmlString);
// Normalize data to match our interface
const normalized: EpgData = {
channels: parsed.channels.map(
(channel: Record<string, unknown>) => ({
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<string, unknown>) => ({
...program,
start: program.start as string, // Already ISO string in v0.4.0
stop: program.stop as string, // Already ISO string in v0.4.0
date:
typeof program.date === 'string'
? program.date
: (program.date as string[])?.[0] || '',
})
) as EpgProgram[],
};
return normalized;
} catch (error) {
console.error(
loggerLabel,
'Error fetching EPG:',
error instanceof Error ? error.message : String(error)
const parser = new StreamingEpgParser(
(channels) => {
// Insert channels directly into database
epgDb.insertChannels(channels, url);
},
(programs) => {
// Insert programs directly into database
epgDb.insertPrograms(programs);
},
(totalChannels, totalPrograms) => {
// Send progress to main thread (lightweight)
const response: WorkerResponse = {
type: 'EPG_PROGRESS',
stats: { totalChannels, totalPrograms },
};
parentPort?.postMessage(response);
}
);
// Convert web stream to Node.js stream
const nodeStream = Readable.fromWeb(response.body as any);
return new Promise((resolve, reject) => {
let dataStream: Readable = nodeStream;
if (isGzipped) {
const gunzip = createGunzip();
dataStream = nodeStream.pipe(gunzip);
gunzip.on('error', (err) => {
console.error(loggerLabel, 'Gunzip error:', err);
epgDb.close();
reject(err);
});
}
dataStream.on('data', (chunk: Buffer) => {
try {
parser.write(chunk.toString('utf-8'));
} catch (err) {
console.error(loggerLabel, 'Parse error:', err);
epgDb.close();
reject(err);
}
});
dataStream.on('end', () => {
try {
const stats = parser.finish();
console.log(
loggerLabel,
`Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs` +
(stats.skippedPrograms > 0
? ` (skipped ${stats.skippedPrograms} expired)`
: '')
);
// Close database connection
epgDb.close();
const response: WorkerResponse = {
type: 'EPG_COMPLETE',
url,
stats: {
totalChannels: stats.totalChannels,
totalPrograms: stats.totalPrograms,
},
};
parentPort?.postMessage(response);
resolve();
} catch (err) {
epgDb.close();
reject(err);
}
});
dataStream.on('error', (err) => {
console.error(loggerLabel, 'Stream error:', err);
epgDb.close();
reject(err);
});
});
} catch (error) {
epgDb.close();
throw error;
}
}
/**
* Clears all EPG data from the database
* Runs in worker thread to avoid blocking main thread
*/
function clearAllEpgData(): void {
const dbPath = getDatabasePath();
const db = new Database(dbPath);
try {
console.log(loggerLabel, 'Clearing all EPG data...');
// Delete programs first (foreign key constraint)
db.exec('DELETE FROM epg_programs');
// Then delete channels
db.exec('DELETE FROM epg_channels');
console.log(loggerLabel, 'All EPG data cleared');
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG data:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
};
parentPort?.postMessage(errorResponse);
} finally {
db.close();
}
}
/**
* Cleans up expired EPG programs from the database
* Runs in worker thread to avoid blocking main thread
*/
function cleanupExpiredPrograms(hoursToKeep: number): void {
const dbPath = getDatabasePath();
const db = new Database(dbPath);
try {
const cutoff = new Date(
Date.now() - hoursToKeep * 60 * 60 * 1000
).toISOString();
console.log(loggerLabel, `Cleaning up programs older than ${hoursToKeep}h (before ${cutoff})...`);
const stmt = db.prepare('DELETE FROM epg_programs WHERE stop <= ?');
const result = stmt.run(cutoff);
console.log(loggerLabel, `Cleaned up ${result.changes} expired programs`);
const response: WorkerResponse = { type: 'CLEANUP_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error cleaning up expired programs:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
};
parentPort?.postMessage(errorResponse);
} finally {
db.close();
}
}
/**
* Worker message handler
*/
@@ -105,15 +726,11 @@ if (parentPort) {
message.type === 'FETCH_EPG' ||
message.type === 'FORCE_FETCH'
) {
const parsedData = await fetchAndParseEpg(message.url);
const response: WorkerResponse = {
type: 'EPG_PARSED',
data: parsedData,
url: message.url,
};
parentPort?.postMessage(response);
await fetchAndParseEpgStreaming(message.url!);
} else if (message.type === 'CLEAR_EPG') {
clearAllEpgData();
} else if (message.type === 'CLEANUP_EXPIRED') {
cleanupExpiredPrograms(message.hoursToKeep ?? 24);
}
} catch (error) {
console.error(loggerLabel, 'Worker error:', error);
@@ -122,7 +739,6 @@ if (parentPort) {
error: error instanceof Error ? error.message : String(error),
url: message.url,
};
parentPort?.postMessage(errorResponse);
}
});
+4 -4
View File
@@ -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",
+29 -2
View File
@@ -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<void> {
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
*/
@@ -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<Map<string, EpgImportProgress>>(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());
}
}
@@ -76,6 +76,14 @@
>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-button
color="warn"
type="button"
(click)="clearEpgData()"
>
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
</button>
</div>
</div>
<mat-divider />
@@ -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<void> => {
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()
@@ -0,0 +1,95 @@
@if (isVisible()) {
<div class="epg-progress-panel">
<div class="panel-header">
<span class="header-title">
<mat-icon>live_tv</mat-icon>
{{ 'EPG.IMPORT_PROGRESS' | translate }}
</span>
@if (imports().length > 1) {
<button
mat-icon-button
class="dismiss-all-btn"
(click)="dismissAll()"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="imports-list">
<!-- Active/Completed imports -->
@for (item of activeImports; track item.url) {
<div class="import-item" [class]="getStatusClass(item.status)">
<div class="item-header">
<div class="status-icon-wrapper">
<mat-icon [class.spinning]="item.status === 'loading'">
{{ getStatusIcon(item.status) }}
</mat-icon>
</div>
<span class="item-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (item.status === 'loading') {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<div class="item-stats">
@if (item.stats) {
<span class="stat-badge">
<mat-icon>tv</mat-icon>
{{ item.stats.totalChannels }}
</span>
<span class="stat-badge">
<mat-icon>event</mat-icon>
{{ item.stats.totalPrograms }}
</span>
} @else if (item.error) {
<span class="error-text">{{ item.error }}</span>
} @else if (item.status === 'loading') {
<span>{{ 'EPG.LOADING' | translate }}...</span>
}
</div>
</div>
}
<!-- Queued imports section -->
@if (queuedImports.length > 0) {
<div class="queue-section">
<div class="queue-header">
<mat-icon>queue</mat-icon>
<span>{{ 'EPG.QUEUED' | translate }} ({{ queuedImports.length }})</span>
</div>
<div class="queue-list">
@for (item of queuedImports; track item.url; let i = $index) {
<div class="queue-item">
<span class="queue-position">{{ i + 1 }}</span>
<span class="queue-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
</div>
}
</div>
</div>
}
@@ -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;
}
}
@@ -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();
}
}
+16 -3
View File
@@ -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": "العربية",
Vendored
+19
View File
@@ -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<any[]>;
updateSettings: (settings: any) => Promise<void>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
@@ -173,6 +182,16 @@ declare global {
}) => void
) => void;
getLocalIpAddresses: () => Promise<string[]>;
// 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;
@@ -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`,
];
/**
+51
View File
@@ -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;
@@ -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;
}
@@ -27,7 +27,7 @@
<div class="epg-content">
@if (epgProgram) {
<div class="epg-title">
{{ epgProgram.title?.[0]?.value }}
{{ epgProgram.title }}
</div>
<div class="epg-timeline">
<span class="epg-time"
@@ -1,42 +1,63 @@
<h2 mat-dialog-title>
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
@if (epgProgram?.title?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
<div class="program-dialog">
<!-- Header with channel and time -->
<header class="program-header">
<div class="time-block">
<div class="time-display">
<span class="time-start">{{ formatTime(epgProgram.start) }}</span>
<span class="time-separator">—</span>
<span class="time-end">{{ formatTime(epgProgram.stop) }}</span>
</div>
<div class="date-display">{{ formatDate(epgProgram.start) }}</div>
</div>
<p data-test="title">{{ epgProgram.title[0]?.value }}</p>
@if (epgProgram.title[0].lang) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
@if (channelName) {
<div class="channel-badge">
<span class="channel-icon">📺</span>
<span class="channel-name">{{ channelName }}</span>
</div>
}
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
}
@if (epgProgram?.category?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</header>
<!-- Title section -->
<div class="program-title-section">
<h1 class="program-title">{{ epgProgram.title }}</h1>
@if (epgProgram.category) {
<span class="category-tag">{{ epgProgram.category }}</span>
}
</div>
<!-- Content -->
<div class="program-content">
@if (epgProgram.desc) {
<p class="program-description">{{ epgProgram.desc }}</p>
}
<!-- Meta info grid -->
<div class="meta-grid">
@if (epgProgram.episodeNum) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }}</span>
<span class="meta-value">{{ epgProgram.episodeNum }}</span>
</div>
}
@if (epgProgram.rating) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}</span>
<span class="meta-value rating">{{ epgProgram.rating }}</span>
</div>
}
@if (duration) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }}</span>
<span class="meta-value">{{ duration }}</span>
</div>
}
</div>
<p data-test="category">{{ epgProgram.category[0]?.value }}</p>
}
@if (epgProgram?.desc?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc[0]?.value }}</p>
}
@if (epgProgram?.rating?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
</div>
<!-- Footer -->
<footer class="program-footer">
<button class="close-button" mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</footer>
</div>
@@ -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;
}
}
}
@@ -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<EpgProgram>(MAT_DIALOG_DATA);
dialogData = inject<any>(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;
}
}
}
@@ -17,7 +17,7 @@
>
}
@if (item?.desc?.length > 0) {
@if (item?.desc) {
<span
(click)="$event.stopPropagation(); showDescription(item)"
matListItemMeta
@@ -16,8 +16,7 @@
<div class="program-name">
{{
playingNow
? (playingNow.title?.[0]?.value || 'EPG.LIVE_STREAM'
| translate)
? (playingNow.title || 'EPG.LIVE_STREAM' | translate)
: ('EPG.LIVE_STREAM' | translate)
}}
</div>
@@ -64,7 +63,7 @@
/>
</div>
<div class="program-title">
{{ program?.title?.[0]?.value }}
{{ program?.title }}
</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
@@ -101,7 +100,7 @@
</div>
<div
class="program-title"
[innerHTML]="program.title?.[0]?.value"
[innerHTML]="program.title"
></div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
@@ -9,7 +9,7 @@
<div class="epg-layout">
<div class="channel-logo">
<img
[src]="epgProgram?.icon?.[0]?.src || channel?.tvg?.logo"
[src]="epgProgram?.iconUrl || channel?.tvg?.logo"
onerror="this.style.display='none'"
alt="channel icon"
/>
@@ -17,15 +17,15 @@
<div class="content">
<div class="content-header">
<div class="program-title">
{{ epgProgram?.title?.[0]?.value }}
{{ epgProgram?.title }}
</div>
<div class="channel-name">
{{ channel.name }}
</div>
</div>
@if (epgProgram?.desc?.[0]?.value) {
@if (epgProgram?.desc) {
<div class="program-description">
{{ epgProgram?.desc?.[0]?.value }}
{{ epgProgram?.desc }}
</div>
}
<div class="program-timeline">
@@ -14,7 +14,7 @@
<mat-icon>chevron_left</mat-icon>
</button>
<div class="today-date">
{{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
{{ today() | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
</div>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
@@ -23,16 +23,99 @@
>
<mat-icon>chevron_right</mat-icon>
</button>
<button mat-icon-button (click)="zoomOut()" [disabled]="(hourWidth$ | async)! <= 50" matTooltip="Zoom out">
<button mat-icon-button (click)="zoomOut()" [disabled]="hourWidth() <= 50" matTooltip="Zoom out">
<mat-icon>remove</mat-icon>
</button>
<button mat-icon-button (click)="zoomIn()" [disabled]="(hourWidth$ | async)! >= 800" matTooltip="Zoom in">
<button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth() >= 800" matTooltip="Zoom in">
<mat-icon>add</mat-icon>
</button>
<div class="search-container" [class.expanded]="isSearchExpanded()">
@if (isSearchExpanded()) {
<div class="channel-search">
<mat-icon class="search-icon">search</mat-icon>
<input
type="text"
[value]="channelFilter()"
(input)="onFilterInput($event)"
placeholder="Filter channels..."
autocomplete="off"
#searchInput
/>
@if (channelFilter()) {
<button class="clear-btn" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
</button>
}
</div>
<span class="filter-count">{{ filteredChannels().length }} / {{ channels().length }}</span>
}
<button
mat-icon-button
(click)="toggleSearch()"
[matTooltip]="isSearchExpanded() ? 'Close filter' : 'Filter channels'"
>
<mat-icon>{{ isSearchExpanded() ? 'filter_list_off' : 'filter_list' }}</mat-icon>
</button>
</div>
<!-- Program Search -->
<div class="program-search-container" [class.open]="isProgramSearchOpen()">
@if (isProgramSearchOpen()) {
<div class="program-search-input">
<mat-icon class="search-icon">manage_search</mat-icon>
<input
type="text"
[value]="programSearchQuery()"
(input)="onProgramSearchInput($event)"
placeholder="Search programs..."
autocomplete="off"
#programSearchInput
/>
@if (programSearchQuery()) {
<button class="clear-btn" (click)="clearProgramSearch()">
<mat-icon>clear</mat-icon>
</button>
}
@if (isSearchingPrograms()) {
<div class="search-spinner"></div>
}
</div>
@if (programSearchResults().length > 0) {
<div class="program-search-results">
@for (program of programSearchResults(); track $index) {
<button class="search-result-item" (click)="showProgramDetails(program)">
<div class="result-info">
<span class="result-title">{{ program.title }}</span>
<span class="result-meta">
<span class="result-time">{{ formatProgramTime(program) }}</span>
<span class="result-date">{{ formatProgramDate(program) }}</span>
</span>
</div>
<mat-icon class="result-arrow">arrow_forward</mat-icon>
</button>
}
</div>
}
@if (programSearchQuery().length >= 2 && programSearchResults().length === 0 && !isSearchingPrograms()) {
<div class="program-search-empty">
<mat-icon>search_off</mat-icon>
<span>No programs found</span>
</div>
}
}
<button
mat-icon-button
(click)="toggleProgramSearch()"
[matTooltip]="isProgramSearchOpen() ? 'Close program search' : 'Search programs'"
>
<mat-icon>{{ isProgramSearchOpen() ? 'close' : 'manage_search' }}</mat-icon>
</button>
</div>
</div>
<div class="parent" #epgContainer (scroll)="onScroll($event)">
<svg id="channels-column" [attr.height]="(channels$ | async)!.length * barHeight + barHeight + 50">
@for (item of channels$ | async; track item.id; let i = $index) {
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
@for (item of filteredChannels(); track item.id; let i = $index) {
<g>
<!-- channel name -->
<g class="channel" [class.active]="item.id === activeChannelId">
@@ -62,20 +145,20 @@
</g>
}
</svg>
<div id="epg-container" [style.height.px]="(channels$ | async)!.length * barHeight + barHeight + 50">
<svg [attr.width]="(hourWidth$ | async)! * 24" [attr.height]="(channels$ | async)!.length * barHeight + barHeight + 50" id="epg-svg">
<div id="epg-container" [style.height.px]="filteredChannels().length * barHeight + barHeight + 50">
<svg [attr.width]="hourWidth() * 24" [attr.height]="filteredChannels().length * barHeight + barHeight + 50" id="epg-svg">
<!-- time headline -->
@for (hour of timeHeader; track hour) {
<g>
<rect
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth$ | async"
[attr.width]="hourWidth()"
[attr.height]="barHeight"
[attr.x]="hour * (hourWidth$ | async)!"
[attr.x]="hour * hourWidth()"
fill="#000"
></rect>
<text
[attr.x]="hour * (hourWidth$ | async)!"
[attr.x]="hour * hourWidth()"
y="10"
font-size="14"
fill="white"
@@ -85,34 +168,35 @@
</text>
<!-- vertical hour separator line -->
<line
[attr.x1]="hour * (hourWidth$ | async)!"
[attr.x1]="hour * hourWidth()"
[attr.y1]="barHeight"
[attr.x2]="hour * (hourWidth$ | async)!"
[attr.y2]="(channels$ | async)!.length * barHeight + barHeight"
[attr.x2]="hour * hourWidth()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
class="hour-separator"
></line>
</g>
}
<!-- epg channels with programs -->
@for (item of channels$ | async; track item.id; let i = $index) {
@for (item of filteredChannels(); track item.id; let i = $index) {
<g [class.active-row]="item.id === activeChannelId">
@for (program of item.programs; track trackByProgram($index, program)) {
<g
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
(click)="showDescription(program)"
>
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[matTooltip]="program.title[0].value"
[matTooltip]="program.title"
></rect>
<foreignObject
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title[0]?.value }}</div>
<div class="program-title">{{ program.title }}</div>
</foreignObject>
</g>
}
@@ -121,13 +205,22 @@
<!-- current time line -->
<line
[attr.x1]="currentTimeLine"
[attr.x1]="currentTimeLine()"
y1="0"
[attr.x2]="currentTimeLine"
[attr.y2]="(channels$ | async)!.length * barHeight + barHeight"
[attr.x2]="currentTimeLine()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
stroke="red"
stroke-width="2"
></line>
</svg>
</div>
</div>
<!-- Empty state when no channels match filter -->
@if (filteredChannels().length === 0 && channelFilter()) {
<div class="no-results">
<mat-icon>search_off</mat-icon>
<p>No channels match "{{ channelFilter() }}"</p>
<button mat-button (click)="clearFilter()">Clear filter</button>
</div>
}
@@ -67,12 +67,14 @@ $gradient-header: linear-gradient(
#epg-navigation {
display: flex;
align-items: center;
padding: 12px 16px;
padding: 12px 16px 12px 80px; // Left padding for macOS traffic lights
background: rgba(18, 18, 26, 0.85);
backdrop-filter: blur(20px);
border-bottom: 1px solid $border-medium;
gap: 8px;
position: relative;
z-index: 100;
-webkit-app-region: drag;
// Subtle glow at bottom
&::after {
@@ -91,12 +93,13 @@ $gradient-header: linear-gradient(
);
}
// Button styling
// Button styling - exclude from drag region
button {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
letter-spacing: 0.02em;
transition: all 0.2s ease;
-webkit-app-region: no-drag;
&:not([disabled]):hover {
background: rgba(139, 92, 246, 0.15);
@@ -112,6 +115,374 @@ $gradient-header: linear-gradient(
}
}
// Search container - exclude from drag region
.search-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
-webkit-app-region: no-drag;
.channel-search {
display: flex;
align-items: center;
gap: 8px;
width: 200px;
height: 36px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid $border-subtle;
border-radius: 8px;
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.08);
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $text-muted;
flex-shrink: 0;
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 13px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
color: $text-primary;
min-width: 0;
&::placeholder {
color: $text-muted;
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: $text-muted;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: $text-primary;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.filter-count {
font-size: 11px;
color: $text-muted;
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
}
&.expanded {
.channel-search {
animation: slideIn 0.2s ease-out;
}
}
}
@keyframes slideIn {
from {
opacity: 0;
width: 0;
transform: translateX(20px);
}
to {
opacity: 1;
width: 200px;
transform: translateX(0);
}
}
// Program search container
.program-search-container {
display: flex;
align-items: center;
gap: 8px;
position: relative;
-webkit-app-region: no-drag;
.program-search-input {
display: flex;
align-items: center;
gap: 8px;
width: 220px;
height: 36px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid $border-subtle;
border-radius: 8px;
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.08);
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $accent-secondary;
flex-shrink: 0;
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 13px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
color: $text-primary;
min-width: 0;
&::placeholder {
color: $text-muted;
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: $text-muted;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: $text-primary;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.search-spinner {
width: 16px;
height: 16px;
border: 2px solid $border-subtle;
border-top-color: $accent-secondary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
}
.program-search-results {
position: fixed;
top: 60px;
right: 16px;
width: 320px;
max-height: 400px;
overflow-y: auto;
background: $bg-elevated;
border: 1px solid $border-medium;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10000;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 3px;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid $border-subtle;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: $bg-hover;
.result-arrow {
opacity: 1;
transform: translateX(0);
}
}
.result-info {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.result-title {
font-size: 13px;
font-weight: 500;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: $text-muted;
}
.result-time {
color: $accent-primary;
font-family: 'JetBrains Mono', monospace;
}
.result-date {
color: $text-muted;
}
.result-arrow {
font-size: 18px;
width: 18px;
height: 18px;
color: $accent-secondary;
opacity: 0;
transform: translateX(-4px);
transition: all 0.15s ease;
}
}
}
.program-search-empty {
position: fixed;
top: 60px;
right: 16px;
width: 220px;
padding: 24px 16px;
background: $bg-elevated;
border: 1px solid $border-medium;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: $text-muted;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
opacity: 0.5;
}
span {
font-size: 12px;
}
}
&.open {
.program-search-input {
animation: slideIn 0.2s ease-out;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes highlightPulse {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6));
}
50% {
filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9))
drop-shadow(0 0 40px rgba(0, 212, 170, 0.4));
}
}
// No results state
.no-results {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: $text-muted;
text-align: center;
z-index: 100;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
}
button {
color: $accent-primary;
}
}
// Today's date display
.today-date {
flex: 1;
@@ -263,6 +634,19 @@ $gradient-header: linear-gradient(
stroke: rgba(139, 92, 246, 0.4);
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2));
}
// Highlighted program (from search)
&.highlighted rect {
fill: rgba(0, 212, 170, 0.25);
stroke: $accent-primary;
stroke-width: 2px;
animation: highlightPulse 1s ease-in-out 3;
}
&.highlighted .program-title {
color: $accent-primary;
font-weight: 600;
}
}
// Active channel row highlighting
@@ -442,6 +826,10 @@ rect {
// Responsive adjustments
@media (max-width: 768px) {
#epg-navigation {
padding-left: 70px; // Slightly reduced for smaller screens
}
#channels-column {
width: 100px;
@@ -3,13 +3,14 @@ import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
ElementRef,
inject,
Input,
OnDestroy,
OnInit,
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
@@ -19,7 +20,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { Observable, Subscription } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
@@ -55,18 +56,15 @@ export class MultiEpgContainerComponent
readonly epgContainer = viewChild.required<ElementRef>('epgContainer');
@Input() set playlistChannels(value: Observable<Channel[]>) {
// Unsubscribe from previous subscription if it exists
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (value) {
this.playlistChannelsSubscription = value.subscribe(() => {
// Reset data for new playlist
this.channelsLowerRange = 0;
this.originalEpgData = [];
this.channels$.next([]);
this.isLastPage = false;
this.originalEpgData.set([]);
this.isLastPage.set(false);
this.initializeVisibleChannels();
this.requestPrograms();
});
@@ -75,34 +73,70 @@ export class MultiEpgContainerComponent
@Input() activeChannelId: string | null = null;
// Signals
readonly hourWidth = signal(150);
readonly today = signal(format(new Date(), 'yyyyMMdd'));
readonly originalEpgData = signal<any[]>([]);
readonly isLoading = signal(false);
readonly isLastPage = signal(false);
readonly channelFilter = signal('');
readonly isSearchExpanded = signal(false);
// Program search signals
readonly isProgramSearchOpen = signal(false);
readonly programSearchQuery = signal('');
readonly programSearchResults = signal<any[]>([]);
readonly isSearchingPrograms = signal(false);
readonly highlightedProgramKey = signal<string | null>(null);
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Computed signal for enriched channels - automatically updates when dependencies change
readonly channels = computed(() => this.enrichProgramData());
// Computed signal for filtered channels based on search term
readonly filteredChannels = computed(() => {
const filter = this.channelFilter().toLowerCase().trim();
const allChannels = this.channels();
if (!filter) {
return allChannels;
}
return allChannels.filter(channel => {
const name = this.getChannelName(channel).toLowerCase();
return name.includes(filter);
});
});
// Computed signal for current time line position
readonly currentTimeLine = computed(() => {
const now = new Date();
return (now.getHours() + now.getMinutes() / 60) * this.hourWidth();
});
// Constants
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
readonly hourWidth$ = new BehaviorSubject<number>(150);
readonly barHeight = 50;
channels$ = new BehaviorSubject<EnrichedChannel[]>([]);
today = format(new Date(), 'yyyyMMdd');
currentTimeLine = 0;
// Pagination state
visibleChannels = 20;
channelsLowerRange = 0;
channelsUpperRange = this.visibleChannels;
originalEpgData: any[] = [];
private dateCache = new Map<string, Date>();
private interval!: number;
private playlistChannelsSubscription?: Subscription;
isLastPage = false;
isLoading = false;
totalChannels = 0;
private dateCache = new Map<string, Date>();
private interval!: ReturnType<typeof setInterval>;
private playlistChannelsSubscription?: Subscription;
private readonly dialog = inject(MatDialog);
private readonly cdr = inject(ChangeDetectorRef);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
ngOnInit() {
this.calculateCurrentTimeBar();
// Update current time line every minute
this.interval = setInterval(() => {
this.calculateCurrentTimeBar();
// Force recomputation by updating hourWidth to same value
// This triggers the computed signal to recalculate
this.hourWidth.update(v => v);
}, 60000);
}
@@ -114,8 +148,7 @@ export class MultiEpgContainerComponent
private scrollToCurrentTime(): void {
const timeNow = new Date();
const scrollPosition =
(timeNow.getHours() + timeNow.getMinutes() / 60) *
this.hourWidth$.value;
(timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth();
requestAnimationFrame(() => {
const container = document.getElementById('epg-container');
@@ -145,14 +178,15 @@ export class MultiEpgContainerComponent
}
ngOnDestroy(): void {
this.hourWidth$.complete();
this.channels$.complete();
clearInterval(this.interval);
// Clean up playlist channels subscription
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
trackByIndex(index: number): number {
@@ -169,11 +203,11 @@ export class MultiEpgContainerComponent
return;
}
if (this.isLoading || this.isLastPage) {
if (this.isLoading() || this.isLastPage()) {
return;
}
this.isLoading = true;
this.isLoading.set(true);
try {
const response = await window.electron.getEpgChannelsByRange(
@@ -183,21 +217,18 @@ export class MultiEpgContainerComponent
if (response && Array.isArray(response)) {
// Append new data to existing data
this.originalEpgData = [...this.originalEpgData, ...response];
this.channels$.next(this.enrichProgramData());
this.originalEpgData.update(data => [...data, ...response]);
// Update isLastPage based on the number of channels received
this.isLastPage = response.length < this.visibleChannels;
this.isLastPage.set(response.length < this.visibleChannels);
// Update range for next fetch
this.channelsLowerRange += response.length;
this.cdr.detectChanges();
}
} catch (error) {
console.error('Error fetching EPG data:', error);
} finally {
this.isLoading = false;
this.isLoading.set(false);
}
}
@@ -223,25 +254,27 @@ export class MultiEpgContainerComponent
}
private enrichProgramData(): EnrichedChannel[] {
const hourWidth = this.hourWidth$.value;
const hourWidthValue = this.hourWidth();
const todayValue = this.today();
const data = this.originalEpgData();
return this.originalEpgData.map((channel) => {
const filteredPrograms = channel.programs
return data.map((channel) => {
const filteredPrograms = (channel.programs || [])
.filter((item: EpgProgram) => {
const itemDate = format(
this.getCachedDate(item.start),
'yyyyMMdd'
);
return itemDate === this.today;
return itemDate === todayValue;
})
.map((program: EpgProgram) => {
const startDate = this.getCachedDate(program.start);
const stopDate = this.getCachedDate(program.stop);
const startPosition =
(startDate.getHours() + startDate.getMinutes() / 60) *
hourWidth;
hourWidthValue;
const duration = differenceInMinutes(stopDate, startDate);
const width = (duration * hourWidth) / 60;
const width = (duration * hourWidthValue) / 60;
return {
...program,
@@ -263,7 +296,10 @@ export class MultiEpgContainerComponent
* Get display name from EpgChannel
*/
getChannelName(channel: EpgChannel): string {
if (channel.displayName && channel.displayName.length > 0) {
if (typeof channel.displayName === 'string') {
return channel.displayName;
}
if (Array.isArray(channel.displayName) && channel.displayName.length > 0) {
return channel.displayName[0].value;
}
return '';
@@ -273,6 +309,9 @@ export class MultiEpgContainerComponent
* Get icon from EpgChannel
*/
getChannelIcon(channel: EpgChannel): string {
if ((channel as any).iconUrl) {
return (channel as any).iconUrl;
}
if (channel.icon && channel.icon.length > 0) {
return channel.icon[0].src;
}
@@ -280,38 +319,38 @@ export class MultiEpgContainerComponent
}
zoomIn(): void {
if (this.hourWidth$.value >= 800) return;
this.hourWidth$.next(this.hourWidth$.value + 50);
this.channels$.next(this.enrichProgramData());
this.calculateCurrentTimeBar();
this.cdr.detectChanges();
if (this.hourWidth() >= 800) return;
this.hourWidth.update(v => v + 50);
}
zoomOut(): void {
if (this.hourWidth$.value <= 50) return;
this.hourWidth$.next(this.hourWidth$.value - 50);
this.channels$.next(this.enrichProgramData());
this.calculateCurrentTimeBar();
this.cdr.detectChanges();
if (this.hourWidth() <= 50) return;
this.hourWidth.update(v => v - 50);
}
calculateCurrentTimeBar(): void {
this.currentTimeLine =
(new Date().getHours() + new Date().getMinutes() / 60) *
this.hourWidth$.value;
this.cdr.detectChanges();
toggleSearch(): void {
this.isSearchExpanded.update(v => !v);
if (!this.isSearchExpanded()) {
this.channelFilter.set('');
}
}
clearFilter(): void {
this.channelFilter.set('');
}
onFilterInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.channelFilter.set(input.value);
}
switchDay(direction: 'prev' | 'next'): void {
const currentDate = parse(this.today, 'yyyyMMdd', new Date());
this.today =
const currentDate = parse(this.today(), 'yyyyMMdd', new Date());
this.today.set(
direction === 'prev'
? format(subDays(currentDate, 1), 'yyyyMMdd')
: format(addDays(currentDate, 1), 'yyyyMMdd');
this.calculateCurrentTimeBar();
this.channels$.next(this.enrichProgramData());
this.cdr.detectChanges();
: format(addDays(currentDate, 1), 'yyyyMMdd')
);
}
showDescription(program: EpgProgram): void {
@@ -320,6 +359,81 @@ export class MultiEpgContainerComponent
});
}
// Program search methods
toggleProgramSearch(): void {
this.isProgramSearchOpen.update(v => !v);
if (!this.isProgramSearchOpen()) {
this.programSearchQuery.set('');
this.programSearchResults.set([]);
}
}
onProgramSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
const query = input.value.trim();
this.programSearchQuery.set(query);
// Clear previous debounce timer
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
if (query.length < 2) {
this.programSearchResults.set([]);
this.isSearchingPrograms.set(false);
return;
}
// Debounce search by 500ms - show spinner only when search actually starts
this.searchDebounceTimer = setTimeout(async () => {
this.isSearchingPrograms.set(true);
try {
const results = await window.electron.searchEpgPrograms(query, 20);
this.programSearchResults.set(results || []);
} catch (error) {
console.error('Error searching programs:', error);
this.programSearchResults.set([]);
} finally {
this.isSearchingPrograms.set(false);
}
}, 500);
}
clearProgramSearch(): void {
this.programSearchQuery.set('');
this.programSearchResults.set([]);
}
showProgramDetails(program: any): void {
// Get channel id (DB returns channel_id, EPG uses channel)
const channelId = program.channel_id || program.channel;
// Find channel name
const channel = this.channels().find(ch => ch.id === channelId);
const channelName = channel ? this.getChannelName(channel) : null;
// Open the program details dialog with channel name
this.dialog.open(EpgItemDescriptionComponent, {
data: { ...program, channelName },
});
}
getProgramKey(program: any): string {
const channelId = program.channel_id || program.channel;
return `${channelId}|${program.start}`;
}
formatProgramTime(program: EpgProgram): string {
const start = new Date(program.start);
const stop = new Date(program.stop);
return `${format(start, 'HH:mm')} - ${format(stop, 'HH:mm')}`;
}
formatProgramDate(program: EpgProgram): string {
const start = new Date(program.start);
return format(start, 'MMM d');
}
close() {
this.overlayRef.detach();
}
+556 -1257
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -64,7 +64,6 @@
"drizzle-orm": "0.44.6",
"electron-builder-sandbox-fix": "1.1.1",
"electron-conf": "1.3.0",
"epg-parser": "0.4.0",
"fix-path": "5.0.0",
"hls.js": "1.6.13",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
@@ -73,6 +72,7 @@
"ngx-indexed-db": "21.0.0",
"ngx-skeleton-loader": "11.3.0",
"rxjs": "7.8.2",
"saxes": "6.0.0",
"semver": "7.7.3",
"uuid": "9.0.0",
"video.js": "8.23.4",
+822 -302
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -7,5 +7,6 @@ ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- better-sqlite3
- electron
- nx