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
+32 -12
View File
@@ -7,6 +7,8 @@ import { Player } from './player.js';
import { Zapper } from './zapper.js';
import { Guide } from './guide.js';
import { Ambient } from './ambient.js';
import { Health } from './health.js';
import { memory } from './memory.js';
import { applyLexicon, setMundane, isMundane } from './lexicon.js';
const $ = (id) => document.getElementById(id);
@@ -44,6 +46,7 @@ function setMode(name, on) {
modes[name] = on;
els.root.setAttribute(`data-mode-${name}`, String(on));
if (name === 'ambient') on ? ambient.start() : ambient.stop();
memory.rememberModes(modes);
}
function toggleMode(name) { setMode(name, !modes[name]); }
function collapseModes() {
@@ -147,12 +150,27 @@ async function boot() {
els.mundane.addEventListener('change', () => setMundane(els.mundane.checked));
els.helpClose.addEventListener('click', () => toggleHelp(false));
const { agg, label } = pickProviders();
const hasRelay = !!document.querySelector('meta[name="orb-relay"]');
ambient = new Ambient(els.video, els.ambientCanvas);
player = new Player(els, (msg) => console.warn('[orb] stream fatal:', msg));
const { agg, label } = pickProviders();
zapper = new Zapper(els, player, agg);
const health = new Health(agg, hasRelay);
zapper = new Zapper(els, player, agg, { memory, health });
guide = new Guide(els, agg, (ch) => zapper.tune(ch));
// Health results → guide reliability glyphs (and the Oracle's history).
health.onResult = (id, state) => guide.setHealth(id, state !== 'dead');
// The Oracle's Memory: restore last session.
els.vol.value = Math.round((memory.settings.volume ?? 1) * 100);
player.video.volume = memory.settings.volume ?? 1;
player.video.muted = !!memory.settings.muted;
player._syncMute?.();
for (const m of ['theater', 'dim', 'ambient']) {
if (memory.settings.modes?.[m]) setMode(m, true);
}
els.btnChup.addEventListener('click', () => zapper.tuneToIndexOffset(-1));
els.btnChdn.addEventListener('click', () => zapper.tuneToIndexOffset(+1));
els.btnGuide.addEventListener('click', () => guide.toggle());
@@ -163,22 +181,24 @@ async function boot() {
try {
const channels = await agg.listAllChannels();
console.info(`[orb] ${label} provider: ${channels.length} channels`);
if (channels.length) await zapper.tune(channels[0]);
runHealthPass(agg, guide);
if (channels.length) {
// resurrect last channel if it still exists, else first
const last = memory.lastChannelId && agg.getChannel(memory.lastChannelId);
await zapper.tune(last || channels[0]);
}
// probe a bounded sample so the guide gets reliability glyphs without a stampede
health.probeMany(channels.slice(0, 60));
} catch (e) {
console.error('[orb] boot failed:', e);
player._showNoSignal?.();
}
}
// background health pass → guide skull glyphs (Oracle's Memory seed)
async function runHealthPass(agg, guide) {
for (const ch of agg.channels) {
const owner = agg.providers.get(ch.source);
if (!owner?.healthCheck) continue;
try { const r = await owner.healthCheck(ch.id); guide.setHealth(ch.id, r.ok); }
catch { guide.setHealth(ch.id, false); }
}
// Register the service worker (PWA / offline shell). Best-effort; never blocks boot.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((e) => console.warn('[orb] sw:', e));
});
}
boot();