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:
@@ -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
|
||||
|
||||
|
||||
+90
-7
@@ -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<HashMap<u64, ProbeCacheEntry>>,
|
||||
}
|
||||
|
||||
#[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<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 ----------------------------------
|
||||
|
||||
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=\"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<Full<Bytes>>, 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<Full<Bytes>>, 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<Full<Bytes>> {
|
||||
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
|
||||
/// 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>) {
|
||||
|
||||
@@ -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<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?
|
||||
pub fn looks_like_manifest(url: &Url, content_type: &str) -> bool {
|
||||
let ct = content_type.to_ascii_lowercase();
|
||||
|
||||
+32
-12
@@ -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();
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
// guide.js — "The Almanac": 1994-style scrolling EPG overlay.
|
||||
// Virtualized rows (fixed height) so thousands of channels stay smooth.
|
||||
|
||||
import { memory } from './memory.js';
|
||||
|
||||
const ROW_H = 46;
|
||||
const CH_COL = 150;
|
||||
const HOUR_W = 180; // px per hour
|
||||
@@ -126,6 +128,19 @@ export class Guide {
|
||||
const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠';
|
||||
sk.title = 'no signal'; cell.appendChild(sk);
|
||||
}
|
||||
// pin (Oracle's Memory) — click the rune to inscribe/banish a channel
|
||||
const pin = document.createElement('button');
|
||||
pin.className = 'pin' + (memory.isPinned(ch.id) ? ' on' : '');
|
||||
pin.textContent = memory.isPinned(ch.id) ? '★' : '☆';
|
||||
pin.setAttribute('aria-label', memory.isPinned(ch.id) ? 'Unpin channel' : 'Pin channel');
|
||||
pin.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const on = memory.togglePin(ch.id);
|
||||
pin.classList.toggle('on', on);
|
||||
pin.textContent = on ? '★' : '☆';
|
||||
pin.setAttribute('aria-label', on ? 'Unpin channel' : 'Pin channel');
|
||||
});
|
||||
cell.appendChild(pin);
|
||||
cell.addEventListener('click', () => { this.onTune(ch); this.hide(); });
|
||||
|
||||
const progs = document.createElement('div');
|
||||
|
||||
+118
@@ -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 |
@@ -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 |
@@ -17,13 +17,19 @@
|
||||
media-src 'self' blob: https:;
|
||||
connect-src 'self' https:;
|
||||
font-src 'self';
|
||||
worker-src 'self';
|
||||
manifest-src 'self';
|
||||
object-src 'none';
|
||||
base-uri 'none';
|
||||
frame-src 'none';
|
||||
form-action 'none'">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#e10000">
|
||||
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
<link rel="icon" href="icons/icon-192.png" type="image/png">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -1,5 +1,7 @@
|
||||
// player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling.
|
||||
|
||||
import { memory } from './memory.js';
|
||||
|
||||
const HLS_CONFIG = {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
@@ -171,6 +173,7 @@ export class Player {
|
||||
const m = this.video.muted || this.video.volume === 0;
|
||||
this.els.btnMute.textContent = m ? '🔇' : '🔊';
|
||||
this.els.btnMute.setAttribute('aria-pressed', String(m));
|
||||
memory.rememberVolume(this.video.volume, this.video.muted);
|
||||
}
|
||||
setVolume(delta) {
|
||||
this.video.muted = false;
|
||||
|
||||
+34
-35
@@ -100,6 +100,7 @@ export class IptvOrgProvider extends SourceProvider {
|
||||
language: clip((c.languages && c.languages[0]) || ''),
|
||||
kind: 'live',
|
||||
streamUrls: playable,
|
||||
probeUrls: urls, // raw https upstreams for the /probe endpoint
|
||||
guideId: c.id,
|
||||
});
|
||||
}
|
||||
@@ -124,10 +125,9 @@ export class IptvOrgProvider extends SourceProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[].
|
||||
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via
|
||||
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is
|
||||
* simply blank until an EPG source is wired in Phase 1.5).
|
||||
* EPG. Returns cached programs (populated by loadEpgXml via the off-thread
|
||||
* worker). Empty until an XMLTV feed is loaded — the guide still renders
|
||||
* channel rows; now/next is simply blank.
|
||||
*/
|
||||
async getPrograms(channelId, window) {
|
||||
const cached = this._epgCache.get(channelId);
|
||||
@@ -135,42 +135,41 @@ export class IptvOrgProvider extends SourceProvider {
|
||||
return cached.filter((p) => p.stop > window.from && p.start < window.to);
|
||||
}
|
||||
|
||||
/** Parse a raw XMLTV string and populate the EPG cache (safe: DOMParser). */
|
||||
setEpgXml(xmlString) {
|
||||
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
|
||||
if (doc.querySelector('parsererror')) return;
|
||||
// map xmltv channel id -> our namespaced id
|
||||
/**
|
||||
* Parse an XMLTV feed OFF the main thread and populate the EPG cache.
|
||||
* @param {string} xmlString
|
||||
* @returns {Promise<number>} number of programmes indexed
|
||||
*/
|
||||
async loadEpgXml(xmlString) {
|
||||
const programs = await this._parseInWorker(xmlString);
|
||||
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
|
||||
for (const prog of doc.getElementsByTagName('programme')) {
|
||||
const xmlCh = prog.getAttribute('channel');
|
||||
const cid = idMap.get(xmlCh);
|
||||
let n = 0;
|
||||
for (const p of programs) {
|
||||
const cid = idMap.get(p.channel);
|
||||
if (!cid) continue;
|
||||
const start = parseXmltvTime(prog.getAttribute('start'));
|
||||
const stop = parseXmltvTime(prog.getAttribute('stop'));
|
||||
if (!start || !stop) continue;
|
||||
const titleEl = prog.getElementsByTagName('title')[0];
|
||||
const descEl = prog.getElementsByTagName('desc')[0];
|
||||
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
|
||||
arr.push({
|
||||
channelId: cid,
|
||||
title: clip(titleEl ? titleEl.textContent : 'Program'),
|
||||
desc: clip(descEl ? descEl.textContent : ''),
|
||||
start, stop,
|
||||
});
|
||||
arr.push({ channelId: cid, title: clip(p.title), desc: clip(p.desc), start: p.start, stop: p.stop });
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/** XMLTV time: "20260615013000 +0000" → epoch ms. */
|
||||
function parseXmltvTime(s) {
|
||||
if (!s) return 0;
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim());
|
||||
if (!m) return 0;
|
||||
const [, Y, Mo, D, H, Mi, S, tz] = m;
|
||||
let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0));
|
||||
if (tz) {
|
||||
const sign = tz[0] === '-' ? -1 : 1;
|
||||
ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000;
|
||||
_parseInWorker(xmlString) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL('../epg-worker.js', import.meta.url), { type: 'module' });
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
worker.onmessage = (e) => {
|
||||
if (e.data?.id !== id) return;
|
||||
worker.terminate();
|
||||
e.data.ok ? resolve(e.data.programs) : reject(new Error(e.data.error));
|
||||
};
|
||||
worker.onerror = (e) => { worker.terminate(); reject(e); };
|
||||
worker.postMessage({ id, xml: xmlString });
|
||||
});
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user