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();
+17
View File
@@ -0,0 +1,17 @@
// epg-worker.js — module worker that parses XMLTV off the main thread.
//
// Keeps the guide responsive when a 50MB+ EPG feed lands: the expensive parse
// happens here, the main thread only receives the finished program array.
// Launched as `new Worker('./epg-worker.js', { type: 'module' })`.
import { parseXmltv } from './lib/xmltv-parse.js';
self.onmessage = (e) => {
const { id, xml } = e.data || {};
try {
const programs = parseXmltv(xml);
self.postMessage({ id, ok: true, programs });
} catch (err) {
self.postMessage({ id, ok: false, error: String(err) });
}
};
+15
View File
@@ -1,6 +1,8 @@
// guide.js — "The Almanac": 1994-style scrolling EPG overlay.
// Virtualized rows (fixed height) so thousands of channels stay smooth.
import { memory } from './memory.js';
const ROW_H = 46;
const CH_COL = 150;
const HOUR_W = 180; // px per hour
@@ -126,6 +128,19 @@ export class Guide {
const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠';
sk.title = 'no signal'; cell.appendChild(sk);
}
// pin (Oracle's Memory) — click the rune to inscribe/banish a channel
const pin = document.createElement('button');
pin.className = 'pin' + (memory.isPinned(ch.id) ? ' on' : '');
pin.textContent = memory.isPinned(ch.id) ? '★' : '☆';
pin.setAttribute('aria-label', memory.isPinned(ch.id) ? 'Unpin channel' : 'Pin channel');
pin.addEventListener('click', (e) => {
e.stopPropagation();
const on = memory.togglePin(ch.id);
pin.classList.toggle('on', on);
pin.textContent = on ? '★' : '☆';
pin.setAttribute('aria-label', on ? 'Unpin channel' : 'Pin channel');
});
cell.appendChild(pin);
cell.addEventListener('click', () => { this.onTune(ch); this.hide(); });
const progs = document.createElement('div');
+118
View File
@@ -0,0 +1,118 @@
// health.js — stream health prober + scoring (upgrade #4).
//
// Lazy + bounded: we probe the current channel and whatever guide rows are
// visible, never the whole catalog (respects upstreams, scales to ~39k). Results
// are cached in-memory (short TTL) and folded into the Oracle's Memory uptime
// history. Uses the relay's /probe endpoint when available; falls back to the
// provider's own healthCheck on a plain static server.
import { memory } from './memory.js';
const TTL_MS = 5 * 60 * 1000; // re-probe a channel at most this often
const SLOW_MS = 4000; // 2xx but slower than this → "degraded"
const POOL = 6; // max concurrent probes
/** @typedef {'live'|'degraded'|'dead'} HealthState */
export class Health {
/**
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
* @param {boolean} hasRelay is the same-origin /probe endpoint available?
*/
constructor(agg, hasRelay) {
this.agg = agg;
this.hasRelay = hasRelay;
this._cache = new Map(); // id -> { state, ts }
this._inflight = new Map(); // id -> Promise<HealthState>
this._queue = [];
this._active = 0;
/** @type {(id:string, state:HealthState)=>void} */
this.onResult = () => {};
}
/** Cached state without triggering a probe (for instant render / auto-skip). */
cached(id) {
const c = this._cache.get(id);
if (c) return c.state;
return memory.reliability(id)?.last ?? null;
}
isKnownDead(id) { return this.cached(id) === 'dead'; }
/** Probe one channel (deduped, TTL-cached). */
async probe(channel) {
const id = channel.id;
const c = this._cache.get(id);
if (c && Date.now() - c.ts < TTL_MS) return c.state;
if (this._inflight.has(id)) return this._inflight.get(id);
const p = this._enqueue(channel).then((state) => {
this._cache.set(id, { state, ts: Date.now() });
this._inflight.delete(id);
memory.recordHealth(id, state);
this.onResult(id, state);
return state;
});
this._inflight.set(id, p);
return p;
}
/** Probe many with bounded concurrency (fire-and-forget UI updates). */
probeMany(channels) {
for (const ch of channels) {
if (!ch) continue;
const c = this._cache.get(ch.id);
if (c && Date.now() - c.ts < TTL_MS) continue;
this.probe(ch);
}
}
// ---- internal pool ----
_enqueue(channel) {
return new Promise((resolve) => {
this._queue.push({ channel, resolve });
this._pump();
});
}
_pump() {
while (this._active < POOL && this._queue.length) {
const { channel, resolve } = this._queue.shift();
this._active++;
this._runOne(channel).then((state) => {
this._active--;
resolve(state);
this._pump();
});
}
}
/** @returns {Promise<HealthState>} */
async _runOne(channel) {
const raw = channel.probeUrls && channel.probeUrls[0];
if (this.hasRelay && raw) {
try {
const t0 = performance.now();
const r = await fetch('/probe?url=' + encodeURIComponent(raw), {
credentials: 'omit', referrerPolicy: 'no-referrer',
});
if (r.ok) {
const j = await r.json();
if (!j.ok) return 'dead';
const ms = j.latency_ms ?? (performance.now() - t0);
return ms >= SLOW_MS ? 'degraded' : 'live';
}
// 4xx/5xx from /probe means policy block or upstream fail
return 'dead';
} catch {
return 'dead';
}
}
// fallback: provider-native check (mock / static server)
const owner = this.agg.providers.get(channel.source);
if (owner?.healthCheck) {
try { const r = await owner.healthCheck(channel.id); return r.ok ? 'live' : 'dead'; }
catch { return 'dead'; }
}
return 'live';
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+50
View File
@@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="orb" cx="42%" cy="38%" r="70%">
<stop offset="0%" stop-color="#ff6a3d"/>
<stop offset="32%" stop-color="#e10000"/>
<stop offset="70%" stop-color="#6e0f0f"/>
<stop offset="100%" stop-color="#1a0608"/>
</radialGradient>
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ff2b2b" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ff2b2b" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- black field -->
<rect width="512" height="512" fill="#050102"/>
<!-- ambient glow -->
<circle cx="256" cy="248" r="220" fill="url(#glow)"/>
<!-- the orb -->
<circle cx="256" cy="248" r="168" fill="url(#orb)" stroke="#ff2b2b" stroke-width="3"/>
<!-- scanlines clipped to the orb -->
<clipPath id="c"><circle cx="256" cy="248" r="168"/></clipPath>
<g clip-path="url(#c)" opacity="0.18">
<g fill="#000">
<rect x="88" y="92" width="336" height="3"/>
<rect x="88" y="116" width="336" height="3"/>
<rect x="88" y="140" width="336" height="3"/>
<rect x="88" y="164" width="336" height="3"/>
<rect x="88" y="188" width="336" height="3"/>
<rect x="88" y="212" width="336" height="3"/>
<rect x="88" y="236" width="336" height="3"/>
<rect x="88" y="260" width="336" height="3"/>
<rect x="88" y="284" width="336" height="3"/>
<rect x="88" y="308" width="336" height="3"/>
<rect x="88" y="332" width="336" height="3"/>
<rect x="88" y="356" width="336" height="3"/>
<rect x="88" y="380" width="336" height="3"/>
</g>
</g>
<!-- highlight -->
<ellipse cx="206" cy="190" rx="46" ry="30" fill="#ffd9d4" opacity="0.35"/>
<!-- wizard stand -->
<path d="M176 410 L336 410 L304 372 L208 372 Z" fill="#1a0608" stroke="#6e0f0f" stroke-width="3"/>
<rect x="150" y="410" width="212" height="20" rx="6" fill="#1a0608" stroke="#6e0f0f" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+6
View File
@@ -17,13 +17,19 @@
media-src 'self' blob: https:;
connect-src 'self' https:;
font-src 'self';
worker-src 'self';
manifest-src 'self';
object-src 'none';
base-uri 'none';
frame-src 'none';
form-action 'none'">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#e10000">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="icon" href="icons/icon-192.png" type="image/png">
<link rel="stylesheet" href="styles.css">
</head>
<body>
+112
View File
@@ -0,0 +1,112 @@
// xmltv-parse.js — pure XMLTV parser (no DOM, node-testable).
//
// XMLTV is regular enough to scan linearly for <programme>…</programme> blocks.
// A manual indexOf scan avoids both DOMParser (unavailable in Workers) and
// regex backtracking on 50MB+ feeds. Exported standalone so it can be unit
// tested in node and reused by the EPG worker.
/** Decode the handful of XML entities XMLTV actually uses. */
function decodeEntities(s) {
if (s.indexOf('&') === -1) return s;
return s.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (m, e) => {
switch (e) {
case 'amp': return '&';
case 'lt': return '<';
case 'gt': return '>';
case 'quot': return '"';
case 'apos': return "'";
default:
if (e[0] === '#') {
const code = e[1] === 'x' || e[1] === 'X'
? parseInt(e.slice(2), 16)
: parseInt(e.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
}
return m;
}
});
}
/** XMLTV time: "20260615013000 +0000" → epoch ms (0 if unparseable). */
export 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;
}
return ms;
}
/** Read attr value from an opening-tag string, e.g. attr(`channel`, '<programme channel="x" …'). */
function attr(name, tag) {
const needle = name + '="';
let i = tag.indexOf(needle);
if (i === -1) return '';
i += needle.length;
const end = tag.indexOf('"', i);
return end === -1 ? '' : decodeEntities(tag.slice(i, end));
}
/** Inner text of the first <tag>…</tag> within `block`. */
function inner(tag, block) {
const open = '<' + tag;
let i = block.indexOf(open);
if (i === -1) return '';
const gt = block.indexOf('>', i);
if (gt === -1) return '';
if (block[gt - 1] === '/') return ''; // self-closing
const close = block.indexOf('</' + tag + '>', gt);
if (close === -1) return '';
return decodeEntities(block.slice(gt + 1, close)).trim();
}
/**
* Parse an XMLTV document into program records.
* @param {string} text
* @param {number} [cap] optional max programmes (DoS guard)
* @returns {{channel:string,start:number,stop:number,title:string,desc:string}[]}
*/
export function parseXmltv(text, cap = 2_000_000) {
const out = [];
let pos = 0;
const OPEN = '<programme';
const CLOSE = '</programme>';
while (out.length < cap) {
const start = text.indexOf(OPEN, pos);
if (start === -1) break;
const tagEnd = text.indexOf('>', start);
if (tagEnd === -1) break;
const openTag = text.slice(start, tagEnd + 1);
// self-closing <programme .../> (rare, no title) — skip the body search
let blockEnd, block;
if (openTag.endsWith('/>')) {
block = openTag;
blockEnd = tagEnd + 1;
} else {
const close = text.indexOf(CLOSE, tagEnd);
if (close === -1) break;
block = text.slice(start, close + CLOSE.length);
blockEnd = close + CLOSE.length;
}
pos = blockEnd;
const channel = attr('channel', openTag);
const startMs = parseXmltvTime(attr('start', openTag));
const stopMs = parseXmltvTime(attr('stop', openTag));
if (!channel || !startMs || !stopMs) continue;
out.push({
channel,
start: startMs,
stop: stopMs,
title: inner('title', block) || 'Program',
desc: inner('desc', block),
});
}
return out;
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "The Pondering Orb",
"short_name": "Pondering Orb",
"description": "A retro CRT-themed web TV — channel-surf live streams through a security-hardened relay.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#050102",
"theme_color": "#e10000",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+78
View File
@@ -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();
+3
View File
@@ -1,5 +1,7 @@
// player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling.
import { memory } from './memory.js';
const HLS_CONFIG = {
enableWorker: true,
lowLatencyMode: false,
@@ -171,6 +173,7 @@ export class Player {
const m = this.video.muted || this.video.volume === 0;
this.els.btnMute.textContent = m ? '🔇' : '🔊';
this.els.btnMute.setAttribute('aria-pressed', String(m));
memory.rememberVolume(this.video.volume, this.video.muted);
}
setVolume(delta) {
this.video.muted = false;
+34 -35
View File
@@ -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;
}
+1
View File
@@ -91,6 +91,7 @@ export class MockProvider extends SourceProvider {
language: 'eng',
kind: 'live',
streamUrls: [TEST_STREAMS[c.stream]],
probeUrls: [TEST_STREAMS[c.stream]],
guideId: `mock:${c.n}`,
}));
}
+2 -1
View File
@@ -16,7 +16,8 @@
* @property {string} [country]
* @property {string} [language]
* @property {'live'|'vod'} kind Always 'live' for Phase 1; self-hosted "channels" are also 'live'.
* @property {string[]} streamUrls One or more upstream HLS URLs (primary + fallbacks).
* @property {string[]} streamUrls Playable HLS URLs (relay-wrapped where needed), primary + fallbacks.
* @property {string[]} [probeUrls] Raw upstream https URLs for the health prober (pre-relay).
* @property {string} [guideId] Key used to match EPG/program data.
*/
+6
View File
@@ -158,6 +158,12 @@ img{display:block}
.guide-chcell .num{color:var(--red);font-weight:bold;font-variant-numeric:tabular-nums;min-width:24px}
.guide-chcell .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
.guide-chcell .pin{background:none;border:0;cursor:pointer;font-size:14px;line-height:1;padding:0 2px;
color:var(--ink-dim);margin-left:6px;flex:0 0 auto}
.guide-chcell .skull + .pin{margin-left:6px}
.guide-chcell .pin:not(.skull + .pin){margin-left:auto}
.guide-chcell .pin.on{color:var(--red);text-shadow:0 0 8px var(--red-glow)}
.guide-chcell .pin:hover{color:#fff}
.guide-progs{position:relative;flex:1}
.guide-prog{position:absolute;top:3px;bottom:3px;background:var(--panel-2);border:1px solid var(--line);
border-radius:4px;padding:5px 8px;font-size:12px;overflow:hidden;cursor:pointer;white-space:nowrap;text-overflow:ellipsis}
+86
View File
@@ -0,0 +1,86 @@
// sw.js — service worker (upgrade #7).
//
// Two strategies:
// - app shell (html/css/js/hls.js/icons): cache-first → instant repeat loads, offline guide UI
// - iptv-org JSON proxied via the relay (…/relay?url=…/*.json): stale-while-revalidate
// → instant paint from cache, silent refresh in the background
//
// Live stream manifests + segments (the rest of /relay) and /probe, /metrics are
// NEVER cached — the relay marks them no-store and we bypass them here, so the SW
// is the sole freshness authority for metadata only.
const SHELL = 'orb-shell-v1';
const DATA = 'orb-data-v1';
const SHELL_ASSETS = [
'/', '/index.html', '/styles.css',
'/app.js', '/player.js', '/zapper.js', '/guide.js', '/ambient.js',
'/lexicon.js', '/memory.js', '/health.js', '/epg-worker.js',
'/lib/hls.min.js', '/lib/xmltv-parse.js',
'/providers/source-provider.js', '/providers/mock.js', '/providers/iptv-org.js',
'/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png',
];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(SHELL).then((c) => c.addAll(SHELL_ASSETS)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => ![SHELL, DATA].includes(k)).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // same-origin only
if (url.pathname === '/probe' || url.pathname === '/metrics') return;
if (url.pathname === '/relay') {
const upstream = url.searchParams.get('url') || '';
// Only the JSON metadata is cacheable; stream manifests/segments are not.
if (/\.json(\?|$)/i.test(upstream)) {
e.respondWith(staleWhileRevalidate(req, DATA));
}
return; // streams/segments → straight to the relay, uncached
}
// App shell / static assets → cache-first.
if (SHELL_ASSETS.includes(url.pathname)
|| url.pathname.startsWith('/icons/')
|| url.pathname.startsWith('/providers/')
|| url.pathname.startsWith('/lib/')) {
e.respondWith(cacheFirst(req, SHELL));
}
});
async function cacheFirst(req, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
if (hit) return hit;
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
if (req.mode === 'navigate') {
const idx = await cache.match('/index.html');
if (idx) return idx;
}
return new Response('offline', { status: 503, statusText: 'offline' });
}
}
async function staleWhileRevalidate(req, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
const network = fetch(req)
.then((res) => { if (res.ok) cache.put(req, res.clone()); return res; })
.catch(() => hit || new Response('offline', { status: 503 }));
return hit || network;
}
+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);