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