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:
@@ -0,0 +1,78 @@
|
||||
// memory.js — "The Oracle's Memory" (upgrade #5).
|
||||
//
|
||||
// Local-first persistence: session resurrection (last channel / volume / modes),
|
||||
// per-channel rolling uptime history (feeds the guide reliability glyphs and
|
||||
// auto-skip), and pinned channels. Everything in localStorage, namespaced.
|
||||
|
||||
const NS = 'orb.';
|
||||
const K = {
|
||||
settings: NS + 'settings',
|
||||
health: NS + 'health',
|
||||
pins: NS + 'pins',
|
||||
};
|
||||
const HISTORY_DECAY = 0.85; // exponential weighting toward recent samples
|
||||
|
||||
function read(key, fallback) {
|
||||
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
|
||||
catch { return fallback; }
|
||||
}
|
||||
function write(key, val) {
|
||||
try { localStorage.setItem(key, JSON.stringify(val)); } catch { /* quota / private mode */ }
|
||||
}
|
||||
|
||||
class OracleMemory {
|
||||
constructor() {
|
||||
this.settings = read(K.settings, {
|
||||
lastChannelId: null,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
modes: { theater: false, dim: false, ambient: false },
|
||||
});
|
||||
/** @type {Object<string,{score:number,samples:number,last:string,ts:number}>} */
|
||||
this.health = read(K.health, {});
|
||||
this.pins = new Set(read(K.pins, []));
|
||||
}
|
||||
|
||||
// ---- session resurrection ----
|
||||
get lastChannelId() { return this.settings.lastChannelId; }
|
||||
rememberChannel(id) { this.settings.lastChannelId = id; this._saveSettings(); }
|
||||
rememberVolume(volume, muted) {
|
||||
this.settings.volume = volume; this.settings.muted = muted; this._saveSettings();
|
||||
}
|
||||
rememberModes(modes) { this.settings.modes = { ...modes }; this._saveSettings(); }
|
||||
_saveSettings() { write(K.settings, this.settings); }
|
||||
|
||||
// ---- uptime history (fed by health probes) ----
|
||||
/** @param {string} id @param {'live'|'degraded'|'dead'} state */
|
||||
recordHealth(id, state) {
|
||||
const ok = state === 'live' ? 1 : state === 'degraded' ? 0.5 : 0;
|
||||
const prev = this.health[id];
|
||||
const score = prev ? prev.score * HISTORY_DECAY + ok * (1 - HISTORY_DECAY) : ok;
|
||||
this.health[id] = {
|
||||
score,
|
||||
samples: (prev?.samples || 0) + 1,
|
||||
last: state,
|
||||
ts: Date.now(),
|
||||
};
|
||||
this._healthDirty = true;
|
||||
this._scheduleHealthSave();
|
||||
}
|
||||
_scheduleHealthSave() {
|
||||
if (this._healthT) return;
|
||||
this._healthT = setTimeout(() => { this._healthT = 0; write(K.health, this.health); }, 1500);
|
||||
}
|
||||
/** @returns {{score:number,samples:number,last:string,ts:number}|null} */
|
||||
reliability(id) { return this.health[id] || null; }
|
||||
isKnownDead(id) { return this.health[id]?.last === 'dead'; }
|
||||
|
||||
// ---- pins ----
|
||||
isPinned(id) { return this.pins.has(id); }
|
||||
togglePin(id) {
|
||||
this.pins.has(id) ? this.pins.delete(id) : this.pins.add(id);
|
||||
write(K.pins, [...this.pins]);
|
||||
return this.pins.has(id);
|
||||
}
|
||||
pinned() { return [...this.pins]; }
|
||||
}
|
||||
|
||||
export const memory = new OracleMemory();
|
||||
Reference in New Issue
Block a user