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:
+32
-12
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user