diff --git a/README.md b/README.md index 148d457..e54e24c 100644 --- a/README.md +++ b/README.md @@ -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. -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 -- 📺 **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. -- 📡 **"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. -- 🎬 **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. -- 🧙 **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. +- **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. +- **"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. +- **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. +- **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. ## Security posture @@ -150,8 +150,8 @@ This project does **not** host, store, or transmit any media. It indexes **publi ## Acknowledgments -- Inspired by a mentor's NAS → CRT "your movies as 24/7 channels" build. -- Channel data and the public stream index come from the excellent [iptv-org](https://github.com/iptv-org/iptv) community project. +- 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 [iptv-org](https://github.com/iptv-org/iptv) community project. ## License diff --git a/server/src/main.rs b/server/src/main.rs index e02939c..ab7a9a4 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -7,12 +7,14 @@ mod relay; mod ssrf; +use std::collections::HashMap; use std::convert::Infallible; +use std::hash::{Hash, Hasher}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use bytes::Bytes; use http_body_util::Full; @@ -27,11 +29,13 @@ use tokio_rustls::TlsConnector; use url::Url; 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, }; const MAX_INFLIGHT_RELAY: usize = 32; +const PROBE_TIMEOUT: Duration = Duration::from_secs(6); +const PROBE_TTL: Duration = Duration::from_secs(300); #[derive(Default)] struct Metrics { @@ -40,13 +44,19 @@ struct Metrics { relay_upstream_fail: AtomicU64, relay_ratelimit: 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 { web_root: PathBuf, tls: TlsConnector, metrics: Metrics, relay_gate: Semaphore, + probe_cache: Mutex>, } #[tokio::main] @@ -64,6 +74,7 @@ async fn main() { tls: build_tls_connector(), metrics: Metrics::default(), relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY), + probe_cache: Mutex::new(HashMap::new()), }); let listener = TcpListener::bind(bind).await.expect("bind"); @@ -109,6 +120,7 @@ async fn handle( let path = req.uri().path().to_string(); let resp = match path.as_str() { "/relay" => serve_relay(&state, &req).await, + "/probe" => serve_probe(&state, &req).await, "/metrics" => serve_metrics(&state), "/healthz" => text(StatusCode::OK, "ok"), _ => serve_static(&state, &path).await, @@ -190,6 +202,58 @@ async fn serve_relay(state: &AppState, req: &Request) -> Response) -> Response> { + 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 ---------------------------------- fn serve_metrics(state: &AppState) -> Response> { @@ -201,12 +265,18 @@ fn serve_metrics(state: &AppState) -> Response> { orb_relay_total{{event=\"ssrf_block\"}} {}\n\ orb_relay_total{{event=\"upstream_fail\"}} {}\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_ssrf_block.load(Ordering::Relaxed), m.relay_upstream_fail.load(Ordering::Relaxed), m.relay_ratelimit.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)) } @@ -250,6 +320,7 @@ fn content_type_for(p: &Path) -> &'static str { Some("js") | Some("mjs") => "text/javascript; charset=utf-8", Some("css") => "text/css; charset=utf-8", Some("json") => "application/json", + Some("webmanifest") => "application/manifest+json", Some("svg") => "image/svg+xml", Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", @@ -273,8 +344,9 @@ fn with_security_headers(mut resp: Response>, path: &str) -> Respons "Content-Security-Policy", hyper::header::HeaderValue::from_static( "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'; \ - base-uri 'none'; frame-src 'none'; form-action 'none'; frame-ancestors 'none'", + media-src 'self' blob:; connect-src 'self'; font-src 'self'; worker-src 'self'; \ + 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")); @@ -289,7 +361,14 @@ fn with_security_headers(mut resp: Response>, path: &str) -> Respons "Strict-Transport-Security", 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 } @@ -305,6 +384,10 @@ fn text(status: StatusCode, msg: &str) -> Response> { bytes_resp(status, "text/plain; charset=utf-8", Bytes::from(msg.to_string())) } +fn json(status: StatusCode, body: &str) -> Response> { + bytes_resp(status, "application/json", Bytes::from(body.to_string())) +} + /// 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. fn log_line(event: &str, detail: &str, host: Option<&str>, latency_ms: Option) { diff --git a/server/src/relay.rs b/server/src/relay.rs index fd8a211..6a27a69 100644 --- a/server/src/relay.rs +++ b/server/src/relay.rs @@ -63,6 +63,13 @@ pub struct Upstream { 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). pub fn build_tls_connector() -> TlsConnector { 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 { + 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::::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? pub fn looks_like_manifest(url: &Url, content_type: &str) -> bool { let ct = content_type.to_ascii_lowercase(); diff --git a/web/app.js b/web/app.js index e8718c2..db3ebee 100644 --- a/web/app.js +++ b/web/app.js @@ -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(); diff --git a/web/epg-worker.js b/web/epg-worker.js new file mode 100644 index 0000000..a29437c --- /dev/null +++ b/web/epg-worker.js @@ -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) }); + } +}; diff --git a/web/guide.js b/web/guide.js index a5800d2..7eab78f 100644 --- a/web/guide.js +++ b/web/guide.js @@ -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'); diff --git a/web/health.js b/web/health.js new file mode 100644 index 0000000..adab3a5 --- /dev/null +++ b/web/health.js @@ -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 + 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} */ + 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'; + } +} diff --git a/web/icons/apple-touch-icon.png b/web/icons/apple-touch-icon.png new file mode 100644 index 0000000..86f908b Binary files /dev/null and b/web/icons/apple-touch-icon.png differ diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png new file mode 100644 index 0000000..e13becb Binary files /dev/null and b/web/icons/icon-192.png differ diff --git a/web/icons/icon-512.png b/web/icons/icon-512.png new file mode 100644 index 0000000..a9c1ee7 Binary files /dev/null and b/web/icons/icon-512.png differ diff --git a/web/icons/icon-maskable-512.png b/web/icons/icon-maskable-512.png new file mode 100644 index 0000000..a9c1ee7 Binary files /dev/null and b/web/icons/icon-maskable-512.png differ diff --git a/web/icons/orb.svg b/web/icons/orb.svg new file mode 100644 index 0000000..9f39b7b --- /dev/null +++ b/web/icons/orb.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html index 61a56f3..66dcbf7 100644 --- a/web/index.html +++ b/web/index.html @@ -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'"> + + + + diff --git a/web/lib/xmltv-parse.js b/web/lib/xmltv-parse.js new file mode 100644 index 0000000..83a7efb --- /dev/null +++ b/web/lib/xmltv-parse.js @@ -0,0 +1,112 @@ +// xmltv-parse.js — pure XMLTV parser (no DOM, node-testable). +// +// XMLTV is regular enough to scan linearly for 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`, '… 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('', 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 = '', start); + if (tagEnd === -1) break; + const openTag = text.slice(start, tagEnd + 1); + + // self-closing (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; +} diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..fed1f09 --- /dev/null +++ b/web/manifest.webmanifest @@ -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" } + ] +} diff --git a/web/memory.js b/web/memory.js new file mode 100644 index 0000000..54226cf --- /dev/null +++ b/web/memory.js @@ -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} */ + 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(); diff --git a/web/player.js b/web/player.js index 800924d..3b6784c 100644 --- a/web/player.js +++ b/web/player.js @@ -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; diff --git a/web/providers/iptv-org.js b/web/providers/iptv-org.js index 235ab8a..9941311 100644 --- a/web/providers/iptv-org.js +++ b/web/providers/iptv-org.js @@ -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 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; } diff --git a/web/providers/mock.js b/web/providers/mock.js index c21ed29..f8e15e4 100644 --- a/web/providers/mock.js +++ b/web/providers/mock.js @@ -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}`, })); } diff --git a/web/providers/source-provider.js b/web/providers/source-provider.js index 4deeed8..ecd47ff 100644 --- a/web/providers/source-provider.js +++ b/web/providers/source-provider.js @@ -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. */ diff --git a/web/styles.css b/web/styles.css index 228f642..7343bd7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..091ccb8 --- /dev/null +++ b/web/sw.js @@ -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; +} diff --git a/web/zapper.js b/web/zapper.js index 5e97efe..e520e93 100644 --- a/web/zapper.js +++ b/web/zapper.js @@ -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);