a1e3e6852f
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>
113 lines
3.6 KiB
JavaScript
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;
|
|
}
|