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:
+34
-35
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user