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
+11 -11
View File
@@ -23,18 +23,18 @@ It is built around one idea: **a channel is "a named thing on a guide that resol
> **"Purple team"** here is a security *identity* (red offense + blue defense), not a color. The palette stays red/black; the wizard theme is naming and lore on top of clear, accessible function. > **"Purple team"** here is a security *identity* (red offense + blue defense), not a color. The palette stays red/black; the wizard theme is naming and lore on top of clear, accessible function.
This is a side project built side-by-side with a mentor's reference design (a NAS + ErsatzTV + Raspberry Pi feeding a physical CRT). Same premise — *your media as 24/7 TV channels* — different architecture: portable, browser-based, and security-first. It is developed as a parallel build to a peer project that takes the same premise — *your media as 24/7 TV channels* through a different architecture (a NAS + ErsatzTV + Raspberry Pi feeding a physical CRT). This implementation is portable, browser-based, and security-first.
## Features ## Features
- 📺 **Authentic CRT presentation** — phosphor glow, scanlines, screen curvature, vignette breathing (all CSS, no WebGL). - **Authentic CRT presentation** — phosphor glow, scanlines, screen curvature, vignette breathing (all CSS, no WebGL).
- 🔢 **Cable-box channel zapping** — number entry with debounce + 7-segment OSD, channel up/down, last-channel toggle, auto-fading now-playing banner. - **Cable-box channel zapping** — number entry with debounce + 7-segment OSD, channel up/down, last-channel toggle, auto-fading now-playing banner.
- 📡 **"Scrying Static" transitions** — procedural channel-change noise that clears the moment the new stream buffers (reduced-motion aware). - **"Scrying Static" transitions** — procedural channel-change noise that clears the moment the new stream buffers (reduced-motion aware).
- 🗓️ **The Almanac** — a virtualized, 1994-style scrolling TV guide with now/next, a live current-time playhead, and keyboard navigation. Scales to tens of thousands of channels. - **The Almanac** — a virtualized, 1994-style scrolling TV guide with now/next, a live current-time playhead, and keyboard navigation. Scales to tens of thousands of channels.
- 🎬 **Immersion stack** — Theater, Dim, and Ambient-backlight modes (à la YouTube), composable and keyboard-driven. - **Immersion stack** — Theater, Dim, and Ambient-backlight modes (à la YouTube), composable and keyboard-driven.
- 🌫️ **Graceful dead-stream handling** — "the orb clouds over" no-signal card, automatic fallback URLs, and auto-skip. - **Graceful dead-stream handling** — "the orb clouds over" no-signal card, automatic fallback URLs, and auto-skip.
- 🧙 **The Lexicon** — optional in-world vocabulary (guide → *The Almanac*, search → *Scry*) with a one-click "mundane mode"; screen-reader labels always stay plain-English. - **The Lexicon** — optional in-world vocabulary (guide → *The Almanac*, search → *Scry*) with a one-click "mundane mode"; screen-reader labels always stay plain-English.
- **Accessible by default** — full keyboard map + help overlay, ARIA roles/live regions, visible focus rings, and a complete `prefers-reduced-motion` kill-switch. - **Accessible by default** — full keyboard map + help overlay, ARIA roles/live regions, visible focus rings, and a complete `prefers-reduced-motion` kill-switch.
## Security posture ## Security posture
@@ -150,8 +150,8 @@ This project does **not** host, store, or transmit any media. It indexes **publi
## Acknowledgments ## Acknowledgments
- Inspired by a mentor's NASCRT "your movies as 24/7 channels" build. - Developed as a parallel exploration alongside a peer's NAS-to-CRT "media as 24/7 channels" project.
- Channel data and the public stream index come from the excellent [iptv-org](https://github.com/iptv-org/iptv) community project. - Channel data and the public stream index come from the [iptv-org](https://github.com/iptv-org/iptv) community project.
## License ## License
+90 -7
View File
@@ -7,12 +7,14 @@
mod relay; mod relay;
mod ssrf; mod ssrf;
use std::collections::HashMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::hash::{Hash, Hasher};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::{Duration, Instant};
use bytes::Bytes; use bytes::Bytes;
use http_body_util::Full; use http_body_util::Full;
@@ -27,11 +29,13 @@ use tokio_rustls::TlsConnector;
use url::Url; use url::Url;
use relay::{ use relay::{
build_tls_connector, fetch_validated, looks_like_manifest, max_for, rewrite_manifest, build_tls_connector, fetch_validated, looks_like_manifest, max_for, probe, rewrite_manifest,
RelayError, RelayError,
}; };
const MAX_INFLIGHT_RELAY: usize = 32; const MAX_INFLIGHT_RELAY: usize = 32;
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
const PROBE_TTL: Duration = Duration::from_secs(300);
#[derive(Default)] #[derive(Default)]
struct Metrics { struct Metrics {
@@ -40,13 +44,19 @@ struct Metrics {
relay_upstream_fail: AtomicU64, relay_upstream_fail: AtomicU64,
relay_ratelimit: AtomicU64, relay_ratelimit: AtomicU64,
relay_bad_request: AtomicU64, relay_bad_request: AtomicU64,
probe_total: AtomicU64,
probe_dead: AtomicU64,
} }
/// Cached probe result: (recorded_at, ok, status, latency_ms).
type ProbeCacheEntry = (Instant, bool, u16, u64);
struct AppState { struct AppState {
web_root: PathBuf, web_root: PathBuf,
tls: TlsConnector, tls: TlsConnector,
metrics: Metrics, metrics: Metrics,
relay_gate: Semaphore, relay_gate: Semaphore,
probe_cache: Mutex<HashMap<u64, ProbeCacheEntry>>,
} }
#[tokio::main] #[tokio::main]
@@ -64,6 +74,7 @@ async fn main() {
tls: build_tls_connector(), tls: build_tls_connector(),
metrics: Metrics::default(), metrics: Metrics::default(),
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY), relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
probe_cache: Mutex::new(HashMap::new()),
}); });
let listener = TcpListener::bind(bind).await.expect("bind"); let listener = TcpListener::bind(bind).await.expect("bind");
@@ -109,6 +120,7 @@ async fn handle(
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let resp = match path.as_str() { let resp = match path.as_str() {
"/relay" => serve_relay(&state, &req).await, "/relay" => serve_relay(&state, &req).await,
"/probe" => serve_probe(&state, &req).await,
"/metrics" => serve_metrics(&state), "/metrics" => serve_metrics(&state),
"/healthz" => text(StatusCode::OK, "ok"), "/healthz" => text(StatusCode::OK, "ok"),
_ => serve_static(&state, &path).await, _ => serve_static(&state, &path).await,
@@ -190,6 +202,58 @@ async fn serve_relay(state: &AppState, req: &Request<Incoming>) -> Response<Full
} }
} }
// ----------------------------- /probe ------------------------------------
async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full<Bytes>> {
let query = req.uri().query().unwrap_or("");
let raw = url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == "url")
.map(|(_, v)| v.into_owned());
let Some(raw) = raw else {
return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}");
};
let parsed = match Url::parse(&raw) {
Ok(u) if u.scheme() == "https" => u,
_ => return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}"),
};
// hash the URL for the cache key (never store the raw URL — token hygiene)
let mut hasher = std::collections::hash_map::DefaultHasher::new();
raw.hash(&mut hasher);
let key = hasher.finish();
if let Ok(cache) = state.probe_cache.lock() {
if let Some((ts, ok, status, ms)) = cache.get(&key) {
if ts.elapsed() < PROBE_TTL {
return json(StatusCode::OK, &probe_json(*ok, *status, *ms));
}
}
}
let _permit = match state.relay_gate.try_acquire() {
Ok(p) => p,
Err(_) => {
state.metrics.relay_ratelimit.fetch_add(1, Ordering::Relaxed);
return json(StatusCode::SERVICE_UNAVAILABLE, "{\"ok\":false,\"status\":0,\"latency_ms\":0}");
}
};
let outcome = probe(&state.tls, &parsed, PROBE_TIMEOUT).await;
state.metrics.probe_total.fetch_add(1, Ordering::Relaxed);
if !outcome.ok {
state.metrics.probe_dead.fetch_add(1, Ordering::Relaxed);
}
if let Ok(mut cache) = state.probe_cache.lock() {
cache.insert(key, (Instant::now(), outcome.ok, outcome.status, outcome.latency_ms));
}
log_line("probe", if outcome.ok { "live" } else { "dead" }, parsed.host_str(), Some(outcome.latency_ms));
json(StatusCode::OK, &probe_json(outcome.ok, outcome.status, outcome.latency_ms))
}
fn probe_json(ok: bool, status: u16, ms: u64) -> String {
format!("{{\"ok\":{ok},\"status\":{status},\"latency_ms\":{ms}}}")
}
// ----------------------------- /metrics ---------------------------------- // ----------------------------- /metrics ----------------------------------
fn serve_metrics(state: &AppState) -> Response<Full<Bytes>> { fn serve_metrics(state: &AppState) -> Response<Full<Bytes>> {
@@ -201,12 +265,18 @@ fn serve_metrics(state: &AppState) -> Response<Full<Bytes>> {
orb_relay_total{{event=\"ssrf_block\"}} {}\n\ orb_relay_total{{event=\"ssrf_block\"}} {}\n\
orb_relay_total{{event=\"upstream_fail\"}} {}\n\ orb_relay_total{{event=\"upstream_fail\"}} {}\n\
orb_relay_total{{event=\"ratelimit\"}} {}\n\ orb_relay_total{{event=\"ratelimit\"}} {}\n\
orb_relay_total{{event=\"bad_request\"}} {}\n", orb_relay_total{{event=\"bad_request\"}} {}\n\
# HELP orb_probe_total Health probes performed.\n\
# TYPE orb_probe_total counter\n\
orb_probe_total{{result=\"all\"}} {}\n\
orb_probe_total{{result=\"dead\"}} {}\n",
m.relay_ok.load(Ordering::Relaxed), m.relay_ok.load(Ordering::Relaxed),
m.relay_ssrf_block.load(Ordering::Relaxed), m.relay_ssrf_block.load(Ordering::Relaxed),
m.relay_upstream_fail.load(Ordering::Relaxed), m.relay_upstream_fail.load(Ordering::Relaxed),
m.relay_ratelimit.load(Ordering::Relaxed), m.relay_ratelimit.load(Ordering::Relaxed),
m.relay_bad_request.load(Ordering::Relaxed), m.relay_bad_request.load(Ordering::Relaxed),
m.probe_total.load(Ordering::Relaxed),
m.probe_dead.load(Ordering::Relaxed),
); );
bytes_resp(StatusCode::OK, "text/plain; version=0.0.4", Bytes::from(body)) bytes_resp(StatusCode::OK, "text/plain; version=0.0.4", Bytes::from(body))
} }
@@ -250,6 +320,7 @@ fn content_type_for(p: &Path) -> &'static str {
Some("js") | Some("mjs") => "text/javascript; charset=utf-8", Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8", Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json", Some("json") => "application/json",
Some("webmanifest") => "application/manifest+json",
Some("svg") => "image/svg+xml", Some("svg") => "image/svg+xml",
Some("png") => "image/png", Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg", Some("jpg") | Some("jpeg") => "image/jpeg",
@@ -273,8 +344,9 @@ fn with_security_headers(mut resp: Response<Full<Bytes>>, path: &str) -> Respons
"Content-Security-Policy", "Content-Security-Policy",
hyper::header::HeaderValue::from_static( hyper::header::HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; \ "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; \
media-src 'self' blob:; connect-src 'self'; font-src 'self'; object-src 'none'; \ media-src 'self' blob:; connect-src 'self'; font-src 'self'; worker-src 'self'; \
base-uri 'none'; frame-src 'none'; form-action 'none'; frame-ancestors 'none'", manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'; \
form-action 'none'; frame-ancestors 'none'",
), ),
); );
h.insert("X-Content-Type-Options", hyper::header::HeaderValue::from_static("nosniff")); h.insert("X-Content-Type-Options", hyper::header::HeaderValue::from_static("nosniff"));
@@ -289,7 +361,14 @@ fn with_security_headers(mut resp: Response<Full<Bytes>>, path: &str) -> Respons
"Strict-Transport-Security", "Strict-Transport-Security",
hyper::header::HeaderValue::from_static("max-age=31536000"), hyper::header::HeaderValue::from_static("max-age=31536000"),
); );
let _ = path; // Live stream + probe traffic is never cached: the service worker is the sole
// freshness authority, and it only caches static shell + JSON metadata.
if path == "/relay" || path == "/probe" {
h.insert(
hyper::header::CACHE_CONTROL,
hyper::header::HeaderValue::from_static("no-store"),
);
}
resp resp
} }
@@ -305,6 +384,10 @@ fn text(status: StatusCode, msg: &str) -> Response<Full<Bytes>> {
bytes_resp(status, "text/plain; charset=utf-8", Bytes::from(msg.to_string())) bytes_resp(status, "text/plain; charset=utf-8", Bytes::from(msg.to_string()))
} }
fn json(status: StatusCode, body: &str) -> Response<Full<Bytes>> {
bytes_resp(status, "application/json", Bytes::from(body.to_string()))
}
/// Structured one-line JSON log. We log host (not full URL) + outcome — query /// Structured one-line JSON log. We log host (not full URL) + outcome — query
/// strings can carry stream tokens, so the raw URL is never recorded. /// strings can carry stream tokens, so the raw URL is never recorded.
fn log_line(event: &str, detail: &str, host: Option<&str>, latency_ms: Option<u64>) { fn log_line(event: &str, detail: &str, host: Option<&str>, latency_ms: Option<u64>) {
+95
View File
@@ -63,6 +63,13 @@ pub struct Upstream {
pub redirects: u32, pub redirects: u32,
} }
/// Lightweight liveness result for the health prober.
pub struct ProbeOutcome {
pub ok: bool,
pub status: u16,
pub latency_ms: u64,
}
/// Build the shared rustls/TLS connector (webpki roots, explicit ring provider). /// Build the shared rustls/TLS connector (webpki roots, explicit ring provider).
pub fn build_tls_connector() -> TlsConnector { pub fn build_tls_connector() -> TlsConnector {
let mut roots = rustls::RootCertStore::empty(); let mut roots = rustls::RootCertStore::empty();
@@ -201,6 +208,94 @@ pub async fn fetch_validated(
} }
} }
/// Open an SSRF-validated HTTPS/1.1 connection and send one request, returning
/// the response status (body is not read — caller decides). Used by the prober.
async fn send_method(
connector: &TlsConnector,
url: &Url,
method: hyper::Method,
range: bool,
) -> Result<StatusCode, RelayError> {
if !is_https(url) {
return Err(RelayError::NotHttps);
}
let host = url.host_str().ok_or(RelayError::BadUrl)?.to_string();
let port = url.port_or_known_default().unwrap_or(443);
let addr = resolve_and_validate(&host, port)
.await
.map_err(RelayError::Ssrf)?;
let tcp = TcpStream::connect(addr)
.await
.map_err(|e| RelayError::Connect(e.to_string()))?;
let _ = tcp.set_nodelay(true);
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
let tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| RelayError::Tls(e.to_string()))?;
let io = TokioIo::new(tls);
let (mut sender, conn) = http1::handshake(io)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
tokio::spawn(async move {
let _ = conn.await;
});
let mut pq = url.path().to_string();
if let Some(q) = url.query() {
pq.push('?');
pq.push_str(q);
}
let mut builder = Request::builder()
.method(method)
.uri(pq)
.header(hyper::header::HOST, host.as_str())
.header(hyper::header::USER_AGENT, UA)
.header(hyper::header::ACCEPT, "*/*");
if range {
builder = builder.header(hyper::header::RANGE, "bytes=0-1");
}
let req = builder
.body(Empty::<Bytes>::new())
.map_err(|e| RelayError::Http(e.to_string()))?;
let resp = sender
.send_request(req)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
Ok(resp.status())
}
/// Probe a stream URL for liveness: HEAD first, falling back to a ranged GET
/// (many CDNs reject HEAD). Whole thing is time-boxed.
pub async fn probe(connector: &TlsConnector, url: &Url, timeout: std::time::Duration) -> ProbeOutcome {
let started = std::time::Instant::now();
let result = tokio::time::timeout(timeout, async {
// HEAD
if let Ok(status) = send_method(connector, url, hyper::Method::HEAD, false).await {
if status.is_success() || status.is_redirection() {
return Some(status);
}
}
// ranged GET fallback
if let Ok(status) = send_method(connector, url, hyper::Method::GET, true).await {
return Some(status);
}
None
})
.await;
let latency_ms = started.elapsed().as_millis() as u64;
match result {
Ok(Some(status)) => ProbeOutcome {
ok: status.is_success() || status.is_redirection(),
status: status.as_u16(),
latency_ms,
},
Ok(None) | Err(_) => ProbeOutcome { ok: false, status: 0, latency_ms },
}
}
/// Heuristic: is this an HLS manifest we should rewrite? /// Heuristic: is this an HLS manifest we should rewrite?
pub fn looks_like_manifest(url: &Url, content_type: &str) -> bool { pub fn looks_like_manifest(url: &Url, content_type: &str) -> bool {
let ct = content_type.to_ascii_lowercase(); let ct = content_type.to_ascii_lowercase();
+32 -12
View File
@@ -7,6 +7,8 @@ import { Player } from './player.js';
import { Zapper } from './zapper.js'; import { Zapper } from './zapper.js';
import { Guide } from './guide.js'; import { Guide } from './guide.js';
import { Ambient } from './ambient.js'; import { Ambient } from './ambient.js';
import { Health } from './health.js';
import { memory } from './memory.js';
import { applyLexicon, setMundane, isMundane } from './lexicon.js'; import { applyLexicon, setMundane, isMundane } from './lexicon.js';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -44,6 +46,7 @@ function setMode(name, on) {
modes[name] = on; modes[name] = on;
els.root.setAttribute(`data-mode-${name}`, String(on)); els.root.setAttribute(`data-mode-${name}`, String(on));
if (name === 'ambient') on ? ambient.start() : ambient.stop(); if (name === 'ambient') on ? ambient.start() : ambient.stop();
memory.rememberModes(modes);
} }
function toggleMode(name) { setMode(name, !modes[name]); } function toggleMode(name) { setMode(name, !modes[name]); }
function collapseModes() { function collapseModes() {
@@ -147,12 +150,27 @@ async function boot() {
els.mundane.addEventListener('change', () => setMundane(els.mundane.checked)); els.mundane.addEventListener('change', () => setMundane(els.mundane.checked));
els.helpClose.addEventListener('click', () => toggleHelp(false)); 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); ambient = new Ambient(els.video, els.ambientCanvas);
player = new Player(els, (msg) => console.warn('[orb] stream fatal:', msg)); player = new Player(els, (msg) => console.warn('[orb] stream fatal:', msg));
const { agg, label } = pickProviders(); const health = new Health(agg, hasRelay);
zapper = new Zapper(els, player, agg); zapper = new Zapper(els, player, agg, { memory, health });
guide = new Guide(els, agg, (ch) => zapper.tune(ch)); 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.btnChup.addEventListener('click', () => zapper.tuneToIndexOffset(-1));
els.btnChdn.addEventListener('click', () => zapper.tuneToIndexOffset(+1)); els.btnChdn.addEventListener('click', () => zapper.tuneToIndexOffset(+1));
els.btnGuide.addEventListener('click', () => guide.toggle()); els.btnGuide.addEventListener('click', () => guide.toggle());
@@ -163,22 +181,24 @@ async function boot() {
try { try {
const channels = await agg.listAllChannels(); const channels = await agg.listAllChannels();
console.info(`[orb] ${label} provider: ${channels.length} channels`); console.info(`[orb] ${label} provider: ${channels.length} channels`);
if (channels.length) await zapper.tune(channels[0]); if (channels.length) {
runHealthPass(agg, guide); // 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) { } catch (e) {
console.error('[orb] boot failed:', e); console.error('[orb] boot failed:', e);
player._showNoSignal?.(); player._showNoSignal?.();
} }
} }
// background health pass → guide skull glyphs (Oracle's Memory seed) // Register the service worker (PWA / offline shell). Best-effort; never blocks boot.
async function runHealthPass(agg, guide) { if ('serviceWorker' in navigator) {
for (const ch of agg.channels) { window.addEventListener('load', () => {
const owner = agg.providers.get(ch.source); navigator.serviceWorker.register('/sw.js').catch((e) => console.warn('[orb] sw:', e));
if (!owner?.healthCheck) continue; });
try { const r = await owner.healthCheck(ch.id); guide.setHealth(ch.id, r.ok); }
catch { guide.setHealth(ch.id, false); }
}
} }
boot(); 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. // guide.js — "The Almanac": 1994-style scrolling EPG overlay.
// Virtualized rows (fixed height) so thousands of channels stay smooth. // Virtualized rows (fixed height) so thousands of channels stay smooth.
import { memory } from './memory.js';
const ROW_H = 46; const ROW_H = 46;
const CH_COL = 150; const CH_COL = 150;
const HOUR_W = 180; // px per hour const HOUR_W = 180; // px per hour
@@ -126,6 +128,19 @@ export class Guide {
const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠'; const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠';
sk.title = 'no signal'; cell.appendChild(sk); 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(); }); cell.addEventListener('click', () => { this.onTune(ch); this.hide(); });
const progs = document.createElement('div'); 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:; media-src 'self' blob: https:;
connect-src 'self' https:; connect-src 'self' https:;
font-src 'self'; font-src 'self';
worker-src 'self';
manifest-src 'self';
object-src 'none'; object-src 'none';
base-uri 'none'; base-uri 'none';
frame-src 'none'; frame-src 'none';
form-action 'none'"> form-action 'none'">
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="dark"> <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"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <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. // player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling.
import { memory } from './memory.js';
const HLS_CONFIG = { const HLS_CONFIG = {
enableWorker: true, enableWorker: true,
lowLatencyMode: false, lowLatencyMode: false,
@@ -171,6 +173,7 @@ export class Player {
const m = this.video.muted || this.video.volume === 0; const m = this.video.muted || this.video.volume === 0;
this.els.btnMute.textContent = m ? '🔇' : '🔊'; this.els.btnMute.textContent = m ? '🔇' : '🔊';
this.els.btnMute.setAttribute('aria-pressed', String(m)); this.els.btnMute.setAttribute('aria-pressed', String(m));
memory.rememberVolume(this.video.volume, this.video.muted);
} }
setVolume(delta) { setVolume(delta) {
this.video.muted = false; this.video.muted = false;
+34 -35
View File
@@ -100,6 +100,7 @@ export class IptvOrgProvider extends SourceProvider {
language: clip((c.languages && c.languages[0]) || ''), language: clip((c.languages && c.languages[0]) || ''),
kind: 'live', kind: 'live',
streamUrls: playable, streamUrls: playable,
probeUrls: urls, // raw https upstreams for the /probe endpoint
guideId: c.id, guideId: c.id,
}); });
} }
@@ -124,10 +125,9 @@ export class IptvOrgProvider extends SourceProvider {
} }
/** /**
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[]. * EPG. Returns cached programs (populated by loadEpgXml via the off-thread
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via * worker). Empty until an XMLTV feed is loaded — the guide still renders
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is * channel rows; now/next is simply blank.
* simply blank until an EPG source is wired in Phase 1.5).
*/ */
async getPrograms(channelId, window) { async getPrograms(channelId, window) {
const cached = this._epgCache.get(channelId); 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); 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) { * Parse an XMLTV feed OFF the main thread and populate the EPG cache.
const doc = new DOMParser().parseFromString(xmlString, 'application/xml'); * @param {string} xmlString
if (doc.querySelector('parsererror')) return; * @returns {Promise<number>} number of programmes indexed
// map xmltv channel id -> our namespaced id */
async loadEpgXml(xmlString) {
const programs = await this._parseInWorker(xmlString);
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id])); const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
for (const prog of doc.getElementsByTagName('programme')) { let n = 0;
const xmlCh = prog.getAttribute('channel'); for (const p of programs) {
const cid = idMap.get(xmlCh); const cid = idMap.get(p.channel);
if (!cid) continue; 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); const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
arr.push({ arr.push({ channelId: cid, title: clip(p.title), desc: clip(p.desc), start: p.start, stop: p.stop });
channelId: cid, n++;
title: clip(titleEl ? titleEl.textContent : 'Program'),
desc: clip(descEl ? descEl.textContent : ''),
start, stop,
});
} }
return n;
} }
}
/** XMLTV time: "20260615013000 +0000" → epoch ms. */ _parseInWorker(xmlString) {
function parseXmltvTime(s) { return new Promise((resolve, reject) => {
if (!s) return 0; let worker;
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim()); try {
if (!m) return 0; worker = new Worker(new URL('../epg-worker.js', import.meta.url), { type: 'module' });
const [, Y, Mo, D, H, Mi, S, tz] = m; } catch (e) {
let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0)); return reject(e);
if (tz) { }
const sign = tz[0] === '-' ? -1 : 1; const id = Math.random().toString(36).slice(2);
ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000; 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', language: 'eng',
kind: 'live', kind: 'live',
streamUrls: [TEST_STREAMS[c.stream]], streamUrls: [TEST_STREAMS[c.stream]],
probeUrls: [TEST_STREAMS[c.stream]],
guideId: `mock:${c.n}`, guideId: `mock:${c.n}`,
})); }));
} }
+2 -1
View File
@@ -16,7 +16,8 @@
* @property {string} [country] * @property {string} [country]
* @property {string} [language] * @property {string} [language]
* @property {'live'|'vod'} kind Always 'live' for Phase 1; self-hosted "channels" are also 'live'. * @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. * @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 .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 .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 .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-progs{position:relative;flex:1}
.guide-prog{position:absolute;top:3px;bottom:3px;background:var(--panel-2);border:1px solid var(--line); .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} 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 {object} els
* @param {import('./player.js').Player} player * @param {import('./player.js').Player} player
* @param {import('./providers/source-provider.js').ChannelAggregator} agg * @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.els = els;
this.player = player; this.player = player;
this.agg = agg; this.agg = agg;
this.memory = opts.memory || null;
this.health = opts.health || null;
this.current = null; // current Channel this.current = null; // current Channel
this.previous = null; // for last-channel toggle this.previous = null; // for last-channel toggle
this._entry = ''; this._entry = '';
@@ -38,9 +41,16 @@ export class Zapper {
async tuneToIndexOffset(delta) { async tuneToIndexOffset(delta) {
const list = this.agg.channels; const list = this.agg.channels;
if (!list.length) return; if (!list.length) return;
let idx = this.current ? list.findIndex((c) => c.id === this.current.id) : -1; const start = this.current ? list.findIndex((c) => c.id === this.current.id) : -1;
idx = (idx + delta + list.length) % list.length; let idx = start;
return this.tune(list[idx]); // 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); } async tuneLast() { if (this.previous) return this.tune(this.previous); }
@@ -50,6 +60,8 @@ export class Zapper {
if (!ch) return; if (!ch) return;
if (this.current && this.current.id !== ch.id) this.previous = this.current; if (this.current && this.current.id !== ch.id) this.previous = this.current;
this.current = ch; 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._showStatic();
this._safetyClear = setTimeout(() => this._clearStatic(), STATIC_MAX); this._safetyClear = setTimeout(() => this._clearStatic(), STATIC_MAX);