// xmltv-parse.js — pure XMLTV parser (no DOM, node-testable). // // XMLTV is regular enough to scan linearly for blocks. // A manual indexOf scan avoids both DOMParser (unavailable in Workers) and // regex backtracking on 50MB+ feeds. Exported standalone so it can be unit // tested in node and reused by the EPG worker. /** Decode the handful of XML entities XMLTV actually uses. */ function decodeEntities(s) { if (s.indexOf('&') === -1) return s; return s.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (m, e) => { switch (e) { case 'amp': return '&'; case 'lt': return '<'; case 'gt': return '>'; case 'quot': return '"'; case 'apos': return "'"; default: if (e[0] === '#') { const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(code) ? String.fromCodePoint(code) : m; } return m; } }); } /** XMLTV time: "20260615013000 +0000" → epoch ms (0 if unparseable). */ export function parseXmltvTime(s) { if (!s) return 0; const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim()); if (!m) return 0; const [, Y, Mo, D, H, Mi, S, tz] = m; let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0)); if (tz) { const sign = tz[0] === '-' ? -1 : 1; ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000; } return ms; } /** Read attr value from an opening-tag string, e.g. attr(`channel`, '… within `block`. */ function inner(tag, block) { const open = '<' + tag; let i = block.indexOf(open); if (i === -1) return ''; const gt = block.indexOf('>', i); if (gt === -1) return ''; if (block[gt - 1] === '/') return ''; // self-closing const close = block.indexOf('', gt); if (close === -1) return ''; return decodeEntities(block.slice(gt + 1, close)).trim(); } /** * Parse an XMLTV document into program records. * @param {string} text * @param {number} [cap] optional max programmes (DoS guard) * @returns {{channel:string,start:number,stop:number,title:string,desc:string}[]} */ export function parseXmltv(text, cap = 2_000_000) { const out = []; let pos = 0; const OPEN = '', start); if (tagEnd === -1) break; const openTag = text.slice(start, tagEnd + 1); // self-closing (rare, no title) — skip the body search let blockEnd, block; if (openTag.endsWith('/>')) { block = openTag; blockEnd = tagEnd + 1; } else { const close = text.indexOf(CLOSE, tagEnd); if (close === -1) break; block = text.slice(start, close + CLOSE.length); blockEnd = close + CLOSE.length; } pos = blockEnd; const channel = attr('channel', openTag); const startMs = parseXmltvTime(attr('start', openTag)); const stopMs = parseXmltvTime(attr('stop', openTag)); if (!channel || !startMs || !stopMs) continue; out.push({ channel, start: startMs, stop: stopMs, title: inner('title', block) || 'Program', desc: inner('desc', block), }); } return out; }