/** * EPG (XMLTV) Parser (Streaming) * Parses XMLTV format EPG data and extracts channel/programme information using streaming XML parser */ const sax = require('sax'); const zlib = require('zlib'); const { Readable } = require('stream'); /** * Parse XMLTV date format (YYYYMMDDHHmmss +ZZZZ) * @param {string} dateStr - XMLTV format date string * @returns {Date} */ function parseXmltvDate(dateStr) { if (!dateStr) return null; // Format: 20231225120000 +0000 const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/); if (!match) { // Try ISO format fallback return new Date(dateStr); } const [, year, month, day, hour, minute, second, tz] = match; let isoStr = `${year}-${month}-${day}T${hour}:${minute}:${second}`; if (tz) { const tzHours = tz.substring(0, 3); const tzMins = tz.substring(3); isoStr += `${tzHours}:${tzMins}`; } else { isoStr += 'Z'; } return new Date(isoStr); } /** * Parse XMLTV content (Stream or String) * @param {Readable|string} input - XMLTV content as Stream or String * @returns {Promise<{ channels: Array, programmes: Array }>} */ function parse(input) { return new Promise((resolve, reject) => { const channels = []; const programmes = []; const saxStream = sax.createStream(true, { trim: true, normalize: true }); // strict mode let currentTag = null; let currentObject = null; let textBuffer = ''; saxStream.on('error', function (e) { // clear the error this._parser.error = null; this._parser.resume(); console.warn('XML Parse Warning:', e.message); }); saxStream.on('opentag', function (node) { currentTag = node.name; const attr = node.attributes; if (currentTag === 'channel') { currentObject = { id: attr.id, name: null, // Will be populated by display-name tag icon: null, url: null }; } else if (currentTag === 'programme') { currentObject = { channelId: attr.channel, start: parseXmltvDate(attr.start), stop: parseXmltvDate(attr.stop), title: null, subtitle: null, description: null, category: [], icon: null, date: null, episodeNum: null }; } else if (currentTag === 'icon') { if (currentObject) { currentObject.icon = attr.src; } } textBuffer = ''; }); saxStream.on('text', function (text) { textBuffer += text; }); saxStream.on('cdata', function (text) { textBuffer += text; }); saxStream.on('closetag', function (tagName) { if (tagName === 'channel') { if (currentObject) channels.push(currentObject); currentObject = null; } else if (tagName === 'programme') { if (currentObject) programmes.push(currentObject); currentObject = null; } else if (currentObject) { // Handle properties within objects switch (tagName) { case 'display-name': // channel name if (!currentObject.name) currentObject.name = textBuffer; break; case 'url': // channel url currentObject.url = textBuffer; break; case 'title': currentObject.title = textBuffer; break; case 'sub-title': currentObject.subtitle = textBuffer; break; case 'desc': currentObject.description = textBuffer; break; case 'category': if (textBuffer) currentObject.category.push(textBuffer); break; case 'date': currentObject.date = textBuffer; break; case 'episode-num': // Prefer system "xmltv_ns" or just take text // Complex episode parsing logic can go here if needed currentObject.episodeNum = textBuffer; break; } } }); saxStream.on('end', function () { resolve({ channels, programmes }); }); // Handle input type if (typeof input === 'string') { const inputStream = Readable.from([input]); inputStream.pipe(saxStream); } else { input.pipe(saxStream); } }); } /** * Get programmes for a specific channel */ function getProgrammesForChannel(programmes, channelId) { return programmes.filter(p => p.channelId === channelId); } /** * Get current and upcoming programmes for a channel */ function getCurrentAndUpcoming(programmes, channelId, count = 5) { const now = new Date(); const channelProgrammes = getProgrammesForChannel(programmes, channelId); // Sort by start time channelProgrammes.sort((a, b) => a.start - b.start); // Find current and upcoming const current = channelProgrammes.find(p => p.start <= now && p.stop > now); const upcoming = channelProgrammes .filter(p => p.start > now) .slice(0, count); return { current, upcoming }; } /** * Fetch and parse XMLTV from URL */ async function fetchAndParse(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`); } let stream; if (response.body && typeof response.body.pipe === 'function') { stream = response.body; } else if (response.body) { stream = Readable.fromWeb(response.body); } else { stream = Readable.from([]); } // Check for GZIP // Note: We can't easily check for magic bytes on a stream without buffering. // We'll rely on response headers or file extension mostly, or try to peek. // For now, let's assume if content-encoding is gzip OR url ends in .gz // However, undici/fetch usually handles 'Content-Encoding: gzip' automatically transparently. // We only need to manually gunzip if the server serves it as application/octet-stream but it's actually gzipped, // or if it's a .gz file download. // A robust way for streams is checking magic bytes, but that requires peeking. // Simplified approach: try to pipe through gunzip if the URL indicates it. const isGzipped = url.endsWith('.gz') || (response.headers.get('content-type') || '').includes('gzip'); if (isGzipped) { const gunzip = zlib.createGunzip(); stream.pipe(gunzip); return parse(gunzip); } // In the previous version we read magic bytes. // To support that with streams we'd need a peek stream. // For now let's trust the transparent decompression of fetch or the URL. return parse(stream); } module.exports = { parse, parseXmltvDate, fetchAndParse, getProgrammesForChannel, getCurrentAndUpcoming };