diff --git a/README.md b/README.md
index d4b3b94..9ac8840 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,15 @@ cargo run -p ec-node -- wt-archive \
--manifest-dir /var/lib/every-channel/manifests
```
+Replay server (archive -> HLS DVR endpoints):
+
+```sh
+cargo run -p ec-node -- wt-archive-serve \
+ --output-dir /tank/every-channel/archive \
+ --manifest-dir /var/lib/every-channel/manifests \
+ --listen 0.0.0.0:7788
+```
+
Control protocol (iroh gossip, relay + direct transport discovery):
```sh
diff --git a/apps/web/app.js b/apps/web/app.js
index 230aa3a..7198fb6 100644
--- a/apps/web/app.js
+++ b/apps/web/app.js
@@ -9,9 +9,16 @@ const MOQ_WATCH_MODULE_URLS = [
"https://cdn.jsdelivr.net/npm/@moq/watch@0.1.1/element/+esm",
"https://unpkg.com/@moq/watch@0.1.1/element.js?module",
];
+const HLS_MODULE_URLS = [
+ "https://esm.sh/hls.js@1.6.2",
+ "https://cdn.jsdelivr.net/npm/hls.js@1.6.2/+esm",
+ "https://unpkg.com/hls.js@1.6.2/dist/hls.mjs",
+];
const PUBLIC_STREAMS_PATH = "/api/public-streams";
let moqWatchModulePromise = null;
+let hlsModulePromise = null;
let disposePlayerSignals = null;
+let activeHlsPlayer = null;
function $(id) {
const el = document.getElementById(id);
@@ -30,14 +37,16 @@ function normalizeName(s) {
return (s || "").trim();
}
-function currentShareLink(relayUrl, name) {
+function currentShareLink(relayUrl, name, mode) {
const u = new URL(window.location.href);
u.pathname = "/watch";
u.searchParams.set("url", relayUrl);
u.searchParams.set("name", name);
+ if (mode === "archive") u.searchParams.set("mode", "archive");
+ else u.searchParams.delete("mode");
// Avoid leaking other params.
for (const k of [...u.searchParams.keys()]) {
- if (k !== "url" && k !== "name") u.searchParams.delete(k);
+ if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
}
return u.toString();
}
@@ -66,6 +75,17 @@ function clearPlayerSignals() {
disposePlayerSignals = null;
}
+function destroyArchivePlayer() {
+ if (activeHlsPlayer && typeof activeHlsPlayer.destroy === "function") {
+ try {
+ activeHlsPlayer.destroy();
+ } catch (_) {
+ // Ignore teardown errors.
+ }
+ }
+ activeHlsPlayer = null;
+}
+
function bindPlayerSignals(watch, name) {
const cleanup = [];
let offlineTimer = null;
@@ -131,6 +151,7 @@ function bindPlayerSignals(watch, name) {
function mountPlayer(relayUrl, name) {
clearPlayerSignals();
+ destroyArchivePlayer();
const mount = $("playerMount");
mount.textContent = "";
@@ -173,6 +194,74 @@ async function ensureMoqWatchElement() {
}
}
+async function ensureHlsPlayerCtor() {
+ if (window.Hls) return window.Hls;
+ if (!hlsModulePromise) {
+ hlsModulePromise = (async () => {
+ let lastErr = null;
+ for (const moduleUrl of HLS_MODULE_URLS) {
+ try {
+ const mod = await import(moduleUrl);
+ if (mod?.default) {
+ window.Hls = mod.default;
+ } else if (mod?.Hls) {
+ window.Hls = mod.Hls;
+ }
+ } catch (err) {
+ lastErr = err;
+ continue;
+ }
+ if (window.Hls) return window.Hls;
+ }
+ throw lastErr || new Error("hls.js module is unavailable");
+ })();
+ }
+ return hlsModulePromise;
+}
+
+async function mountArchivePlayer(name) {
+ clearPlayerSignals();
+ destroyArchivePlayer();
+
+ const mount = $("playerMount");
+ mount.textContent = "";
+
+ const video = document.createElement("video");
+ video.className = "archiveVideo";
+ video.controls = true;
+ video.autoplay = true;
+ video.muted = false;
+ video.playsInline = true;
+ mount.appendChild(video);
+
+ const archiveUrl = `/api/archive/${encodeURIComponent(name)}/master.m3u8`;
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
+ video.src = archiveUrl;
+ void video.play().catch(() => {});
+ return;
+ }
+
+ const HlsCtor = await ensureHlsPlayerCtor();
+ if (!HlsCtor || typeof HlsCtor.isSupported !== "function" || !HlsCtor.isSupported()) {
+ throw new Error("HLS playback is unsupported in this browser");
+ }
+
+ const hls = new HlsCtor({
+ liveDurationInfinity: true,
+ lowLatencyMode: false,
+ backBufferLength: 120,
+ });
+ activeHlsPlayer = hls;
+ hls.on(HlsCtor.Events.ERROR, (_event, data) => {
+ if (data?.fatal) {
+ setHint(`Archive playback error: ${data.type || "fatal"}`, "warn");
+ }
+ });
+ hls.loadSource(archiveUrl);
+ hls.attachMedia(video);
+ void video.play().catch(() => {});
+}
+
async function copyToClipboard(text) {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -199,20 +288,24 @@ function readParams() {
u.searchParams.get("name") ||
u.searchParams.get("broadcast") ||
u.searchParams.get("path");
+ const mode = u.searchParams.get("mode") === "archive" ? "archive" : "live";
return {
relayUrl: normalizeRelayUrl(relay || DEFAULT_RELAY_URL),
name: normalizeName(name || ""),
+ mode,
};
}
-function writeParams(relayUrl, name) {
+function writeParams(relayUrl, name, mode) {
const u = new URL(window.location.href);
u.pathname = "/watch";
u.searchParams.set("url", relayUrl);
u.searchParams.set("name", name);
+ if (mode === "archive") u.searchParams.set("mode", "archive");
+ else u.searchParams.delete("mode");
// Canonicalize by dropping stale aliases/extra params.
for (const k of [...u.searchParams.keys()]) {
- if (k !== "url" && k !== "name") u.searchParams.delete(k);
+ if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
}
window.history.replaceState({}, "", u.toString());
}
@@ -221,7 +314,7 @@ function hasWebTransport() {
return typeof window.WebTransport !== "undefined";
}
-function renderLiveList(entries, onWatch) {
+function renderLiveList(entries, onWatchLive, onWatchArchive) {
const mount = $("liveList");
mount.textContent = "";
if (!entries.length) {
@@ -245,15 +338,28 @@ function renderLiveList(entries, onWatch) {
meta.textContent = `${entry.broadcast_name || ""} @ ${entry.relay_url || DEFAULT_RELAY_URL}`;
info.appendChild(meta);
- const btn = document.createElement("button");
- btn.className = "btn secondary";
- btn.textContent = "Watch";
- btn.addEventListener("click", () => {
- onWatch(entry);
+ const actions = document.createElement("div");
+ actions.className = "liveActions";
+
+ const watchBtn = document.createElement("button");
+ watchBtn.className = "btn secondary";
+ watchBtn.textContent = "Live";
+ watchBtn.addEventListener("click", () => {
+ onWatchLive(entry);
});
+ const archiveBtn = document.createElement("button");
+ archiveBtn.className = "btn secondary";
+ archiveBtn.textContent = "Archive";
+ archiveBtn.addEventListener("click", () => {
+ onWatchArchive(entry);
+ });
+
+ actions.appendChild(watchBtn);
+ actions.appendChild(archiveBtn);
+
row.appendChild(info);
- row.appendChild(btn);
+ row.appendChild(actions);
mount.appendChild(row);
}
}
@@ -271,6 +377,7 @@ async function fetchLiveList() {
function main() {
const relayInput = $("relayUrl");
const nameInput = $("broadcastName");
+ const archiveModeInput = $("archiveMode");
const watchBtn = $("watchBtn");
const copyBtn = $("copyLinkBtn");
const refreshListBtn = $("refreshListBtn");
@@ -278,20 +385,23 @@ function main() {
const initial = readParams();
relayInput.value = initial.relayUrl;
nameInput.value = initial.name;
+ archiveModeInput.checked = initial.mode === "archive";
function updateSharePreview() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
+ const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) {
setShareLink("");
return;
}
- setShareLink(currentShareLink(relayUrl, name));
+ setShareLink(currentShareLink(relayUrl, name, mode));
}
async function start() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
+ const mode = archiveModeInput.checked ? "archive" : "live";
updateSharePreview();
@@ -300,6 +410,20 @@ function main() {
return;
}
+ if (mode === "archive") {
+ writeParams(relayUrl, name, mode);
+ setHint(`Loading archive DVR: ${name}`, "ok");
+ try {
+ await mountArchivePlayer(name);
+ } catch (e) {
+ setHint(
+ `Archive playback unavailable: ${String(e)}. Ensure /api/archive is configured.`,
+ "warn",
+ );
+ }
+ return;
+ }
+
if (!hasWebTransport()) {
setHint(
"WebTransport is not available in this browser. Try Chrome or Firefox Nightly. Safari support is still incomplete.",
@@ -318,13 +442,14 @@ function main() {
return;
}
- writeParams(relayUrl, name);
+ writeParams(relayUrl, name, mode);
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
mountPlayer(relayUrl, name);
}
relayInput.addEventListener("input", updateSharePreview);
nameInput.addEventListener("input", updateSharePreview);
+ archiveModeInput.addEventListener("input", updateSharePreview);
watchBtn.addEventListener("click", () => {
void start();
@@ -336,11 +461,12 @@ function main() {
copyBtn.addEventListener("click", async () => {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
+ const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) {
setHint("Enter a broadcast name first.", "warn");
return;
}
- const link = currentShareLink(relayUrl, name);
+ const link = currentShareLink(relayUrl, name, mode);
try {
await copyToClipboard(link);
setHint("Link copied.", "ok");
@@ -355,12 +481,23 @@ function main() {
setListHint("Loading live streams...", "");
try {
const entries = await fetchLiveList();
- renderLiveList(entries, (entry) => {
- relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
- nameInput.value = normalizeName(entry.broadcast_name || "");
- updateSharePreview();
- void start();
- });
+ renderLiveList(
+ entries,
+ (entry) => {
+ archiveModeInput.checked = false;
+ relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
+ nameInput.value = normalizeName(entry.broadcast_name || "");
+ updateSharePreview();
+ void start();
+ },
+ (entry) => {
+ archiveModeInput.checked = true;
+ relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
+ nameInput.value = normalizeName(entry.broadcast_name || "");
+ updateSharePreview();
+ void start();
+ },
+ );
} catch (e) {
$("liveList").textContent = "";
setListHint(`Live list error: ${String(e)}`, "warn");
diff --git a/apps/web/index.html b/apps/web/index.html
index c94b8ce..5f3702e 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -34,6 +34,13 @@
Broadcast name
+
diff --git a/apps/web/style.css b/apps/web/style.css
index 3f1bf14..49c941a 100644
--- a/apps/web/style.css
+++ b/apps/web/style.css
@@ -106,7 +106,7 @@ body {
.row {
display: grid;
- grid-template-columns: 1.15fr 1fr auto;
+ grid-template-columns: 1.15fr 1fr auto auto;
gap: 10px;
align-items: end;
}
@@ -133,6 +133,23 @@ body {
box-shadow: 0 0 0 3px rgba(255, 184, 108, 0.16);
}
+.checkField .checkRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 10px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(0, 0, 0, 0.28);
+ color: var(--text);
+}
+
+.checkField input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+}
+
.btn {
padding: 11px 14px;
border-radius: 12px;
@@ -221,6 +238,11 @@ body {
white-space: nowrap;
}
+.liveActions {
+ display: flex;
+ gap: 6px;
+}
+
.player {
padding: 0;
}
@@ -272,6 +294,13 @@ body {
display: block;
}
+.archiveVideo {
+ width: 100%;
+ height: 100%;
+ display: block;
+ background: #000;
+}
+
.tv-scanlines {
position: absolute;
inset: 12px;
diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs
index a629a5d..f0065fe 100644
--- a/crates/ec-node/src/main.rs
+++ b/crates/ec-node/src/main.rs
@@ -38,6 +38,8 @@ use std::process::{Command, Stdio};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
use tokio::process::Command as TokioCommand;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use url::Url;
@@ -48,7 +50,8 @@ const DIRECT_WIRE_TAG_PING: u8 = 0x02;
const DIRECT_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(8);
// Conservatively under typical SCTP data channel max message sizes.
const DIRECT_WIRE_CHUNK_BYTES: usize = 16 * 1024;
-const WT_ARCHIVE_DEFAULT_TRACKS: &[&str] = &["catalog.json", "init.mp4", "video0.m4s", "audio0.m4s"];
+const WT_ARCHIVE_DEFAULT_TRACKS: &[&str] =
+ &["catalog.json", "init.mp4", "video0.m4s", "audio0.m4s"];
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::RwLock;
@@ -83,6 +86,8 @@ enum Commands {
WtPublish(WtPublishArgs),
/// Subscribe to a relay broadcast over WebTransport/MoQ and archive groups into CAS.
WtArchive(WtArchiveArgs),
+ /// Serve archived relay groups as DVR-style HLS playlists + object endpoints.
+ WtArchiveServe(WtArchiveServeArgs),
/// Announce stream transport availability over iroh gossip control topic.
ControlAnnounce(ControlAnnounceArgs),
/// Listen for stream transport announcements from iroh gossip control topic.
@@ -483,6 +488,20 @@ struct WtArchiveArgs {
tls_disable_verify: bool,
}
+#[derive(Parser, Debug)]
+struct WtArchiveServeArgs {
+ /// Output directory used by `wt-archive`.
+ #[arg(long, default_value = "./tmp/wt-archive")]
+ output_dir: PathBuf,
+ /// Optional manifest/index directory.
+ /// Defaults to `/manifests` when omitted.
+ #[arg(long)]
+ manifest_dir: Option,
+ /// TCP listen address for HTTP replay endpoints.
+ #[arg(long, default_value = "0.0.0.0:7788")]
+ listen: String,
+}
+
#[derive(Parser, Debug)]
struct ControlAnnounceArgs {
/// Stable stream id to announce.
@@ -694,6 +713,7 @@ fn main() -> Result<()> {
Commands::WsSubscribe(args) => run_async(ws_subscribe(args))?,
Commands::WtPublish(args) => run_async(wt_publish(args))?,
Commands::WtArchive(args) => run_async(wt_archive(args))?,
+ Commands::WtArchiveServe(args) => run_async(wt_archive_serve(args))?,
Commands::ControlAnnounce(args) => run_async(control_announce(args))?,
Commands::ControlListen(args) => run_async(control_listen(args))?,
Commands::ControlResolve(args) => run_async(control_resolve(args))?,
@@ -4874,7 +4894,7 @@ fn wait_for_stable_file(path: &Path, timeout: Duration) -> Result<()> {
))
}
-#[derive(Debug, serde::Serialize)]
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ArchiveIndexRecord {
received_unix_ms: u64,
relay_url: String,
@@ -5000,7 +5020,8 @@ async fn archive_track_loop(
"track {} read failed for broadcast {}",
track_name, broadcast_name
)
- })? else {
+ })?
+ else {
return Err(anyhow!(
"track {} ended for broadcast {}",
track_name,
@@ -5097,8 +5118,10 @@ async fn wt_archive(args: WtArchiveArgs) -> Result<()> {
}
impl web_transport_trait::Session for ProtocolOverride {
- type SendStream = ::SendStream;
- type RecvStream = ::RecvStream;
+ type SendStream =
+ ::SendStream;
+ type RecvStream =
+ ::RecvStream;
type Error = ::Error;
fn accept_bi(
@@ -5391,6 +5414,544 @@ async fn wt_archive(args: WtArchiveArgs) -> Result<()> {
Ok(())
}
+#[derive(Debug, Clone)]
+struct ArchiveReplayState {
+ cas_root: PathBuf,
+ manifest_root: PathBuf,
+}
+
+#[derive(Debug, serde::Serialize)]
+struct ArchiveTimelineResponse {
+ broadcast_name: String,
+ start_unix_ms: Option,
+ end_unix_ms: Option,
+ video_segments: usize,
+ audio_segments: usize,
+}
+
+#[derive(Debug, Clone)]
+struct ArchiveHlsSegment {
+ sequence: u64,
+ duration_secs: f64,
+ hash: String,
+}
+
+#[derive(Debug)]
+struct ArchiveHttpResponse {
+ status: u16,
+ content_type: String,
+ body: Vec,
+}
+
+fn read_archive_records(
+ manifest_root: &Path,
+ broadcast_name: &str,
+ track_name: &str,
+) -> Result> {
+ let broadcast_dir = sanitize_path_component(broadcast_name);
+ let track_file = sanitize_path_component(track_name);
+ let path = manifest_root
+ .join(broadcast_dir)
+ .join(format!("{track_file}.jsonl"));
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let mut out = Vec::new();
+ let data = fs::read_to_string(&path)
+ .with_context(|| format!("failed to read archive index {}", path.display()))?;
+ for line in data.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ match serde_json::from_str::(line) {
+ Ok(record) => out.push(record),
+ Err(err) => {
+ tracing::warn!(
+ path = %path.display(),
+ err = %err,
+ "failed to parse archive index line"
+ );
+ }
+ }
+ }
+ Ok(out)
+}
+
+fn dedupe_by_group_sequence(mut records: Vec) -> Vec {
+ records.sort_by_key(|record| record.group_sequence);
+ let mut out: Vec = Vec::with_capacity(records.len());
+ for record in records {
+ if let Some(last) = out.last_mut() {
+ if last.group_sequence == record.group_sequence {
+ *last = record;
+ continue;
+ }
+ }
+ out.push(record);
+ }
+ out
+}
+
+fn default_segment_duration_ms(records: &[ArchiveIndexRecord]) -> u64 {
+ if records.len() < 2 {
+ return 1000;
+ }
+ let mut deltas = Vec::with_capacity(records.len().saturating_sub(1));
+ for pair in records.windows(2) {
+ let current = pair[0].received_unix_ms;
+ let next = pair[1].received_unix_ms;
+ if next > current {
+ deltas.push(next - current);
+ }
+ }
+ if deltas.is_empty() {
+ return 1000;
+ }
+ deltas.sort_unstable();
+ deltas[deltas.len() / 2].clamp(200, 10000)
+}
+
+fn build_hls_segments(records: &[ArchiveIndexRecord]) -> Vec {
+ if records.is_empty() {
+ return Vec::new();
+ }
+ let fallback_ms = default_segment_duration_ms(records);
+ let mut out = Vec::with_capacity(records.len());
+ for (idx, record) in records.iter().enumerate() {
+ let mut dur_ms = fallback_ms;
+ if let Some(next) = records.get(idx + 1) {
+ if next.received_unix_ms > record.received_unix_ms {
+ dur_ms = (next.received_unix_ms - record.received_unix_ms).clamp(200, 10000);
+ }
+ }
+ out.push(ArchiveHlsSegment {
+ sequence: record.group_sequence,
+ duration_secs: (dur_ms as f64) / 1000.0,
+ hash: record.blake3.clone(),
+ });
+ }
+ out
+}
+
+fn latest_init_hash(manifest_root: &Path, broadcast_name: &str) -> Result