// 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('' + tag + '>', 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;
}