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
+16 -4
View File
@@ -11,11 +11,14 @@ export class Zapper {
* @param {object} els
* @param {import('./player.js').Player} player
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
* @param {{memory?:any, health?:import('./health.js').Health}} [opts]
*/
constructor(els, player, agg) {
constructor(els, player, agg, opts = {}) {
this.els = els;
this.player = player;
this.agg = agg;
this.memory = opts.memory || null;
this.health = opts.health || null;
this.current = null; // current Channel
this.previous = null; // for last-channel toggle
this._entry = '';
@@ -38,9 +41,16 @@ export class Zapper {
async tuneToIndexOffset(delta) {
const list = this.agg.channels;
if (!list.length) return;
let idx = this.current ? list.findIndex((c) => c.id === this.current.id) : -1;
idx = (idx + delta + list.length) % list.length;
return this.tune(list[idx]);
const start = this.current ? list.findIndex((c) => c.id === this.current.id) : -1;
let idx = start;
// Step in the requested direction, skipping channels the Oracle knows are dead.
for (let n = 0; n < list.length; n++) {
idx = (idx + delta + list.length) % list.length;
const ch = list[idx];
if (!this.health || !this.health.isKnownDead(ch.id)) return this.tune(ch);
}
// everything known-dead → just tune the immediate neighbour
return this.tune(list[(start + delta + list.length) % list.length]);
}
async tuneLast() { if (this.previous) return this.tune(this.previous); }
@@ -50,6 +60,8 @@ export class Zapper {
if (!ch) return;
if (this.current && this.current.id !== ch.id) this.previous = this.current;
this.current = ch;
if (this.memory) this.memory.rememberChannel(ch.id);
if (this.health) this.health.probe(ch); // refresh this channel's reliability
this._showStatic();
this._safetyClear = setTimeout(() => this._clearStatic(), STATIC_MAX);