mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
Merge pull request #706 from 4gray/refactor/streaming-sax-epg-parser
refactor/streaming-sax-epg-parser
This commit is contained in:
34 files changed
+5027
-1956
No files matched your search
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+59
-38
@@ -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>
|
||||
+297
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-6
@@ -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();
|
||||
}
|
||||
|
||||
Generated
+556
-1257
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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",
|
||||
|
||||
Generated
+822
-302
File diff suppressed because it is too large.
Load diff
@@ -7,5 +7,6 @@ ignoredBuiltDependencies:
|
||||
- unrs-resolver
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- electron
|
||||
- nx
|
||||
Reference in new issue
Block a user