Files
pondering-orb/web/lib/xmltv-parse.js
T
Diablo_Rain a1e3e6852f feat: Phase 1.5 — health prober, Oracle's Memory, off-thread EPG, PWA
Resilience + scale pass on top of the v1 core.

Stream health prober:
- New SSRF-validated /probe endpoint (HEAD -> ranged-GET fallback, 6s timeout,
  5-min TTL cache, concurrency-capped, no-store) + orb_probe_total metrics
- Front-end health.js: lazy bounded-concurrency probing (current + sampled
  channels, not the whole catalog), LIVE/DEGRADED/DEAD scoring, guide skull
  glyph, instant no-signal on zap to a known-dead channel

Oracle's Memory (memory.js):
- Session resurrection (last channel, volume, mute, theater/dim/ambient)
- Exponential-decay per-channel uptime history; pinning; dead-channel auto-skip

Off-thread XMLTV:
- lib/xmltv-parse.js (pure, hand-rolled scanner, no DOMParser/regex backtracking)
  run in epg-worker.js module worker; wired into the iptv-org provider
- Unit-tested in node: entity decode, TZ offsets, 20k programmes off-thread

Service worker / PWA:
- manifest.webmanifest + orb icons; sw.js (cache-first shell + hls.js,
  stale-while-revalidate for JSON-over-relay, never caches streams)
- CSP gains worker-src + manifest-src; relay sends no-store on /relay + /probe
  so the SW is the sole metadata freshness authority; webmanifest MIME fix

Docs: README de-emojified (title only) and reframed as a peer parallel build.

Verified: SSRF probes blocked, legit probe + cache, new headers present,
PWA assets served, parser tests 8/8, JS syntax clean, zero Rust warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:25:55 -04:00

113 lines
3.6 KiB
JavaScript

// xmltv-parse.js — pure XMLTV parser (no DOM, node-testable).
//
// XMLTV is regular enough to scan linearly for <programme>…</programme> 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`, '<programme channel="x" …'). */
function attr(name, tag) {
const needle = name + '="';
let i = tag.indexOf(needle);
if (i === -1) return '';
i += needle.length;
const end = tag.indexOf('"', i);
return end === -1 ? '' : decodeEntities(tag.slice(i, end));
}
/** Inner text of the first <tag>…</tag> 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 = '<programme';
const CLOSE = '</programme>';
while (out.length < cap) {
const start = text.indexOf(OPEN, pos);
if (start === -1) break;
const tagEnd = text.indexOf('>', start);
if (tagEnd === -1) break;
const openTag = text.slice(start, tagEnd + 1);
// self-closing <programme .../> (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;
}