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>
This commit is contained in:
2026-06-15 11:25:55 -04:00
parent c58fa9591b
commit a1e3e6852f
23 changed files with 789 additions and 70 deletions
+34 -35
View File
@@ -100,6 +100,7 @@ export class IptvOrgProvider extends SourceProvider {
language: clip((c.languages && c.languages[0]) || ''),
kind: 'live',
streamUrls: playable,
probeUrls: urls, // raw https upstreams for the /probe endpoint
guideId: c.id,
});
}
@@ -124,10 +125,9 @@ export class IptvOrgProvider extends SourceProvider {
}
/**
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[].
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is
* simply blank until an EPG source is wired in Phase 1.5).
* EPG. Returns cached programs (populated by loadEpgXml via the off-thread
* worker). Empty until an XMLTV feed is loaded — the guide still renders
* channel rows; now/next is simply blank.
*/
async getPrograms(channelId, window) {
const cached = this._epgCache.get(channelId);
@@ -135,42 +135,41 @@ export class IptvOrgProvider extends SourceProvider {
return cached.filter((p) => p.stop > window.from && p.start < window.to);
}
/** Parse a raw XMLTV string and populate the EPG cache (safe: DOMParser). */
setEpgXml(xmlString) {
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
if (doc.querySelector('parsererror')) return;
// map xmltv channel id -> our namespaced id
/**
* Parse an XMLTV feed OFF the main thread and populate the EPG cache.
* @param {string} xmlString
* @returns {Promise<number>} number of programmes indexed
*/
async loadEpgXml(xmlString) {
const programs = await this._parseInWorker(xmlString);
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
for (const prog of doc.getElementsByTagName('programme')) {
const xmlCh = prog.getAttribute('channel');
const cid = idMap.get(xmlCh);
let n = 0;
for (const p of programs) {
const cid = idMap.get(p.channel);
if (!cid) continue;
const start = parseXmltvTime(prog.getAttribute('start'));
const stop = parseXmltvTime(prog.getAttribute('stop'));
if (!start || !stop) continue;
const titleEl = prog.getElementsByTagName('title')[0];
const descEl = prog.getElementsByTagName('desc')[0];
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
arr.push({
channelId: cid,
title: clip(titleEl ? titleEl.textContent : 'Program'),
desc: clip(descEl ? descEl.textContent : ''),
start, stop,
});
arr.push({ channelId: cid, title: clip(p.title), desc: clip(p.desc), start: p.start, stop: p.stop });
n++;
}
return n;
}
}
/** XMLTV time: "20260615013000 +0000" → epoch ms. */
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;
_parseInWorker(xmlString) {
return new Promise((resolve, reject) => {
let worker;
try {
worker = new Worker(new URL('../epg-worker.js', import.meta.url), { type: 'module' });
} catch (e) {
return reject(e);
}
const id = Math.random().toString(36).slice(2);
worker.onmessage = (e) => {
if (e.data?.id !== id) return;
worker.terminate();
e.data.ok ? resolve(e.data.programs) : reject(new Error(e.data.error));
};
worker.onerror = (e) => { worker.terminate(); reject(e); };
worker.postMessage({ id, xml: xmlString });
});
}
return ms;
}